@syncular/typegen 0.6.0 → 0.7.0

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.
@@ -33,14 +33,18 @@ export interface SyqlValueParameter {
33
33
  readonly name: string;
34
34
  readonly optional: boolean;
35
35
  readonly type?: SyqlValueType;
36
+ /** A source-level default. Revision 1 currently permits only `bool = false`. */
37
+ readonly default?: false;
36
38
  readonly span: SyqlSourceSpan;
37
39
  readonly nameSpan: SyqlSourceSpan;
38
40
  }
39
41
 
40
- export interface SyqlSwitchParameter {
41
- readonly kind: 'switch';
42
+ export interface SyqlRangeParameter {
43
+ readonly kind: 'range';
42
44
  readonly name: string;
43
- readonly optional: true;
45
+ readonly optional: boolean;
46
+ /** Element type, inferred from the left side of BETWEEN when omitted. */
47
+ readonly type?: SyqlValueType;
44
48
  readonly span: SyqlSourceSpan;
45
49
  readonly nameSpan: SyqlSourceSpan;
46
50
  }
@@ -63,7 +67,7 @@ export interface SyqlGroupParameter {
63
67
 
64
68
  export type SyqlQueryParameter =
65
69
  | SyqlValueParameter
66
- | SyqlSwitchParameter
70
+ | SyqlRangeParameter
67
71
  | SyqlGroupParameter;
68
72
 
69
73
  export interface SyqlPredicateParameter {
@@ -106,12 +110,6 @@ export interface SyqlPredicateDeclaration {
106
110
  readonly nameSpan: SyqlSourceSpan;
107
111
  }
108
112
 
109
- export interface SyqlSqlSection {
110
- readonly kind: 'sql';
111
- readonly body: SyqlTemplate;
112
- readonly span: SyqlSourceSpan;
113
- }
114
-
115
113
  export interface SyqlSortProfile {
116
114
  readonly name: string;
117
115
  readonly order: SyqlTemplate;
@@ -128,8 +126,8 @@ export interface SyqlSortSection {
128
126
  readonly controlSpan: SyqlSourceSpan;
129
127
  }
130
128
 
131
- export interface SyqlPageDeclaration {
132
- readonly kind: 'page';
129
+ export interface SyqlLimitDeclaration {
130
+ readonly kind: 'limit';
133
131
  readonly control: string;
134
132
  readonly defaultSize: number;
135
133
  readonly maxSize: number;
@@ -137,20 +135,22 @@ export interface SyqlPageDeclaration {
137
135
  readonly controlSpan: SyqlSourceSpan;
138
136
  }
139
137
 
140
- export interface SyqlIdentityDeclaration {
141
- readonly kind: 'identity';
142
- readonly fields: readonly string[];
138
+ export interface SyqlSyncDimension {
139
+ readonly qualifier: string;
140
+ readonly column: string;
143
141
  readonly span: SyqlSourceSpan;
144
142
  }
145
143
 
146
144
  export interface SyqlQueryDeclaration {
147
145
  readonly kind: 'query';
148
146
  readonly name: string;
147
+ readonly sync: boolean;
148
+ readonly syncBy?: SyqlSyncDimension;
149
149
  readonly parameters: readonly SyqlQueryParameter[];
150
- readonly sql: SyqlSqlSection;
150
+ /** The SQL-shaped statement body, excluding its terminating semicolon. */
151
+ readonly statement: SyqlTemplate;
151
152
  readonly sort?: SyqlSortSection;
152
- readonly page?: SyqlPageDeclaration;
153
- readonly identity?: SyqlIdentityDeclaration;
153
+ readonly limit?: SyqlLimitDeclaration;
154
154
  readonly span: SyqlSourceSpan;
155
155
  readonly nameSpan: SyqlSourceSpan;
156
156
  }
@@ -180,10 +180,10 @@ export type SyqlParseErrorCode =
180
180
  | 'SYQL2008_INVALID_MEMBER'
181
181
  | 'SYQL2009_INVALID_INTEGER'
182
182
  | 'SYQL2010_INVALID_PAGE_RANGE'
183
- | 'SYQL2011_INVALID_PARAMETER';
183
+ | 'SYQL2011_INVALID_PARAMETER'
184
+ | 'SYQL2012_INVALID_QUERY_BODY';
184
185
 
185
186
  const CAMEL_IDENT_RE = /^[a-z][A-Za-z0-9]*$/;
186
- const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
187
187
  const INTEGER_LITERAL_RE = /^(?:0|[1-9][0-9]*)$/;
188
188
  const BASE_TYPES = new Set<SyqlBaseType>([
189
189
  'string',
@@ -198,29 +198,29 @@ const BASE_TYPES = new Set<SyqlBaseType>([
198
198
  const RESERVED_NAMES = new Set([
199
199
  'as',
200
200
  'blob_ref',
201
+ 'bool',
201
202
  'boolean',
202
203
  'by',
203
204
  'bytes',
204
- 'cover',
205
205
  'crdt',
206
206
  'default',
207
207
  'float',
208
+ 'false',
208
209
  'from',
209
- 'identity',
210
210
  'import',
211
211
  'in',
212
212
  'integer',
213
213
  'json',
214
214
  'max',
215
215
  'null',
216
- 'page',
216
+ 'limit',
217
+ 'present',
217
218
  'predicate',
218
219
  'query',
219
- 'scope',
220
220
  'sort',
221
- 'sql',
222
221
  'string',
223
- 'switch',
222
+ 'sync',
223
+ 'true',
224
224
  'when',
225
225
  ]);
226
226
 
@@ -236,6 +236,13 @@ interface ParsedQueryParameters {
236
236
  readonly bindNames: Set<string>;
237
237
  }
238
238
 
239
+ interface ParsedQueryBody {
240
+ readonly statement: SyqlTemplate;
241
+ readonly sort?: SyqlSortSection;
242
+ readonly limit?: SyqlLimitDeclaration;
243
+ readonly close: SyqlToken;
244
+ }
245
+
239
246
  function spanBetween(start: SyqlToken, end: SyqlToken): SyqlSourceSpan {
240
247
  return {
241
248
  file: start.span.file,
@@ -301,8 +308,8 @@ class Parser {
301
308
  );
302
309
  declarations.push(declaration);
303
310
  predicates.push(declaration);
304
- } else if (keyword.text === 'query') {
305
- const declaration = this.#parseQuery();
311
+ } else if (keyword.text === 'query' || keyword.text === 'sync') {
312
+ const declaration = this.#parseQuery(keyword.text === 'sync');
306
313
  this.#claimName(
307
314
  fileNames,
308
315
  declaration.name,
@@ -315,7 +322,7 @@ class Parser {
315
322
  this.#fail(
316
323
  'SYQL2008_INVALID_MEMBER',
317
324
  keyword,
318
- `expected import, predicate, or query; found ${JSON.stringify(keyword.text)}`,
325
+ `expected import, predicate, query, or sync query; found ${JSON.stringify(keyword.text)}`,
319
326
  );
320
327
  }
321
328
  }
@@ -462,54 +469,89 @@ class Parser {
462
469
  };
463
470
  }
464
471
 
465
- #parseQuery(): SyqlQueryDeclaration {
466
- const start = this.#expectText('query');
472
+ #parseQuery(sync: boolean): SyqlQueryDeclaration {
473
+ const start = sync ? this.#expectText('sync') : this.#peek();
474
+ this.#expectText('query');
467
475
  const name = this.#parseCamelName('query name');
468
476
  const parsedParameters = this.#parseQueryParameters();
469
- const publicNames = parsedParameters.publicNames;
470
- this.#expectText('{');
471
-
472
- if (this.#peek().text !== 'sql') {
473
- this.#fail(
474
- 'SYQL2008_INVALID_MEMBER',
475
- this.#peek(),
476
- 'a query body must begin with exactly one sql { ... } section',
477
- );
478
- }
479
- const sql = this.#parseSqlSection();
480
-
481
- let sort: SyqlSortSection | undefined;
482
- let page: SyqlPageDeclaration | undefined;
483
- let identity: SyqlIdentityDeclaration | undefined;
484
-
485
- if (this.#peek().text === 'sort') {
486
- sort = this.#parseSortSection(publicNames);
487
- }
488
- if (this.#peek().text === 'page') {
489
- page = this.#parsePageDeclaration(publicNames);
490
- }
491
- if (this.#peek().text === 'identity') {
492
- identity = this.#parseIdentityDeclaration();
477
+ let syncBy: SyqlSyncDimension | undefined;
478
+ if (this.#peek().text === 'by') {
479
+ const by = this.#take();
480
+ if (!sync) {
481
+ this.#fail(
482
+ 'SYQL2012_INVALID_QUERY_BODY',
483
+ by,
484
+ '`by table.scope` is valid only on a sync query',
485
+ );
486
+ }
487
+ const qualifier = this.#expectKind('identifier', 'sync table or alias');
488
+ this.#expectText('.');
489
+ const column = this.#expectKind('identifier', 'sync scope column');
490
+ syncBy = {
491
+ qualifier: qualifier.text,
492
+ column: column.text,
493
+ span: spanBetween(qualifier, column),
494
+ };
493
495
  }
494
-
495
- if (this.#peek().text !== '}') {
496
- this.#fail(
497
- 'SYQL2008_INVALID_MEMBER',
498
- this.#peek(),
499
- `unexpected or out-of-order query member ${JSON.stringify(this.#peek().text)}`,
500
- );
496
+ const declaredRanges = new Set(
497
+ parsedParameters.parameters.flatMap((parameter) =>
498
+ parameter.kind === 'range' ? [parameter.name] : [],
499
+ ),
500
+ );
501
+ const body = this.#parseQueryBody(
502
+ parsedParameters.publicNames,
503
+ declaredRanges,
504
+ );
505
+ const implicitRanges = this.#rangeShorthandNames(
506
+ body.statement.tokens,
507
+ declaredRanges,
508
+ );
509
+ const parameters = parsedParameters.parameters.map((parameter) => {
510
+ if (!implicitRanges.has(parameter.name)) return parameter;
511
+ if (parameter.kind === 'group') {
512
+ this.#fail(
513
+ 'SYQL2011_INVALID_PARAMETER',
514
+ this.#tokenAt(parameter.nameSpan),
515
+ `range shorthand :${parameter.name} must name a scalar query input`,
516
+ );
517
+ }
518
+ if (parameter.kind === 'range') return parameter;
519
+ if (parameter.default !== undefined) {
520
+ this.#fail(
521
+ 'SYQL2011_INVALID_PARAMETER',
522
+ this.#tokenAt(parameter.nameSpan),
523
+ `range input ${parameter.name} cannot have a boolean default`,
524
+ );
525
+ }
526
+ return {
527
+ kind: 'range' as const,
528
+ name: parameter.name,
529
+ optional: parameter.optional,
530
+ ...(parameter.type === undefined ? {} : { type: parameter.type }),
531
+ span: parameter.span,
532
+ nameSpan: parameter.nameSpan,
533
+ };
534
+ });
535
+ for (const parameter of parameters) {
536
+ if (parameter.kind === 'range' && !implicitRanges.has(parameter.name)) {
537
+ this.#fail(
538
+ 'SYQL2011_INVALID_PARAMETER',
539
+ this.#tokenAt(parameter.nameSpan),
540
+ `range input ${parameter.name} must be used as BETWEEN :${parameter.name}`,
541
+ );
542
+ }
501
543
  }
502
- const close = this.#expectText('}');
503
544
 
504
545
  return {
505
546
  kind: 'query',
506
547
  name: name.text,
507
- parameters: parsedParameters.parameters,
508
- sql,
509
- ...(sort === undefined ? {} : { sort }),
510
- ...(page === undefined ? {} : { page }),
511
- ...(identity === undefined ? {} : { identity }),
512
- span: spanBetween(start, close),
548
+ sync,
549
+ ...(syncBy === undefined ? {} : { syncBy }),
550
+ parameters,
551
+ statement: body.statement,
552
+ ...(body.sort === undefined ? {} : { sort: body.sort }),
553
+ ...(body.limit === undefined ? {} : { limit: body.limit }),
554
+ span: spanBetween(start, body.close),
513
555
  nameSpan: name.span,
514
556
  };
515
557
  }
@@ -526,7 +568,19 @@ class Parser {
526
568
  const optional = this.#peek().text === '?';
527
569
  if (optional) this.#take();
528
570
 
529
- if (optional && this.#peek().text === '(') {
571
+ if (
572
+ optional &&
573
+ this.#peek().text === ':' &&
574
+ this.#peekSignificantAfter(1)?.text === '{'
575
+ ) {
576
+ this.#take();
577
+ if (this.#peek().text !== '{') {
578
+ this.#fail(
579
+ 'SYQL2011_INVALID_PARAMETER',
580
+ this.#peek(),
581
+ 'an optional structured input must use `name?: { ... }`',
582
+ );
583
+ }
530
584
  if (publicNames.has(name.text)) {
531
585
  this.#duplicate(name, 'public query input');
532
586
  }
@@ -562,9 +616,9 @@ class Parser {
562
616
  });
563
617
  if (this.#peek().text !== ',') break;
564
618
  this.#take();
565
- if (this.#peek().text === ')') break;
619
+ if (this.#peek().text === '}') break;
566
620
  }
567
- const groupClose = this.#expectText(')');
621
+ const groupClose = this.#expectText('}');
568
622
  if (members.length < 2) {
569
623
  this.#fail(
570
624
  'SYQL2011_INVALID_PARAMETER',
@@ -593,19 +647,24 @@ class Parser {
593
647
  if (this.#peek().text === ':') {
594
648
  const colon = this.#take();
595
649
  const typeName = this.#expectKind('identifier', 'parameter type');
596
- if (typeName.text === 'switch') {
597
- if (!optional) {
598
- this.#fail(
599
- 'SYQL2011_INVALID_PARAMETER',
600
- typeName,
601
- `switch ${name.text} must use the exact optional form ${name.text}?: switch`,
602
- );
650
+ if (typeName.text === 'range') {
651
+ if (bindNames.has(name.text)) {
652
+ this.#duplicate(name, 'query bind');
603
653
  }
654
+ bindNames.add(name.text);
655
+ this.#expectText('<');
656
+ const elementName = this.#expectKind(
657
+ 'identifier',
658
+ 'range element type',
659
+ );
660
+ const type = this.#parseValueTypeAfterName(colon, elementName);
661
+ const close = this.#expectText('>');
604
662
  parameters.push({
605
- kind: 'switch',
663
+ kind: 'range',
606
664
  name: name.text,
607
- optional: true,
608
- span: spanBetween(name, typeName),
665
+ optional,
666
+ type,
667
+ span: spanBetween(name, close),
609
668
  nameSpan: name.span,
610
669
  });
611
670
  } else {
@@ -614,16 +673,30 @@ class Parser {
614
673
  this.#duplicate(name, 'query bind');
615
674
  }
616
675
  bindNames.add(name.text);
676
+ let end = type.span.end;
677
+ let defaultValue: false | undefined;
678
+ if (this.#peek().text === '=') {
679
+ this.#take();
680
+ const value = this.#expectText('false');
681
+ if (type.base !== 'boolean' || type.nullable || optional) {
682
+ this.#fail(
683
+ 'SYQL2011_INVALID_PARAMETER',
684
+ value,
685
+ 'only a non-optional bool parameter may declare `= false`',
686
+ );
687
+ }
688
+ defaultValue = false;
689
+ end = value.span.end;
690
+ }
617
691
  parameters.push({
618
692
  kind: 'value',
619
693
  name: name.text,
620
694
  optional,
621
695
  type,
622
- span: spanFromPositions(
623
- this.#file,
624
- name.span.start,
625
- type.span.end,
626
- ),
696
+ ...(defaultValue === undefined
697
+ ? {}
698
+ : { default: defaultValue }),
699
+ span: spanFromPositions(this.#file, name.span.start, end),
627
700
  nameSpan: name.span,
628
701
  });
629
702
  }
@@ -657,18 +730,265 @@ class Parser {
657
730
  return { parameters, publicNames, bindNames };
658
731
  }
659
732
 
660
- #parseSqlSection(): SyqlSqlSection {
661
- const start = this.#expectText('sql');
662
- const block = this.#parseTemplateBlock('sql section', 'statement');
733
+ #parseQueryBody(
734
+ publicNames: Set<string>,
735
+ declaredRanges: ReadonlySet<string>,
736
+ ): ParsedQueryBody {
737
+ const open = this.#expectText('{');
738
+ const bodyStart = this.#index;
739
+ let cursor = bodyStart;
740
+ let braceDepth = 1;
741
+ let parenDepth = 0;
742
+ let terminatorIndex: number | undefined;
743
+ while (cursor < this.#tokens.length) {
744
+ const token = this.#tokens[cursor] as SyqlToken;
745
+ if (token.kind === 'eof') {
746
+ this.#fail(
747
+ 'SYQL2001_EXPECTED_TOKEN',
748
+ token,
749
+ 'expected ; and } to close query body',
750
+ );
751
+ }
752
+ if (token.text === '(') parenDepth += 1;
753
+ else if (token.text === ')') parenDepth -= 1;
754
+ else if (token.text === '{') braceDepth += 1;
755
+ else if (token.text === '}') {
756
+ if (braceDepth === 1) {
757
+ this.#fail(
758
+ 'SYQL2012_INVALID_QUERY_BODY',
759
+ token,
760
+ 'a query statement must end with a semicolon',
761
+ );
762
+ }
763
+ braceDepth -= 1;
764
+ } else if (token.text === ';' && braceDepth === 1 && parenDepth === 0) {
765
+ terminatorIndex = cursor;
766
+ break;
767
+ }
768
+ cursor += 1;
769
+ }
770
+ const terminator = this.#tokens[terminatorIndex as number] as SyqlToken;
771
+
772
+ let dynamicSortIndex: number | undefined;
773
+ let dynamicLimitIndex: number | undefined;
774
+ braceDepth = 1;
775
+ parenDepth = 0;
776
+ for (
777
+ cursor = bodyStart;
778
+ cursor < (terminatorIndex as number);
779
+ cursor += 1
780
+ ) {
781
+ const token = this.#tokens[cursor] as SyqlToken;
782
+ if (isSyqlTrivia(token)) continue;
783
+ if (token.text === '(') parenDepth += 1;
784
+ else if (token.text === ')') parenDepth -= 1;
785
+ if (braceDepth === 1 && parenDepth === 0) {
786
+ const lower = token.text.toLowerCase();
787
+ if (
788
+ lower === 'order' &&
789
+ this.#significantTextAfter(cursor, 1) === 'by' &&
790
+ this.#significantTextAfter(cursor, 3) === 'default' &&
791
+ this.#significantTextAfter(cursor, 5) === '{'
792
+ ) {
793
+ dynamicSortIndex ??= cursor;
794
+ } else if (
795
+ lower === 'limit' &&
796
+ this.#significantTextAfter(cursor, 2) === 'default' &&
797
+ this.#significantTextAfter(cursor, 4) === 'max'
798
+ ) {
799
+ dynamicLimitIndex ??= cursor;
800
+ }
801
+ }
802
+ if (token.text === '{') braceDepth += 1;
803
+ else if (token.text === '}') braceDepth -= 1;
804
+ }
805
+ if (
806
+ dynamicSortIndex !== undefined &&
807
+ dynamicLimitIndex !== undefined &&
808
+ dynamicLimitIndex < dynamicSortIndex
809
+ ) {
810
+ this.#fail(
811
+ 'SYQL2012_INVALID_QUERY_BODY',
812
+ this.#tokens[dynamicLimitIndex] as SyqlToken,
813
+ 'dynamic ORDER BY must precede dynamic LIMIT',
814
+ );
815
+ }
816
+
817
+ const sqlEnd = Math.min(
818
+ dynamicSortIndex ?? Number.MAX_SAFE_INTEGER,
819
+ dynamicLimitIndex ?? Number.MAX_SAFE_INTEGER,
820
+ terminatorIndex as number,
821
+ );
822
+ const statementTokens = this.#tokens.slice(bodyStart, sqlEnd);
823
+ const rangeNames = this.#rangeShorthandNames(
824
+ statementTokens,
825
+ declaredRanges,
826
+ );
827
+ const statement = this.#makeTemplate(
828
+ statementTokens,
829
+ 'query statement',
830
+ 'statement',
831
+ rangeNames,
832
+ open.span.end,
833
+ );
834
+
835
+ let sort: SyqlSortSection | undefined;
836
+ let limit: SyqlLimitDeclaration | undefined;
837
+ this.#index = sqlEnd;
838
+ if (dynamicSortIndex !== undefined) {
839
+ sort = this.#parseInlineSort(publicNames);
840
+ }
841
+ if (dynamicLimitIndex !== undefined) {
842
+ limit = this.#parseLimitDeclaration(publicNames);
843
+ }
844
+ const end = this.#expectText(';');
845
+ if (end !== terminator) {
846
+ this.#fail(
847
+ 'SYQL2012_INVALID_QUERY_BODY',
848
+ end,
849
+ 'unexpected query member before the statement terminator',
850
+ );
851
+ }
852
+ const close = this.#expectText('}');
853
+ return {
854
+ statement,
855
+ ...(sort === undefined ? {} : { sort }),
856
+ ...(limit === undefined ? {} : { limit }),
857
+ close,
858
+ };
859
+ }
860
+
861
+ #rangeShorthandNames(
862
+ tokens: readonly SyqlToken[],
863
+ declaredRanges: ReadonlySet<string> = new Set(),
864
+ ): ReadonlySet<string> {
865
+ const significant = tokens.filter((token) => !isSyqlTrivia(token));
866
+ const names = new Set<string>();
867
+ const clauseEnd = new Set([
868
+ 'group',
869
+ 'having',
870
+ 'order',
871
+ 'limit',
872
+ 'offset',
873
+ 'window',
874
+ 'union',
875
+ 'intersect',
876
+ 'except',
877
+ ]);
878
+ for (let index = 0; index < significant.length; index += 1) {
879
+ const token = significant[index] as SyqlToken;
880
+ if (token.kind !== 'identifier' || token.text.toLowerCase() !== 'between')
881
+ continue;
882
+ const bind = significant[index + 1];
883
+ if (bind?.kind !== 'bind') continue;
884
+ const name = bind.text.slice(1);
885
+ const next = significant[index + 2];
886
+ const afterNext = significant[index + 3];
887
+ let conjunctStart = index - 1;
888
+ while (
889
+ conjunctStart >= 0 &&
890
+ !(
891
+ significant[conjunctStart]?.kind === 'identifier' &&
892
+ significant[conjunctStart]?.text.toLowerCase() === 'and'
893
+ )
894
+ ) {
895
+ conjunctStart -= 1;
896
+ }
897
+ const controlledByRange = significant
898
+ .slice(conjunctStart + 1, index)
899
+ .some((candidate, candidateIndex, conjunct) => {
900
+ if (
901
+ candidate.kind !== 'identifier' ||
902
+ candidate.text.toLowerCase() !== 'when' ||
903
+ conjunct[candidateIndex + 1]?.text !== '('
904
+ ) {
905
+ return false;
906
+ }
907
+ let depth = 0;
908
+ for (
909
+ let controlIndex = candidateIndex + 1;
910
+ controlIndex < conjunct.length;
911
+ controlIndex += 1
912
+ ) {
913
+ const control = conjunct[controlIndex] as SyqlToken;
914
+ if (control.text === '(') depth += 1;
915
+ else if (control.text === ')') {
916
+ depth -= 1;
917
+ if (depth === 0) return false;
918
+ } else if (
919
+ depth > 0 &&
920
+ control.kind === 'identifier' &&
921
+ control.text === name
922
+ ) {
923
+ return true;
924
+ }
925
+ }
926
+ return false;
927
+ });
928
+ const endsRange =
929
+ declaredRanges.has(name) ||
930
+ controlledByRange ||
931
+ next === undefined ||
932
+ next.text === '}' ||
933
+ (next.kind === 'identifier' &&
934
+ clauseEnd.has(next.text.toLowerCase())) ||
935
+ (next.kind === 'identifier' &&
936
+ next.text.toLowerCase() === 'and' &&
937
+ afterNext?.kind === 'identifier' &&
938
+ afterNext.text === 'when');
939
+ if (endsRange) names.add(name);
940
+ }
941
+ return names;
942
+ }
943
+
944
+ #makeTemplate(
945
+ tokens: readonly SyqlToken[],
946
+ label: string,
947
+ mode: SyqlTemplateMode,
948
+ rangeNames: ReadonlySet<string> = new Set(),
949
+ fallbackStart?: SyqlSourceSpan['end'],
950
+ ): SyqlTemplate {
951
+ const semantic = tokens.filter((token) => !isSyqlTrivia(token));
952
+ if (semantic.length === 0) {
953
+ this.#fail(
954
+ 'SYQL2006_EMPTY_TEMPLATE',
955
+ semantic[0] ?? this.#peek(),
956
+ `${label} must not be empty`,
957
+ );
958
+ }
959
+ const first = tokens[0] ?? semantic[0];
960
+ const last = tokens[tokens.length - 1] ?? semantic[semantic.length - 1];
961
+ const start = first?.span.start ?? fallbackStart ?? this.#peek().span.start;
962
+ const end = last?.span.end ?? start;
963
+ const span = spanFromPositions(this.#file, start, end);
663
964
  return {
664
- kind: 'sql',
665
- body: block.template,
666
- span: spanBetween(start, block.close),
965
+ text: this.#source.slice(start.offset, end.offset),
966
+ tokens,
967
+ tree: parseSyqlEmbeddedTemplate(
968
+ this.#file,
969
+ tokens,
970
+ span,
971
+ mode,
972
+ rangeNames,
973
+ ),
974
+ span,
667
975
  };
668
976
  }
