parent
43c3356942
commit
2dc6136d51
@ -1,217 +0,0 @@ |
||||
import { describe, test, expect } from "vitest"; |
||||
import { |
||||
applyDiff, |
||||
} from "@nextgraph-monorepo/ng-signals"; |
||||
import type { Patch } from "@nextgraph-monorepo/ng-signals"; |
||||
|
||||
/** |
||||
* Build a patch path string from segments (auto-prefix /) |
||||
*/ |
||||
function p(...segs: (string | number)[]) { |
||||
return "/" + segs.map(String).join("/"); |
||||
} |
||||
|
||||
describe("applyDiff - set operations (primitives)", () => { |
||||
test("add single primitive into new set", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", type: "set", path: p("tags"), value: "a" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.tags).toBeInstanceOf(Set); |
||||
expect([...state.tags]).toEqual(["a"]); |
||||
}); |
||||
test("add multiple primitives into new set", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", type: "set", path: p("nums"), value: [1, 2, 3] }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect([...state.nums]).toEqual([1, 2, 3]); |
||||
}); |
||||
test("add primitives merging into existing set", () => { |
||||
const state: any = { nums: new Set([1]) }; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", type: "set", path: p("nums"), value: [2, 3] }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect([...state.nums].sort()).toEqual([1, 2, 3]); |
||||
}); |
||||
test("remove single primitive from set", () => { |
||||
const state: any = { tags: new Set(["a", "b"]) }; |
||||
const diff: Patch[] = [ |
||||
{ op: "remove", type: "set", path: p("tags"), value: "a" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect([...state.tags]).toEqual(["b"]); |
||||
}); |
||||
test("remove multiple primitives from set", () => { |
||||
const state: any = { nums: new Set([1, 2, 3, 4]) }; |
||||
const diff: Patch[] = [ |
||||
{ op: "remove", type: "set", path: p("nums"), value: [2, 4] }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect([...state.nums].sort()).toEqual([1, 3]); |
||||
}); |
||||
}); |
||||
|
||||
describe("applyDiff - set operations (object sets)", () => { |
||||
test("add object entries to new object-set", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ |
||||
op: "add", |
||||
type: "set", |
||||
path: p("users"), |
||||
value: { u1: { id: "u1", n: 1 }, u2: { id: "u2", n: 2 } }, |
||||
}, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.users.u1).toEqual({ id: "u1", n: 1 }); |
||||
expect(state.users.u2).toEqual({ id: "u2", n: 2 }); |
||||
}); |
||||
test("merge object entries into existing object-set", () => { |
||||
const state: any = { users: { u1: { id: "u1", n: 1 } } }; |
||||
const diff: Patch[] = [ |
||||
{ |
||||
op: "add", |
||||
type: "set", |
||||
path: p("users"), |
||||
value: { u2: { id: "u2", n: 2 } }, |
||||
}, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); |
||||
}); |
||||
test("remove object entries from object-set", () => { |
||||
const state: any = { users: { u1: {}, u2: {}, u3: {} } }; |
||||
const diff: Patch[] = [ |
||||
{ op: "remove", type: "set", path: p("users"), value: ["u1", "u3"] }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(Object.keys(state.users)).toEqual(["u2"]); |
||||
}); |
||||
test("adding primitives to existing object-set replaces with Set", () => { |
||||
const state: any = { mixed: { a: {}, b: {} } }; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", type: "set", path: p("mixed"), value: [1, 2] }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.mixed).toBeInstanceOf(Set); |
||||
expect([...state.mixed]).toEqual([1, 2]); |
||||
}); |
||||
}); |
||||
|
||||
describe("applyDiff - object & literal operations", () => { |
||||
test("add object (create empty object)", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [{ op: "add", path: p("address"), type: "object" }]; |
||||
applyDiff(state, diff); |
||||
expect(state.address).toEqual({}); |
||||
}); |
||||
test("add nested object path with ensurePathExists", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: p("a", "b", "c"), type: "object" }, |
||||
]; |
||||
applyDiff(state, diff, true); |
||||
expect(state.a.b.c).toEqual({}); |
||||
}); |
||||
test("add primitive value", () => { |
||||
const state: any = { address: {} }; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: p("address", "street"), value: "1st" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.address.street).toBe("1st"); |
||||
}); |
||||
test("overwrite primitive value", () => { |
||||
const state: any = { address: { street: "old" } }; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: p("address", "street"), value: "new" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.address.street).toBe("new"); |
||||
}); |
||||
test("remove primitive", () => { |
||||
const state: any = { address: { street: "1st", country: "Greece" } }; |
||||
const diff: Patch[] = [{ op: "remove", path: p("address", "street") }]; |
||||
applyDiff(state, diff); |
||||
expect(state.address.street).toBeUndefined(); |
||||
expect(state.address.country).toBe("Greece"); |
||||
}); |
||||
test("remove object branch", () => { |
||||
const state: any = { address: { street: "1st" }, other: 1 }; |
||||
const diff: Patch[] = [{ op: "remove", path: p("address") }]; |
||||
applyDiff(state, diff); |
||||
expect(state.address).toBeUndefined(); |
||||
expect(state.other).toBe(1); |
||||
}); |
||||
}); |
||||
|
||||
describe("applyDiff - multiple mixed patches in a single diff", () => { |
||||
test("sequence of mixed set/object/literal add & remove", () => { |
||||
const state: any = { |
||||
users: { u1: { id: "u1" } }, |
||||
tags: new Set(["old"]), |
||||
}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", type: "set", path: p("users"), value: { u2: { id: "u2" } } }, |
||||
{ op: "add", path: p("profile"), type: "object" }, |
||||
{ op: "add", path: p("profile", "name"), value: "Alice" }, |
||||
{ op: "add", type: "set", path: p("tags"), value: ["new"] }, |
||||
{ op: "remove", type: "set", path: p("tags"), value: "old" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); |
||||
expect(state.profile.name).toBe("Alice"); |
||||
expect([...state.tags]).toEqual(["new"]); |
||||
}); |
||||
|
||||
test("complex nested path creation and mutations with ensurePathExists", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: p("a", "b"), type: "object" }, |
||||
{ op: "add", path: p("a", "b", "c"), value: 1 }, |
||||
{ op: "add", type: "set", path: p("a", "nums"), value: [1, 2, 3] }, |
||||
{ op: "remove", type: "set", path: p("a", "nums"), value: 2 }, |
||||
{ op: "add", path: p("a", "b", "d"), value: 2 }, |
||||
{ op: "remove", path: p("a", "b", "c") }, |
||||
]; |
||||
applyDiff(state, diff, true); |
||||
expect(state.a.b.c).toBeUndefined(); |
||||
expect(state.a.b.d).toBe(2); |
||||
expect(state.a.nums).toBeInstanceOf(Set); |
||||
expect([...state.a.nums].sort()).toEqual([1, 3]); |
||||
}); |
||||
}); |
||||
|
||||
describe("applyDiff - ignored / invalid scenarios", () => { |
||||
test("skip patch with non-leading slash path", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [{ op: "add", path: "address/street", value: "x" }]; |
||||
applyDiff(state, diff); |
||||
expect(state).toEqual({}); |
||||
}); |
||||
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; |
||||
applyDiff(state, diff, false); |
||||
expect(state).toEqual({}); |
||||
}); |
||||
}); |
||||
|
||||
describe("applyDiff - ignored / invalid scenarios", () => { |
||||
test("skip patch with non-leading slash path", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [{ op: "add", path: "address/street", value: "x" }]; |
||||
applyDiff(state, diff); |
||||
expect(state).toEqual({}); |
||||
}); |
||||
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; |
||||
applyDiff(state, diff, false); |
||||
expect(state).toEqual({}); |
||||
}); |
||||
}); |
@ -1,56 +0,0 @@ |
||||
import { describe, expect, test } from "vitest"; |
||||
import { createSignalObjectForShape } from "@nextgraph-monorepo/ng-signals"; |
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); |
||||
|
||||
describe("Signal modification and propagation to backend with or without signal pooling", () => { |
||||
for (const withPooling of [true, false]) { |
||||
test(`shape object notification comes back to others ${ |
||||
withPooling ? "with" : "without" |
||||
} signal pooling`, async () => {
|
||||
const object1 = createSignalObjectForShape( |
||||
"TestShape", |
||||
undefined, |
||||
withPooling |
||||
); |
||||
const object2 = createSignalObjectForShape( |
||||
"TestShape", |
||||
undefined, |
||||
withPooling |
||||
); |
||||
|
||||
const object3 = createSignalObjectForShape( |
||||
"Shape2", |
||||
undefined, |
||||
withPooling |
||||
); |
||||
const object4 = createSignalObjectForShape( |
||||
"Shape2", |
||||
undefined, |
||||
withPooling |
||||
); |
||||
|
||||
await wait(10); |
||||
|
||||
// Update object 1 and expect object 2 to update as well.
|
||||
// @ts-expect-error
|
||||
object1.name = "Updated name from object1"; |
||||
|
||||
await wait(10); |
||||
// @ts-expect-error
|
||||
expect(object2.name).toBe("Updated name from object1"); |
||||
|
||||
// Expect object of different shape not to have changed.
|
||||
// @ts-expect-error
|
||||
expect(object3.name).toBe("Niko's cat"); |
||||
|
||||
// Update object 4 and expect object 3 with same shape to have updated.
|
||||
// @ts-expect-error
|
||||
object4.name = "Updated name from object4"; |
||||
|
||||
await wait(10); |
||||
// @ts-expect-error
|
||||
expect(object3!.name).toBe("Updated name from object4"); |
||||
}); |
||||
} |
||||
}); |
@ -1,147 +0,0 @@ |
||||
import type { |
||||
ShapeType, |
||||
Shape, |
||||
Predicate, |
||||
Schema, |
||||
} from "@nextgraph-monorepo/ng-shex-orm"; |
||||
|
||||
interface Triple { |
||||
s: string; |
||||
p: string; |
||||
o: string; |
||||
} |
||||
interface TrackedSubject { |
||||
shape: Shape; |
||||
parentObject?: TrackedSubject; |
||||
valid?: boolean; |
||||
trackedPredicates: Record<string, TrackedPredicate>; |
||||
/** For Sub-objects only */ |
||||
untracked?: boolean; |
||||
} |
||||
|
||||
interface TrackedPredicate { |
||||
shape: Predicate; |
||||
currentCardinality: number; |
||||
childSubjects?: TrackedSubject[]; |
||||
} |
||||
|
||||
export function buildObjects(shapeType: ShapeType<any>) { |
||||
//
|
||||
} |
||||
|
||||
export function onTriplesRemoved( |
||||
trackedSubjects: Record<string, TrackedSubject[]>, |
||||
triplesRemoved: string[][], |
||||
shapeType: ShapeType<any> |
||||
) { |
||||
//
|
||||
} |
||||
|
||||
/** |
||||
* Adds new triples to tracked subjects and creates and returns |
||||
* new tracked (possibly nested) subjects. |
||||
*/ |
||||
export function onTriplesAdded( |
||||
trackedSubjects: Record<string, TrackedSubject[]>, |
||||
triplesAdded: Triple[], |
||||
schema: Schema, |
||||
rootShape?: Shape |
||||
): { newTrackedSubjects: Record<string, TrackedSubject[]> } { |
||||
// Track for secondary iterations.
|
||||
const newTrackedSubjectsCreated: Record<string, TrackedSubject[]> = {}; |
||||
|
||||
for (const triple of triplesAdded) { |
||||
if (!trackedSubjects[triple.s]) { |
||||
// If rootShape is not set, we are applying the triples new nested objects
|
||||
// and we don't create new tracked subjects.
|
||||
if (!rootShape) continue; |
||||
|
||||
// Check if predicate is in root shape type.
|
||||
const matchedPredicate = rootShape.predicates.find((p) => triple.p); |
||||
if (!matchedPredicate) { |
||||
// Nothing to track.
|
||||
continue; |
||||
} |
||||
// The triple should be tracked. Create new tracked subject.
|
||||
const newTrackedSubject: TrackedSubject = { |
||||
shape: rootShape, |
||||
trackedPredicates: { |
||||
[triple.p]: { |
||||
shape: matchedPredicate, |
||||
currentCardinality: 1, |
||||
}, |
||||
}, |
||||
}; |
||||
trackedSubjects[triple.s] = [newTrackedSubject]; |
||||
} else { |
||||
// Add triple to tracked subject(s).
|
||||
// In the case of nested shapes, the same subject can be tracked
|
||||
// in multiple shapes.
|
||||
const trackedSubjectsMatching = trackedSubjects[triple.s]; |
||||
for (const trackedSubject of trackedSubjectsMatching) { |
||||
// Is predicate tracked in this subject's shape?
|
||||
const matchedPredShape = trackedSubject.shape.predicates.find( |
||||
(predShape) => predShape.iri === triple.p |
||||
); |
||||
if (!matchedPredShape) continue; |
||||
// Get or create tracked predicate for tracked shape.
|
||||
let trackedPredicate = |
||||
trackedSubject.trackedPredicates[matchedPredShape?.iri]; |
||||
if (!trackedSubjects) { |
||||
trackedPredicate = { |
||||
currentCardinality: 0, |
||||
shape: matchedPredShape, |
||||
}; |
||||
} |
||||
// Increase cardinality.
|
||||
trackedPredicate.currentCardinality += 1; |
||||
|
||||
// If predicate shape has nested object, track that too.
|
||||
if (trackedPredicate.shape.nestedShape) { |
||||
const newTrackedObject: TrackedSubject = { |
||||
shape: schema[matchedPredShape.nestedShape as string], |
||||
trackedPredicates: {}, |
||||
parentObject: trackedSubject, |
||||
}; |
||||
// Remember for adding to registry and for re-running on nested shapes.
|
||||
const newTracked = newTrackedSubjectsCreated[triple.o]; |
||||
if (!newTracked) |
||||
newTrackedSubjectsCreated[triple.o] = [ |
||||
newTrackedObject, |
||||
]; |
||||
newTracked.push(newTrackedObject); |
||||
|
||||
// Link to parent
|
||||
const childSubjects = trackedPredicate.childSubjects; |
||||
if (childSubjects) childSubjects.push(newTrackedObject); |
||||
else trackedPredicate.childSubjects = [newTrackedObject]; |
||||
} |
||||
|
||||
// TODO: Inform tracked subjects about change
|
||||
} |
||||
} |
||||
} |
||||
|
||||
// Rerun triples on new tracked subjects created.
|
||||
// Then merge with parent tracked subjects
|
||||
const newNestedSubjectsCreated = onTriplesAdded( |
||||
newTrackedSubjectsCreated, |
||||
triplesAdded, |
||||
schema |
||||
); |
||||
|
||||
// TODO: Is it correct to do this?
|
||||
const ret: Record<string, TrackedSubject[]> = {}; |
||||
for (const subjectIri of Object.keys(newTrackedSubjectsCreated)) { |
||||
ret[subjectIri] = [ |
||||
...newTrackedSubjectsCreated[subjectIri], |
||||
...newNestedSubjectsCreated.newTrackedSubjects[subjectIri], |
||||
]; |
||||
} |
||||
|
||||
// Update Valid invalid
|
||||
|
||||
return { newTrackedSubjects: ret }; |
||||
} |
||||
|
||||
// Do we have infinite-loop issues?
|
@ -1,172 +0,0 @@ |
||||
import * as shapeManager from "./shapeManager"; |
||||
import type { WasmConnection, Diff, Scope } from "./types"; |
||||
import type { ShapeType, BaseType } from "@nextgraph-monorepo/ng-shex-orm"; |
||||
import * as ng from "@nextgraph-monorepo/ng-sdk-js"; |
||||
import type { Person } from "../../shapes/ldo/personShape.typings"; |
||||
import type { Cat } from "../../shapes/ldo/catShape.typings"; |
||||
import type { TestObject } from "../../shapes/ldo/testShape.typings"; |
||||
import updateShape from "./updateShape"; |
||||
import { testShapeSchema } from "../../shapes/ldo/testShape.schema"; |
||||
import { TestObjectShapeType } from "../../shapes/ldo/testShape.shapeTypes"; |
||||
|
||||
// Messages exchanged over the BroadcastChannel("shape-manager")
|
||||
interface WasmMessage { |
||||
type: |
||||
| "Request" |
||||
| "InitialResponse" |
||||
| "FrontendUpdate" |
||||
| "BackendUpdate" |
||||
| "Stop"; |
||||
connectionId: string; |
||||
diff?: Diff; |
||||
shapeType?: ShapeType<any>; |
||||
initialData?: BaseType; |
||||
} |
||||
|
||||
export const mockTestObject = { |
||||
id: "ex:mock-id-1", |
||||
type: "TestObject", |
||||
stringValue: "string", |
||||
numValue: 42, |
||||
boolValue: true, |
||||
arrayValue: [1, 2, 3], |
||||
objectValue: { |
||||
id: "urn:obj-1", |
||||
nestedString: "nested", |
||||
nestedNum: 7, |
||||
|
||||
nestedArray: [10, 12], |
||||
}, |
||||
anotherObject: { |
||||
"id:1": { |
||||
id: "id:1", |
||||
prop1: "prop1 value", |
||||
prop2: 100, |
||||
}, |
||||
"id:2": { |
||||
id: "id:1", |
||||
prop1: "prop2 value", |
||||
prop2: 200, |
||||
}, |
||||
}, |
||||
} satisfies TestObject; |
||||
|
||||
const mockShapeObject1 = { |
||||
id: "ex:person-1", |
||||
type: "Person", |
||||
name: "Bob", |
||||
address: { |
||||
id: "urn:person-home-1", |
||||
street: "First street", |
||||
houseNumber: "15", |
||||
}, |
||||
hasChildren: true, |
||||
numberOfHouses: 0, |
||||
} satisfies Person; |
||||
|
||||
const mockShapeObject2 = { |
||||
id: "ex:cat-1", |
||||
type: "Cat", |
||||
name: "Niko's cat", |
||||
age: 12, |
||||
numberOfHomes: 3, |
||||
address: { |
||||
id: "Nikos-cat-home", |
||||
street: "Niko's street", |
||||
houseNumber: "15", |
||||
floor: 0, |
||||
}, |
||||
} satisfies Cat; |
||||
|
||||
// Single BroadcastChannel for wasm-land side
|
||||
const communicationChannel = new BroadcastChannel("shape-manager"); |
||||
|
||||
function getInitialObjectByShapeId<T extends BaseType>(shapeId?: string): T { |
||||
if (shapeId?.includes("TestObject")) return mockTestObject as unknown as T; |
||||
if (shapeId?.includes("Person")) return mockShapeObject1 as unknown as T; |
||||
if (shapeId?.includes("Cat")) return mockShapeObject2 as unknown as T; |
||||
console.warn( |
||||
"BACKEND: requestShape for unknown shape, returning empty object.", |
||||
shapeId |
||||
); |
||||
return {} as T; |
||||
} |
||||
|
||||
// Register handler for messages coming from js-land
|
||||
communicationChannel.addEventListener( |
||||
"message", |
||||
(event: MessageEvent<WasmMessage>) => { |
||||
console.log("BACKEND: Received message", event.data); |
||||
// call WASM ng-sdk-js
|
||||
const { type, connectionId, shapeType } = event.data; |
||||
|
||||
if (type === "Request") { |
||||
ng.orm_update("", "", {}, 1); |
||||
/* unsub = await ng.orm_start(scope, shapeType, session_id, |
||||
async (response) => { |
||||
//console.log("GOT APP RESPONSE", response);
|
||||
if (response.V0.OrmInitial) {
|
||||
|
||||
} else if (response.V0.OrmUpdate) { |
||||
let diff = response.V0.OrmUpdate.diff |
||||
const msg: WasmMessage = { |
||||
type: "BackendUpdate", |
||||
connectionId, |
||||
diff, |
||||
}; |
||||
communicationChannel.postMessage(msg); |
||||
} else if (response.V0.OrmError) { |
||||
|
||||
}
|
||||
*/ |
||||
|
||||
const shapeId = shapeType?.shape; |
||||
const initialData = getInitialObjectByShapeId(shapeId); |
||||
|
||||
// Store connection. We store the shapeId string to allow equality across connections.
|
||||
shapeManager.connections.set(connectionId, { |
||||
id: connectionId, |
||||
// Cast to any to satisfy WasmConnection type, comparison in updateShape uses ==
|
||||
shape: (shapeId ?? "__unknown__") as any, |
||||
state: initialData, |
||||
callback: (diff: Diff, conId: WasmConnection["id"]) => { |
||||
// Notify js-land about backend updates
|
||||
const msg: WasmMessage = { |
||||
type: "BackendUpdate", |
||||
connectionId, |
||||
diff, |
||||
}; |
||||
communicationChannel.postMessage(msg); |
||||
}, |
||||
}); |
||||
|
||||
const msg: WasmMessage = { |
||||
type: "InitialResponse", |
||||
connectionId, |
||||
initialData, |
||||
}; |
||||
communicationChannel.postMessage(msg); |
||||
return; |
||||
} |
||||
|
||||
if (type === "Stop") { |
||||
shapeManager.connections.delete(connectionId); |
||||
// await ng.app_request ( OrmStop )
|
||||
return; |
||||
} |
||||
|
||||
if (type === "FrontendUpdate" && event.data.diff) { |
||||
updateShape(connectionId, event.data.diff); |
||||
return; |
||||
} |
||||
|
||||
console.warn( |
||||
"BACKEND: Unknown message type or missing diff", |
||||
event.data |
||||
); |
||||
} |
||||
); |
||||
|
||||
ng.orm_start("", TestObjectShapeType, 12, (...params: any) => { |
||||
console.log("updates received with params", params); |
||||
}).catch((err) => console.error(err)); |
@ -1,10 +0,0 @@ |
||||
import type { Diff, ObjectState, WasmConnection } from "./types"; |
||||
|
||||
const connections: Map<WasmConnection["id"], WasmConnection> = new Map(); |
||||
|
||||
/** Mock function to apply diffs. Just uses a copy of the diff as the new object. */ |
||||
export function applyDiff(currentState: ObjectState, diff: Diff): ObjectState { |
||||
return JSON.parse(JSON.stringify(diff)); |
||||
} |
||||
|
||||
export { connections }; |
@ -1,220 +0,0 @@ |
||||
import type { Predicate, Shape, Schema } from "@nextgraph-monorepo/ng-shex-orm"; |
||||
|
||||
/** |
||||
* Build a SPARQL CONSTRUCT query from a ShapeConstraint definition. |
||||
* The WHERE mirrors the graph template. Optional predicates (min=0) are wrapped in OPTIONAL in WHERE |
||||
* but still appear in the CONSTRUCT template so that matched triples are constructed. |
||||
*/ |
||||
export function buildConstructQuery( |
||||
shape: Shape, |
||||
schema: Schema, |
||||
options?: SparqlBuildOptions |
||||
): string { |
||||
const ctx: BuildContext = { usedVars: new Set<string>() }; |
||||
const prefixes = prefixesToText(options?.prefixes); |
||||
const subject = shape.iri; |
||||
|
||||
const templateLines: string[] = []; |
||||
const whereLines: string[] = []; |
||||
const postFilters: string[] = []; |
||||
const valuesBlocks: string[] = []; |
||||
|
||||
const rootVar = |
||||
subject.startsWith("?") || subject.startsWith("$") |
||||
? subject |
||||
: uniqueVar(ctx, "s"); |
||||
if (!subject.startsWith("?") && !subject.startsWith("$")) { |
||||
valuesBlocks.push(valuesBlock(rootVar, [subject] as any)); |
||||
} |
||||
|
||||
const predicates = Array.isArray(shape.predicates) |
||||
? shape.predicates |
||||
: [...shape.predicates]; |
||||
for (const pred of predicates) { |
||||
addConstructPattern( |
||||
ctx, |
||||
pred, |
||||
rootVar, |
||||
templateLines, |
||||
whereLines, |
||||
postFilters, |
||||
valuesBlocks, |
||||
options |
||||
); |
||||
} |
||||
|
||||
const graphWrap = (body: string) => |
||||
options?.graph |
||||
? `GRAPH ${toIriOrCurie(options.graph)} {\n${body}\n}` |
||||
: body; |
||||
|
||||
const where = [ |
||||
...valuesBlocks, |
||||
graphWrap(whereLines.join("\n")), |
||||
...postFilters, |
||||
] |
||||
.filter(Boolean) |
||||
.join("\n"); |
||||
|
||||
const template = templateLines.join("\n"); |
||||
|
||||
return [prefixes, `CONSTRUCT {`, template, `} WHERE {`, where, `}`].join( |
||||
"\n" |
||||
); |
||||
} |
||||
|
||||
function addConstructPattern( |
||||
ctx: BuildContext, |
||||
pred: Predicate, |
||||
subjectVar: string, |
||||
template: string[], |
||||
where: string[], |
||||
postFilters: string[], |
||||
valuesBlocks: string[], |
||||
options?: SparqlBuildOptions |
||||
) { |
||||
const p = `<${pred.iri}>`; |
||||
const objVar = uniqueVar(ctx, pred.readablePredicate); |
||||
|
||||
const triple = `${subjectVar} ${p} ${objTerm} .`; |
||||
|
||||
const isOptional = |
||||
(pred.minCardinality ?? 0) === 0 && |
||||
(options?.includeOptionalForMinZero ?? true); |
||||
|
||||
if (pred.dataTypes === "nested" && pred.nestedShape) { |
||||
template.push(triple); |
||||
const nestedBody: string[] = [triple]; |
||||
const nestedPreds = pred.nestedShape.predicates; |
||||
|
||||
for (const n of nestedPreds) { |
||||
addConstructPattern( |
||||
ctx, |
||||
n, |
||||
objTerm, |
||||
template, |
||||
nestedBody, |
||||
postFilters, |
||||
valuesBlocks, |
||||
options |
||||
); |
||||
} |
||||
const block = nestedBody.join("\n"); |
||||
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block); |
||||
return; |
||||
} |
||||
|
||||
// Non-nested
|
||||
template.push(triple); |
||||
const blockLines: string[] = [triple]; |
||||
|
||||
if (pred.dataTypes === "literal" && pred.literalValue !== undefined) { |
||||
if (Array.isArray(pred.literalValue)) { |
||||
valuesBlocks.push(valuesBlock(objVar, pred.literalValue as any[])); |
||||
} else { |
||||
const lit = |
||||
typeof pred.literalValue === "string" || |
||||
typeof pred.literalValue === "number" || |
||||
typeof pred.literalValue === "boolean" |
||||
? pred.literalValue |
||||
: String(pred.literalValue); |
||||
postFilters.push( |
||||
`FILTER(${objVar} = ${typeof lit === "string" ? `"${String(lit).replace(/"/g, '\\"')}"` : lit})` |
||||
); |
||||
} |
||||
} |
||||
|
||||
const block = blockLines.join("\n"); |
||||
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block); |
||||
} |
||||
|
||||
export type LiteralKind = |
||||
| "number" |
||||
| "string" |
||||
| "boolean" |
||||
| "nested" |
||||
| "literal"; |
||||
|
||||
export interface SparqlBuildOptions { |
||||
prefixes?: Record<string, string>; |
||||
graph?: string; // IRI of the named graph to query, if any
|
||||
includeOptionalForMinZero?: boolean; // default true
|
||||
} |
||||
|
||||
export const defaultPrefixes: Record<string, string> = { |
||||
xsd: "http://www.w3.org/2001/XMLSchema#", |
||||
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", |
||||
rdfs: "http://www.w3.org/2000/01/rdf-schema#", |
||||
}; |
||||
|
||||
export function prefixesToText(prefixes?: Record<string, string>): string { |
||||
const all = { ...defaultPrefixes, ...(prefixes ?? {}) }; |
||||
return Object.entries(all) |
||||
.map(([p, iri]) => `PREFIX ${p}: <${iri}>`) |
||||
.join("\n"); |
||||
} |
||||
|
||||
export function safeVarName(name: string): string { |
||||
const base = name |
||||
.replace(/[^a-zA-Z0-9_]/g, "_") |
||||
.replace(/^([0-9])/, "_$1") |
||||
.slice(0, 60); |
||||
return base || "v"; |
||||
} |
||||
|
||||
export function varToken(name: string): string { |
||||
const n = |
||||
name.startsWith("?") || name.startsWith("$") ? name.slice(1) : name; |
||||
return `?${safeVarName(n)}`; |
||||
} |
||||
|
||||
export function formatLiteral(value: string | number | boolean): string { |
||||
if (typeof value === "number") return String(value); |
||||
if (typeof value === "boolean") return value ? "true" : "false"; |
||||
// default string literal
|
||||
const escaped = value.replace(/"/g, '\\"'); |
||||
return `"${escaped}"`; |
||||
} |
||||
|
||||
export function formatTermForValues(value: string | number | boolean): string { |
||||
if (typeof value === "number" || typeof value === "boolean") |
||||
return formatLiteral(value); |
||||
// strings: detect IRI or CURIE and keep raw; otherwise quote
|
||||
const v = value.trim(); |
||||
const looksLikeIri = v.startsWith("<") && v.endsWith(">"); |
||||
const looksLikeHttp = v.includes("://"); |
||||
const looksLikeCurie = |
||||
/^[A-Za-z_][A-Za-z0-9_-]*:.+$/u.test(v) && !looksLikeHttp; |
||||
if (looksLikeIri || looksLikeHttp || looksLikeCurie) { |
||||
return looksLikeHttp ? `<${v}>` : v; |
||||
} |
||||
return formatLiteral(v); |
||||
} |
||||
|
||||
export function valuesBlock( |
||||
varName: string, |
||||
values: Array<string | number | boolean> |
||||
): string { |
||||
const rendered = values.map(formatTermForValues).join(" "); |
||||
return `VALUES ${varName} { ${rendered} }`; |
||||
} |
||||
|
||||
export interface BuildContext { |
||||
// Tracks used variable names to avoid collisions
|
||||
usedVars: Set<string>; |
||||
} |
||||
|
||||
export function uniqueVar(ctx: BuildContext, base: string): string { |
||||
let candidate = varToken(base); |
||||
if (!ctx.usedVars.has(candidate)) { |
||||
ctx.usedVars.add(candidate); |
||||
return candidate; |
||||
} |
||||
let i = 2; |
||||
while (ctx.usedVars.has(`${candidate}_${i}`)) i++; |
||||
const unique = `${candidate}_${i}`; |
||||
ctx.usedVars.add(unique); |
||||
return unique; |
||||
} |
||||
|
||||
export default buildConstructQuery; |
@ -1,140 +0,0 @@ |
||||
import type { Predicate, Shape, Schema } from "@nextgraph-monorepo/ng-shex-orm"; |
||||
|
||||
export const buildConstructQuery = ({ |
||||
schema, |
||||
shapeId, |
||||
}: { |
||||
schema: Schema; |
||||
shapeId: keyof Schema; |
||||
}): string => { |
||||
const rootShape = schema[shapeId]; |
||||
|
||||
const constructStatements: { |
||||
s: string; |
||||
p: string; |
||||
o: string; |
||||
optional: boolean; |
||||
literals: Predicate["literalValue"]; |
||||
}[] = []; |
||||
|
||||
const idToVarName: Record<string, string> = {}; |
||||
const getVarNameFor = (id: string) => { |
||||
const currentName = idToVarName[id]; |
||||
if (currentName) return currentName; |
||||
|
||||
const newVar = `o${Object.entries(idToVarName).length + 1}`; |
||||
idToVarName[id] = newVar; |
||||
return newVar; |
||||
}; |
||||
|
||||
// Create s,p,o records where subject and object var names are mapped to shape or predicate ids.
|
||||
const addTriples = (shape: Shape) => { |
||||
const predicates = shape.predicates; |
||||
const shapeId = shape.iri; |
||||
|
||||
for (const pred of predicates) { |
||||
const subjectVarName = getVarNameFor(shapeId); |
||||
|
||||
if (pred.dataTypes === "nested") { |
||||
if (typeof pred.nestedShape !== "string") |
||||
throw new Error("Nested shapes must be by reference"); |
||||
|
||||
// If a name for this shape was assigned already, it's triples have been added
|
||||
// and we don't have to recurse.
|
||||
const shapeAlreadyRegistered = !!idToVarName[pred.nestedShape]; |
||||
|
||||
const shapeVarName = getVarNameFor(pred.nestedShape); |
||||
|
||||
constructStatements.push({ |
||||
s: `?${subjectVarName}`, |
||||
p: `<${pred.iri}>`, |
||||
o: `?${shapeVarName}`, |
||||
optional: pred.minCardinality < 1, |
||||
literals: pred.literalValue, |
||||
// TODO: eitherOf ?
|
||||
}); |
||||
|
||||
if (!shapeAlreadyRegistered) |
||||
addTriples(schema[pred.nestedShape]); |
||||
} else { |
||||
const objVarName = getVarNameFor( |
||||
shapeId + "__separator__" + pred.iri |
||||
); |
||||
|
||||
constructStatements.push({ |
||||
s: `?${subjectVarName}`, |
||||
p: `<${pred.iri}>`, |
||||
o: `?${objVarName}`, |
||||
optional: pred.minCardinality < 1, |
||||
literals: pred.literalValue, |
||||
// TODO: eitherOf ?
|
||||
}); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
addTriples(rootShape); |
||||
|
||||
const construct = `CONSTRUCT {
|
||||
${constructStatements.map(({ s, p, o }) => ` ${s} ${p} ${o} .\n`).join("")} }`;
|
||||
|
||||
const statementToWhere = ({ |
||||
s, |
||||
p, |
||||
o, |
||||
optional, |
||||
}: { |
||||
s: string; |
||||
p: string; |
||||
o: string; |
||||
optional: boolean; |
||||
}) => { |
||||
if (optional) return ` OPTIONAL { ${s} ${p} ${o} . }\n`; |
||||
else return ` ${s} ${p} ${o} .\n`; |
||||
}; |
||||
|
||||
const literalToSparqlFormat = ( |
||||
literal: string | number | boolean |
||||
): string => { |
||||
if (typeof literal === "number") return String(literal); |
||||
if (typeof literal === "boolean") return literal ? "true" : "false"; |
||||
if (typeof literal === "string") { |
||||
return isIri(literal) |
||||
? `<${literal}>` |
||||
: `"${escapeString(literal)}"`; |
||||
} |
||||
return `"${String(literal)}"`; |
||||
}; |
||||
|
||||
// Filters for optional values.
|
||||
const filters = constructStatements |
||||
.filter((statement) => statement.literals !== undefined) |
||||
.map((statement) => { |
||||
const vals = arrayOf(statement.literals!); |
||||
if (vals.length === 0) return ""; |
||||
if (vals.length === 1) { |
||||
return ` FILTER(${statement.o} = ${literalToSparqlFormat(vals[0]!)})\n`; |
||||
} |
||||
const list = vals.map(literalToSparqlFormat).join(", "); |
||||
return ` FILTER(${statement.o} IN (${list}))\n`; |
||||
}) |
||||
.join(""); |
||||
|
||||
const where = `WHERE {
|
||||
${constructStatements.map(statementToWhere).join("")} |
||||
${filters} |
||||
}`;
|
||||
|
||||
return `${construct}\n${where}`; |
||||
}; |
||||
|
||||
const arrayOf = <T extends any>(arrayOrLiteral: T | T[]) => { |
||||
if (typeof arrayOrLiteral === "undefined" || arrayOrLiteral === null) |
||||
return []; |
||||
if (Array.isArray(arrayOrLiteral)) return arrayOrLiteral; |
||||
return [arrayOrLiteral]; |
||||
}; |
||||
|
||||
const isIri = (str: string) => /^[a-zA-Z][a-zA-Z0-9+.-]{1,7}:/.test(str); |
||||
|
||||
const escapeString = (str: string) => str.replace(/["\\]/g, "\\$&"); |
@ -1,9 +0,0 @@ |
||||
import { buildConstructQuery } from "./buildSparqlConstructFromShape.ts"; |
||||
import { testShapeSchema } from "./testShape.schema.ts"; |
||||
|
||||
console.log( |
||||
buildConstructQuery({ |
||||
schema: testShapeSchema, |
||||
shapeId: "http://example.org/TestObject", |
||||
}) |
||||
); |
@ -1,129 +0,0 @@ |
||||
import type { Schema } from "@nextgraph-monorepo/ng-shex-orm"; |
||||
|
||||
/** |
||||
* ============================================================================= |
||||
* testShapeSchema: Schema for testShape |
||||
* ============================================================================= |
||||
*/ |
||||
export const testShapeSchema: Schema = { |
||||
"http://example.org/TestObject": { |
||||
iri: "http://example.org/TestObject", |
||||
predicates: [ |
||||
{ |
||||
dataTypes: "literal", |
||||
literalValue: ["TestObject"], |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", |
||||
readablePredicate: "type", |
||||
extra: true, |
||||
}, |
||||
{ |
||||
dataTypes: "string", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
iri: "http://example.org/stringValue", |
||||
readablePredicate: "stringValue", |
||||
}, |
||||
{ |
||||
dataTypes: "number", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
iri: "http://example.org/numValue", |
||||
readablePredicate: "numValue", |
||||
}, |
||||
{ |
||||
dataTypes: "boolean", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
iri: "http://example.org/boolValue", |
||||
readablePredicate: "boolValue", |
||||
}, |
||||
{ |
||||
dataTypes: "number", |
||||
maxCardinality: -1, |
||||
minCardinality: 0, |
||||
iri: "http://example.org/arrayValue", |
||||
readablePredicate: "arrayValue", |
||||
}, |
||||
{ |
||||
dataTypes: "nested", |
||||
nestedShape: |
||||
"http://example.org/TestObject||http://example.org/objectValue", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
iri: "http://example.org/objectValue", |
||||
readablePredicate: "objectValue", |
||||
}, |
||||
{ |
||||
dataTypes: "nested", |
||||
nestedShape: |
||||
"http://example.org/TestObject||http://example.org/anotherObject", |
||||
maxCardinality: -1, |
||||
minCardinality: 0, |
||||
iri: "http://example.org/anotherObject", |
||||
readablePredicate: "anotherObject", |
||||
}, |
||||
{ |
||||
dataTypes: "eitherOf", |
||||
eitherOf: [ |
||||
{ |
||||
valType: "string", |
||||
}, |
||||
{ |
||||
valType: "number", |
||||
}, |
||||
], |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
iri: "http://example.org/numOrStr", |
||||
readablePredicate: "numOrStr", |
||||
}, |
||||
], |
||||
}, |
||||
"http://example.org/TestObject||http://example.org/objectValue": { |
||||
iri: "http://example.org/TestObject||http://example.org/objectValue", |
||||
predicates: [ |
||||
{ |
||||
dataTypes: "string", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
iri: "http://example.org/nestedString", |
||||
readablePredicate: "nestedString", |
||||
}, |
||||
{ |
||||
dataTypes: "number", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
iri: "http://example.org/nestedNum", |
||||
readablePredicate: "nestedNum", |
||||
}, |
||||
{ |
||||
dataTypes: "number", |
||||
maxCardinality: -1, |
||||
minCardinality: 0, |
||||
iri: "http://example.org/nestedArray", |
||||
readablePredicate: "nestedArray", |
||||
}, |
||||
], |
||||
}, |
||||
"http://example.org/TestObject||http://example.org/anotherObject": { |
||||
iri: "http://example.org/TestObject||http://example.org/anotherObject", |
||||
predicates: [ |
||||
{ |
||||
dataTypes: "string", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
iri: "http://example.org/prop1", |
||||
readablePredicate: "prop1", |
||||
}, |
||||
{ |
||||
dataTypes: "number", |
||||
maxCardinality: 1, |
||||
minCardinality: 1, |
||||
iri: "http://example.org/prop2", |
||||
readablePredicate: "prop2", |
||||
}, |
||||
], |
||||
}, |
||||
}; |
@ -1,18 +0,0 @@ |
||||
import type { ShapeType, BaseType } from "@nextgraph-monorepo/ng-shex-orm"; |
||||
import type { Patch } from "@nextgraph-monorepo/ng-signals"; |
||||
|
||||
/** The Scope of a shape request */ |
||||
export type Scope = string | string[]; |
||||
|
||||
/** The diff format used to communicate updates between wasm-land and js-land. */ |
||||
export type Diff = Patch[]; |
||||
|
||||
export type ObjectState = object; |
||||
|
||||
/** A connection established between wasm-land and js-land for subscription of a shape. */ |
||||
export type WasmConnection<T extends BaseType = BaseType> = { |
||||
id: string; |
||||
shape: ShapeType<T>; |
||||
state: ObjectState; |
||||
callback: (diff: Diff, connectionId: WasmConnection["id"]) => void; |
||||
}; |
@ -1,22 +0,0 @@ |
||||
import * as shapeManager from "./shapeManager"; |
||||
import type { WasmConnection, Diff } from "./types"; |
||||
|
||||
export default async function updateShape( |
||||
connectionId: WasmConnection["id"], |
||||
diff: Diff, |
||||
) { |
||||
const connection = shapeManager.connections.get(connectionId); |
||||
if (!connection) throw new Error("No Connection found."); |
||||
|
||||
console.log("BACKEND: Received update request from ", connectionId); |
||||
|
||||
const newState = shapeManager.applyDiff(connection.state, diff); |
||||
connection.state = newState; |
||||
|
||||
shapeManager.connections.forEach((con) => { |
||||
// if (con.shape == connection.shape) {
|
||||
// con.state = newState;
|
||||
// con.callback(diff, con.id);
|
||||
// }
|
||||
}); |
||||
} |
@ -0,0 +1,235 @@ |
||||
import { describe, test, expect } from "vitest"; |
||||
import { applyDiff, Patch } from "../index.ts"; |
||||
|
||||
/** |
||||
* Build a patch path string from segments (auto-prefix /) |
||||
*/ |
||||
function p(...segs: (string | number)[]) { |
||||
return "/" + segs.map(String).join("/"); |
||||
} |
||||
|
||||
describe("applyDiff - set operations (primitives)", () => { |
||||
test("add single primitive into new set", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", valType: "set", path: p("tags"), value: "a" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.tags).toBeInstanceOf(Set); |
||||
expect([...state.tags]).toEqual(["a"]); |
||||
}); |
||||
test("add multiple primitives into new set", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", valType: "set", path: p("nums"), value: [1, 2, 3] }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect([...state.nums]).toEqual([1, 2, 3]); |
||||
}); |
||||
test("add primitives merging into existing set", () => { |
||||
const state: any = { nums: new Set([1]) }; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", valType: "set", path: p("nums"), value: [2, 3] }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect([...state.nums].sort()).toEqual([1, 2, 3]); |
||||
}); |
||||
test("remove single primitive from set", () => { |
||||
const state: any = { tags: new Set(["a", "b"]) }; |
||||
const diff: Patch[] = [ |
||||
{ op: "remove", valType: "set", path: p("tags"), value: "a" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect([...state.tags]).toEqual(["b"]); |
||||
}); |
||||
test("remove multiple primitives from set", () => { |
||||
const state: any = { nums: new Set([1, 2, 3, 4]) }; |
||||
const diff: Patch[] = [ |
||||
{ op: "remove", valType: "set", path: p("nums"), value: [2, 4] }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect([...state.nums].sort()).toEqual([1, 3]); |
||||
}); |
||||
}); |
||||
|
||||
describe("applyDiff - set operations (object sets)", () => { |
||||
test("add object entries to new object-set", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ |
||||
op: "add", |
||||
valType: "set", |
||||
path: p("users"), |
||||
value: { u1: { id: "u1", n: 1 }, u2: { id: "u2", n: 2 } }, |
||||
}, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.users.u1).toEqual({ id: "u1", n: 1 }); |
||||
expect(state.users.u2).toEqual({ id: "u2", n: 2 }); |
||||
}); |
||||
test("merge object entries into existing object-set", () => { |
||||
const state: any = { users: { u1: { id: "u1", n: 1 } } }; |
||||
const diff: Patch[] = [ |
||||
{ |
||||
op: "add", |
||||
valType: "set", |
||||
path: p("users"), |
||||
value: { u2: { id: "u2", n: 2 } }, |
||||
}, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); |
||||
}); |
||||
test("remove object entries from object-set", () => { |
||||
const state: any = { users: { u1: {}, u2: {}, u3: {} } }; |
||||
const diff: Patch[] = [ |
||||
{ |
||||
op: "remove", |
||||
valType: "set", |
||||
path: p("users"), |
||||
value: ["u1", "u3"], |
||||
}, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(Object.keys(state.users)).toEqual(["u2"]); |
||||
}); |
||||
test("adding primitives to existing object-set replaces with Set", () => { |
||||
const state: any = { mixed: { a: {}, b: {} } }; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", valType: "set", path: p("mixed"), value: [1, 2] }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.mixed).toBeInstanceOf(Set); |
||||
expect([...state.mixed]).toEqual([1, 2]); |
||||
}); |
||||
}); |
||||
|
||||
describe("applyDiff - object & literal operations", () => { |
||||
test("add object (create empty object)", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: p("address"), valType: "object" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.address).toEqual({}); |
||||
}); |
||||
test("add nested object path with ensurePathExists", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: p("a", "b", "c"), valType: "object" }, |
||||
]; |
||||
applyDiff(state, diff, true); |
||||
expect(state.a.b.c).toEqual({}); |
||||
}); |
||||
test("add primitive value", () => { |
||||
const state: any = { address: {} }; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: p("address", "street"), value: "1st" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.address.street).toBe("1st"); |
||||
}); |
||||
test("overwrite primitive value", () => { |
||||
const state: any = { address: { street: "old" } }; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: p("address", "street"), value: "new" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state.address.street).toBe("new"); |
||||
}); |
||||
test("remove primitive", () => { |
||||
const state: any = { address: { street: "1st", country: "Greece" } }; |
||||
const diff: Patch[] = [{ op: "remove", path: p("address", "street") }]; |
||||
applyDiff(state, diff); |
||||
expect(state.address.street).toBeUndefined(); |
||||
expect(state.address.country).toBe("Greece"); |
||||
}); |
||||
test("remove object branch", () => { |
||||
const state: any = { address: { street: "1st" }, other: 1 }; |
||||
const diff: Patch[] = [{ op: "remove", path: p("address") }]; |
||||
applyDiff(state, diff); |
||||
expect(state.address).toBeUndefined(); |
||||
expect(state.other).toBe(1); |
||||
}); |
||||
}); |
||||
|
||||
describe("applyDiff - multiple mixed patches in a single diff", () => { |
||||
test("sequence of mixed set/object/literal add & remove", () => { |
||||
const state: any = { |
||||
users: { u1: { id: "u1" } }, |
||||
tags: new Set(["old"]), |
||||
}; |
||||
const diff: Patch[] = [ |
||||
{ |
||||
op: "add", |
||||
valType: "set", |
||||
path: p("users"), |
||||
value: { u2: { id: "u2" } }, |
||||
}, |
||||
{ op: "add", path: p("profile"), valType: "object" }, |
||||
{ op: "add", path: p("profile", "name"), value: "Alice" }, |
||||
{ op: "add", valType: "set", path: p("tags"), value: ["new"] }, |
||||
{ op: "remove", valType: "set", path: p("tags"), value: "old" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); |
||||
expect(state.profile.name).toBe("Alice"); |
||||
expect([...state.tags]).toEqual(["new"]); |
||||
}); |
||||
|
||||
test("complex nested path creation and mutations with ensurePathExists", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: p("a", "b"), valType: "object" }, |
||||
{ op: "add", path: p("a", "b", "c"), value: 1 }, |
||||
{ |
||||
op: "add", |
||||
valType: "set", |
||||
path: p("a", "nums"), |
||||
value: [1, 2, 3], |
||||
}, |
||||
{ op: "remove", valType: "set", path: p("a", "nums"), value: 2 }, |
||||
{ op: "add", path: p("a", "b", "d"), value: 2 }, |
||||
{ op: "remove", path: p("a", "b", "c") }, |
||||
]; |
||||
applyDiff(state, diff, true); |
||||
expect(state.a.b.c).toBeUndefined(); |
||||
expect(state.a.b.d).toBe(2); |
||||
expect(state.a.nums).toBeInstanceOf(Set); |
||||
expect([...state.a.nums].sort()).toEqual([1, 3]); |
||||
}); |
||||
}); |
||||
|
||||
describe("applyDiff - ignored / invalid scenarios", () => { |
||||
test("skip patch with non-leading slash path", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: "address/street", value: "x" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state).toEqual({}); |
||||
}); |
||||
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; |
||||
applyDiff(state, diff, false); |
||||
expect(state).toEqual({}); |
||||
}); |
||||
}); |
||||
|
||||
describe("applyDiff - ignored / invalid scenarios", () => { |
||||
test("skip patch with non-leading slash path", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [ |
||||
{ op: "add", path: "address/street", value: "x" }, |
||||
]; |
||||
applyDiff(state, diff); |
||||
expect(state).toEqual({}); |
||||
}); |
||||
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { |
||||
const state: any = {}; |
||||
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; |
||||
applyDiff(state, diff, false); |
||||
expect(state).toEqual({}); |
||||
}); |
||||
}); |
Loading…
Reference in new issue