From 9bcfad02f9e31cf9623dee3a531b0ff5108ec257 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 3 Apr 2026 15:18:03 +0000 Subject: [PATCH 1/9] chore: sync package.json version to 2.4.0 from main --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a8ceabf..0c18a1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@_linked/core", - "version": "2.2.3", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@_linked/core", - "version": "2.2.3", + "version": "2.4.0", "license": "MIT", "dependencies": { "next-tick": "^1.1.0", diff --git a/package.json b/package.json index d411238..5007026 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@_linked/core", - "version": "2.2.3", + "version": "2.4.0", "license": "MIT", "description": "Linked.js core query and SHACL shape DSL (copy-then-prune baseline)", "repository": { From 1ae72991f3b3c0bb764c1dfd4a4a130c1e55815f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:08:37 +0000 Subject: [PATCH 2/9] Fix toJSON to serialize where clause, sort key, minus entries, and other missing fields QueryBuilder.toJSON() was silently dropping where clauses, sort keys, minus entries, nullSubject, and pendingContextName. This meant any query with .where() would lose its filter when round-tripping through JSON serialization. - Add QueryBuilderSerialization.ts with serialize/deserialize helpers for WherePath, SortByPath, RawMinusEntry, and QueryArg types - Update QueryBuilderJSON type with where, sortBy, minusEntries, nullSubject, and pendingContextName fields - Update toJSON() to evaluate callbacks and serialize all fields - Update fromJSON() to deserialize and restore all fields via pre-evaluated data (_where, _sortBy, _rawMinusEntries) - Update _buildDirectRawInput() to use pre-evaluated data as fallback when callbacks aren't available (i.e. after JSON deserialization) - Add 18 round-trip serialization tests confirming identical IR output https://claude.ai/code/session_01P6n2XLm5DSugoa8RWN97yy --- src/queries/QueryBuilder.ts | 146 ++++++++++- src/queries/QueryBuilderSerialization.ts | 298 +++++++++++++++++++++++ src/tests/serialization.test.ts | 239 +++++++++++++++++- 3 files changed, 671 insertions(+), 12 deletions(-) create mode 100644 src/queries/QueryBuilderSerialization.ts diff --git a/src/queries/QueryBuilder.ts b/src/queries/QueryBuilder.ts index c1adca4..916d2d4 100644 --- a/src/queries/QueryBuilder.ts +++ b/src/queries/QueryBuilder.ts @@ -12,7 +12,7 @@ import { evaluateSortCallback, } from './SelectQuery.js'; import type {SortByPath, WherePath} from './SelectQuery.js'; -import type {PropertyPathSegment, RawSelectInput} from './IRDesugar.js'; +import type {PropertyPathSegment, RawMinusEntry, RawSelectInput} from './IRDesugar.js'; import {buildSelectQuery} from './IRPipeline.js'; import {getQueryDispatch} from './queryDispatch.js'; import type {NodeReferenceValue} from './QueryFactory.js'; @@ -20,6 +20,17 @@ import {resolveUriOrThrow} from '../utils/NodeReference.js'; import {FieldSet, FieldSetJSON, FieldSetFieldJSON} from './FieldSet.js'; import {PendingQueryContext} from './QueryContext.js'; import {createProxiedPathBuilder} from './ProxiedPathBuilder.js'; +import { + serializeWherePath, + serializeSortByPath, + serializeRawMinusEntry, + deserializeWherePath, + deserializeSortByPath, + deserializeRawMinusEntry, + type WherePathJSON, + type SortByPathJSON, + type RawMinusEntryJSON, +} from './QueryBuilderSerialization.js'; /** JSON representation of a QueryBuilder. */ export type QueryBuilderJSON = { @@ -31,6 +42,11 @@ export type QueryBuilderJSON = { subjects?: string[]; singleResult?: boolean; orderDirection?: 'ASC' | 'DESC'; + where?: WherePathJSON; + sortBy?: SortByPathJSON; + minusEntries?: RawMinusEntryJSON[]; + nullSubject?: boolean; + pendingContextName?: string; }; /** A preload entry binding a property path to a component's query. */ @@ -63,6 +79,10 @@ interface QueryBuilderInit { minusEntries?: MinusEntry[]; _nullSubject?: boolean; _pendingContextName?: string; + // Pre-evaluated data (restored from JSON; used when callbacks are not available) + _where?: WherePath; + _sortBy?: SortByPath; + _rawMinusEntries?: RawMinusEntry[]; } /** @@ -97,6 +117,10 @@ export class QueryBuilder private readonly _minusEntries?: MinusEntry[]; private readonly _nullSubject?: boolean; private readonly _pendingContextName?: string; + // Pre-evaluated data (restored from JSON; used when callbacks are not available) + private readonly _where?: WherePath; + private readonly _sortBy?: SortByPath; + private readonly _rawMinusEntries?: RawMinusEntry[]; private constructor(init: QueryBuilderInit) { this._shape = init.shape; @@ -115,6 +139,9 @@ export class QueryBuilder this._minusEntries = init.minusEntries; this._nullSubject = init._nullSubject; this._pendingContextName = init._pendingContextName; + this._where = init._where; + this._sortBy = init._sortBy; + this._rawMinusEntries = init._rawMinusEntries; } /** Create a shallow clone with overrides. */ @@ -136,6 +163,9 @@ export class QueryBuilder minusEntries: this._minusEntries, _nullSubject: this._nullSubject, _pendingContextName: this._pendingContextName, + _where: this._where, + _sortBy: this._sortBy, + _rawMinusEntries: this._rawMinusEntries, ...overrides, }); } @@ -338,8 +368,8 @@ export class QueryBuilder * string[], selectAll, or callback). Callback-based selections are eagerly * evaluated through the proxy to produce a FieldSet. * - * The `where` and `orderBy` callbacks are not serialized (only the direction - * is preserved for orderBy). + * Where, orderBy, and minus clauses are evaluated through the proxy and + * serialized as plain data structures. */ toJSON(): QueryBuilderJSON { const shapeId = this._shape.shape?.id || ''; @@ -373,6 +403,63 @@ export class QueryBuilder json.orderDirection = this._sortDirection; } + // Serialize where clause (evaluate callback if needed) + if (this._whereFn) { + json.where = serializeWherePath(processWhereClause(this._whereFn, this._shape)); + } else if (this._where) { + json.where = serializeWherePath(this._where); + } + + // Serialize sort key (evaluate callback if needed) + if (this._sortByFn) { + json.sortBy = serializeSortByPath( + evaluateSortCallback(this._shape, this._sortByFn as unknown as (p: any) => any, this._sortDirection || 'ASC'), + ); + } else if (this._sortBy) { + json.sortBy = serializeSortByPath(this._sortBy); + } + + // Serialize minus entries (evaluate callbacks if needed) + if (this._minusEntries && this._minusEntries.length > 0) { + json.minusEntries = this._minusEntries.map((entry) => { + if (entry.shapeId) { + return {shapeId: entry.shapeId}; + } + if (entry.whereFn) { + const proxy = createProxiedPathBuilder(this._shape); + const result = (entry.whereFn as Function)(proxy); + + if (Array.isArray(result)) { + const propertyPaths = result.map((item: any) => { + const segments = FieldSet.collectPropertySegments(item); + return segments.map((seg) => seg.id); + }); + return {propertyPaths}; + } + + if (result && typeof result === 'object' && 'property' in result && 'subject' in result) { + const segments = FieldSet.collectPropertySegments(result); + return {propertyPaths: [segments.map((seg) => seg.id)]}; + } + + return {where: serializeWherePath(processWhereClause(entry.whereFn, this._shape))}; + } + return {}; + }); + } else if (this._rawMinusEntries && this._rawMinusEntries.length > 0) { + json.minusEntries = this._rawMinusEntries.map(serializeRawMinusEntry); + } + + // Serialize nullSubject flag + if (this._nullSubject) { + json.nullSubject = true; + } + + // Serialize pending context name + if (this._pendingContextName) { + json.pendingContextName = this._pendingContextName; + } + return json; } @@ -406,12 +493,43 @@ export class QueryBuilder if (json.singleResult && !json.subject) { builder = builder.one() as QueryBuilder; } - // Restore orderDirection. The sort key callback isn't serializable, - // so we only store the direction. When a sort key is later re-applied - // via .orderBy(), the direction will be available. - if (json.orderDirection) { - // Access private clone() — safe because fromJSON is in the same class. - builder = (builder as any).clone({sortDirection: json.orderDirection}) as QueryBuilder; + + // Restore pre-evaluated data via clone — safe because fromJSON is in the same class. + const overrides: Partial> = {}; + const nodeShape = builder._shape.shape; + + // Restore where clause + if (json.where && nodeShape) { + overrides._where = deserializeWherePath(nodeShape, json.where); + } + + // Restore sort key + direction + if (json.sortBy && nodeShape) { + overrides._sortBy = deserializeSortByPath(nodeShape, json.sortBy); + overrides.sortDirection = json.sortBy.direction; + } else if (json.orderDirection) { + overrides.sortDirection = json.orderDirection; + } + + // Restore minus entries + if (json.minusEntries && json.minusEntries.length > 0 && nodeShape) { + overrides._rawMinusEntries = json.minusEntries.map((e) => + deserializeRawMinusEntry(nodeShape, e), + ); + } + + // Restore nullSubject flag + if (json.nullSubject) { + overrides._nullSubject = true; + } + + // Restore pending context name + if (json.pendingContextName) { + overrides._pendingContextName = json.pendingContextName; + } + + if (Object.keys(overrides).length > 0) { + builder = (builder as any).clone(overrides) as QueryBuilder; } return builder; @@ -458,13 +576,15 @@ export class QueryBuilder const entries = fs ? fs.entries : []; - // Evaluate where callback + // Evaluate where callback (or use pre-evaluated data from JSON deserialization) let where: WherePath | undefined; if (this._whereFn) { where = processWhereClause(this._whereFn, this._shape); + } else if (this._where) { + where = this._where; } - // Evaluate sort callback + // Evaluate sort callback (or use pre-evaluated data from JSON deserialization) let sortBy: SortByPath | undefined; if (this._sortByFn) { sortBy = evaluateSortCallback( @@ -472,6 +592,8 @@ export class QueryBuilder this._sortByFn as unknown as (p: any) => any, this._sortDirection || 'ASC', ); + } else if (this._sortBy) { + sortBy = this._sortBy; } const input: RawSelectInput = { @@ -529,6 +651,8 @@ export class QueryBuilder } return {}; }); + } else if (this._rawMinusEntries && this._rawMinusEntries.length > 0) { + input.minusEntries = this._rawMinusEntries; } return input; diff --git a/src/queries/QueryBuilderSerialization.ts b/src/queries/QueryBuilderSerialization.ts new file mode 100644 index 0000000..ad4778f --- /dev/null +++ b/src/queries/QueryBuilderSerialization.ts @@ -0,0 +1,298 @@ +/** + * Serialization and deserialization helpers for QueryBuilder fields that contain + * live object references (PropertyShape, ExpressionNode, etc.). + * + * These convert between runtime WherePath / SortByPath / RawMinusEntry structures + * and plain JSON-safe representations that can round-trip through JSON.stringify/parse. + */ + +import type {NodeShape} from '../shapes/SHACL.js'; +import {getShapeClass} from '../utils/ShapeClass.js'; +import {PropertyPath, walkPropertyPath} from './PropertyPath.js'; +import {ExpressionNode} from '../expressions/ExpressionNode.js'; +import type { + WherePath, + WhereEvaluationPath, + WhereAndOr, + WhereExpressionPath, + QueryPropertyPath, + QueryStep, + PropertyQueryStep, + SizeStep, + QueryArg, + ArgPath, + SortByPath, + AndOrQueryToken, +} from './SelectQuery.js'; +import type {ShapeReferenceValue, NodeReferenceValue} from './QueryFactory.js'; +import type {IRExpression} from './IntermediateRepresentation.js'; +import type {RawMinusEntry, PropertyPathSegment} from './IRDesugar.js'; + +// ============================================================================= +// JSON types +// ============================================================================= + +export type QueryStepJSON = + | {kind: 'property'; label: string; where?: WherePathJSON} + | {kind: 'size'; count: QueryStepJSON[]; label?: string} + | {kind: 'shapeRef'; id: string; shapeId: string}; + +export type WherePathJSON = + | {kind: 'evaluation'; path: QueryStepJSON[]; method: string; args: QueryArgJSON[]} + | {kind: 'andOr'; firstPath: WherePathJSON; andOr: {and?: WherePathJSON; or?: WherePathJSON}[]} + | {kind: 'expression'; ir: IRExpression; refs?: Record}; + +export type QueryArgJSON = + | {kind: 'nodeRef'; id: string} + | {kind: 'primitive'; value: string | number | boolean} + | {kind: 'date'; value: string} + | {kind: 'argPath'; path: QueryStepJSON[]; subject: {id: string; shapeId: string}} + | {kind: 'where'; where: WherePathJSON}; + +export type SortByPathJSON = { + paths: string[]; + direction: 'ASC' | 'DESC'; +}; + +export type RawMinusEntryJSON = { + shapeId?: string; + where?: WherePathJSON; + propertyPaths?: string[][]; +}; + +// ============================================================================= +// Serialization +// ============================================================================= + +export function serializeQueryPropertyPath(path: QueryPropertyPath): QueryStepJSON[] { + return path.map(serializeQueryStep); +} + +function serializeQueryStep(step: QueryStep): QueryStepJSON { + // PropertyQueryStep — has a .property field that is a PropertyShape + if ('property' in step && (step as PropertyQueryStep).property) { + const pqs = step as PropertyQueryStep; + const json: {kind: 'property'; label: string; where?: WherePathJSON} = { + kind: 'property', + label: pqs.property.label, + }; + if (pqs.where) { + json.where = serializeWherePath(pqs.where); + } + return json; + } + // SizeStep — has a .count field + if ('count' in step) { + const ss = step as SizeStep; + const json: {kind: 'size'; count: QueryStepJSON[]; label?: string} = { + kind: 'size', + count: serializeQueryPropertyPath(ss.count), + }; + if (ss.label) json.label = ss.label; + return json; + } + // ShapeReferenceValue — has .id and .shape + const ref = step as ShapeReferenceValue; + return {kind: 'shapeRef', id: ref.id, shapeId: ref.shape.id}; +} + +export function serializeWherePath(where: WherePath): WherePathJSON { + // WhereExpressionPath + if ('expressionNode' in where) { + const expr = (where as WhereExpressionPath).expressionNode; + const json: {kind: 'expression'; ir: IRExpression; refs?: Record} = { + kind: 'expression', + ir: expr.ir, + }; + if (expr._refs.size > 0) { + const refs: Record = {}; + for (const [k, v] of expr._refs) refs[k] = [...v]; + json.refs = refs; + } + return json; + } + // WhereAndOr + if ('firstPath' in where) { + const andOr = where as WhereAndOr; + return { + kind: 'andOr', + firstPath: serializeWherePath(andOr.firstPath), + andOr: andOr.andOr.map((token) => { + const t: {and?: WherePathJSON; or?: WherePathJSON} = {}; + if (token.and) t.and = serializeWherePath(token.and); + if (token.or) t.or = serializeWherePath(token.or); + return t; + }), + }; + } + // WhereEvaluationPath + const ev = where as WhereEvaluationPath; + return { + kind: 'evaluation', + path: serializeQueryPropertyPath(ev.path), + method: ev.method, + args: ev.args.map(serializeQueryArg), + }; +} + +function serializeQueryArg(arg: QueryArg): QueryArgJSON { + if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') { + return {kind: 'primitive', value: arg}; + } + if (arg instanceof Date) { + return {kind: 'date', value: arg.toISOString()}; + } + if (typeof arg === 'object' && arg !== null) { + // WhereExpressionPath, WhereAndOr, or WhereEvaluationPath (nested WherePath) + if ('expressionNode' in arg || 'firstPath' in arg || ('path' in arg && 'method' in arg && 'args' in arg)) { + return {kind: 'where', where: serializeWherePath(arg as WherePath)}; + } + // ArgPath — has 'subject' and 'path' + if ('subject' in arg && 'path' in arg) { + const ap = arg as ArgPath; + return { + kind: 'argPath', + path: serializeQueryPropertyPath(ap.path), + subject: {id: ap.subject.id, shapeId: ap.subject.shape.id}, + }; + } + // NodeReferenceValue — has 'id' + if ('id' in arg) { + return {kind: 'nodeRef', id: (arg as NodeReferenceValue).id}; + } + } + throw new Error(`Cannot serialize query arg: ${JSON.stringify(arg)}`); +} + +export function serializeSortByPath(sort: SortByPath): SortByPathJSON { + return { + paths: sort.paths.map((p) => p.toString()), + direction: sort.direction, + }; +} + +export function serializeRawMinusEntry(entry: RawMinusEntry): RawMinusEntryJSON { + const json: RawMinusEntryJSON = {}; + if (entry.shapeId) json.shapeId = entry.shapeId; + if (entry.where) json.where = serializeWherePath(entry.where); + if (entry.propertyPaths) { + json.propertyPaths = entry.propertyPaths.map((pp) => + pp.map((seg) => seg.propertyShapeId), + ); + } + return json; +} + +// ============================================================================= +// Deserialization +// ============================================================================= + +export function deserializeQueryPropertyPath( + shape: NodeShape, + steps: QueryStepJSON[], +): QueryPropertyPath { + const result: QueryStep[] = []; + let currentShape = shape; + + for (const step of steps) { + if (step.kind === 'property') { + const propertyShape = currentShape.getPropertyShape(step.label); + if (!propertyShape) { + throw new Error( + `Property '${step.label}' not found on shape '${currentShape.label || currentShape.id}'`, + ); + } + const queryStep: PropertyQueryStep = {property: propertyShape}; + if (step.where) { + const nestedShape = propertyShape.valueShape + ? getShapeClass(propertyShape.valueShape)?.shape + : currentShape; + queryStep.where = deserializeWherePath(nestedShape || currentShape, step.where); + } + result.push(queryStep); + + // Advance shape context for subsequent traversals + if (propertyShape.valueShape) { + const cls = getShapeClass(propertyShape.valueShape); + if (cls?.shape) currentShape = cls.shape; + } + } else if (step.kind === 'size') { + result.push({ + count: deserializeQueryPropertyPath(currentShape, step.count), + label: step.label, + } as SizeStep); + } else { + // shapeRef + result.push({id: step.id, shape: {id: step.shapeId}} as ShapeReferenceValue); + } + } + + return result; +} + +export function deserializeWherePath(shape: NodeShape, json: WherePathJSON): WherePath { + if (json.kind === 'expression') { + const refs = new Map(); + if (json.refs) { + for (const [k, v] of Object.entries(json.refs)) refs.set(k, v); + } + return {expressionNode: new ExpressionNode(json.ir, refs)}; + } + if (json.kind === 'andOr') { + return { + firstPath: deserializeWherePath(shape, json.firstPath), + andOr: json.andOr.map((token): AndOrQueryToken => { + const t: AndOrQueryToken = {}; + if (token.and) t.and = deserializeWherePath(shape, token.and); + if (token.or) t.or = deserializeWherePath(shape, token.or); + return t; + }), + }; + } + // evaluation + return { + path: deserializeQueryPropertyPath(shape, json.path), + method: json.method as any, + args: json.args.map((a) => deserializeQueryArg(shape, a)), + }; +} + +function deserializeQueryArg(shape: NodeShape, json: QueryArgJSON): QueryArg { + switch (json.kind) { + case 'primitive': + return json.value; + case 'date': + return new Date(json.value); + case 'nodeRef': + return {id: json.id}; + case 'where': + return deserializeWherePath(shape, json.where); + case 'argPath': + return { + path: deserializeQueryPropertyPath(shape, json.path), + subject: {id: json.subject.id, shape: {id: json.subject.shapeId}}, + }; + } +} + +export function deserializeSortByPath(shape: NodeShape, json: SortByPathJSON): SortByPath { + return { + paths: json.paths.map((p) => walkPropertyPath(shape, p)), + direction: json.direction, + }; +} + +export function deserializeRawMinusEntry( + shape: NodeShape, + json: RawMinusEntryJSON, +): RawMinusEntry { + const entry: RawMinusEntry = {}; + if (json.shapeId) entry.shapeId = json.shapeId; + if (json.where) entry.where = deserializeWherePath(shape, json.where); + if (json.propertyPaths) { + entry.propertyPaths = json.propertyPaths.map((pp) => + pp.map((id): PropertyPathSegment => ({propertyShapeId: id})), + ); + } + return entry; +} diff --git a/src/tests/serialization.test.ts b/src/tests/serialization.test.ts index 977710f..ac079ed 100644 --- a/src/tests/serialization.test.ts +++ b/src/tests/serialization.test.ts @@ -1,5 +1,5 @@ import {describe, expect, test} from '@jest/globals'; -import {Person, tmpEntityBase} from '../test-helpers/query-fixtures'; +import {Person, Employee, tmpEntityBase} from '../test-helpers/query-fixtures'; import {sanitize} from '../test-helpers/test-utils'; import {FieldSet} from '../queries/FieldSet'; import {QueryBuilder} from '../queries/QueryBuilder'; @@ -234,3 +234,240 @@ describe('QueryBuilder — serialization', () => { expect(restoredJson.orderDirection).toBe('DESC'); }); }); + +// ============================================================================= +// Where clause serialization tests +// ============================================================================= + +describe('QueryBuilder — where clause serialization', () => { + test('toJSON — simple where equals', () => { + const json = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob')) + .toJSON(); + + expect(json.where).toBeDefined(); + expect(json.where!.kind).toBe('evaluation'); + if (json.where!.kind === 'evaluation') { + expect(json.where!.method).toBe('='); + expect(json.where!.path).toHaveLength(1); + expect((json.where!.path[0] as any).label).toBe('name'); + } + }); + + test('toJSON — where with nodeRef arg', () => { + const json = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.bestFriend.equals({id: `${tmpEntityBase}p1`})) + .toJSON(); + + expect(json.where).toBeDefined(); + expect(json.where!.kind).toBe('evaluation'); + if (json.where!.kind === 'evaluation') { + expect(json.where!.args).toHaveLength(1); + expect(json.where!.args[0].kind).toBe('nodeRef'); + } + }); + + test('toJSON — where with AND', () => { + const json = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob').and(p.hobby.equals('Chess'))) + .toJSON(); + + expect(json.where).toBeDefined(); + expect(json.where!.kind).toBe('andOr'); + }); + + test('round-trip — simple where equals produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob')); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — where with nodeRef produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.bestFriend.equals({id: `${tmpEntityBase}p1`})); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — where AND produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob').and(p.hobby.equals('Chess'))); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — nested where path produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.bestFriend.name.equals('Alice')); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — where + limit + one produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob')) + .one(); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); +}); + +// ============================================================================= +// Sort key serialization tests +// ============================================================================= + +describe('QueryBuilder — sort key serialization', () => { + test('toJSON — orderBy includes sort key path', () => { + const json = QueryBuilder.from(Person) + .select(['name']) + .orderBy((p) => p.name, 'DESC') + .toJSON(); + + expect(json.sortBy).toBeDefined(); + expect(json.sortBy!.paths).toContain('name'); + expect(json.sortBy!.direction).toBe('DESC'); + }); + + test('round-trip — orderBy produces same IR', () => { + const original = QueryBuilder.from(Person) + .select(['name', 'hobby']) + .orderBy((p) => p.name, 'ASC'); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); +}); + +// ============================================================================= +// Minus entry serialization tests +// ============================================================================= + +describe('QueryBuilder — minus entry serialization', () => { + test('toJSON — minus with shape type', () => { + const json = QueryBuilder.from(Person) + .select(['name']) + .minus(Employee) + .toJSON(); + + expect(json.minusEntries).toBeDefined(); + expect(json.minusEntries).toHaveLength(1); + expect(json.minusEntries![0].shapeId).toBeDefined(); + }); + + test('toJSON — minus with where callback', () => { + const json = QueryBuilder.from(Person) + .select(['name']) + .minus((p) => p.hobby.equals('Chess')) + .toJSON(); + + expect(json.minusEntries).toBeDefined(); + expect(json.minusEntries).toHaveLength(1); + expect(json.minusEntries![0].where).toBeDefined(); + }); + + test('round-trip — minus with shape type produces same IR', () => { + const original = QueryBuilder.from(Person) + .select(['name']) + .minus(Employee); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — minus with where produces same IR', () => { + const original = QueryBuilder.from(Person) + .select(['name']) + .minus((p) => p.hobby.equals('Chess')); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); +}); + +// ============================================================================= +// nullSubject and pendingContextName serialization tests +// ============================================================================= + +describe('QueryBuilder — nullSubject & pendingContextName serialization', () => { + test('toJSON — nullSubject preserved', () => { + const json = QueryBuilder.from(Person) + .select(['name']) + .for(null) + .toJSON(); + + expect(json.nullSubject).toBe(true); + }); + + test('fromJSON — nullSubject restored', () => { + const json: QueryBuilderJSON = { + shape: personShape.id, + fields: [{path: 'name'}], + nullSubject: true, + singleResult: true, + }; + const restored = QueryBuilder.fromJSON(json); + // The restored builder should have _nullSubject set + expect(restored.toJSON().nullSubject).toBe(true); + }); +}); + +// ============================================================================= +// Combined round-trip: complex queries +// ============================================================================= + +describe('QueryBuilder — complex round-trip', () => { + test('round-trip — where + subject + limit + fields', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name, p.hobby]) + .where((p) => p.name.equals('Bob')) + .for({id: `${tmpEntityBase}p1`}); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — JSON string serialization', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .where((p) => p.name.equals('Bob')) + .limit(10); + + const jsonString = JSON.stringify(original.toJSON()); + const parsed = JSON.parse(jsonString); + const restored = QueryBuilder.fromJSON(parsed); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); +}); From d323985380d744b0bb04b7d9d702fab8ef3ed2f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:13:29 +0000 Subject: [PATCH 3/9] Add preload serialization to toJSON by merging into FieldSet as subSelect Preloads were the last remaining field not serialized in toJSON(). Fixed by: - Merging preload entries into the FieldSet at serialization time (same merge logic as _buildDirectRawInput), so preloaded component fields appear as subSelect entries in the JSON - Adding preloadSubSelect handling in FieldSet.toJSON() so it serializes identically to subSelect (both produce the same IR) - Adding 3 round-trip tests confirming preload serialization https://claude.ai/code/session_01P6n2XLm5DSugoa8RWN97yy --- src/queries/FieldSet.ts | 3 +++ src/queries/QueryBuilder.ts | 23 +++++++++++++++- src/tests/serialization.test.ts | 47 +++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/queries/FieldSet.ts b/src/queries/FieldSet.ts index 090af34..3cca3af 100644 --- a/src/queries/FieldSet.ts +++ b/src/queries/FieldSet.ts @@ -367,6 +367,9 @@ export class FieldSet { } if (entry.subSelect) { field.subSelect = entry.subSelect.toJSON(); + } else if (entry.preloadSubSelect) { + // Preloads produce identical IR to subSelect — serialize as subSelect. + field.subSelect = entry.preloadSubSelect.toJSON(); } if (entry.aggregation) { field.aggregation = entry.aggregation; diff --git a/src/queries/QueryBuilder.ts b/src/queries/QueryBuilder.ts index 916d2d4..6ebab83 100644 --- a/src/queries/QueryBuilder.ts +++ b/src/queries/QueryBuilder.ts @@ -379,7 +379,28 @@ export class QueryBuilder // Serialize fields — fields() already handles _selectAllLabels, so // no separate branch is needed (T1: dead else-if removed). - const fs = this.fields(); + // When preloads exist, merge them into the FieldSet before serializing + // (same merge logic as _buildDirectRawInput). Preloads become subSelect + // entries in the JSON, producing identical IR on deserialization. + let fs = this.fields(); + if (this._preloads && this._preloads.length > 0) { + const preloadFn = (p: any) => { + const results: any[] = []; + for (const entry of this._preloads!) { + results.push(p[entry.path].preloadFor(entry.component)); + } + return results; + }; + const preloadFs = FieldSet.for(this._shape, preloadFn); + if (fs) { + fs = FieldSet.createFromEntries(fs.shape, [ + ...(fs.entries as any[]), + ...(preloadFs.entries as any[]), + ]); + } else { + fs = preloadFs; + } + } if (fs) { json.fields = fs.toJSON().fields; } diff --git a/src/tests/serialization.test.ts b/src/tests/serialization.test.ts index ac079ed..d820d5b 100644 --- a/src/tests/serialization.test.ts +++ b/src/tests/serialization.test.ts @@ -471,3 +471,50 @@ describe('QueryBuilder — complex round-trip', () => { expect(sanitize(restored.build())).toEqual(sanitize(original.build())); }); }); + +// ============================================================================= +// Preload serialization tests +// ============================================================================= + +describe('QueryBuilder — preload serialization', () => { + const componentLike = {query: QueryBuilder.from(Person).select((p: any) => ({name: p.name}))}; + + test('toJSON — preload merges into fields as subSelect', () => { + const json = QueryBuilder.from(Person) + .select((p) => [p.name]) + .preload('bestFriend', componentLike) + .toJSON(); + + // Should have 2 fields: name + bestFriend (with subSelect from preload) + expect(json.fields!.length).toBe(2); + const bestFriendField = json.fields!.find((f) => f.path === 'bestFriend'); + expect(bestFriendField).toBeDefined(); + expect(bestFriendField!.subSelect).toBeDefined(); + expect(bestFriendField!.subSelect!.fields.length).toBeGreaterThan(0); + }); + + test('round-trip — preload produces same IR', () => { + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .preload('bestFriend', componentLike); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); + + test('round-trip — preload with FieldSet component', () => { + const componentFs = FieldSet.for(personShape, ['name']); + const componentWithFs = {query: componentFs, fields: componentFs}; + + const original = QueryBuilder.from(Person) + .select((p) => [p.name]) + .preload('bestFriend', componentWithFs); + + const json = original.toJSON(); + const restored = QueryBuilder.fromJSON(json); + + expect(sanitize(restored.build())).toEqual(sanitize(original.build())); + }); +}); From ec239d301d38580b3e58eee1227090dd5f831c2a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:28:28 +0000 Subject: [PATCH 4/9] Clean up serialization code: extract helpers, fix types, add changeset - Extract _fieldsWithPreloads() from duplicated preload merge logic in toJSON() and _buildDirectRawInput() - Extract _evaluateMinusEntries() from duplicated minus entry evaluation logic, reusing a single proxy instance - Fix WhereMethods cast from `as any` to `as WhereMethods` - Remove unused PropertyPath import from QueryBuilderSerialization.ts - Remove redundant inline comments that restated the code - Add changeset patch for the toJSON fix https://claude.ai/code/session_01P6n2XLm5DSugoa8RWN97yy --- .changeset/fix-tojson-where-clause.md | 15 ++ src/queries/QueryBuilder.ts | 178 ++++++++--------------- src/queries/QueryBuilderSerialization.ts | 31 ++-- 3 files changed, 93 insertions(+), 131 deletions(-) create mode 100644 .changeset/fix-tojson-where-clause.md diff --git a/.changeset/fix-tojson-where-clause.md b/.changeset/fix-tojson-where-clause.md new file mode 100644 index 0000000..0e35549 --- /dev/null +++ b/.changeset/fix-tojson-where-clause.md @@ -0,0 +1,15 @@ +--- +"@_linked/core": patch +--- + +Fix QueryBuilder.toJSON() to serialize where clauses, sort keys, minus entries, preloads, and other missing fields + +`QueryBuilder.toJSON()` was silently dropping several fields during serialization, making JSON round-trips lossy. The following are now correctly serialized and restored via `fromJSON()`: + +- **Where clauses** (`.where(p => p.name.equals('Bob'))`) — previously lost entirely +- **Sort keys** (`.orderBy(p => p.name, 'DESC')`) — previously only the direction was preserved, not the sort key +- **Minus entries** (`.minus(Employee)`, `.minus(p => p.hobby.equals('Chess'))`) — previously lost entirely +- **Preloads** (`.preload('bestFriend', component)`) — merged into the FieldSet as subSelect entries +- **Null subject flag** (`.for(null)`) and **pending context name** — previously lost + +All callback-based fields are evaluated through the proxy at serialization time and stored as plain data structures. On deserialization, they are restored as pre-evaluated data and used directly during query building. diff --git a/src/queries/QueryBuilder.ts b/src/queries/QueryBuilder.ts index 6ebab83..4f22848 100644 --- a/src/queries/QueryBuilder.ts +++ b/src/queries/QueryBuilder.ts @@ -17,7 +17,7 @@ import {buildSelectQuery} from './IRPipeline.js'; import {getQueryDispatch} from './queryDispatch.js'; import type {NodeReferenceValue} from './QueryFactory.js'; import {resolveUriOrThrow} from '../utils/NodeReference.js'; -import {FieldSet, FieldSetJSON, FieldSetFieldJSON} from './FieldSet.js'; +import {FieldSet, FieldSetJSON, FieldSetFieldJSON, type FieldSetEntry} from './FieldSet.js'; import {PendingQueryContext} from './QueryContext.js'; import {createProxiedPathBuilder} from './ProxiedPathBuilder.js'; import { @@ -357,6 +357,60 @@ export class QueryBuilder return undefined; } + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + /** Return the FieldSet with preload entries merged in (if any). */ + private _fieldsWithPreloads(): FieldSet | undefined { + let fs = this.fields(); + if (this._preloads && this._preloads.length > 0) { + const preloadFn = (p: any) => { + return this._preloads!.map((entry) => p[entry.path].preloadFor(entry.component)); + }; + const preloadFs = FieldSet.for(this._shape, preloadFn); + if (fs) { + fs = FieldSet.createFromEntries(fs.shape, [ + ...(fs.entries as FieldSetEntry[]), + ...(preloadFs.entries as FieldSetEntry[]), + ]); + } else { + fs = preloadFs; + } + } + return fs; + } + + /** Evaluate minus entry callbacks into RawMinusEntry[] (plain data). */ + private _evaluateMinusEntries(): RawMinusEntry[] { + const proxy = createProxiedPathBuilder(this._shape); + return this._minusEntries!.map((entry) => { + if (entry.shapeId) { + return {shapeId: entry.shapeId}; + } + if (entry.whereFn) { + const result = (entry.whereFn as Function)(proxy); + + if (Array.isArray(result)) { + const propertyPaths = result.map((item: any) => { + const segments = FieldSet.collectPropertySegments(item); + return segments.map((seg): PropertyPathSegment => ({propertyShapeId: seg.id})); + }); + return {propertyPaths}; + } + + if (result && typeof result === 'object' && 'property' in result && 'subject' in result) { + const segments = FieldSet.collectPropertySegments(result); + return {propertyPaths: [segments.map((seg): PropertyPathSegment => ({propertyShapeId: seg.id}))]}; + } + + // WHERE-based exclusion + return {where: processWhereClause(entry.whereFn, this._shape)}; + } + return {}; + }); + } + // --------------------------------------------------------------------------- // Serialization // --------------------------------------------------------------------------- @@ -369,7 +423,8 @@ export class QueryBuilder * evaluated through the proxy to produce a FieldSet. * * Where, orderBy, and minus clauses are evaluated through the proxy and - * serialized as plain data structures. + * serialized as plain data structures. Preloads are merged into the FieldSet + * as subSelect entries, producing identical IR on deserialization. */ toJSON(): QueryBuilderJSON { const shapeId = this._shape.shape?.id || ''; @@ -377,30 +432,7 @@ export class QueryBuilder shape: shapeId, }; - // Serialize fields — fields() already handles _selectAllLabels, so - // no separate branch is needed (T1: dead else-if removed). - // When preloads exist, merge them into the FieldSet before serializing - // (same merge logic as _buildDirectRawInput). Preloads become subSelect - // entries in the JSON, producing identical IR on deserialization. - let fs = this.fields(); - if (this._preloads && this._preloads.length > 0) { - const preloadFn = (p: any) => { - const results: any[] = []; - for (const entry of this._preloads!) { - results.push(p[entry.path].preloadFor(entry.component)); - } - return results; - }; - const preloadFs = FieldSet.for(this._shape, preloadFn); - if (fs) { - fs = FieldSet.createFromEntries(fs.shape, [ - ...(fs.entries as any[]), - ...(preloadFs.entries as any[]), - ]); - } else { - fs = preloadFs; - } - } + const fs = this._fieldsWithPreloads(); if (fs) { json.fields = fs.toJSON().fields; } @@ -424,14 +456,12 @@ export class QueryBuilder json.orderDirection = this._sortDirection; } - // Serialize where clause (evaluate callback if needed) if (this._whereFn) { json.where = serializeWherePath(processWhereClause(this._whereFn, this._shape)); } else if (this._where) { json.where = serializeWherePath(this._where); } - // Serialize sort key (evaluate callback if needed) if (this._sortByFn) { json.sortBy = serializeSortByPath( evaluateSortCallback(this._shape, this._sortByFn as unknown as (p: any) => any, this._sortDirection || 'ASC'), @@ -440,43 +470,15 @@ export class QueryBuilder json.sortBy = serializeSortByPath(this._sortBy); } - // Serialize minus entries (evaluate callbacks if needed) if (this._minusEntries && this._minusEntries.length > 0) { - json.minusEntries = this._minusEntries.map((entry) => { - if (entry.shapeId) { - return {shapeId: entry.shapeId}; - } - if (entry.whereFn) { - const proxy = createProxiedPathBuilder(this._shape); - const result = (entry.whereFn as Function)(proxy); - - if (Array.isArray(result)) { - const propertyPaths = result.map((item: any) => { - const segments = FieldSet.collectPropertySegments(item); - return segments.map((seg) => seg.id); - }); - return {propertyPaths}; - } - - if (result && typeof result === 'object' && 'property' in result && 'subject' in result) { - const segments = FieldSet.collectPropertySegments(result); - return {propertyPaths: [segments.map((seg) => seg.id)]}; - } - - return {where: serializeWherePath(processWhereClause(entry.whereFn, this._shape))}; - } - return {}; - }); + json.minusEntries = this._evaluateMinusEntries().map(serializeRawMinusEntry); } else if (this._rawMinusEntries && this._rawMinusEntries.length > 0) { json.minusEntries = this._rawMinusEntries.map(serializeRawMinusEntry); } - // Serialize nullSubject flag if (this._nullSubject) { json.nullSubject = true; } - - // Serialize pending context name if (this._pendingContextName) { json.pendingContextName = this._pendingContextName; } @@ -569,35 +571,11 @@ export class QueryBuilder return this._buildDirectRawInput(); } - /** - * Build RawSelectInput directly from FieldSet entries. - */ + /** Build RawSelectInput directly from FieldSet entries. */ private _buildDirectRawInput(): RawSelectInput { - let fs = this.fields(); - - // When preloads exist, trace them through the proxy and merge with the FieldSet. - if (this._preloads && this._preloads.length > 0) { - const preloadFn = (p: any) => { - const results: any[] = []; - for (const entry of this._preloads!) { - results.push(p[entry.path].preloadFor(entry.component)); - } - return results; - }; - const preloadFs = FieldSet.for(this._shape, preloadFn); - if (fs) { - fs = FieldSet.createFromEntries(fs.shape, [ - ...(fs.entries as any[]), - ...(preloadFs.entries as any[]), - ]); - } else { - fs = preloadFs; - } - } - + const fs = this._fieldsWithPreloads(); const entries = fs ? fs.entries : []; - // Evaluate where callback (or use pre-evaluated data from JSON deserialization) let where: WherePath | undefined; if (this._whereFn) { where = processWhereClause(this._whereFn, this._shape); @@ -605,7 +583,6 @@ export class QueryBuilder where = this._where; } - // Evaluate sort callback (or use pre-evaluated data from JSON deserialization) let sortBy: SortByPath | undefined; if (this._sortByFn) { sortBy = evaluateSortCallback( @@ -639,39 +616,8 @@ export class QueryBuilder if (this._subjects && this._subjects.length > 0) { input.subjects = this._subjects; } - - // Process minus entries → convert callbacks to WherePaths or property paths if (this._minusEntries && this._minusEntries.length > 0) { - input.minusEntries = this._minusEntries.map((entry) => { - if (entry.shapeId) { - return {shapeId: entry.shapeId}; - } - if (entry.whereFn) { - // Call the callback through the proxy and inspect the result type - const proxy = createProxiedPathBuilder(this._shape); - const result = (entry.whereFn as Function)(proxy); - - // Array of QBOs → property existence paths - if (Array.isArray(result)) { - const propertyPaths = result.map((item: any) => { - const segments = FieldSet.collectPropertySegments(item); - return segments.map((seg): PropertyPathSegment => ({propertyShapeId: seg.id})); - }); - return {propertyPaths}; - } - - // Single QBO (has .property field) → single property existence path - if (result && typeof result === 'object' && 'property' in result && 'subject' in result) { - const segments = FieldSet.collectPropertySegments(result); - const propertyPaths = [segments.map((seg): PropertyPathSegment => ({propertyShapeId: seg.id}))]; - return {propertyPaths}; - } - - // Evaluation → existing WHERE-based path - return {where: processWhereClause(entry.whereFn, this._shape)}; - } - return {}; - }); + input.minusEntries = this._evaluateMinusEntries(); } else if (this._rawMinusEntries && this._rawMinusEntries.length > 0) { input.minusEntries = this._rawMinusEntries; } diff --git a/src/queries/QueryBuilderSerialization.ts b/src/queries/QueryBuilderSerialization.ts index ad4778f..463af81 100644 --- a/src/queries/QueryBuilderSerialization.ts +++ b/src/queries/QueryBuilderSerialization.ts @@ -8,21 +8,22 @@ import type {NodeShape} from '../shapes/SHACL.js'; import {getShapeClass} from '../utils/ShapeClass.js'; -import {PropertyPath, walkPropertyPath} from './PropertyPath.js'; +import {walkPropertyPath} from './PropertyPath.js'; import {ExpressionNode} from '../expressions/ExpressionNode.js'; -import type { - WherePath, - WhereEvaluationPath, - WhereAndOr, - WhereExpressionPath, - QueryPropertyPath, - QueryStep, - PropertyQueryStep, - SizeStep, - QueryArg, - ArgPath, - SortByPath, - AndOrQueryToken, +import { + WhereMethods, + type WherePath, + type WhereEvaluationPath, + type WhereAndOr, + type WhereExpressionPath, + type QueryPropertyPath, + type QueryStep, + type PropertyQueryStep, + type SizeStep, + type QueryArg, + type ArgPath, + type SortByPath, + type AndOrQueryToken, } from './SelectQuery.js'; import type {ShapeReferenceValue, NodeReferenceValue} from './QueryFactory.js'; import type {IRExpression} from './IntermediateRepresentation.js'; @@ -252,7 +253,7 @@ export function deserializeWherePath(shape: NodeShape, json: WherePathJSON): Whe // evaluation return { path: deserializeQueryPropertyPath(shape, json.path), - method: json.method as any, + method: json.method as WhereMethods, args: json.args.map((a) => deserializeQueryArg(shape, a)), }; } From d6f7924677bca409e36f24690aa9200a948cffdc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:35:08 +0000 Subject: [PATCH 5/9] Shorten changeset description https://claude.ai/code/session_01P6n2XLm5DSugoa8RWN97yy --- .changeset/fix-tojson-where-clause.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.changeset/fix-tojson-where-clause.md b/.changeset/fix-tojson-where-clause.md index 0e35549..56ed649 100644 --- a/.changeset/fix-tojson-where-clause.md +++ b/.changeset/fix-tojson-where-clause.md @@ -2,14 +2,4 @@ "@_linked/core": patch --- -Fix QueryBuilder.toJSON() to serialize where clauses, sort keys, minus entries, preloads, and other missing fields - -`QueryBuilder.toJSON()` was silently dropping several fields during serialization, making JSON round-trips lossy. The following are now correctly serialized and restored via `fromJSON()`: - -- **Where clauses** (`.where(p => p.name.equals('Bob'))`) — previously lost entirely -- **Sort keys** (`.orderBy(p => p.name, 'DESC')`) — previously only the direction was preserved, not the sort key -- **Minus entries** (`.minus(Employee)`, `.minus(p => p.hobby.equals('Chess'))`) — previously lost entirely -- **Preloads** (`.preload('bestFriend', component)`) — merged into the FieldSet as subSelect entries -- **Null subject flag** (`.for(null)`) and **pending context name** — previously lost - -All callback-based fields are evaluated through the proxy at serialization time and stored as plain data structures. On deserialization, they are restored as pre-evaluated data and used directly during query building. +Fix QueryBuilder.toJSON() to serialize where, orderBy, minus, and preload clauses that were previously silently dropped during JSON round-trips From b0240e93e8192a0107fde48a0817ede56a6c9c6d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:47:42 +0000 Subject: [PATCH 6/9] Fix build errors: remove non-portable type imports from SelectQuery WhereMethods, WhereEvaluationPath, WhereAndOr, and AndOrQueryToken were not reliably exported under CJS/ESM build configs. Replace with inline structural types cast via WherePath. https://claude.ai/code/session_01P6n2XLm5DSugoa8RWN97yy --- src/queries/QueryBuilderSerialization.ts | 43 ++++++++++-------------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/src/queries/QueryBuilderSerialization.ts b/src/queries/QueryBuilderSerialization.ts index 463af81..e19d5f0 100644 --- a/src/queries/QueryBuilderSerialization.ts +++ b/src/queries/QueryBuilderSerialization.ts @@ -10,20 +10,15 @@ import type {NodeShape} from '../shapes/SHACL.js'; import {getShapeClass} from '../utils/ShapeClass.js'; import {walkPropertyPath} from './PropertyPath.js'; import {ExpressionNode} from '../expressions/ExpressionNode.js'; -import { - WhereMethods, - type WherePath, - type WhereEvaluationPath, - type WhereAndOr, - type WhereExpressionPath, - type QueryPropertyPath, - type QueryStep, - type PropertyQueryStep, - type SizeStep, - type QueryArg, - type ArgPath, - type SortByPath, - type AndOrQueryToken, +import type { + WherePath, + QueryPropertyPath, + QueryStep, + PropertyQueryStep, + SizeStep, + QueryArg, + ArgPath, + SortByPath, } from './SelectQuery.js'; import type {ShapeReferenceValue, NodeReferenceValue} from './QueryFactory.js'; import type {IRExpression} from './IntermediateRepresentation.js'; @@ -98,9 +93,8 @@ function serializeQueryStep(step: QueryStep): QueryStepJSON { } export function serializeWherePath(where: WherePath): WherePathJSON { - // WhereExpressionPath if ('expressionNode' in where) { - const expr = (where as WhereExpressionPath).expressionNode; + const expr = (where as {expressionNode: ExpressionNode}).expressionNode; const json: {kind: 'expression'; ir: IRExpression; refs?: Record} = { kind: 'expression', ir: expr.ir, @@ -112,9 +106,8 @@ export function serializeWherePath(where: WherePath): WherePathJSON { } return json; } - // WhereAndOr if ('firstPath' in where) { - const andOr = where as WhereAndOr; + const andOr = where as {firstPath: WherePath; andOr: {and?: WherePath; or?: WherePath}[]}; return { kind: 'andOr', firstPath: serializeWherePath(andOr.firstPath), @@ -126,8 +119,7 @@ export function serializeWherePath(where: WherePath): WherePathJSON { }), }; } - // WhereEvaluationPath - const ev = where as WhereEvaluationPath; + const ev = where as {path: QueryPropertyPath; method: string; args: QueryArg[]}; return { kind: 'evaluation', path: serializeQueryPropertyPath(ev.path), @@ -242,20 +234,19 @@ export function deserializeWherePath(shape: NodeShape, json: WherePathJSON): Whe if (json.kind === 'andOr') { return { firstPath: deserializeWherePath(shape, json.firstPath), - andOr: json.andOr.map((token): AndOrQueryToken => { - const t: AndOrQueryToken = {}; + andOr: json.andOr.map((token) => { + const t: {and?: WherePath; or?: WherePath} = {}; if (token.and) t.and = deserializeWherePath(shape, token.and); if (token.or) t.or = deserializeWherePath(shape, token.or); return t; }), - }; + } as WherePath; } - // evaluation return { path: deserializeQueryPropertyPath(shape, json.path), - method: json.method as WhereMethods, + method: json.method, args: json.args.map((a) => deserializeQueryArg(shape, a)), - }; + } as WherePath; } function deserializeQueryArg(shape: NodeShape, json: QueryArgJSON): QueryArg { From 4c8320355a9571f65be1f93b8b69b2e869a7a92c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 14:20:32 +0000 Subject: [PATCH 7/9] Fix build: handle both old and new WherePath type structures The WherePath type was refactored on main (WhereEvaluationPath/WhereAndOr replaced with WhereExpressionPath/WhereExistsPath). Update serialization to work with both variants: - Use `as unknown as` for structural casts that span both type structures - Add ExistsCondition serialization/deserialization (exists kind) - Dynamically resolve ExistsCondition constructor for cross-branch compat - Guard evaluation path with runtime `in` check instead of fallthrough https://claude.ai/code/session_01P6n2XLm5DSugoa8RWN97yy --- src/queries/QueryBuilderSerialization.ts | 90 ++++++++++++++++++++---- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/src/queries/QueryBuilderSerialization.ts b/src/queries/QueryBuilderSerialization.ts index e19d5f0..ac8f228 100644 --- a/src/queries/QueryBuilderSerialization.ts +++ b/src/queries/QueryBuilderSerialization.ts @@ -10,6 +10,11 @@ import type {NodeShape} from '../shapes/SHACL.js'; import {getShapeClass} from '../utils/ShapeClass.js'; import {walkPropertyPath} from './PropertyPath.js'; import {ExpressionNode} from '../expressions/ExpressionNode.js'; +// ExistsCondition only exists after the WherePath refactor; access dynamically +// so this file compiles on branches where it doesn't exist yet. +import * as _exprModule from '../expressions/ExpressionNode.js'; +const ExistsConditionCtor: (new (...args: any[]) => any) | undefined = + (_exprModule as any).ExistsCondition; import type { WherePath, QueryPropertyPath, @@ -33,10 +38,19 @@ export type QueryStepJSON = | {kind: 'size'; count: QueryStepJSON[]; label?: string} | {kind: 'shapeRef'; id: string; shapeId: string}; +export type ExistsConditionJSON = { + kind: 'exists'; + pathSegmentIds: string[]; + predicate: {ir: IRExpression; refs?: Record}; + negated: boolean; + chain: {op: 'and' | 'or'; condition: ExistsConditionJSON | {kind: 'expression'; ir: IRExpression; refs?: Record}}[]; +}; + export type WherePathJSON = | {kind: 'evaluation'; path: QueryStepJSON[]; method: string; args: QueryArgJSON[]} | {kind: 'andOr'; firstPath: WherePathJSON; andOr: {and?: WherePathJSON; or?: WherePathJSON}[]} - | {kind: 'expression'; ir: IRExpression; refs?: Record}; + | {kind: 'expression'; ir: IRExpression; refs?: Record} + | ExistsConditionJSON; export type QueryArgJSON = | {kind: 'nodeRef'; id: string} @@ -94,7 +108,7 @@ function serializeQueryStep(step: QueryStep): QueryStepJSON { export function serializeWherePath(where: WherePath): WherePathJSON { if ('expressionNode' in where) { - const expr = (where as {expressionNode: ExpressionNode}).expressionNode; + const expr = (where as unknown as {expressionNode: ExpressionNode}).expressionNode; const json: {kind: 'expression'; ir: IRExpression; refs?: Record} = { kind: 'expression', ir: expr.ir, @@ -107,7 +121,7 @@ export function serializeWherePath(where: WherePath): WherePathJSON { return json; } if ('firstPath' in where) { - const andOr = where as {firstPath: WherePath; andOr: {and?: WherePath; or?: WherePath}[]}; + const andOr = where as unknown as {firstPath: WherePath; andOr: {and?: WherePath; or?: WherePath}[]}; return { kind: 'andOr', firstPath: serializeWherePath(andOr.firstPath), @@ -119,13 +133,39 @@ export function serializeWherePath(where: WherePath): WherePathJSON { }), }; } - const ev = where as {path: QueryPropertyPath; method: string; args: QueryArg[]}; - return { - kind: 'evaluation', - path: serializeQueryPropertyPath(ev.path), - method: ev.method, - args: ev.args.map(serializeQueryArg), - }; + if ('path' in where && 'method' in where && 'args' in where) { + const ev = where as unknown as {path: QueryPropertyPath; method: string; args: QueryArg[]}; + return { + kind: 'evaluation', + path: serializeQueryPropertyPath(ev.path), + method: ev.method, + args: ev.args.map(serializeQueryArg), + }; + } + if ('existsCondition' in where) { + // WhereExistsPath — serialize the ExistsCondition structurally + const ec = (where as unknown as {existsCondition: any}).existsCondition; + return { + kind: 'exists', + pathSegmentIds: [...ec.pathSegmentIds], + predicate: {ir: ec.predicate.ir, refs: serializePropertyRefMap(ec.predicate._refs)}, + negated: ec.negated ?? false, + chain: (ec.chain ?? []).map((c: any) => ({ + op: c.op, + condition: 'pathSegmentIds' in c.condition + ? {kind: 'exists' as const, pathSegmentIds: [...c.condition.pathSegmentIds], predicate: {ir: c.condition.predicate.ir, refs: serializePropertyRefMap(c.condition.predicate._refs)}, negated: c.condition.negated ?? false, chain: []} + : {kind: 'expression' as const, ir: c.condition.ir, refs: serializePropertyRefMap(c.condition._refs)}, + })), + }; + } + throw new Error(`Cannot serialize WherePath: ${JSON.stringify(Object.keys(where))}`); +} + +function serializePropertyRefMap(refs: ReadonlyMap): Record | undefined { + if (!refs || refs.size === 0) return undefined; + const out: Record = {}; + for (const [k, v] of refs) out[k] = [...v]; + return out; } function serializeQueryArg(arg: QueryArg): QueryArgJSON { @@ -136,8 +176,7 @@ function serializeQueryArg(arg: QueryArg): QueryArgJSON { return {kind: 'date', value: arg.toISOString()}; } if (typeof arg === 'object' && arg !== null) { - // WhereExpressionPath, WhereAndOr, or WhereEvaluationPath (nested WherePath) - if ('expressionNode' in arg || 'firstPath' in arg || ('path' in arg && 'method' in arg && 'args' in arg)) { + if ('expressionNode' in arg || 'existsCondition' in arg || 'firstPath' in arg || ('path' in arg && 'method' in arg && 'args' in arg)) { return {kind: 'where', where: serializeWherePath(arg as WherePath)}; } // ArgPath — has 'subject' and 'path' @@ -231,6 +270,20 @@ export function deserializeWherePath(shape: NodeShape, json: WherePathJSON): Whe } return {expressionNode: new ExpressionNode(json.ir, refs)}; } + if (json.kind === 'exists') { + if (!ExistsConditionCtor) throw new Error('ExistsCondition is not available on this build'); + const predRefs = deserializeRefMap(json.predicate.refs); + const predicate = new ExpressionNode(json.predicate.ir, predRefs); + const chain = json.chain.map((c) => { + if (c.condition.kind === 'exists') { + const innerRefs = deserializeRefMap(c.condition.predicate.refs); + return {op: c.op, condition: new ExistsConditionCtor(c.condition.pathSegmentIds, new ExpressionNode(c.condition.predicate.ir, innerRefs), c.condition.negated)}; + } + const exprRefs = deserializeRefMap(c.condition.refs); + return {op: c.op, condition: new ExpressionNode(c.condition.ir, exprRefs)}; + }); + return {existsCondition: new ExistsConditionCtor(json.pathSegmentIds, predicate, json.negated, chain)} as unknown as WherePath; + } if (json.kind === 'andOr') { return { firstPath: deserializeWherePath(shape, json.firstPath), @@ -240,13 +293,22 @@ export function deserializeWherePath(shape: NodeShape, json: WherePathJSON): Whe if (token.or) t.or = deserializeWherePath(shape, token.or); return t; }), - } as WherePath; + } as unknown as WherePath; } + // evaluation (legacy) return { path: deserializeQueryPropertyPath(shape, json.path), method: json.method, args: json.args.map((a) => deserializeQueryArg(shape, a)), - } as WherePath; + } as unknown as WherePath; +} + +function deserializeRefMap(refs?: Record): Map { + const m = new Map(); + if (refs) { + for (const [k, v] of Object.entries(refs)) m.set(k, v); + } + return m; } function deserializeQueryArg(shape: NodeShape, json: QueryArgJSON): QueryArg { From a8d9ad955579418388649b524b4bb30ce2654d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Mon, 6 Apr 2026 16:24:39 +0100 Subject: [PATCH 8/9] Refine SPARQL lowering for required outer filter bindings --- .changeset/required-filter-bindings.md | 9 + docs/reports/013-required-filter-bindings.md | 306 +++++++++++++++++++ documentation/sparql-algebra.md | 19 +- src/sparql/irToAlgebra.ts | 167 ++++++++-- src/test-helpers/query-fixtures.ts | 3 + src/tests/sparql-algebra.test.ts | 64 ++++ src/tests/sparql-fuseki.test.ts | 14 + src/tests/sparql-select-golden.test.ts | 45 +-- 8 files changed, 583 insertions(+), 44 deletions(-) create mode 100644 .changeset/required-filter-bindings.md create mode 100644 docs/reports/013-required-filter-bindings.md diff --git a/.changeset/required-filter-bindings.md b/.changeset/required-filter-bindings.md new file mode 100644 index 0000000..7d7faf6 --- /dev/null +++ b/.changeset/required-filter-bindings.md @@ -0,0 +1,9 @@ +--- +'@_linked/core': patch +--- + +Refine SPARQL select lowering so top-level null-rejecting filters emit required triples instead of redundant `OPTIONAL` bindings. Queries like `Person.select().where((p) => p.name.equals('Semmy'))` now lower to a required `?a0 ?a0_name` triple, while cases that still need nullable behavior such as `p.name.equals('Jinx').or(p.hobby.equals('Jogging'))` remain optional. + +This change does not add new DSL APIs, but it does change the generated SPARQL shape for some outer `where()` clauses to better match hand-written intent. Inline traversal `.where(...)`, `EXISTS` filters, and aggregate `HAVING` paths keep their previous behavior. + +See `documentation/sparql-algebra.md` for the updated lowering rules and examples. diff --git a/docs/reports/013-required-filter-bindings.md b/docs/reports/013-required-filter-bindings.md new file mode 100644 index 0000000..1d3bae5 --- /dev/null +++ b/docs/reports/013-required-filter-bindings.md @@ -0,0 +1,306 @@ +--- +summary: Refine SPARQL select lowering so top-level null-rejecting filters use required bindings instead of redundant OPTIONAL triples. +packages: [core] +--- + +# Required Filter Bindings + +## Scope + +This report documents the refinement to `@_linked/core` SPARQL select lowering that promotes top-level filter bindings from `OPTIONAL` to required triples when the filter would reject rows without those bindings. + +The change is intentionally narrow: + +- it affects top-level `query.where` lowering in `selectToAlgebra()` +- it does not change inline traversal `.where(...)` lowering +- it does not change `EXISTS` block lowering +- it does not change aggregate HAVING behavior + +## Motivation + +The previous lowering strategy treated every discovered property binding as optional unless it came from a required traversal. That produced SPARQL such as: + +```sparql +SELECT DISTINCT ?a0 +WHERE { + ?a0 rdf:type . + OPTIONAL { ?a0 ?a0_name . } + FILTER(?a0_name = "Semmy") +} +``` + +That query is valid, but it is not how a human would typically write the same intent. Because the `FILTER` already rejects rows where `?a0_name` is unbound, the `OPTIONAL` is redundant. + +The goal of this scope was to improve generated SPARQL so it more closely matches hand-written intent without changing result semantics. + +## Architecture Overview + +The relevant pipeline remains: + +1. DSL builders emit `IRSelectQuery` +2. `selectToAlgebra()` in `src/sparql/irToAlgebra.ts` converts IR into SPARQL algebra +3. algebra serialization produces the final SPARQL string +4. result mapping reconstructs projected objects from bindings + +The refinement sits inside step 2. + +## Final Design + +### Core rule + +Bindings referenced by a top-level `query.where` are partitioned into: + +- required bindings: emitted in the main BGP +- optional bindings: emitted via `OPTIONAL` `LeftJoin`s exactly as before + +Bindings are promoted only when the outer filter is null-rejecting with respect to that binding. + +### Boolean composition rules + +The implementation uses a small recursive analysis over IR expressions: + +- `AND`: required bindings are the union of both sides +- `OR`: required bindings are the intersection of both sides +- `NOT`: forwards the required set of its inner expression +- `binary_expr` and `function_expr`: gather required bindings from their arguments +- `exists_expr`: contributes nothing to the outer required set because it lowers inside its own block +- `aggregate_expr`: contributes nothing because aggregate comparisons continue through HAVING behavior + +This gives the desired result: + +- `p.name.equals("Semmy")` promotes `name` +- `p.name.equals("A").or(p.name.equals("B"))` promotes `name` +- `p.name.equals("A").or(p.hobby.equals("B"))` promotes neither + +### Why this design + +This was chosen over a simpler “selected and filtered” heuristic because the simplification is about row elimination semantics, not projection overlap. The null-rejection approach is small enough to maintain, scales to function-based filters, and avoids over-constraining `OR` expressions. + +## File Responsibilities + +### `src/sparql/irToAlgebra.ts` + +This file owns the lowering change. + +Key additions: + +- `bindingKey()` and `contextAliasKey()` for stable lookup keys +- `mergeKeySets()` and `intersectKeySets()` for boolean composition +- `collectRequiredBindingKeys()` to compute which outer filter bindings are mandatory +- partitioning logic in `processExpressionForProperties()` to route each discovered binding to either `requiredPropertyTriples` or `optionalPropertyTriples` + +The rest of the select-lowering pipeline stays intact: + +- root type triple is always required +- traversals are still required unless they are already modeled as filtered optional traversal blocks +- inline traversal filters still produce nested OPTIONAL blocks +- `EXISTS` and `MINUS` keep their local property-collection behavior + +### `src/test-helpers/query-fixtures.ts` + +Added one focused fixture: + +- `outerWhereDifferentPropsOr` + +This exists specifically to guard the case that must not simplify: + +```ts +Person.select((p) => [p.name, p.hobby]) + .where((p) => p.name.equals('Jinx').or(p.hobby.equals('Jogging'))) +``` + +### `src/tests/sparql-algebra.test.ts` + +Expanded structural assertions for: + +- simple top-level equality +- top-level filter plus projection +- same-property `OR` +- different-property `OR` +- context-property filters +- implicit traversal filters +- function-based filters +- aggregate filter inputs remaining optional + +These tests verify placement in the algebra tree, not just textual output. + +### `src/tests/sparql-select-golden.test.ts` + +Updated goldens where simplification is now intended and added a golden for the non-simplifying different-property `OR` case. + +### `src/tests/sparql-fuseki.test.ts` + +Added integration coverage to prove semantic preservation for the different-property `OR` case. This is the highest-risk case for accidental over-promotion. + +### `documentation/sparql-algebra.md` + +Updated the docs so the public description no longer claims that every discovered property becomes optional. The documentation now explains that top-level null-rejecting filters promote required triples while projection-only and conditional bindings remain optional. + +## Behavior Examples + +### Example 1: simple top-level equality + +DSL: + +```ts +Person.select().where((p) => p.name.equals('Semmy')) +``` + +Current SPARQL: + +```sparql +SELECT DISTINCT ?a0 +WHERE { + ?a0 rdf:type . + ?a0 ?a0_name . + FILTER(?a0_name = "Semmy") +} +``` + +### Example 2: projected property plus filtered property + +DSL: + +```ts +Person.select((p) => p.name).where((p) => p.bestFriend.equals(getQueryContext('user'))) +``` + +Current SPARQL shape: + +- `bestFriend` is required because the filter depends on it +- `name` stays optional because it is projection-only + +### Example 3: same-property OR + +DSL: + +```ts +Person.select((p) => p.name) + .where((p) => p.name.equals('Semmy').or(p.name.equals('Moa'))) +``` + +`name` is required because both branches depend on the same binding. + +### Example 4: different-property OR + +DSL: + +```ts +Person.select((p) => [p.name, p.hobby]) + .where((p) => p.name.equals('Jinx').or(p.hobby.equals('Jogging'))) +``` + +Both bindings stay optional because either branch can match without the other property being present. + +### Example 5: implicit traversal filter + +DSL: + +```ts +Person.select().where((p) => p.friends.name.equals('Moa')) +``` + +The friend traversal remains required, and the filtered `name` binding is now also required because the outer filter cannot pass without it. + +## Public API Surface + +There are no new public exports, classes, or user-facing DSL methods in this scope. + +The behavioral change is in SPARQL generation only: + +- simpler SPARQL for outer null-rejecting filters +- unchanged result mapping and DSL surface + +## Resolved Edge Cases + +- `OR` across different properties does not simplify +- shared-property `OR` does simplify +- function filters such as `strlen(name) > 5` simplify because they are null-rejecting +- context-property comparisons simplify only the filtered binding and leave projection-only bindings optional +- aggregate comparisons such as `friends.size().equals(2)` keep aggregate inputs optional and continue to rely on HAVING semantics +- `EXISTS` internals do not leak required bindings into the outer query + +## Validation Coverage + +Validation was run at two levels. + +### Targeted validation + +Command: + +```bash +npm test -- --runInBand --runTestsByPath src/tests/sparql-algebra.test.ts src/tests/sparql-select-golden.test.ts src/tests/sparql-fuseki.test.ts +``` + +Result: + +- 3 suites passed +- 204 tests passed + +### Full package validation + +Command: + +```bash +npm test -- --runInBand +``` + +Result: + +- 33 suites passed +- 3 suites skipped +- 1033 tests passed +- 114 skipped tests remained skipped + +## Documentation Links + +- `documentation/sparql-algebra.md` +- `src/sparql/irToAlgebra.ts` +- `src/tests/sparql-algebra.test.ts` +- `src/tests/sparql-select-golden.test.ts` +- `src/tests/sparql-fuseki.test.ts` + +## Tradeoffs And Final Decisions + +- Kept the implementation local to select lowering instead of rewriting all property discovery paths. +- Chose semantic null-rejection analysis over a projection-aware heuristic. +- Treated `OR` conservatively via set intersection to preserve correctness. +- Left inline traversal `.where(...)` and `EXISTS` lowering untouched because they already model their own scope and optionality rules. + +## Limitations + +- The required-binding analysis currently treats function-based outer filter usage as null-rejecting by default. That matches the current function set and SPARQL behavior used here, but future additions with explicit null-tolerant semantics may need an exception list. +- This scope does not attempt to further optimize nested `EXISTS`, traversal-local OPTIONAL blocks, or serializer-level formatting beyond required-vs-optional placement. + +## Deferred Work + +Nothing was deferred from this specific scope. + +If future work expands the expression system with null-tolerant functions, add a focused follow-up ideation doc rather than weakening the current rule implicitly. + +## Wrapup Status + +Code readability: + +- reviewed +- one clarifying comment added to `src/sparql/irToAlgebra.ts` + +Dead code: + +- none found in scope + +Documentation: + +- updated and aligned with behavior + +PR readiness: + +- implementation: ready +- tests: ready +- docs: ready +- changeset: pending user-selected bump level +- final commit: pending changeset resolution + +PR reference: + +- no PR created during this scope diff --git a/documentation/sparql-algebra.md b/documentation/sparql-algebra.md index 1ec18db..da05eb5 100644 --- a/documentation/sparql-algebra.md +++ b/documentation/sparql-algebra.md @@ -181,7 +181,7 @@ Converts an `IRSelectQuery` to a `SparqlSelectPlan`. The algorithm: 1. **Root type triple** — `?a0 rdf:type ` becomes the required BGP. 2. **Traverse patterns** — each `IRTraversePattern` becomes a triple: `?from ?to`. Filtered traversals (inline `.where()`) are collected separately. -3. **Property discovery** — walks all projection, where, and orderBy expressions to find `property_expr` references. Each unique property becomes an OPTIONAL triple: `OPTIONAL { ?alias ?var }`. +3. **Property discovery** — walks all projection, where, and orderBy expressions to find `property_expr` references. Projection-only and conditionally-needed bindings stay OPTIONAL, but top-level WHERE bindings that are mandatory for row survival are emitted as required triples in the main BGP. 4. **Filtered OPTIONAL blocks** — inline `.where()` filters produce OPTIONAL blocks containing the traverse triple, filter property triples, and FILTER expression together. 5. **WHERE clause** — `query.where` becomes either a FILTER (for non-aggregate expressions) or HAVING (for expressions containing aggregates like COUNT > N). 6. **Subject targeting** — `query.subjectId` becomes an additional FILTER: `?a0 = `. @@ -206,6 +206,23 @@ SparqlSelectPlan { } ``` +#### Example: outer where promotion + +DSL: `Person.select().where((p) => p.name.equals('Semmy'))` + +Because the outer filter rejects rows when `?a0_name` is unbound, the `name` triple is emitted as required instead of `OPTIONAL`: + +```sparql +SELECT DISTINCT ?a0 +WHERE { + ?a0 rdf:type . + ?a0 ?a0_name . + FILTER(?a0_name = "Semmy") +} +``` + +For `OR` filters, only bindings required by every branch are promoted. For example, `p.name.equals('Jinx').or(p.hobby.equals('Jogging'))` keeps both bindings optional because either branch can satisfy the filter on its own. + Serialized SPARQL: ```sparql PREFIX rdf: diff --git a/src/sparql/irToAlgebra.ts b/src/sparql/irToAlgebra.ts index 94b27fb..0cc2cb0 100644 --- a/src/sparql/irToAlgebra.ts +++ b/src/sparql/irToAlgebra.ts @@ -145,6 +145,39 @@ function joinNodes( return {type: 'join', left, right}; } +function bindingKey(alias: string, property: string): string { + return `${alias}::${property}`; +} + +function contextAliasKey(contextIri: string): string { + return `__ctx__${contextIri}`; +} + +function mergeKeySets(...sets: ReadonlySet[]): Set { + const merged = new Set(); + for (const set of sets) { + for (const key of set) { + merged.add(key); + } + } + return merged; +} + +function intersectKeySets(sets: ReadonlySet[]): Set { + if (sets.length === 0) { + return new Set(); + } + + const [first, ...rest] = sets; + const intersection = new Set(first); + for (const value of intersection) { + if (!rest.every((set) => set.has(value))) { + intersection.delete(value); + } + } + return intersection; +} + // --------------------------------------------------------------------------- // Pattern helpers // --------------------------------------------------------------------------- @@ -184,7 +217,7 @@ class VariableRegistry { private usedVarNames = new Set(); private key(alias: string, property: string): string { - return `${alias}::${property}`; + return bindingKey(alias, property); } has(alias: string, property: string): boolean { @@ -255,6 +288,14 @@ export function selectToAlgebra( ): SparqlSelectPlan { const registry = new VariableRegistry(); + // Promote bindings only when the top-level WHERE would reject rows without + // them. This keeps human-like SPARQL for null-rejecting filters without + // over-constraining OR cases that can still match through other branches. + const requiredPropertyKeys = query.where + ? collectRequiredBindingKeys(query.where) + : new Set(); + + const requiredPropertyTriples: SparqlTriple[] = []; // Track property triples that need to be added as OPTIONAL const optionalPropertyTriples: SparqlTriple[] = []; @@ -307,6 +348,8 @@ export function selectToAlgebra( item.expression, registry, optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, ); } @@ -315,6 +358,8 @@ export function selectToAlgebra( query.where, registry, optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, ); } @@ -324,6 +369,8 @@ export function selectToAlgebra( orderItem.expression, registry, optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, ); } } @@ -333,7 +380,7 @@ export function selectToAlgebra( // - Wrap each optional property triple in a LeftJoin const requiredBgp: SparqlBGP = { type: 'bgp', - triples: [...requiredTriples, ...traverseTriples], + triples: [...requiredTriples, ...traverseTriples, ...requiredPropertyTriples], }; let algebra: SparqlAlgebraNode = requiredBgp; @@ -603,45 +650,84 @@ function processExpressionForProperties( expr: IRExpression, registry: VariableRegistry, optionalPropertyTriples: SparqlTriple[], + requiredPropertyTriples: SparqlTriple[] = [], + requiredPropertyKeys = new Set(), ): void { switch (expr.kind) { case 'property_expr': { if (!registry.has(expr.sourceAlias, expr.property)) { - // Create a new OPTIONAL triple for this property const varName = registry.getOrCreate(expr.sourceAlias, expr.property); const predicate = expr.pathExpr ? {kind: 'path' as const, value: pathExprToSparql(expr.pathExpr), uris: collectPathUris(expr.pathExpr)} : iriTerm(expr.property); - optionalPropertyTriples.push( - tripleOf( - varTerm(expr.sourceAlias), - predicate, - varTerm(varName), - ), + const triple = tripleOf( + varTerm(expr.sourceAlias), + predicate, + varTerm(varName), ); + const triples = requiredPropertyKeys.has(bindingKey(expr.sourceAlias, expr.property)) + ? requiredPropertyTriples + : optionalPropertyTriples; + triples.push(triple); } break; } case 'binary_expr': - processExpressionForProperties(expr.left, registry, optionalPropertyTriples); - processExpressionForProperties(expr.right, registry, optionalPropertyTriples); + processExpressionForProperties( + expr.left, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); + processExpressionForProperties( + expr.right, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); break; case 'logical_expr': for (const sub of expr.expressions) { - processExpressionForProperties(sub, registry, optionalPropertyTriples); + processExpressionForProperties( + sub, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); } break; case 'not_expr': - processExpressionForProperties(expr.expression, registry, optionalPropertyTriples); + processExpressionForProperties( + expr.expression, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); break; case 'function_expr': for (const arg of expr.args) { - processExpressionForProperties(arg, registry, optionalPropertyTriples); + processExpressionForProperties( + arg, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); } break; case 'aggregate_expr': for (const arg of expr.args) { - processExpressionForProperties(arg, registry, optionalPropertyTriples); + processExpressionForProperties( + arg, + registry, + optionalPropertyTriples, + requiredPropertyTriples, + requiredPropertyKeys, + ); } break; case 'exists_expr': @@ -653,16 +739,18 @@ function processExpressionForProperties( // Context entity property — emit a triple with fixed IRI as subject. // Use raw IRI as registry key to avoid collision between IRIs that // sanitize to the same string (e.g. ctx-1 vs ctx_1). - const ctxKey = `__ctx__${expr.contextIri}`; + const ctxKey = contextAliasKey(expr.contextIri); if (!registry.has(ctxKey, expr.property)) { const varName = registry.getOrCreate(ctxKey, expr.property); - optionalPropertyTriples.push( - tripleOf( - iriTerm(expr.contextIri), - iriTerm(expr.property), - varTerm(varName), - ), + const triple = tripleOf( + iriTerm(expr.contextIri), + iriTerm(expr.property), + varTerm(varName), ); + const triples = requiredPropertyKeys.has(bindingKey(ctxKey, expr.property)) + ? requiredPropertyTriples + : optionalPropertyTriples; + triples.push(triple); } break; } @@ -674,6 +762,41 @@ function processExpressionForProperties( } } +/** + * Compute which bindings are mandatory for a top-level FILTER to keep a row. + * AND makes either side required; OR only keeps bindings required by every branch. + */ +function collectRequiredBindingKeys(expr: IRExpression): Set { + switch (expr.kind) { + case 'property_expr': + return new Set([bindingKey(expr.sourceAlias, expr.property)]); + case 'context_property_expr': + return new Set([bindingKey(contextAliasKey(expr.contextIri), expr.property)]); + case 'binary_expr': + return mergeKeySets( + collectRequiredBindingKeys(expr.left), + collectRequiredBindingKeys(expr.right), + ); + case 'function_expr': + return mergeKeySets(...expr.args.map((arg) => collectRequiredBindingKeys(arg))); + case 'not_expr': + return collectRequiredBindingKeys(expr.expression); + case 'logical_expr': { + const childSets = expr.expressions.map((sub) => collectRequiredBindingKeys(sub)); + if (expr.operator === 'and') { + return mergeKeySets(...childSets); + } + return intersectKeySets(childSets); + } + case 'aggregate_expr': + case 'exists_expr': + case 'literal_expr': + case 'reference_expr': + case 'alias_expr': + return new Set(); + } +} + // --------------------------------------------------------------------------- // Expression conversion // --------------------------------------------------------------------------- diff --git a/src/test-helpers/query-fixtures.ts b/src/test-helpers/query-fixtures.ts index f3432ce..703ca0a 100644 --- a/src/test-helpers/query-fixtures.ts +++ b/src/test-helpers/query-fixtures.ts @@ -314,6 +314,9 @@ export const queryFactories = { Person.select((p) => p.name) .where((p) => p.name.equals('Semmy').or(p.name.equals('Moa'))) .limit(1), + outerWhereDifferentPropsOr: () => + Person.select((p) => [p.name, p.hobby]) + .where((p) => p.name.equals('Jinx').or(p.hobby.equals('Jogging'))), sortByAsc: () => Person.select((p) => p.name).orderBy((p) => p.name), sortByDesc: () => Person.select((p) => p.name).orderBy((p) => p.name, 'DESC'), diff --git a/src/tests/sparql-algebra.test.ts b/src/tests/sparql-algebra.test.ts index 58e6d36..5e671b7 100644 --- a/src/tests/sparql-algebra.test.ts +++ b/src/tests/sparql-algebra.test.ts @@ -406,6 +406,11 @@ describe('selectToAlgebra — where clauses', () => { (f) => f.expression.kind === 'binary_expr', ); expect(binaryFilter).toBeDefined(); + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(0); }); test('outerWhere has Filter wrapping the entire pattern', async () => { @@ -425,6 +430,65 @@ describe('selectToAlgebra — where clauses', () => { // Left side should be a variable referencing name on root alias expect(binaryFilter.expression.left.kind).toBe('variable_expr'); } + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(0); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/friends`)).toBe(1); + }); + + test('outerWhereLimit promotes same-property OR to a required triple', async () => { + const plan = await capturePlan(() => queryFactories.outerWhereLimit()); + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(0); + }); + + test('outerWhereDifferentPropsOr keeps both properties optional', async () => { + const plan = await capturePlan(() => queryFactories.outerWhereDifferentPropsOr()); + + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/hobby`)).toBe(1); + }); + + test('whereWithContext keeps projection optional but promotes filter binding', async () => { + const plan = await capturePlan(() => queryFactories.whereWithContext()); + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/bestFriend`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/bestFriend`)).toBe(0); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(1); + }); + + test('whereSomeImplicit promotes the traversed filter property to required', async () => { + const plan = await capturePlan(() => queryFactories.whereSomeImplicit()); + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/friends`)).toBe(1); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(0); + }); + + test('whereExprStrlen promotes function-filter property bindings to required', async () => { + const plan = await capturePlan(() => queryFactories.whereExprStrlen()); + + const allTriples = collectAllTriples(plan.algebra); + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(allTriples, `${Person.shape.id}/name`)).toBe(1); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/name`)).toBe(0); + }); + + test('countEquals keeps aggregate inputs optional', async () => { + const plan = await capturePlan(() => queryFactories.countEquals()); + + const optionalTriples = collectOptionalTriples(plan.algebra); + expect(countTriplesByPredicate(optionalTriples, `${Person.shape.id}/friends`)).toBe(1); }); }); diff --git a/src/tests/sparql-fuseki.test.ts b/src/tests/sparql-fuseki.test.ts index cfea2ef..749dd0e 100644 --- a/src/tests/sparql-fuseki.test.ts +++ b/src/tests/sparql-fuseki.test.ts @@ -831,6 +831,20 @@ describe('Fuseki SELECT — outer where (FILTER)', () => { expect(rows.length).toBeLessThanOrEqual(1); }); + test('outerWhereDifferentPropsOr — different-property OR still matches rows with only one side bound', async () => { + if (!fusekiAvailable) return; + + const result = await runSelectMapped('outerWhereDifferentPropsOr'); + expect(Array.isArray(result)).toBe(true); + const rows = result as ResultRow[]; + + // name = Jinx matches p3 even though hobby is missing. + // hobby = Jogging matches p2. + expect(rows.length).toBe(2); + expect(rows.some((row) => row.id.includes('p2'))).toBe(true); + expect(rows.some((row) => row.id.includes('p3'))).toBe(true); + }); + test('whereWithContext — filter bestFriend = context user (p3)', async () => { if (!fusekiAvailable) return; diff --git a/src/tests/sparql-select-golden.test.ts b/src/tests/sparql-select-golden.test.ts index bf47fb1..dccae43 100644 --- a/src/tests/sparql-select-golden.test.ts +++ b/src/tests/sparql-select-golden.test.ts @@ -496,9 +496,7 @@ describe('SPARQL golden — outer where', () => { SELECT DISTINCT ?a0 WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/bestFriend> ?a0_bestFriend . - } + ?a0 <${P}/bestFriend> ?a0_bestFriend . FILTER(?a0_bestFriend = ) }`); }); @@ -510,9 +508,7 @@ WHERE { SELECT DISTINCT ?a0 WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } + ?a0 <${P}/name> ?a0_name . FILTER(?a0_name = "Semmy") }`); }); @@ -524,12 +520,10 @@ WHERE { SELECT DISTINCT ?a0 ?a0_friends WHERE { ?a0 rdf:type <${P}> . + ?a0 <${P}/name> ?a0_name . OPTIONAL { ?a0 <${P}/friends> ?a0_friends . } - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } FILTER(?a0_name = "Semmy") }`); }); @@ -541,14 +535,29 @@ WHERE { SELECT DISTINCT ?a0 ?a0_name WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } + ?a0 <${P}/name> ?a0_name . FILTER(?a0_name = "Semmy" || ?a0_name = "Moa") } LIMIT 1`); }); + test('outerWhereDifferentPropsOr', async () => { + const sparql = await goldenSelect(queryFactories.outerWhereDifferentPropsOr); + expect(sparql).toBe( +`PREFIX rdf: +SELECT DISTINCT ?a0 ?a0_name ?a0_hobby +WHERE { + ?a0 rdf:type <${P}> . + OPTIONAL { + ?a0 <${P}/name> ?a0_name . + } + OPTIONAL { + ?a0 <${P}/hobby> ?a0_hobby . + } + FILTER(?a0_name = "Jinx" || ?a0_hobby = "Jogging") +}`); + }); + test('whereSomeImplicit', async () => { const sparql = await goldenSelect(queryFactories.whereSomeImplicit); expect(sparql).toBe( @@ -557,9 +566,7 @@ SELECT DISTINCT ?a0 ?a1 WHERE { ?a0 rdf:type <${P}> . ?a0 <${P}/friends> ?a1 . - OPTIONAL { - ?a1 <${P}/name> ?a1_name . - } + ?a1 <${P}/name> ?a1_name . FILTER(?a1_name = "Moa") }`); }); @@ -601,9 +608,7 @@ WHERE { SELECT DISTINCT ?a0 WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } + ?a0 <${P}/name> ?a0_name . FILTER(EXISTS { ?a0 <${P}/friends> ?a1 . ?a1 <${P}/name> ?a1_name . @@ -619,12 +624,10 @@ WHERE { SELECT DISTINCT ?a0 ?a0_name WHERE { ?a0 rdf:type <${P}> . + ?a0 <${P}/bestFriend> ?a0_bestFriend . OPTIONAL { ?a0 <${P}/name> ?a0_name . } - OPTIONAL { - ?a0 <${P}/bestFriend> ?a0_bestFriend . - } FILTER(?a0_bestFriend = ) }`); }); From ce7634b70a107e295a7758400ab437c127cb2047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rene=CC=81=20Verheij?= Date: Mon, 6 Apr 2026 16:43:00 +0100 Subject: [PATCH 9/9] Fix main merge test expectations --- src/tests/serialization.test.ts | 29 +++++++++++++++++--------- src/tests/sparql-select-golden.test.ts | 20 +++++------------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/tests/serialization.test.ts b/src/tests/serialization.test.ts index d820d5b..bdb2aa2 100644 --- a/src/tests/serialization.test.ts +++ b/src/tests/serialization.test.ts @@ -247,11 +247,12 @@ describe('QueryBuilder — where clause serialization', () => { .toJSON(); expect(json.where).toBeDefined(); - expect(json.where!.kind).toBe('evaluation'); - if (json.where!.kind === 'evaluation') { - expect(json.where!.method).toBe('='); - expect(json.where!.path).toHaveLength(1); - expect((json.where!.path[0] as any).label).toBe('name'); + expect(json.where!.kind).toBe('expression'); + if (json.where!.kind === 'expression') { + expect(json.where!.ir.kind).toBe('binary_expr'); + if (json.where!.ir.kind === 'binary_expr') { + expect(json.where!.ir.operator).toBe('='); + } } }); @@ -262,10 +263,12 @@ describe('QueryBuilder — where clause serialization', () => { .toJSON(); expect(json.where).toBeDefined(); - expect(json.where!.kind).toBe('evaluation'); - if (json.where!.kind === 'evaluation') { - expect(json.where!.args).toHaveLength(1); - expect(json.where!.args[0].kind).toBe('nodeRef'); + expect(json.where!.kind).toBe('expression'); + if (json.where!.kind === 'expression') { + expect(json.where!.ir.kind).toBe('binary_expr'); + if (json.where!.ir.kind === 'binary_expr') { + expect(json.where!.ir.right.kind).toBe('reference_expr'); + } } }); @@ -276,7 +279,13 @@ describe('QueryBuilder — where clause serialization', () => { .toJSON(); expect(json.where).toBeDefined(); - expect(json.where!.kind).toBe('andOr'); + expect(json.where!.kind).toBe('expression'); + if (json.where!.kind === 'expression') { + expect(json.where!.ir.kind).toBe('logical_expr'); + if (json.where!.ir.kind === 'logical_expr') { + expect(json.where!.ir.operator).toBe('and'); + } + } }); test('round-trip — simple where equals produces same IR', () => { diff --git a/src/tests/sparql-select-golden.test.ts b/src/tests/sparql-select-golden.test.ts index df780b6..e82b1b8 100644 --- a/src/tests/sparql-select-golden.test.ts +++ b/src/tests/sparql-select-golden.test.ts @@ -644,9 +644,7 @@ WHERE { SELECT DISTINCT ?a0 ?a0_name WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } + ?a0 <${P}/name> ?a0_name . FILTER(!(?a0_name = "Alice")) }`); }); @@ -658,9 +656,7 @@ WHERE { SELECT DISTINCT ?a0 ?a0_name WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } + ?a0 <${P}/name> ?a0_name . FILTER(!(EXISTS { ?a0 <${P}/friends> ?a1 . ?a1 <${P}/hobby> ?a1_hobby . @@ -676,9 +672,7 @@ WHERE { SELECT DISTINCT ?a0 ?a0_name WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } + ?a0 <${P}/name> ?a0_name . FILTER(?a0_name != "Alice") }`); }); @@ -690,12 +684,8 @@ WHERE { SELECT DISTINCT ?a0 ?a0_name WHERE { ?a0 rdf:type <${P}> . - OPTIONAL { - ?a0 <${P}/name> ?a0_name . - } - OPTIONAL { - ?a0 <${P}/hobby> ?a0_hobby . - } + ?a0 <${P}/name> ?a0_name . + ?a0 <${P}/hobby> ?a0_hobby . FILTER(!(?a0_name = "Alice" && ?a0_hobby = "Chess")) }`); });