669
977
 
670
- #parseSortSection(publicNames: Set<string>): SyqlSortSection {
671
- const start = this.#expectText('sort');
978
+ #significantTextAfter(index: number, count: number): string | undefined {
979
+ let remaining = count;
980
+ for (let cursor = index + 1; cursor < this.#tokens.length; cursor += 1) {
981
+ const token = this.#tokens[cursor] as SyqlToken;
982
+ if (isSyqlTrivia(token)) continue;
983
+ remaining -= 1;
984
+ if (remaining === 0) return token.text.toLowerCase();
985
+ }
986
+ return undefined;
987
+ }
988
+
989
+ #parseInlineSort(publicNames: Set<string>): SyqlSortSection {
990
+ const start = this.#expectText('order');
991
+ this.#expectText('by');
672
992
  const control = this.#parseCamelName('sort control');
673
993
  if (publicNames.has(control.text)) {
674
994
  this.#duplicate(control, 'public query input');
@@ -693,11 +1013,32 @@ class Parser {
693
1013
  this.#duplicate(profileName, 'sort profile');
694
1014
  }
695
1015
  profileNames.add(profileName.text);
696
- const order = this.#parseTemplateBlock('sort profile', 'order');
1016
+ this.#expectText(':');
1017
+ const contentStart = this.#index;
1018
+ let cursor = contentStart;
1019
+ let depth = 0;
1020
+ while (cursor < this.#tokens.length) {
1021
+ const token = this.#tokens[cursor] as SyqlToken;
1022
+ if (token.kind === 'eof' || token.text === '}') {
1023
+ this.#fail(
1024
+ 'SYQL2001_EXPECTED_TOKEN',
1025
+ token,
1026
+ 'expected ; after sort profile',
1027
+ );
1028
+ }
1029
+ if (token.text === '(') depth += 1;
1030
+ else if (token.text === ')') depth -= 1;
1031
+ else if (token.text === ';' && depth === 0) break;
1032
+ cursor += 1;
1033
+ }
1034
+ const end = this.#tokens[cursor] as SyqlToken;
1035
+ const orderTokens = this.#tokens.slice(contentStart, cursor);
1036
+ const order = this.#makeTemplate(orderTokens, 'sort profile', 'order');
1037
+ this.#index = cursor + 1;
697
1038
  profiles.push({
698
1039
  name: profileName.text,
699
- order: order.template,
700
- span: spanBetween(profileName, order.close),
1040
+ order,
1041
+ span: spanBetween(profileName, end),
701
1042
  nameSpan: profileName.span,
702
1043
  });
