@spooky-sync/query-builder 0.0.1-canary.107 → 0.0.1-canary.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,11 @@ import type {
8
8
  SchemaAwareQueryModifier,
9
9
  SchemaAwareQueryModifierBuilder,
10
10
  WhereInput,
11
+ QueryPlan,
12
+ RelationPlan,
13
+ WhereNode,
14
+ WhereComparison,
15
+ ComparisonOp,
11
16
  } from './types';
12
17
  import type {
13
18
  TableNames,
@@ -838,9 +843,169 @@ export function buildQueryFromOptions<TModel extends GenericModel, IsOne extends
838
843
  0
839
844
  ),
840
845
  vars: Object.keys(vars).length > 0 ? vars : undefined,
846
+ // Engine-neutral plan mirrors the SELECT above for non-SurrealQL backends.
847
+ // Only SELECT carries a plan; the isOne→limit=1 mutation above is already
848
+ // reflected in `options.limit`, so the plan sees it too.
849
+ plan: method === 'SELECT' ? buildQueryPlan(tableName, options, schema) : undefined,
841
850
  };
842
851
  }
843
852
 
853
+ /**
854
+ * Build the engine-neutral {@link QueryPlan} for a SELECT. Mirrors the string
855
+ * assembly in {@link buildQueryFromOptions} / {@link buildSubquery} exactly so a
856
+ * non-SurrealQL backend produces results identical to the SurrealQL path.
857
+ */
858
+ function buildQueryPlan<TModel extends GenericModel, IsOne extends boolean>(
859
+ tableName: string,
860
+ options: QueryOptions<TModel, IsOne>,
861
+ schema: SchemaStructure
862
+ ): QueryPlan {
863
+ const plan: QueryPlan = { table: tableName };
864
+
865
+ if (options.select && options.select.length > 0 && !options.select.includes('*')) {
866
+ plan.select = options.select.filter((f) => f !== '*') as string[];
867
+ }
868
+
869
+ const parsedWhere = options.where
870
+ ? (parseObjectIdsToRecordId(options.where, tableName) as Record<string, unknown>)
871
+ : undefined;
872
+ if (parsedWhere && Object.keys(parsedWhere).length > 0) {
873
+ const nodes = buildWhereNodes(parsedWhere);
874
+ if (nodes.length > 0) plan.where = nodes;
875
+ }
876
+
877
+ if (options.orderBy && Object.keys(options.orderBy).length > 0) {
878
+ plan.orderBy = Object.entries(options.orderBy).map(
879
+ ([field, direction]) => [field, direction as 'asc' | 'desc']
880
+ );
881
+ }
882
+
883
+ if (options.limit !== undefined) plan.limit = options.limit;
884
+ if (options.offset !== undefined) plan.offset = options.offset;
885
+
886
+ if (options.related && options.related.length > 0) {
887
+ plan.relations = options.related.map((rel) => buildRelationPlan(rel, schema));
888
+ }
889
+
890
+ return plan;
891
+ }
892
+
893
+ /**
894
+ * Engine-neutral counterpart of {@link buildSubquery}. Resolves the same
895
+ * cardinality / foreign-key / nested-relation metadata but returns a structured
896
+ * {@link RelationPlan} instead of a SurrealQL subquery string.
897
+ */
898
+ function buildRelationPlan(
899
+ rel: RelatedQuery & { foreignKeyField?: string },
900
+ schema: SchemaStructure
901
+ ): RelationPlan {
902
+ const { relatedTable, alias, modifier, cardinality } = rel;
903
+ // Same fallback chain as buildSubquery (`rel.foreignKeyField || alias`); the
904
+ // top-level foreignKeyField is already reverse-resolved by `.related()`.
905
+ const foreignKeyField = (rel.foreignKeyField || alias || relatedTable) as string;
906
+
907
+ const plan: RelationPlan = {
908
+ alias: (alias || relatedTable) as string,
909
+ table: relatedTable,
910
+ cardinality,
911
+ foreignKeyField,
912
+ };
913
+
914
+ if (modifier) {
915
+ const modifierBuilder = new SchemaAwareQueryModifierBuilderImpl(relatedTable, schema);
916
+ modifier(modifierBuilder as any);
917
+ const subOptions = modifierBuilder._getOptions();
918
+
919
+ if (subOptions.select && subOptions.select.length > 0 && !subOptions.select.includes('*')) {
920
+ plan.select = subOptions.select.filter((f) => f !== '*') as string[];
921
+ }
922
+
923
+ if (subOptions.where && Object.keys(subOptions.where).length > 0) {
924
+ const parsedSubWhere = parseObjectIdsToRecordId(subOptions.where, relatedTable) as Record<
925
+ string,
926
+ unknown
927
+ >;
928
+ const nodes = buildWhereNodes(parsedSubWhere);
929
+ if (nodes.length > 0) plan.where = nodes;
930
+ }
931
+
932
+ if (subOptions.orderBy && Object.keys(subOptions.orderBy).length > 0) {
933
+ plan.orderBy = Object.entries(subOptions.orderBy).map(
934
+ ([field, direction]) => [field, direction as 'asc' | 'desc']
935
+ );
936
+ }
937
+
938
+ if (subOptions.limit !== undefined) plan.limit = subOptions.limit;
939
+
940
+ // Nested relations: re-resolve exactly as buildSubquery does — the child's
941
+ // foreignKeyField comes from the relatedTable-based lookup, not the reverse
942
+ // heuristic used for top-level relations.
943
+ if (subOptions.related && subOptions.related.length > 0) {
944
+ const resolvedNestedRels = subOptions.related.map((nestedRel) => {
945
+ const relationship = schema.relationships.find(
946
+ (r) => r.from === relatedTable && r.field === nestedRel.alias
947
+ );
948
+ if (relationship) {
949
+ const nestedForeignKeyField =
950
+ relationship.cardinality === 'many' ? relatedTable : (nestedRel.alias as string);
951
+ return {
952
+ ...nestedRel,
953
+ relatedTable: relationship.to,
954
+ cardinality: relationship.cardinality,
955
+ foreignKeyField: nestedForeignKeyField,
956
+ } as RelatedQuery & { foreignKeyField: string };
957
+ }
958
+ return nestedRel;
959
+ });
960
+ plan.relations = resolvedNestedRels.map((nestedRel) => buildRelationPlan(nestedRel, schema));
961
+ }
962
+ }
963
+
964
+ // one-to-one gets an implicit per-parent LIMIT 1 (matches buildSubquery).
965
+ if (cardinality === 'one' && plan.limit === undefined) {
966
+ plan.limit = 1;
967
+ }
968
+
969
+ return plan;
970
+ }
971
+
972
+ /**
973
+ * Convert a parsed WHERE object (string IDs already → RecordId) into the
974
+ * engine-neutral {@link WhereNode}[] conjunction. Mirrors the `_or` / comparison
975
+ * / equality handling in {@link buildQueryFromOptions}. A `$`-prefixed `_val`
976
+ * becomes a `paramRef` (with the leading `$` stripped).
977
+ */
978
+ function buildWhereNodes(parsedWhere: Record<string, unknown>): WhereNode[] {
979
+ const toComparison = (field: string, value: unknown): WhereComparison => {
980
+ if (value && typeof value === 'object' && '_op' in value && '_val' in value) {
981
+ const { _op, _val, _swap } = value as ComparisonOp;
982
+ if (typeof _val === 'string' && _val.startsWith('$')) {
983
+ return { field, op: _op, value: undefined, paramRef: _val.slice(1), swap: _swap };
984
+ }
985
+ return { field, op: _op, value: _val, swap: _swap };
986
+ }
987
+ return { field, op: '=', value };
988
+ };
989
+
990
+ const nodes: WhereNode[] = [];
991
+ for (const [key, value] of Object.entries(parsedWhere)) {
992
+ if (key === '_or' && Array.isArray(value)) {
993
+ const or: WhereComparison[] = [];
994
+ for (const branch of value) {
995
+ if (branch && typeof branch === 'object') {
996
+ for (const [bField, bVal] of Object.entries(branch as Record<string, unknown>)) {
997
+ or.push(toComparison(bField, bVal));
998
+ }
999
+ }
1000
+ }
1001
+ if (or.length > 0) nodes.push({ or });
1002
+ continue;
1003
+ }
1004
+ nodes.push(toComparison(key, value));
1005
+ }
1006
+ return nodes;
1007
+ }
1008
+
844
1009
  /**
845
1010
  * Build a subquery for a related field
846
1011
  */
