@syncular/typegen 0.4.1 → 0.5.1

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/src/query.ts CHANGED
@@ -135,6 +135,35 @@ export interface QueryColumn {
135
135
  /** `exact` = resolved to an IR column (plain ref); `fallback` = a computed
136
136
  * expression typed by the documented honest fallback. */
137
137
  readonly fidelity: 'exact' | 'fallback';
138
+ /** Proven physical origin for reactive row identity and diagnostics. */
139
+ readonly origin?: { readonly table: string; readonly column: string };
140
+ }
141
+
142
+ export interface QueryScopeBinding {
143
+ readonly table: string;
144
+ readonly variable: string;
145
+ readonly pattern: string;
146
+ readonly params: readonly string[];
147
+ }
148
+
149
+ export interface QueryCoverageBinding {
150
+ readonly table: string;
151
+ readonly variable: string;
152
+ readonly units: readonly string[];
153
+ readonly fixedScopes: readonly {
154
+ readonly variable: string;
155
+ readonly params: readonly string[];
156
+ }[];
157
+ }
158
+
159
+ export interface QueryReactiveMetadata {
160
+ readonly dependencies: readonly {
161
+ readonly table: string;
162
+ readonly scopes: readonly QueryScopeBinding[];
163
+ }[];
164
+ readonly coverage: readonly QueryCoverageBinding[];
165
+ /** Language-facing projection fields forming a proven unique row key. */
166
+ readonly rowKey?: readonly string[];
138
167
  }
139
168
 
140
169
  /** §7/§8 backend selection: neutralization (default), always-variants, or
@@ -174,6 +203,7 @@ export interface AnalyzedQuery {
174
203
  readonly columns: readonly QueryColumn[];
175
204
  /** IR tables this query reads (the useRawSql `{tables}` set), sorted. */
176
205
  readonly tables: readonly string[];
206
+ readonly reactive: QueryReactiveMetadata;
177
207
  /** §6 orderBy knob (`.syql` tier). When present, `sql`/`positionalSql`
178
208
  * carry the DEFAULT order-by tail and `positionalSqlBase` is the static
179
209
  * prefix emitters compose the selected column onto. */