703
1044
  }
@@ -706,7 +1047,7 @@ class Parser {
706
1047
  this.#fail(
707
1048
  'SYQL2008_INVALID_MEMBER',
708
1049
  close,
709
- 'a sort section must contain at least one profile',
1050
+ 'a dynamic ORDER BY must contain at least one profile',
710
1051
  );
711
1052
  }
712
1053
  if (!profileNames.has(defaultProfile.text)) {
@@ -726,61 +1067,36 @@ class Parser {
726
1067
  };
727
1068
  }
728
1069
 
729
- #parsePageDeclaration(publicNames: Set<string>): SyqlPageDeclaration {
730
- const start = this.#expectText('page');
731
- const control = this.#parseCamelName('page control');
1070
+ #parseLimitDeclaration(publicNames: Set<string>): SyqlLimitDeclaration {
1071
+ const start = this.#expectText('limit');
1072
+ const control = this.#parseCamelName('limit control');
732
1073
  if (publicNames.has(control.text)) {
733
1074
  this.#duplicate(control, 'public query input');
734
1075
  }
735
1076
  publicNames.add(control.text);
736
1077
  this.#expectText('default');
737
- const defaultToken = this.#parseInteger('page default');
1078
+ const defaultToken = this.#parseInteger('limit default');
738
1079
  this.#expectText('max');
