From 1ae72991f3b3c0bb764c1dfd4a4a130c1e55815f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:08:37 +0000 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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