@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.
- package/dist/index.d.mts +69 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.d.ts +69 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +105 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +105 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +9 -0
- package/src/query-builder.ts +165 -0
- package/src/types.ts +73 -0
package/src/query-builder.ts
CHANGED
|
@@ -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 {
|