Skip to content

Commit 7b01ed3

Browse files
committed
Auto-inversing and no flatten node reference
1 parent ce6f657 commit 7b01ed3

File tree

6 files changed

+201
-44
lines changed

6 files changed

+201
-44
lines changed

packages/core-graph/src/private/SlantGraph.spec.ts

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ scenario('SlantGraph auto-inversing', bdd => {
159159
},
160160
{
161161
'@id': '_:m1',
162-
'@type': 'Message',
162+
'@type': 'MessageLike',
163163
text: 'Hello, World!'
164164
}
165165
)
@@ -177,7 +177,7 @@ scenario('SlantGraph auto-inversing', bdd => {
177177
},
178178
'_:m1': {
179179
'@id': '_:m1',
180-
'@type': ['Message'],
180+
'@type': ['MessageLike'],
181181
isPartOf: [{ '@id': '_:c1' }],
182182
text: ['Hello, World!']
183183
}
@@ -198,7 +198,7 @@ scenario('SlantGraph auto-inversing', bdd => {
198198
},
199199
{
200200
'@id': '_:m1',
201-
'@type': 'Message',
201+
'@type': 'MessageLike',
202202
isPartOf: [{ '@id': '_:c1' }],
203203
text: 'Hello, World!'
204204
}
@@ -217,7 +217,7 @@ scenario('SlantGraph auto-inversing', bdd => {
217217
},
218218
'_:m1': {
219219
'@id': '_:m1',
220-
'@type': ['Message'],
220+
'@type': ['MessageLike'],
221221
isPartOf: [{ '@id': '_:c1' }],
222222
text: ['Hello, World!']
223223
}
@@ -226,28 +226,3 @@ scenario('SlantGraph auto-inversing', bdd => {
226226
)
227227
);
228228
});
229-
230-
scenario('SlantGraph empty node', bdd => {
231-
bdd
232-
.given('a SlantGraph', () => new SlantGraph())
233-
.when('upserting a node with @id and @type only', graph =>
234-
graph.act(graph =>
235-
graph.upsert({
236-
'@id': '_:c1',
237-
'@type': 'Conversation'
238-
})
239-
)
240-
)
241-
.then('should be upserted', graph =>
242-
expect(graph.getState()).toEqual(
243-
new Map(
244-
Object.entries({
245-
'_:c1': {
246-
'@id': '_:c1',
247-
'@type': ['Conversation']
248-
}
249-
})
250-
)
251-
)
252-
);
253-
});

packages/core-graph/src/private/SlantGraph.ts

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { assert } from 'valibot';
1+
import { array, assert, fallback, parse } from 'valibot';
22
import Graph, { type GraphMiddleware } from './Graph2';
33
import colorNode, { SlantNodeSchema, type SlantNode } from './schemas/colorNode';
44
import flattenNodeObject from './schemas/flattenNodeObject';
55
import type { Identifier } from './schemas/Identifier';
66
import isOfType from './schemas/isOfType';
77
import { MessageNodeSchema } from './schemas/MessageNode';
8+
import { NodeReferenceSchema, type NodeReference } from './schemas/NodeReference';
89

910
type AnyNode = Record<string, unknown> & {
1011
readonly '@id': Identifier;
@@ -13,7 +14,7 @@ type AnyNode = Record<string, unknown> & {
1314

1415
const VALIDATION_SCHEMAS_BY_TYPE = new Map([['Message', MessageNodeSchema]]);
1516

16-
const color: GraphMiddleware<AnyNode, SlantNode> = () => () => upsertingNodeMap => {
17+
const color: GraphMiddleware<AnyNode, SlantNode> = () => next => upsertingNodeMap => {
1718
const nextUpsertingNodeMap = new Map<Identifier, SlantNode>();
1819

1920
for (const node of upsertingNodeMap.values()) {
@@ -22,10 +23,10 @@ const color: GraphMiddleware<AnyNode, SlantNode> = () => () => upsertingNodeMap
2223
}
2324
}
2425

25-
return nextUpsertingNodeMap;
26+
return next(nextUpsertingNodeMap);
2627
};
2728

28-
const validateSlantNode: GraphMiddleware<AnyNode, SlantNode> = () => () => upsertingNodeMap => {
29+
const validateSlantNode: GraphMiddleware<AnyNode, SlantNode> = () => next => upsertingNodeMap => {
2930
for (const node of upsertingNodeMap.values()) {
3031
assert(SlantNodeSchema, node);
3132

@@ -34,12 +35,160 @@ const validateSlantNode: GraphMiddleware<AnyNode, SlantNode> = () => () => upser
3435
}
3536
}
3637

37-
return upsertingNodeMap as ReadonlyMap<Identifier, SlantNode>;
38+
return next(upsertingNodeMap as ReadonlyMap<Identifier, SlantNode>);
3839
};
3940

41+
function nodeReferenceListToIdentifierSet(nodeReferences: readonly NodeReference[]): Set<Identifier> {
42+
return new Set(nodeReferences.map(ref => ref['@id']));
43+
}
44+
45+
function identifierSetToNodeReferenceList(identifierSet: ReadonlySet<Identifier>): readonly NodeReference[] {
46+
return Object.freeze(
47+
Array.from(identifierSet.values().map(identifier => parse(NodeReferenceSchema, { '@id': identifier })))
48+
);
49+
}
50+
51+
const NodeReferenceListSchema = fallback(array(NodeReferenceSchema), []);
52+
53+
// eslint-disable-next-line complexity
54+
function setTriplet(
55+
subject: SlantNode,
56+
linkType: 'hasPart' | 'isPartOf',
57+
object: SlantNode,
58+
operation: 'add' | 'delete'
59+
): readonly SlantNode[] {
60+
const objectId = object['@id'];
61+
const subjectId = subject['@id'];
62+
63+
let nextObject: SlantNode | undefined;
64+
let nextSubject: SlantNode | undefined;
65+
66+
const objectHasPart = nodeReferenceListToIdentifierSet(object.hasPart || []);
67+
const objectIsPartOf = nodeReferenceListToIdentifierSet(object.isPartOf || []);
68+
const subjectHasPart = nodeReferenceListToIdentifierSet(subject.hasPart || []);
69+
const subjectIsPartOf = nodeReferenceListToIdentifierSet(subject.isPartOf || []);
70+
71+
if (linkType === 'hasPart') {
72+
if (operation === 'add') {
73+
if (!subjectHasPart.has(objectId)) {
74+
subjectHasPart.add(objectId);
75+
nextSubject = { ...subject, hasPart: identifierSetToNodeReferenceList(subjectHasPart) };
76+
}
77+
78+
if (!objectIsPartOf.has(subjectId)) {
79+
objectIsPartOf.add(subjectId);
80+
nextObject = { ...object, isPartOf: identifierSetToNodeReferenceList(objectIsPartOf) };
81+
}
82+
} else if (operation === 'delete') {
83+
if (subjectHasPart.has(objectId)) {
84+
subjectHasPart.delete(objectId);
85+
nextSubject = { ...subject, hasPart: identifierSetToNodeReferenceList(subjectHasPart) };
86+
}
87+
88+
if (objectIsPartOf.has(subjectId)) {
89+
objectIsPartOf.delete(subjectId);
90+
nextObject = { ...object, isPartOf: identifierSetToNodeReferenceList(objectIsPartOf) };
91+
}
92+
} else {
93+
operation satisfies never;
94+
}
95+
} else if (linkType === 'isPartOf') {
96+
if (operation === 'add') {
97+
if (!subjectIsPartOf.has(objectId)) {
98+
subjectIsPartOf.add(objectId);
99+
nextSubject = { ...subject, isPartOf: identifierSetToNodeReferenceList(subjectIsPartOf) };
100+
}
101+
102+
if (!objectHasPart.has(subjectId)) {
103+
objectHasPart.add(subjectId);
104+
nextObject = { ...object, hasPart: identifierSetToNodeReferenceList(objectHasPart) };
105+
}
106+
} else if (operation === 'delete') {
107+
if (subjectIsPartOf.has(objectId)) {
108+
subjectIsPartOf.delete(objectId);
109+
nextSubject = { ...subject, isPartOf: identifierSetToNodeReferenceList(subjectIsPartOf) };
110+
}
111+
112+
if (objectHasPart.has(subjectId)) {
113+
objectHasPart.delete(subjectId);
114+
nextObject = { ...object, hasPart: identifierSetToNodeReferenceList(objectHasPart) };
115+
}
116+
} else {
117+
operation satisfies never;
118+
}
119+
} else {
120+
linkType satisfies never;
121+
}
122+
123+
return Object.freeze([nextObject, nextSubject].filter(Boolean as unknown as (node: unknown) => node is SlantNode));
124+
}
125+
126+
// TODO: [P*] Review this auto-inversing middleware.
127+
const autoInversion: GraphMiddleware<AnyNode, SlantNode> =
128+
({ getState }) =>
129+
() =>
130+
upsertingNodeMap => {
131+
const state = getState();
132+
const nextUpsertingNodeMap = new Map<Identifier, SlantNode>(upsertingNodeMap as any);
133+
134+
function addToResult(...nodes: readonly SlantNode[]) {
135+
for (const node of nodes) {
136+
nextUpsertingNodeMap.set(node['@id'], node);
137+
}
138+
}
139+
140+
function getDirty(id: Identifier) {
141+
const node = (nextUpsertingNodeMap.get(id) as SlantNode | undefined) ?? state.get(id);
142+
143+
if (!node) {
144+
throw new Error(`Cannot find node with @id "${id}"`);
145+
}
146+
147+
return node;
148+
}
149+
150+
for (const [_key, node] of upsertingNodeMap) {
151+
const id = node['@id'];
152+
153+
const existingNode = getDirty(id);
154+
155+
// Remove hasPart/isPartOf if the existing node does not match the upserted node.
156+
if (existingNode) {
157+
const removedHasPartIdSet = nodeReferenceListToIdentifierSet(existingNode.hasPart ?? []).difference(
158+
nodeReferenceListToIdentifierSet(parse(NodeReferenceListSchema, node['hasPart']))
159+
);
160+
161+
for (const removedHasPartId of removedHasPartIdSet) {
162+
addToResult(...setTriplet(existingNode, 'hasPart', getDirty(removedHasPartId)!, 'delete'));
163+
}
164+
165+
const removedIsPartOfIdSet = nodeReferenceListToIdentifierSet(existingNode.isPartOf ?? []).difference(
166+
nodeReferenceListToIdentifierSet(parse(NodeReferenceListSchema, node['isPartOf']))
167+
);
168+
169+
for (const removedIsPartOfId of removedIsPartOfIdSet) {
170+
addToResult(...setTriplet(existingNode, 'isPartOf', getDirty(removedIsPartOfId)!, 'delete'));
171+
}
172+
}
173+
}
174+
175+
// Add hasPart/isPartOf.
176+
for (const [_, node] of upsertingNodeMap) {
177+
for (const { '@id': childId } of parse(NodeReferenceListSchema, node['hasPart'])) {
178+
addToResult(...setTriplet(node as SlantNode, 'hasPart', getDirty(childId), 'add'));
179+
}
180+
181+
for (const { '@id': parentId } of parse(NodeReferenceListSchema, node['isPartOf'])) {
182+
addToResult(...setTriplet(node as SlantNode, 'isPartOf', getDirty(parentId), 'add'));
183+
}
184+
}
185+
186+
return nextUpsertingNodeMap;
187+
};
188+
40189
class SlantGraph extends Graph<AnyNode, SlantNode> {
41190
constructor() {
42-
super(color, validateSlantNode);
191+
super(color, validateSlantNode, autoInversion);
43192
}
44193
}
45194

packages/core-graph/src/private/schemas/NodeReference.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { array, minLength, optional, pipe, safeParse, strictObject, string, union, type InferOutput } from 'valibot';
1+
import { array, is, minLength, optional, pipe, strictObject, string, union, type InferOutput } from 'valibot';
22

33
import { IdentifierSchema } from './Identifier';
44
import freeze from './private/freeze';
@@ -26,8 +26,6 @@ const NodeReferenceSchema = pipe(
2626

2727
type NodeReference = InferOutput<typeof NodeReferenceSchema>;
2828

29-
function isNodeReference(nodeObject: NodeReference): nodeObject is NodeReference {
30-
return safeParse(NodeReferenceSchema, nodeObject).success;
31-
}
29+
const isNodeReference = is.bind(undefined, NodeReferenceSchema);
3230

3331
export { isNodeReference, NodeReferenceSchema, type NodeReference };

packages/core-graph/src/private/schemas/colorNode.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ const SlantNodeWithFixSchema = pipe(
141141
* - `@value` could be null, if unwrapped, will be confusing as we removed nulls
142142
* - Do not handle full JSON-LD spec: `@context` is an opaque string and its schema is not honored
143143
* - Node reference only has `@id` and it should not contain `@type`
144+
* - Reduce confusion: node reference must not appear at the root of the flattened graph
144145
* - Auto-linking for Schema.org: `hasPart` and `isPartOf` are auto-inversed
145146
* - Keep its root: every node is compliant to JSON-LD, understood by standard parsers
146147
* - Debuggability: must have at least one `@type`

packages/core-graph/src/private/schemas/flattenNodeObject.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,31 @@ scenario('flattenNodeObject()', bdd => {
195195
}).toThrow('Only literals, JSON literals, and plain object can be flattened');
196196
});
197197
});
198+
199+
scenario('Reduce confusion: node reference must not appear at the root of the flattened graph', bdd => {
200+
bdd
201+
.given(
202+
'a node with @id and @type only',
203+
() =>
204+
({
205+
'@id': '_:c1',
206+
'@type': 'Conversation'
207+
}) as const
208+
)
209+
.when('colored', node => {
210+
try {
211+
flattenNodeObject(node);
212+
} catch (error) {
213+
return error;
214+
}
215+
216+
return undefined;
217+
})
218+
.then('should throw', (_, error) => {
219+
expect(() => {
220+
if (error) {
221+
throw error;
222+
}
223+
}).toThrow('Node reference cannot be flattened');
224+
});
225+
});

packages/core-graph/src/private/schemas/flattenNodeObject.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
// TODO: [P0] This flattening can probably fold into `colorNode()` as it has slanted view of the system.
22

3-
import { check, object, optional, parse, pipe, safeParse } from 'valibot';
3+
import { assert, check, looseObject, object, optional, parse, pipe, safeParse } from 'valibot';
44

55
import { FlatNodeObjectSchema, type FlatNodeObject, type FlatNodeObjectPropertyValue } from './FlatNodeObject';
6-
import { IdentifierSchema } from './Identifier';
6+
import { IdentifierSchema, type Identifier } from './Identifier';
77
import { JSONLiteralSchema, type JSONLiteral } from './JSONLiteral';
88
import { LiteralSchema, type Literal } from './Literal';
9-
import { NodeReferenceSchema, type NodeReference } from './NodeReference';
9+
import { isNodeReference, NodeReferenceSchema, type NodeReference } from './NodeReference';
1010
import isPlainObject from './private/isPlainObject';
1111

1212
type FlattenNodeObjectInput = Literal | (object & { '@id'?: string });
@@ -155,9 +155,15 @@ type FlattenNodeObjectReturnValue = {
155155
* @returns {FlattenNodeObjectReturnValue} A graph and a node reference.
156156
*/
157157
function flattenNodeObject(input: FlattenNodeObjectInput): FlattenNodeObjectReturnValue {
158-
parse(object({}), input);
158+
assert(
159+
pipe(
160+
looseObject({}),
161+
check(value => !isNodeReference(value), 'Node reference cannot be flattened')
162+
),
163+
input
164+
);
159165

160-
const graph = new Map<string, FlatNodeObject>();
166+
const graph = new Map<Identifier, FlatNodeObject>();
161167
const refMap = new Map<object, NodeReference>();
162168
const output = flattenNodeObject_(input, graph, refMap);
163169

0 commit comments

Comments
 (0)