739
- const maxToken = this.#parseInteger('page maximum');
740
- const end = this.#expectText(';');
1080
+ const maxToken = this.#parseInteger('limit maximum');
741
1081
  const defaultSize = Number(defaultToken.text);
742
1082
  const maxSize = Number(maxToken.text);
743
1083
  if (defaultSize < 1 || defaultSize > maxSize || maxSize > 2_147_483_647) {
744
1084
  this.#fail(
745
1085
  'SYQL2010_INVALID_PAGE_RANGE',
746
1086
  defaultToken,
747
- `page bounds must satisfy 1 <= default <= max <= 2147483647; found default ${defaultSize}, max ${maxSize}`,
1087
+ `limit bounds must satisfy 1 <= default <= max <= 2147483647; found default ${defaultSize}, max ${maxSize}`,
748
1088
  );
749
1089
  }
750
1090
  return {
751
- kind: 'page',
1091
+ kind: 'limit',
752
1092
  control: control.text,
753
1093
  defaultSize,
754
1094
  maxSize,
755
- span: spanBetween(start, end),
1095
+ span: spanBetween(start, maxToken),
756
1096
  controlSpan: control.span,
757
1097
  };
758
1098
  }
759
1099
 
760
- #parseIdentityDeclaration(): SyqlIdentityDeclaration {
761
- const start = this.#expectText('identity');
762
- this.#expectText('by');
763
- const fields: string[] = [];
764
- const names = new Set<string>();
765
- for (;;) {
766
- const field = this.#expectKind('identifier', 'identity result name');
767
- if (!IDENT_RE.test(field.text)) {
768
- this.#fail(
769
- 'SYQL2002_INVALID_NAME',
770
- field,
771
- `invalid identity result name ${JSON.stringify(field.text)}`,
772
- );
773
- }
774
- if (names.has(field.text)) this.#duplicate(field, 'identity field');
775
- names.add(field.text);
776
- fields.push(field.text);
777
- if (this.#peek().text !== ',') break;
778
- this.#take();
779
- }
780
- const end = this.#expectText(';');
781
- return { kind: 'identity', fields, span: spanBetween(start, end) };
782
- }
783
-
784
1100
  #parseTypeAnnotation(): SyqlValueType {