package/src/types.ts CHANGED
@@ -23,6 +23,79 @@ export interface QueryInfo {
23
23
  query: string;
24
24
  hash: number;
25
25
  vars?: Record<string, unknown>;
26
+ /**
27
+ * Engine-neutral description of the same SELECT, used by non-SurrealQL local
28
+ * cache backends (e.g. SQLite) that cannot parse the `query` string. Only
29
+ * populated for `SELECT` (undefined for LIVE/UPDATE/DELETE). See `QueryPlan`.
30
+ */
31
+ plan?: QueryPlan;
32
+ }
33
+
34
+ /**
35
+ * A single WHERE comparison. `value` is the resolved value (string IDs already
36
+ * converted to `RecordId`); when `paramRef` is set the condition references an
37
+ * existing query param verbatim (`$name`) instead of an inline value. `swap`
38
+ * flips the operands (`value op field`), mirroring `ComparisonOp._swap`.
39
+ */
40
+ export interface WhereComparison {
41
+ field: string;
42
+ op: ComparisonOp['_op'];
43
+ value: unknown;
44
+ paramRef?: string;
45
+ swap?: boolean;
46
+ }
47
+
48
+ /** A parenthesised `(c1 OR c2 …)` group, from a `_or` fragment. */
49
+ export interface WhereOr {
50
+ or: WhereComparison[];
51
+ }
52
+
53
+ /**
54
+ * Engine-neutral WHERE: a top-level conjunction (AND) of comparisons and/or
55
+ * OR-groups. Mirrors `buildQueryFromOptions`'s condition assembly exactly.
56
+ */
57
+ export type WhereNode = WhereComparison | WhereOr;
58
+
59
+ /**
60
+ * Engine-neutral description of a SELECT query. Backends render it to their own
61
+ * dialect (SurrealQL, SQLite, …). Relations are resolved by the caller via
62
+ * level-ordered decomposition rather than nested projection, so `relations`
63
+ * carries the tree rather than a flattened subquery string.
64
+ */
65
+ export interface QueryPlan {
66
+ table: string;
67
+ /** Projection field names; undefined means all (`*`). */
68
+ select?: string[];
69
+ where?: WhereNode[];
70
+ orderBy?: [field: string, direction: 'asc' | 'desc'][];
71
+ limit?: number;
72
+ offset?: number;
73
+ relations?: RelationPlan[];
74
+ /**
75
+ * Window materialization: when set, the base rows are EXACTLY these record
76
+ * ids (the window the SSP already computed), ignoring `where`/`limit`/
77
+ * `offset`. `orderBy`, `select` and `relations` still apply. Set by
78
+ * {@link buildWindowMaterializationPlan}; see `window-query.ts`.
79
+ */
80
+ ids?: unknown[];
81
+ }
82
+
83
+ /**
84
+ * One `.related()` edge in a {@link QueryPlan}. Correlation:
85
+ * - `one` → parent[`foreignKeyField`] = child.id (attach `bucket[0] ?? null`)
86
+ * - `many` → child[`foreignKeyField`] = parent.id (attach `bucket`)
87
+ * `limit`/`orderBy` are applied PER PARENT during decomposition.
88
+ */
89
+ export interface RelationPlan {
90
+ alias: string;
91
+ table: string;
92
+ cardinality: 'one' | 'many';
93
+ foreignKeyField: string;
94
+ select?: string[];
95
+ where?: WhereNode[];
96
+ orderBy?: [field: string, direction: 'asc' | 'desc'][];
97
+ limit?: number;
98
+ relations?: RelationPlan[];
26
99
  }
27
100
 
28
101
  export interface RelatedQuery {