Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/abstractions/src/IDatabaseCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface IDatabaseCollection {

update(oldDoc: any, data: any): Promise<any>;

patch(oldDoc: any, data: any): Promise<any>;

delete(query: any): Promise<boolean>;

list(): Promise<any[]>;
Expand Down
1 change: 1 addition & 0 deletions packages/loki/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"dependencies": {
"@js-soft/docdb-access-abstractions": "1.2.1",
"@types/lokijs": "1.5.14",
"fast-json-patch": "^3.1.1",
"lokijs": "1.5.12"
},
"publishConfig": {
Expand Down
25 changes: 25 additions & 0 deletions packages/loki/src/LokiJsCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DatabaseType,
IDatabaseCollection
} from "@js-soft/docdb-access-abstractions";
import jsonpatch from "fast-json-patch";

export class LokiJsCollection implements IDatabaseCollection {
public readonly name: string;
Expand Down Expand Up @@ -43,6 +44,30 @@ export class LokiJsCollection implements IDatabaseCollection {
return data;
}

public async patch(oldDocument: any, data: any): Promise<any> {
if (typeof data.toJSON === "function") {
data = data.toJSON();
}

if (!("id" in oldDocument)) {
throw new Error("Patching is not supported for documents with an 'id' field. Use 'update' instead.");
}

data.$loki = oldDocument.$loki;
data.meta = oldDocument.meta;

const patch = jsonpatch.compare(oldDocument, data);
const updated = this.collection
.chain()
.find({ id: oldDocument.id }, true)
.update((doc) => jsonpatch.applyPatch(doc, patch))
.data();

if (updated.length < 1) throw new Error("Document not found for patching");

return updated[0];
}

public async delete(query: any): Promise<boolean> {
if (typeof query === "string") {
query = { id: query };
Expand Down
36 changes: 36 additions & 0 deletions packages/loki/test/DatabaseCollection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,40 @@ describe("DatabaseCollection", () => {

await expect(db.create({ id: "uniqueValue" })).rejects.toThrow("Duplicate key for property id: uniqueValue");
});

describe("patch vs update", () => {
test("update is not working properly", async () => {
const db = await getRandomCollection();

const entry = { id: "test", name: "test" };
const createdEntry = await db.create(entry);

// this will completely replaced
await db.update(createdEntry, { id: "test", name: "updated" });

// this will replace the above
await db.update(createdEntry, { id: "test", name: "test", anotherField: "newField" });

const queriedEntry = await db.findOne({ id: "test" });
expect(queriedEntry.name).toBe("test");
expect(queriedEntry.anotherField).toBe("newField");
});

test("patch is better", async () => {
const db = await getRandomCollection();

const entry = { id: "test", name: "test" };
const createdEntry = await db.create(entry);

// this changes name
await db.patch(createdEntry, { id: "test", name: "updated" });

// this only adds another field
await db.patch(createdEntry, { id: "test", name: "test", anotherField: "newField" });

const queriedEntry = await db.findOne({ id: "test" });
expect(queriedEntry.name).toBe("updated");
expect(queriedEntry.anotherField).toBe("newField");
});
});
});
1 change: 1 addition & 0 deletions packages/mongo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
},
"dependencies": {
"@js-soft/docdb-access-abstractions": "1.2.1",
"fast-json-patch": "^3.1.1",
"mongodb": "6.20.0"
},
"publishConfig": {
Expand Down
38 changes: 32 additions & 6 deletions packages/mongo/src/MongoDbCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
DatabaseType,
IDatabaseCollection
} from "@js-soft/docdb-access-abstractions";
import jsonpatch from "fast-json-patch";
import { Collection } from "mongodb";
import { jsonPatchToMongoDbOps } from "./jsonPatchToMongoDbOps";
import { removeContainsInQuery } from "./queryUtils";

export class MongoDbCollection implements IDatabaseCollection {
Expand Down Expand Up @@ -34,16 +36,40 @@ export class MongoDbCollection implements IDatabaseCollection {
}

public async update(oldDoc: any, data: any): Promise<any> {
let doc: any;
if (typeof data.toJSON === "function") data = data.toJSON();

const updateResult = await this.collection.replaceOne(oldDoc, data);
if (updateResult.modifiedCount < 1) throw new Error("Document not found for updating");

return data;
}

public async patch(oldDoc: any, data: any): Promise<any> {
if (typeof data.toJSON === "function") {
doc = data.toJSON();
} else {
doc = data;
data = data.toJSON();
}

await this.collection.replaceOne(oldDoc, doc);
return data;
if (!("id" in oldDoc)) {
throw new Error("Patching is not supported for documents with an 'id' field. Use 'update' instead.");
}

const patch = jsonpatch.compare(oldDoc, data);
const filter = { id: oldDoc.id };

const operations = await jsonPatchToMongoDbOps(
patch.filter((v) => v.path !== "/_id"),
filter,
this.collection
);
await this.collection.bulkWrite(operations);

const updated = await this.collection.findOne({ id: oldDoc.id });

if (!updated) {
throw new Error("Document not found for patching");
}

return updated;
}

public async delete(query: any): Promise<boolean> {
Expand Down
119 changes: 119 additions & 0 deletions packages/mongo/src/jsonPatchToMongoDbOps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* credit to https://github.com/fathomas/json-patch-to-mongodb-ops/tree/main
*/

import { Operation, validate } from "fast-json-patch";
import { PatchError } from "fast-json-patch/module/helpers";
import { AnyBulkWriteOperation, Collection } from "mongodb";

export const jsonPatchPathToDot = (path: string): string =>
path.replace(/^\//, "").replace(/\//g, ".").replace(/~1/g, "/").replace(/~0/g, "~");

function getTrailingPos(path: string): number {
const parts = path.split(".");
const pathTail = parts.slice(-1)[0];
return parts.length > 1 ? (pathTail === "-" ? -1 : parseInt(pathTail, 10)) : NaN;
}

function $remove(path: any) {
const result: any[] = [];
const trailingPos = getTrailingPos(path);
result.push({ $unset: { [path]: 1 } });
if (!Number.isNaN(trailingPos)) {
const pathHead = path.split(".").slice(0, -1).join(".");
result.push({ $pull: { [pathHead]: null } });
}
return result;
}

function $add(path: any, value) {
const trailingPos = getTrailingPos(path);
if (Number.isNaN(trailingPos)) {
return { $set: { [path]: value } };
}
const pathHead = path.split(".").slice(0, -1).join(".");
return {
$push: {
[pathHead]: trailingPos >= 0 ? { $each: [value], $position: trailingPos } : value
}
};
}

export async function jsonPatchToMongoDbOps(
patch: Operation[],
targetFilter: Record<string, any>,
collection: Collection
): Promise<AnyBulkWriteOperation<{}>[]> {
const error = validate(patch) as PatchError | undefined;
if (error) throw error;

return (
await Promise.all(
patch.map(async (operation) => {
const path = jsonPatchPathToDot(operation.path);
if (!path || path.endsWith(".")) {
throw new Error("Invalid update path.");
}

switch (operation.op) {
case "add": {
return $add(path, operation.value);
}
case "remove": {
return $remove(path);
}
case "replace": {
return { $set: { [path]: operation.value } };
}
case "copy":
case "move": {
const from = jsonPatchPathToDot(operation.from);
const fromParts = from.split(".");
const targetIndex = fromParts[1] && /^\d+$/.test(fromParts[1]) ? parseInt(fromParts[1]) : NaN;

const currDoc = await collection.findOne(targetFilter, {
// Reduce memory usage and speed up query.
projection: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_id: 0,
...(Number.isNaN(targetIndex)
? {
[fromParts[0]]: 1
}
: { [fromParts[0]]: { $slice: [targetIndex, 1] } })
}
});

let segmentsToWalk;
let startPoint;
if (Number.isNaN(targetIndex)) {
segmentsToWalk = fromParts;
startPoint = currDoc;
} else {
segmentsToWalk = fromParts.slice(2);
startPoint = currDoc![fromParts[0]][0];
}

const valueToTransfer = segmentsToWalk.reduce((curr, propName) => curr[propName], startPoint);
if (typeof valueToTransfer === "undefined") {
throw new Error("Can't move undefined value!");
}

const addOp = $add(path, valueToTransfer);
return operation.op === "copy" ? addOp : [...$remove(from), addOp];
}
case "test": {
return undefined;
}
case "_get":
default: {
throw new Error(`Unsupported Operation! op = ${operation.op}`);
}
}
})
)
)
.flat()
.filter((update) => update)
.map((update) => ({ updateOne: { update, filter: targetFilter } }));
}
38 changes: 38 additions & 0 deletions packages/mongo/test/DatabaseCollection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,42 @@ describe("DatabaseCollection", () => {

await expect(db.create({ id: "uniqueValue" })).rejects.toThrow(/[Dd]uplicate key/);
});

describe("patch vs update", () => {
test("update is not working properly", async () => {
const db = await getRandomCollection();

const entry = { id: "test", name: "test" };
const createdEntry = await db.create(entry);

// this will completely replaced
await db.update(createdEntry, { id: "test", name: "updated" });

// this will not replace the above b/c it's not even found
await expect(
db.update(createdEntry, { id: "test", name: "test", anotherField: "newField" })
).rejects.toThrow("Document not found for updating");

const queriedEntry = await db.findOne({ id: "test" });
expect(queriedEntry.name).toBe("updated");
expect(queriedEntry.anotherField).toBeUndefined();
});

test("patch is better", async () => {
const db = await getRandomCollection();

const entry = { id: "test", name: "test" };
const createdEntry = await db.create(entry);

// this changes name
await db.patch(createdEntry, { id: "test", name: "updated" });

// this only adds another field
await db.patch(createdEntry, { id: "test", name: "test", anotherField: "newField" });

const queriedEntry = await db.findOne({ id: "test" });
expect(queriedEntry.name).toBe("updated");
expect(queriedEntry.anotherField).toBe("newField");
});
});
});