785
1101
  const colon = this.#expectText(':');
786
1102
  const name = this.#expectKind('identifier', 'value type');
@@ -788,7 +1104,8 @@ class Parser {
788
1104
  }
789
1105
 
790
1106
  #parseValueTypeAfterName(colon: SyqlToken, name: SyqlToken): SyqlValueType {
791
- if (!BASE_TYPES.has(name.text as SyqlBaseType)) {
1107
+ const base = name.text === 'bool' ? 'boolean' : name.text;
1108
+ if (name.text === 'boolean' || !BASE_TYPES.has(base as SyqlBaseType)) {
792
1109
  this.#fail(
793
1110
  'SYQL2011_INVALID_PARAMETER',
794
1111
  name,
@@ -803,7 +1120,7 @@ class Parser {
803
1120
  nullable = true;
804
1121
  }
805
1122
  return {
806
- base: name.text as SyqlBaseType,
1123
+ base: base as SyqlBaseType,
807
1124
  nullable,
808
1125
  span: spanBetween(colon, end),
809
1126
  };
@@ -961,6 +1278,19 @@ class Parser {
961
1278
  return this.#tokens[index] as SyqlToken;
962
1279
  }
963
1280
 
1281
+ #peekSignificantAfter(count: number): SyqlToken | undefined {
1282
+ let index = this.#index;
1283
+ let remaining = count;
1284
+ while (index < this.#tokens.length) {
1285
+ const token = this.#tokens[index] as SyqlToken;
1286
+ index += 1;
1287
+ if (isSyqlTrivia(token)) continue;
1288
+ if (remaining === 0) return token;
1289
+ remaining -= 1;
1290
+ }
1291
+ return undefined;
1292
+ }
1293
+
964
1294
  #take(): SyqlToken {
965
1295
  while (
966
1296
  this.#index < this.#tokens.length &&
@@ -983,6 +1313,14 @@ class Parser {
983
1313
  return undefined;
984
1314
  }
985
1315
 
1316
+ #tokenAt(span: SyqlSourceSpan): SyqlToken {
1317
+ return (
1318
+ this.#tokens.find(
1319
+ (token) => token.span.start.offset === span.start.offset,
1320
+ ) ?? this.#peek()
1321
+ );
1322
+ }
1323
+
986
1324
  #expectText(text: string): SyqlToken {
987
1325
  const token = this.#take();
988
1326
  if (token.text !== text) {