@@ -480,15 +510,29 @@ function stripCommentsAndStrings(sql: string): string {
480
510
  const next = sql[i + 1];
481
511
  if (ch === '-' && next === '-') {
482
512
  const end = sql.indexOf('\n', i);
483
- i = end === -1 ? sql.length : end;
513
+ const stop = end === -1 ? sql.length : end;
514
+ out += ' '.repeat(stop - i);
515
+ i = stop;
484
516
  } else if (ch === '/' && next === '*') {
485
517
  const end = sql.indexOf('*/', i + 2);
486
- i = end === -1 ? sql.length : end + 2;
487
- out += ' ';
518
+ const stop = end === -1 ? sql.length : end + 2;
519
+ out += sql.slice(i, stop).replace(/[^\n]/g, ' ');
520
+ i = stop;
488
521
  } else if (ch === "'") {
489
- const end = sql.indexOf("'", i + 1);
490
- i = end === -1 ? sql.length : end + 1;
491
- out += ' ';
522
+ let stop = i + 1;
523
+ while (stop < sql.length) {
524
+ if (sql[stop] === "'" && sql[stop + 1] === "'") {
525
+ stop += 2;
526
+ continue;
527
+ }
528
+ if (sql[stop] === "'") {
529
+ stop += 1;
530
+ break;
531
+ }
532
+ stop += 1;
533
+ }
534
+ out += sql.slice(i, stop).replace(/[^\n]/g, ' ');
535
+ i = stop;
492
536
  } else {
493
537
  out += ch;
494
538
  i += 1;
@@ -691,6 +735,7 @@ function splitSelectList(sql: string): SelectItem[] | null {
691
735
  const PLAIN_REF_RE = new RegExp(`^(?:(${IDENT})\\.)?(${IDENT})$`);
692
736
 
693
737
  interface ResolvedSource {
738
+ readonly table: string;
694
739
  readonly column: IrTable['columns'][number];
695
740
  }
696
741
 
@@ -710,14 +755,14 @@ function resolveSource(
710
755
  if (ref === undefined) return null;
711
756
  const table = byName.get(ref.table);
712
757
  const col = table?.columns.find((c) => c.name === columnName);
713
- return col === undefined ? null : { column: col };
758
+ return col === undefined ? null : { table: ref.table, column: col };
714
759
  }
715
760
  // Unqualified: search every FROM/JOIN table (SQLite already resolved
716
761
  // ambiguity; a single-table query is the common case).
717
762
  for (const ref of refs) {
718
763
  const table = byName.get(ref.table);
719
764
  const col = table?.columns.find((c) => c.name === columnName);
720
- if (col !== undefined) return { column: col };
765
+ if (col !== undefined) return { table: ref.table, column: col };
721
766
  }
722
767
  return null;
723
768
  }
@@ -821,6 +866,156 @@ function fallbackColumnType(expr: string): IrColumnType {
821
866
  return 'string';
822
867
  }
823
868
 
869
+ function inferReactiveMetadata(
870
+ sql: string,
871
+ refs: readonly TableRef[],
872
+ ir: IrDocument,
873
+ params: readonly QueryParam[],
874
+ columns: readonly QueryColumn[],
875
+ ): QueryReactiveMetadata {
876
+ const conservative = /\b(?:UNION|EXCEPT|INTERSECT)\b/i.test(
877
+ stripCommentsAndStrings(sql),
878
+ );
879
+ const requiredAt = (index: number): boolean => {
880
+ const cleaned = stripCommentsAndStrings(sql);
881
+ const where = /\bWHERE\b/i.exec(cleaned);
882
+ if (where === null || index < where.index + where[0].length) return false;
883
+ const matchStack: number[] = [];
884
+ for (
885
+ let cursor = where.index + where[0].length;
886
+ cursor < index;
887
+ cursor += 1
888
+ ) {
889
+ if (cleaned[cursor] === '(') matchStack.push(cursor);
890
+ else if (cleaned[cursor] === ')') matchStack.pop();
891
+ }
892
+ const stack: number[] = [];
893
+ for (
894
+ let cursor = where.index + where[0].length;
895
+ cursor < cleaned.length;
896
+ cursor += 1
897
+ ) {
898
+ const char = cleaned[cursor];
899
+ if (char === '(') stack.push(cursor);
900
+ else if (char === ')') stack.pop();
901
+ if (
902
+ cursor > index &&
903
+ /^(?:GROUP\s+BY|ORDER\s+BY|LIMIT|HAVING)\b/i.test(cleaned.slice(cursor))
904
+ ) {
905
+ break;
906
+ }
907
+ if (
908
+ /^OR\b/i.test(cleaned.slice(cursor)) &&
909
+ stack.every((open, stackIndex) => matchStack[stackIndex] === open)
910
+ ) {
911
+ return false;
912
+ }
913
+ }
914
+ return true;
915
+ };
916
+ const byParam = new Map(params.map((param) => [param.name, param] as const));
917
+ const dependencies: Array<{
918
+ table: string;
919
+ scopes: QueryScopeBinding[];
920
+ }> = [];
921
+ const coverage: QueryCoverageBinding[] = [];
922
+
923
+ for (const tableName of [...new Set(refs.map((ref) => ref.table))].sort()) {
924
+ const table = ir.tables.find((candidate) => candidate.name === tableName);
925
+ const tableRefs = refs.filter((ref) => ref.table === tableName);
926
+ const scopes: QueryScopeBinding[] = [];
927
+ if (table !== undefined && tableRefs.length === 1 && !conservative) {
928
+ const qualifier = tableRefs[0]?.alias;
929
+ for (const scope of table.scopes) {
930
+ const column = scope.column.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
931
+ const qualified =
932
+ qualifier === undefined
933
+ ? `(?:${IDENT}\\.)?${column}`
934
+ : `(?:${qualifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.)?${column}`;
935
+ const found: string[] = [];
936
+ const equality = new RegExp(
937
+ `${qualified}\\s*(?:=|==|\\bIS\\b)\\s*:(${IDENT})\\b|:(${IDENT})\\b\\s*(?:=|==)\\s*${qualified}`,
938
+ 'gi',
939
+ );
940
+ for (const match of sql.matchAll(equality)) {
941
+ if (!requiredAt(match.index)) continue;
942
+ const name = match[1] ?? match[2];
943
+ const param = name === undefined ? undefined : byParam.get(name);
944
+ if (
945
+ param !== undefined &&
946
+ param.optional !== true &&
947
+ param.flag !== true
948
+ ) {
949
+ found.push(param.name);
950
+ }
951
+ }
952
+ const inList = new RegExp(`${qualified}\\s+IN\\s*\\(([^)]*)\\)`, 'gi');
953
+ for (const match of sql.matchAll(inList)) {
954
+ if (!requiredAt(match.index)) continue;
955
+ for (const paramMatch of (match[1] ?? '').matchAll(
956
+ /:([A-Za-z_][A-Za-z0-9_]*)/g,
957
+ )) {
958
+ const param = byParam.get(paramMatch[1] as string);
959
+ if (
960
+ param !== undefined &&
961
+ param.optional !== true &&
962
+ param.flag !== true
963
+ ) {
964
+ found.push(param.name);
965
+ }
966
+ }
967
+ }
968
+ const unique = [...new Set(found)];
969
+ if (unique.length > 0) {
970
+ scopes.push({
971
+ table: tableName,
972
+ variable: scope.variable,
973
+ pattern: scope.pattern,
974
+ params: unique,
975
+ });
976
+ }
977
+ }
978
+ if (table.scopes.length > 0 && scopes.length === table.scopes.length) {
979
+ const variable = scopes[0] as QueryScopeBinding;
980
+ coverage.push({
981
+ table: tableName,
982
+ variable: variable.variable,
983
+ units: variable.params,
984
+ fixedScopes: scopes.slice(1).map((scope) => ({
985
+ variable: scope.variable,
986
+ params: scope.params,
987
+ })),
988
+ });
989
+ }
990
+ }
991
+ dependencies.push({ table: tableName, scopes });
992
+ }
993
+
994
+ let rowKey: readonly string[] | undefined;
995
+ const simpleSingleTable =
996
+ refs.length === 1 &&
997
+ !/\b(?:DISTINCT|GROUP\s+BY|UNION|EXCEPT|INTERSECT)\b/i.test(
998
+ stripCommentsAndStrings(sql),
999
+ );
1000
+ if (simpleSingleTable) {
1001
+ const table = ir.tables.find(
1002
+ (candidate) => candidate.name === refs[0]?.table,
1003
+ );
1004
+ const primary = columns.find(
1005
+ (column) =>
1006
+ table !== undefined &&
1007
+ column.origin?.table === table.name &&
1008
+ column.origin?.column === table.primaryKey,
1009
+ );
1010
+ if (primary !== undefined) rowKey = [primary.langName];
1011
+ }
1012
+ return {
1013
+ dependencies,
1014
+ coverage,
1015
+ ...(rowKey !== undefined ? { rowKey } : {}),
1016
+ };
1017
+ }
1018
+
824
1019
  // -- public API ---------------------------------------------------------------
825
1020
 
826
1021
  export interface QueryDb {
@@ -989,6 +1184,7 @@ export function analyzeStatement(
989
1184
  type: source.column.type,
990
1185
  nullable: source.column.nullable,
991
1186
  fidelity: 'exact',
1187
+ origin: { table: source.table, column: source.column.name },
992
1188
  };
993
1189
  }
994
1190
  // Fallback: decltype affinity if present, else the expr-shape fallback.
@@ -1051,6 +1247,8 @@ export function analyzeStatement(
1051
1247
  }
1052
1248
  }
1053
1249
 
1250
+ const reactive = inferReactiveMetadata(sql, refs, ir, params, columns);
1251
+
1054
1252
  return {
1055
1253
  name,
1056
1254
  file,
@@ -1060,6 +1258,7 @@ export function analyzeStatement(
1060
1258
  params,
1061
1259
  columns,
1062
1260
  tables,
1261
+ reactive,
1063
1262
  };
1064
1263
  }
1065
1264
 
@@ -0,0 +1,277 @@
1
+ /** Stable, span-free semantic AST used by revision-1 conformance fixtures. */
2
+ import { isSyqlTrivia, type SyqlToken } from './syql-lexer';
3
+ import type {
4
+ SyqlDeclaration,
5
+ SyqlGroupMember,
6
+ SyqlQueryParameter,
7
+ SyqlSyntaxFile,
8
+ SyqlTemplate,
9
+ SyqlValueType,
10
+ } from './syql-parser';
11
+ import type { SyqlEmbeddedNode } from './syql-template-parser';
12
+
13
+ export interface SyqlSemanticToken {
14
+ readonly kind: SyqlToken['kind'];
15
+ readonly text: string;
16
+ }
17
+
18
+ export interface SyqlSemanticType {
19
+ readonly base: SyqlValueType['base'];
20
+ readonly nullable: boolean;
21
+ }
22
+
23
+ export type SyqlSemanticTemplateNode =
24
+ | {
25
+ readonly kind: 'sql';
26
+ readonly tokens: readonly SyqlSemanticToken[];
27
+ }
28
+ | {
29
+ readonly kind: 'predicate-call';
30
+ readonly name: string;
31
+ readonly arguments: readonly string[];
32
+ }
33
+ | {
34
+ readonly kind: 'when';
35
+ readonly controls: readonly string[];
36
+ readonly body: readonly SyqlSemanticTemplateNode[];
37
+ }
38
+ | {
39
+ readonly kind: 'scope' | 'cover';
40
+ readonly bindings: readonly {
41
+ readonly qualifier: string;
42
+ readonly column: string;
43
+ readonly operator: 'equal' | 'in';
44
+ readonly values: readonly string[];
45
+ }[];
46
+ };
47
+
48
+ export interface SyqlSemanticTemplate {
49
+ readonly mode: SyqlTemplate['tree']['mode'];
50
+ readonly nodes: readonly SyqlSemanticTemplateNode[];
51
+ }
52
+
53
+ export type SyqlSemanticQueryParameter =
54
+ | {
55
+ readonly kind: 'value';
56
+ readonly name: string;
57
+ readonly optional: boolean;
58
+ readonly type?: SyqlSemanticType;
59
+ }
60
+ | {
61
+ readonly kind: 'switch';
62
+ readonly name: string;
63
+ readonly optional: true;
64
+ }
65
+ | {
66
+ readonly kind: 'group';
67
+ readonly name: string;
68
+ readonly optional: true;
69
+ readonly members: readonly {
70
+ readonly name: string;
71
+ readonly type?: SyqlSemanticType;
72
+ }[];
73
+ };
74
+
75
+ export type SyqlSemanticDeclaration =
76
+ | {
77
+ readonly kind: 'predicate';
78
+ readonly name: string;
79
+ readonly parameters: readonly {
80
+ readonly name: string;
81
+ readonly type?: SyqlSemanticType;
82
+ }[];
83
+ readonly body: SyqlSemanticTemplate;
84
+ }
85
+ | {
86
+ readonly kind: 'query';
87
+ readonly name: string;
88
+ readonly parameters: readonly SyqlSemanticQueryParameter[];
89
+ readonly sql: SyqlSemanticTemplate;
90
+ readonly sort?: {
91
+ readonly control: string;
92
+ readonly defaultProfile: string;
93
+ readonly profiles: readonly {
94
+ readonly name: string;
95
+ readonly order: SyqlSemanticTemplate;
96
+ }[];
97
+ };
98
+ readonly page?: {
99
+ readonly control: string;
100
+ readonly defaultSize: number;
101
+ readonly maxSize: number;
102
+ };
103
+ readonly identity?: readonly string[];
104
+ };
105
+
106
+ export interface SyqlSemanticFile {
107
+ readonly kind: 'syql-file';
108
+ readonly revision: 1;
109
+ readonly imports: readonly {
110
+ readonly path: string;
111
+ readonly items: readonly {
112
+ readonly imported: string;
113
+ readonly local: string;
114
+ }[];
115
+ }[];
116
+ readonly declarations: readonly SyqlSemanticDeclaration[];
117
+ }
118
+
119
+ function semanticType(
120
+ type: SyqlValueType | undefined,
121
+ ): SyqlSemanticType | undefined {
122
+ return type === undefined
123
+ ? undefined
124
+ : { base: type.base, nullable: type.nullable };
125
+ }
126
+
127
+ function semanticMember(member: SyqlGroupMember): {
128
+ readonly name: string;
129
+ readonly type?: SyqlSemanticType;
130
+ } {
131
+ const type = semanticType(member.type);
132
+ return { name: member.name, ...(type === undefined ? {} : { type }) };
133
+ }
134
+
135
+ function semanticParameter(
136
+ parameter: SyqlQueryParameter,
137
+ ): SyqlSemanticQueryParameter {
138
+ if (parameter.kind === 'switch') {
139
+ return { kind: 'switch', name: parameter.name, optional: true };
140
+ }
141
+ if (parameter.kind === 'group') {
142
+ return {
143
+ kind: 'group',
144
+ name: parameter.name,
145
+ optional: true,
146
+ members: parameter.members.map(semanticMember),
147
+ };
148
+ }
149
+ const type = semanticType(parameter.type);
150
+ return {
151
+ kind: 'value',
152
+ name: parameter.name,
153
+ optional: parameter.optional,
154
+ ...(type === undefined ? {} : { type }),
155
+ };
156
+ }
157
+
158
+ function semanticRawTokens(
159
+ tokens: readonly SyqlToken[],
160
+ ): readonly SyqlSemanticToken[] {
161
+ return tokens
162
+ .filter((token) => !isSyqlTrivia(token))
163
+ .map((token) => ({ kind: token.kind, text: token.text }));
164
+ }
165
+
166
+ function semanticNode(
167
+ node: SyqlEmbeddedNode,
168
+ ): SyqlSemanticTemplateNode | undefined {
169
+ if (node.kind === 'raw') {
170
+ const tokens = semanticRawTokens(node.tokens);
171
+ return tokens.length === 0 ? undefined : { kind: 'sql', tokens };
172
+ }
173
+ if (node.kind === 'predicate-call') {
174
+ return {
175
+ kind: 'predicate-call',
176
+ name: node.name,
177
+ arguments: node.arguments.map((argument) => argument.name),
178
+ };
179
+ }
180
+ if (node.kind === 'when') {
181
+ return {
182
+ kind: 'when',
183
+ controls: node.controls,
184
+ body: semanticNodes(node.body.nodes),
185
+ };
186
+ }
187
+ return {
188
+ kind: node.kind,
189
+ bindings: node.bindings.map((binding) => ({
190
+ qualifier: binding.column.qualifier,
191
+ column: binding.column.name,
192
+ operator: binding.operator,
193
+ values: binding.values.map((value) => value.name),
194
+ })),
195
+ };
196
+ }
197
+
198
+ function semanticNodes(
199
+ nodes: readonly SyqlEmbeddedNode[],
200
+ ): readonly SyqlSemanticTemplateNode[] {
201
+ return nodes.flatMap((node) => {
202
+ const semantic = semanticNode(node);
203
+ return semantic === undefined ? [] : [semantic];
204
+ });
205
+ }
206
+
207
+ function semanticTemplate(template: SyqlTemplate): SyqlSemanticTemplate {
208
+ return {
209
+ mode: template.tree.mode,
210
+ nodes: semanticNodes(template.tree.nodes),
211
+ };
212
+ }
213
+
214
+ function semanticDeclaration(
215
+ declaration: SyqlDeclaration,
216
+ ): SyqlSemanticDeclaration {
217
+ if (declaration.kind === 'predicate') {
218
+ return {
219
+ kind: 'predicate',
220
+ name: declaration.name,
221
+ parameters: declaration.parameters.map((parameter) => {
222
+ const type = semanticType(parameter.type);
223
+ return {
224
+ name: parameter.name,
225
+ ...(type === undefined ? {} : { type }),
226
+ };
227
+ }),
228
+ body: semanticTemplate(declaration.body),
229
+ };
230
+ }
231
+ return {
232
+ kind: 'query',
233
+ name: declaration.name,
234
+ parameters: declaration.parameters.map(semanticParameter),
235
+ sql: semanticTemplate(declaration.sql.body),
236
+ ...(declaration.sort === undefined
237
+ ? {}
238
+ : {
239
+ sort: {
240
+ control: declaration.sort.control,
241
+ defaultProfile: declaration.sort.defaultProfile,
242
+ profiles: declaration.sort.profiles.map((profile) => ({
243
+ name: profile.name,
244
+ order: semanticTemplate(profile.order),
245
+ })),
246
+ },
247
+ }),
248
+ ...(declaration.page === undefined
249
+ ? {}
250
+ : {
251
+ page: {
252
+ control: declaration.page.control,
253
+ defaultSize: declaration.page.defaultSize,
254
+ maxSize: declaration.page.maxSize,
255
+ },
256
+ }),
257
+ ...(declaration.identity === undefined
258
+ ? {}
259
+ : { identity: declaration.identity.fields }),
260
+ };
261
+ }
262
+
263
+ /** Return the stable syntax/semantic AST pinned by `spec/syql` fixtures. */
264
+ export function toSyqlSemanticAst(file: SyqlSyntaxFile): SyqlSemanticFile {
265
+ return {
266
+ kind: 'syql-file',
267
+ revision: 1,
268
+ imports: file.imports.map((declaration) => ({
269
+ path: declaration.path,
270
+ items: declaration.items.map((item) => ({
271
+ imported: item.imported,
272
+ local: item.local,
273
+ })),
274
+ })),
275
+ declarations: file.declarations.map(semanticDeclaration),
276
+ };
277
+ }