diff --git a/.changeset/fix-tojson-where-clause.md b/.changeset/fix-tojson-where-clause.md new file mode 100644 index 0000000..56ed649 --- /dev/null +++ b/.changeset/fix-tojson-where-clause.md @@ -0,0 +1,5 @@ +--- +"@_linked/core": patch +--- + +Fix QueryBuilder.toJSON() to serialize where, orderBy, minus, and preload clauses that were previously silently dropped during JSON round-trips 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 c1adca4..4f22848 100644 --- a/src/queries/QueryBuilder.ts +++ b/src/queries/QueryBuilder.ts @@ -12,14 +12,25 @@ 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'; 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 { + 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, }); } @@ -327,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 // --------------------------------------------------------------------------- @@ -338,8 +422,9 @@ 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. Preloads are merged into the FieldSet + * as subSelect entries, producing identical IR on deserialization. */ toJSON(): QueryBuilderJSON { const shapeId = this._shape.shape?.id || ''; @@ -347,9 +432,7 @@ export class QueryBuilder shape: shapeId, }; - // Serialize fields — fields() already handles _selectAllLabels, so - // no separate branch is needed (T1: dead else-if removed). - const fs = this.fields(); + const fs = this._fieldsWithPreloads(); if (fs) { json.fields = fs.toJSON().fields; } @@ -373,6 +456,33 @@ export class QueryBuilder json.orderDirection = this._sortDirection; } + if (this._whereFn) { + json.where = serializeWherePath(processWhereClause(this._whereFn, this._shape)); + } else if (this._where) { + json.where = serializeWherePath(this._where); + } + + 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); + } + + if (this._minusEntries && this._minusEntries.length > 0) { + json.minusEntries = this._evaluateMinusEntries().map(serializeRawMinusEntry); + } else if (this._rawMinusEntries && this._rawMinusEntries.length > 0) { + json.minusEntries = this._rawMinusEntries.map(serializeRawMinusEntry); + } + + if (this._nullSubject) { + json.nullSubject = true; + } + if (this._pendingContextName) { + json.pendingContextName = this._pendingContextName; + } + return json; } @@ -406,12 +516,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; @@ -430,41 +571,18 @@ 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 let where: WherePath | undefined; if (this._whereFn) { where = processWhereClause(this._whereFn, this._shape); + } else if (this._where) { + where = this._where; } - // Evaluate sort callback let sortBy: SortByPath | undefined; if (this._sortByFn) { sortBy = evaluateSortCallback( @@ -472,6 +590,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 = { @@ -496,39 +616,10 @@ 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; } return input; diff --git a/src/queries/QueryBuilderSerialization.ts b/src/queries/QueryBuilderSerialization.ts new file mode 100644 index 0000000..463af81 --- /dev/null +++ b/src/queries/QueryBuilderSerialization.ts @@ -0,0 +1,299 @@ +/** + * 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 {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, +} 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 WhereMethods, + 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..d820d5b 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,287 @@ 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())); + }); +}); + +// ============================================================================= +// 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())); + }); +});