patram 0.1.1 → 0.3.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.
Files changed (49) hide show
  1. package/lib/build-graph-identity.js +57 -24
  2. package/lib/build-graph.js +383 -17
  3. package/lib/build-graph.types.ts +5 -2
  4. package/lib/check-directive-metadata.js +516 -0
  5. package/lib/check-directive-value.js +282 -0
  6. package/lib/check-graph.js +24 -5
  7. package/lib/cli-help-metadata.js +580 -0
  8. package/lib/derived-summary.js +280 -0
  9. package/lib/directive-diagnostics.js +38 -0
  10. package/lib/directive-type-rules.js +133 -0
  11. package/lib/discover-fields.js +427 -0
  12. package/lib/discover-fields.types.ts +52 -0
  13. package/lib/format-derived-summary-row.js +9 -0
  14. package/lib/format-node-header.js +21 -0
  15. package/lib/format-output-item-block.js +22 -0
  16. package/lib/format-output-metadata.js +54 -0
  17. package/lib/layout-stored-queries.js +96 -2
  18. package/lib/load-patram-config.js +754 -18
  19. package/lib/load-patram-config.types.ts +128 -2
  20. package/lib/load-project-graph.js +4 -1
  21. package/lib/output-view.types.ts +29 -6
  22. package/lib/parse-cli-arguments-helpers.js +263 -90
  23. package/lib/parse-cli-arguments.js +160 -8
  24. package/lib/parse-cli-arguments.types.ts +49 -4
  25. package/lib/parse-where-clause.js +670 -209
  26. package/lib/parse-where-clause.types.ts +72 -0
  27. package/lib/patram-cli.js +180 -21
  28. package/lib/patram-config.js +31 -31
  29. package/lib/patram-config.types.ts +10 -4
  30. package/lib/patram.js +6 -0
  31. package/lib/query-graph.js +444 -113
  32. package/lib/query-inspection.js +798 -0
  33. package/lib/render-check-output.js +1 -1
  34. package/lib/render-cli-help.js +419 -0
  35. package/lib/render-field-discovery.js +148 -0
  36. package/lib/render-json-output.js +66 -14
  37. package/lib/render-output-view.js +272 -22
  38. package/lib/render-plain-output.js +31 -86
  39. package/lib/render-rich-output.js +34 -87
  40. package/lib/resolve-patram-graph-config.js +15 -9
  41. package/lib/resolve-where-clause.js +18 -3
  42. package/lib/show-document.js +51 -7
  43. package/lib/tagged-fenced-block-error.js +17 -0
  44. package/lib/tagged-fenced-block-markdown.js +111 -0
  45. package/lib/tagged-fenced-block-metadata.js +97 -0
  46. package/lib/tagged-fenced-block-parser.js +292 -0
  47. package/lib/tagged-fenced-blocks.js +100 -0
  48. package/lib/tagged-fenced-blocks.types.ts +38 -0
  49. package/package.json +12 -7
@@ -0,0 +1,798 @@
1
+ /** @import * as $k$$l$parse$j$where$j$clause$k$types$k$ts from './parse-where-clause.types.ts'; */
2
+ /* eslint-disable max-lines */
3
+ /**
4
+ * @import { PatramDiagnostic, PatramRepoConfig } from './load-patram-config.types.ts';
5
+ * @import { ResolvedOutputMode } from './output-view.types.ts';
6
+ * @import {
7
+ * ParsedClause,
8
+ * ParsedRelationTargetTerm,
9
+ * ParsedRelationTerm,
10
+ * ParsedTerm,
11
+ * ParsedTraversalTerm,
12
+ * } from './parse-where-clause.types.ts';
13
+ */
14
+
15
+ import { Ansis } from 'ansis';
16
+
17
+ import { parseWhereClause } from './parse-where-clause.js';
18
+
19
+ /**
20
+ * @typedef {{ kind: 'ad_hoc' } | { kind: 'stored_query', name: string }} QuerySource
21
+ */
22
+
23
+ /**
24
+ * @typedef {import('./parse-where-clause.types.ts').ParsedFieldTerm | import('./parse-where-clause.types.ts').ParsedFieldSetTerm} FieldDiagnosticTerm
25
+ */
26
+
27
+ /**
28
+ * @typedef {{ query_source: QuerySource, where_clause: string }} ResolvedWhereClause
29
+ */
30
+
31
+ /**
32
+ * @typedef {{ inspection_mode: 'explain' | 'lint', limit: number | null, offset: number }} QueryInspectionOptions
33
+ */
34
+
35
+ /**
36
+ * @typedef {{
37
+ * execution?: {
38
+ * limit: number | null,
39
+ * offset: number,
40
+ * },
41
+ * inspection_mode: 'explain' | 'lint',
42
+ * query_source: QuerySource,
43
+ * where_clause: string,
44
+ * clauses?: ParsedClause[],
45
+ * }} QueryInspectionSuccess
46
+ */
47
+
48
+ /**
49
+ * Inspect one resolved query without executing it.
50
+ *
51
+ * @param {PatramRepoConfig} repo_config
52
+ * @param {ResolvedWhereClause} resolved_where_clause
53
+ * @param {QueryInspectionOptions} inspection_options
54
+ * @returns {{ success: true, value: QueryInspectionSuccess } | { diagnostics: PatramDiagnostic[], success: false }}
55
+ */
56
+ export function inspectQuery(
57
+ repo_config,
58
+ resolved_where_clause,
59
+ inspection_options,
60
+ ) {
61
+ const parse_result = parseWhereClause(resolved_where_clause.where_clause);
62
+
63
+ if (!parse_result.success) {
64
+ return {
65
+ diagnostics: [
66
+ replaceDiagnosticPath(
67
+ parse_result.diagnostic,
68
+ formatQueryDiagnosticPath(resolved_where_clause.query_source),
69
+ ),
70
+ ],
71
+ success: false,
72
+ };
73
+ }
74
+
75
+ const diagnostics = getQuerySemanticDiagnostics(
76
+ repo_config,
77
+ resolved_where_clause.query_source,
78
+ parse_result.clauses,
79
+ );
80
+
81
+ if (diagnostics.length > 0) {
82
+ return {
83
+ diagnostics,
84
+ success: false,
85
+ };
86
+ }
87
+
88
+ if (inspection_options.inspection_mode === 'lint') {
89
+ return {
90
+ success: true,
91
+ value: {
92
+ inspection_mode: 'lint',
93
+ query_source: resolved_where_clause.query_source,
94
+ where_clause: resolved_where_clause.where_clause,
95
+ },
96
+ };
97
+ }
98
+
99
+ return {
100
+ success: true,
101
+ value: {
102
+ clauses: parse_result.clauses,
103
+ execution: {
104
+ limit: inspection_options.limit,
105
+ offset: inspection_options.offset,
106
+ },
107
+ inspection_mode: 'explain',
108
+ query_source: resolved_where_clause.query_source,
109
+ where_clause: resolved_where_clause.where_clause,
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Render a successful query inspection in one output mode.
116
+ *
117
+ * @param {QueryInspectionSuccess} query_inspection
118
+ * @param {ResolvedOutputMode} output_mode
119
+ * @returns {string}
120
+ */
121
+ export function renderQueryInspection(query_inspection, output_mode) {
122
+ if (output_mode.renderer_name === 'json') {
123
+ return `${JSON.stringify(formatJsonQueryInspection(query_inspection), null, 2)}\n`;
124
+ }
125
+
126
+ if (output_mode.renderer_name === 'plain') {
127
+ return renderTextQueryInspection(query_inspection, {
128
+ header: identity,
129
+ label: identity,
130
+ });
131
+ }
132
+
133
+ const ansi = new Ansis(output_mode.color_enabled ? 3 : 0);
134
+
135
+ return renderTextQueryInspection(query_inspection, {
136
+ header(value) {
137
+ return ansi.green(value);
138
+ },
139
+ label(value) {
140
+ return ansi.gray(value);
141
+ },
142
+ });
143
+ }
144
+
145
+ /**
146
+ * @param {PatramRepoConfig} repo_config
147
+ * @param {QuerySource} query_source
148
+ * @param {ParsedClause[]} clauses
149
+ * @returns {PatramDiagnostic[]}
150
+ */
151
+ function collectSemanticDiagnostics(repo_config, query_source, clauses) {
152
+ const known_relation_names = new Set(
153
+ Object.keys(repo_config.relations ?? {}),
154
+ );
155
+ /** @type {PatramDiagnostic[]} */
156
+ const diagnostics = [];
157
+
158
+ collectClauseDiagnostics(
159
+ clauses,
160
+ diagnostics,
161
+ known_relation_names,
162
+ repo_config.fields ?? {},
163
+ formatQueryDiagnosticPath(query_source),
164
+ );
165
+
166
+ return diagnostics;
167
+ }
168
+
169
+ /**
170
+ * Collect schema-aware diagnostics for one parsed where clause.
171
+ *
172
+ * @param {PatramRepoConfig} repo_config
173
+ * @param {QuerySource} query_source
174
+ * @param {ParsedClause[]} clauses
175
+ * @returns {PatramDiagnostic[]}
176
+ */
177
+ export function getQuerySemanticDiagnostics(
178
+ repo_config,
179
+ query_source,
180
+ clauses,
181
+ ) {
182
+ return collectSemanticDiagnostics(repo_config, query_source, clauses);
183
+ }
184
+
185
+ /**
186
+ * @param {ParsedClause[]} clauses
187
+ * @param {PatramDiagnostic[]} diagnostics
188
+ * @param {Set<string>} known_relation_names
189
+ * @param {Record<string, import('./load-patram-config.types.ts').MetadataFieldConfig>} known_field_definitions
190
+ * @param {string} diagnostic_path
191
+ */
192
+ function collectClauseDiagnostics(
193
+ clauses,
194
+ diagnostics,
195
+ known_relation_names,
196
+ known_field_definitions,
197
+ diagnostic_path,
198
+ ) {
199
+ for (const clause of clauses) {
200
+ collectTermDiagnostics(
201
+ clause.term,
202
+ diagnostics,
203
+ known_relation_names,
204
+ known_field_definitions,
205
+ diagnostic_path,
206
+ );
207
+ }
208
+ }
209
+
210
+ /**
211
+ * @param {ParsedTerm} term
212
+ * @param {PatramDiagnostic[]} diagnostics
213
+ * @param {Set<string>} known_relation_names
214
+ * @param {Record<string, import('./load-patram-config.types.ts').MetadataFieldConfig>} known_field_definitions
215
+ * @param {string} diagnostic_path
216
+ */
217
+ function collectTermDiagnostics(
218
+ term,
219
+ diagnostics,
220
+ known_relation_names,
221
+ known_field_definitions,
222
+ diagnostic_path,
223
+ ) {
224
+ if (term.kind === 'aggregate') {
225
+ collectTraversalDiagnostic(
226
+ term.traversal,
227
+ diagnostics,
228
+ known_relation_names,
229
+ diagnostic_path,
230
+ );
231
+ collectClauseDiagnostics(
232
+ term.clauses,
233
+ diagnostics,
234
+ known_relation_names,
235
+ known_field_definitions,
236
+ diagnostic_path,
237
+ );
238
+
239
+ return;
240
+ }
241
+
242
+ if (term.kind === 'relation') {
243
+ collectRelationDiagnostic(
244
+ term,
245
+ diagnostics,
246
+ known_relation_names,
247
+ diagnostic_path,
248
+ 'relation clause',
249
+ );
250
+
251
+ return;
252
+ }
253
+
254
+ if (term.kind === 'relation_target') {
255
+ collectRelationDiagnostic(
256
+ term,
257
+ diagnostics,
258
+ known_relation_names,
259
+ diagnostic_path,
260
+ 'relation clause',
261
+ );
262
+
263
+ return;
264
+ }
265
+
266
+ collectFieldDiagnostics(
267
+ /** @type {FieldDiagnosticTerm} */ (term),
268
+ diagnostics,
269
+ known_field_definitions,
270
+ diagnostic_path,
271
+ );
272
+ }
273
+
274
+ /**
275
+ * @param {FieldDiagnosticTerm} term
276
+ * @param {PatramDiagnostic[]} diagnostics
277
+ * @param {Record<string, import('./load-patram-config.types.ts').MetadataFieldConfig>} known_field_definitions
278
+ * @param {string} diagnostic_path
279
+ */
280
+ function collectFieldDiagnostics(
281
+ term,
282
+ diagnostics,
283
+ known_field_definitions,
284
+ diagnostic_path,
285
+ ) {
286
+ const field_diagnostics_collector = getFieldDiagnosticsCollector(
287
+ term.field_name,
288
+ );
289
+
290
+ if (field_diagnostics_collector) {
291
+ field_diagnostics_collector(term, diagnostics, diagnostic_path);
292
+ return;
293
+ }
294
+
295
+ if (term.field_name.startsWith('$')) {
296
+ diagnostics.push(
297
+ createQueryDiagnostic(
298
+ diagnostic_path,
299
+ term.column,
300
+ `Reserved field "${term.field_name}" is not available.`,
301
+ 'query.reserved_field',
302
+ ),
303
+ );
304
+
305
+ return;
306
+ }
307
+
308
+ const field_definition = known_field_definitions[term.field_name];
309
+
310
+ if (!field_definition) {
311
+ diagnostics.push(
312
+ createQueryDiagnostic(
313
+ diagnostic_path,
314
+ term.column,
315
+ `Unknown field "${term.field_name}".`,
316
+ 'query.unknown_field',
317
+ ),
318
+ );
319
+
320
+ return;
321
+ }
322
+
323
+ if (term.kind === 'field_set') {
324
+ if (term.operator === 'in' || term.operator === 'not in') {
325
+ return;
326
+ }
327
+ }
328
+
329
+ if (term.kind === 'field') {
330
+ const operator_diagnostic = getMetadataFieldOperatorDiagnostic(
331
+ term,
332
+ field_definition,
333
+ diagnostic_path,
334
+ );
335
+
336
+ if (operator_diagnostic) {
337
+ diagnostics.push(operator_diagnostic);
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * @param {string} field_name
344
+ * @returns {((term: FieldDiagnosticTerm, diagnostics: PatramDiagnostic[], diagnostic_path: string) => void) | null}
345
+ */
346
+ function getFieldDiagnosticsCollector(field_name) {
347
+ if (field_name === 'title') {
348
+ return collectTitleFieldDiagnostics;
349
+ }
350
+
351
+ if (
352
+ field_name === '$id' ||
353
+ field_name === '$class' ||
354
+ field_name === '$path'
355
+ ) {
356
+ return collectStructuralFieldDiagnostics;
357
+ }
358
+
359
+ return null;
360
+ }
361
+
362
+ /**
363
+ * @param {FieldDiagnosticTerm} term
364
+ * @param {PatramDiagnostic[]} diagnostics
365
+ * @param {string} diagnostic_path
366
+ */
367
+ function collectTitleFieldDiagnostics(term, diagnostics, diagnostic_path) {
368
+ const operator = term.kind === 'field' ? term.operator : term.operator;
369
+ const allowed = new Set(['=', '!=', 'in', 'not in', '~']);
370
+
371
+ if (allowed.has(operator)) {
372
+ return;
373
+ }
374
+
375
+ diagnostics.push(
376
+ createQueryDiagnostic(
377
+ diagnostic_path,
378
+ term.column,
379
+ `Field "title" does not support the "${operator}" operator.`,
380
+ 'query.invalid_operator',
381
+ ),
382
+ );
383
+ }
384
+
385
+ /**
386
+ * @param {FieldDiagnosticTerm} term
387
+ * @param {PatramDiagnostic[]} diagnostics
388
+ * @param {string} diagnostic_path
389
+ */
390
+ function collectStructuralFieldDiagnostics(term, diagnostics, diagnostic_path) {
391
+ const operator = term.kind === 'field' ? term.operator : term.operator;
392
+ const allowed_by_field = new Map([
393
+ ['$class', new Set(['=', '!=', 'in', 'not in'])],
394
+ ['$id', new Set(['=', '!=', 'in', 'not in', '^='])],
395
+ ['$path', new Set(['=', '!=', 'in', 'not in', '^='])],
396
+ ]);
397
+ const allowed = allowed_by_field.get(term.field_name);
398
+
399
+ if (!allowed || allowed.has(operator)) {
400
+ return;
401
+ }
402
+
403
+ diagnostics.push(
404
+ createQueryDiagnostic(
405
+ diagnostic_path,
406
+ term.column,
407
+ `Field "${term.field_name}" does not support the "${operator}" operator.`,
408
+ 'query.invalid_operator',
409
+ ),
410
+ );
411
+ }
412
+
413
+ /**
414
+ * @param {import('./parse-where-clause.types.ts').ParsedFieldTerm} term
415
+ * @param {import('./load-patram-config.types.ts').MetadataFieldConfig} field_definition
416
+ * @param {string} diagnostic_path
417
+ * @returns {PatramDiagnostic | null}
418
+ */
419
+ function getMetadataFieldOperatorDiagnostic(
420
+ term,
421
+ field_definition,
422
+ diagnostic_path,
423
+ ) {
424
+ if (supportsMetadataFieldOperator(term.operator, field_definition)) {
425
+ return null;
426
+ }
427
+
428
+ return createQueryDiagnostic(
429
+ diagnostic_path,
430
+ term.column,
431
+ getUnsupportedOperatorMessage(term.field_name, term.operator),
432
+ 'query.invalid_operator',
433
+ );
434
+ }
435
+
436
+ /**
437
+ * @param {string} operator
438
+ * @param {import('./load-patram-config.types.ts').MetadataFieldConfig} field_definition
439
+ * @returns {boolean}
440
+ */
441
+ function supportsMetadataFieldOperator(operator, field_definition) {
442
+ if (operator === '=' || operator === '!=') {
443
+ return true;
444
+ }
445
+
446
+ if (operator === '^=') {
447
+ return supportsPrefixOperator(field_definition);
448
+ }
449
+
450
+ if (operator === '~') {
451
+ return supportsContainsOperator(field_definition);
452
+ }
453
+
454
+ return supportsOrderedComparison(operator, field_definition.type);
455
+ }
456
+
457
+ /**
458
+ * @param {import('./load-patram-config.types.ts').MetadataFieldConfig} field_definition
459
+ * @returns {boolean}
460
+ */
461
+ function supportsPrefixOperator(field_definition) {
462
+ if (field_definition.type === 'path') {
463
+ return true;
464
+ }
465
+
466
+ return (
467
+ field_definition.type === 'string' &&
468
+ field_definition.query?.prefix === true
469
+ );
470
+ }
471
+
472
+ /**
473
+ * @param {import('./load-patram-config.types.ts').MetadataFieldConfig} field_definition
474
+ * @returns {boolean}
475
+ */
476
+ function supportsContainsOperator(field_definition) {
477
+ return (
478
+ field_definition.type === 'string' &&
479
+ field_definition.query?.contains === true
480
+ );
481
+ }
482
+
483
+ /**
484
+ * @param {string} operator
485
+ * @param {string} field_type
486
+ * @returns {boolean}
487
+ */
488
+ function supportsOrderedComparison(operator, field_type) {
489
+ if (
490
+ operator !== '<' &&
491
+ operator !== '<=' &&
492
+ operator !== '>' &&
493
+ operator !== '>='
494
+ ) {
495
+ return false;
496
+ }
497
+
498
+ return (
499
+ field_type === 'integer' ||
500
+ field_type === 'date' ||
501
+ field_type === 'date_time'
502
+ );
503
+ }
504
+
505
+ /**
506
+ * @param {string} field_name
507
+ * @param {string} operator
508
+ * @returns {string}
509
+ */
510
+ function getUnsupportedOperatorMessage(field_name, operator) {
511
+ return `Field "${field_name}" does not support the "${operator}" operator.`;
512
+ }
513
+
514
+ /**
515
+ * @param {ParsedRelationTerm | ParsedRelationTargetTerm} term
516
+ * @param {PatramDiagnostic[]} diagnostics
517
+ * @param {Set<string>} known_relation_names
518
+ * @param {string} diagnostic_path
519
+ * @param {string} clause_kind
520
+ */
521
+ function collectRelationDiagnostic(
522
+ term,
523
+ diagnostics,
524
+ known_relation_names,
525
+ diagnostic_path,
526
+ clause_kind,
527
+ ) {
528
+ if (known_relation_names.has(term.relation_name)) {
529
+ return;
530
+ }
531
+
532
+ diagnostics.push(
533
+ createQueryDiagnostic(
534
+ diagnostic_path,
535
+ term.column,
536
+ `Unknown relation "${term.relation_name}" in ${clause_kind}.`,
537
+ ),
538
+ );
539
+ }
540
+
541
+ /**
542
+ * @param {ParsedTraversalTerm} traversal
543
+ * @param {PatramDiagnostic[]} diagnostics
544
+ * @param {Set<string>} known_relation_names
545
+ * @param {string} diagnostic_path
546
+ */
547
+ function collectTraversalDiagnostic(
548
+ traversal,
549
+ diagnostics,
550
+ known_relation_names,
551
+ diagnostic_path,
552
+ ) {
553
+ if (known_relation_names.has(traversal.relation_name)) {
554
+ return;
555
+ }
556
+
557
+ diagnostics.push(
558
+ createQueryDiagnostic(
559
+ diagnostic_path,
560
+ traversal.column,
561
+ `Unknown relation "${traversal.relation_name}" in traversal clause.`,
562
+ ),
563
+ );
564
+ }
565
+
566
+ /**
567
+ * @param {QueryInspectionSuccess} query_inspection
568
+ */
569
+ function formatJsonQueryInspection(query_inspection) {
570
+ if (query_inspection.inspection_mode === 'lint') {
571
+ return {
572
+ diagnostics: [],
573
+ mode: 'lint',
574
+ source: query_inspection.query_source,
575
+ where: query_inspection.where_clause,
576
+ };
577
+ }
578
+
579
+ return {
580
+ clauses: query_inspection.clauses,
581
+ diagnostics: [],
582
+ execution: query_inspection.execution,
583
+ mode: 'explain',
584
+ source: query_inspection.query_source,
585
+ where: query_inspection.where_clause,
586
+ };
587
+ }
588
+
589
+ /**
590
+ * @param {QueryInspectionSuccess} query_inspection
591
+ * @param {{ header: (value: string) => string, label: (value: string) => string }} render_options
592
+ * @returns {string}
593
+ */
594
+ function renderTextQueryInspection(query_inspection, render_options) {
595
+ /** @type {string[]} */
596
+ const output_lines = [
597
+ render_options.header(
598
+ query_inspection.inspection_mode === 'lint'
599
+ ? 'Query is valid.'
600
+ : 'Query explanation',
601
+ ),
602
+ formatLabeledLine(
603
+ render_options,
604
+ 'source',
605
+ formatQuerySource(query_inspection.query_source),
606
+ ),
607
+ formatLabeledLine(render_options, 'where', query_inspection.where_clause),
608
+ ];
609
+
610
+ if (query_inspection.inspection_mode === 'lint') {
611
+ return `${output_lines.join('\n')}\n`;
612
+ }
613
+
614
+ output_lines.push(
615
+ formatLabeledLine(
616
+ render_options,
617
+ 'offset',
618
+ String(query_inspection.execution?.offset ?? 0),
619
+ ),
620
+ formatLabeledLine(
621
+ render_options,
622
+ 'limit',
623
+ query_inspection.execution?.limit === null
624
+ ? 'none'
625
+ : String(query_inspection.execution?.limit ?? ''),
626
+ ),
627
+ '',
628
+ `${render_options.label('clauses:')}`,
629
+ ...formatExplainClauseBlock(
630
+ query_inspection.clauses ?? [],
631
+ render_options,
632
+ '',
633
+ ),
634
+ );
635
+
636
+ return `${output_lines.join('\n')}\n`;
637
+ }
638
+
639
+ /**
640
+ * @param {ParsedClause[]} clauses
641
+ * @param {{ header: (value: string) => string, label: (value: string) => string }} render_options
642
+ * @param {string} indentation
643
+ * @returns {string[]}
644
+ */
645
+ function formatExplainClauseBlock(clauses, render_options, indentation) {
646
+ /** @type {string[]} */
647
+ const output_lines = [];
648
+
649
+ clauses.forEach((clause, clause_index) => {
650
+ const clause_number = clause_index + 1;
651
+ const clause_text = formatClauseSummary(clause);
652
+
653
+ output_lines.push(`${indentation}${clause_number}. ${clause_text}`);
654
+
655
+ if (clause.term.kind !== 'aggregate') {
656
+ return;
657
+ }
658
+
659
+ output_lines.push(
660
+ `${indentation} ${render_options.label('traversal:')} ${formatTraversal(clause.term.traversal)}`,
661
+ );
662
+
663
+ if (clause.term.aggregate_name === 'count') {
664
+ output_lines.push(
665
+ `${indentation} ${render_options.label('comparison:')} ${clause.term.comparison} ${clause.term.value}`,
666
+ );
667
+ }
668
+
669
+ output_lines.push(
670
+ `${indentation} ${render_options.label('nested clauses:')}`,
671
+ ...formatExplainClauseBlock(
672
+ clause.term.clauses,
673
+ render_options,
674
+ `${indentation} `,
675
+ ),
676
+ );
677
+ });
678
+
679
+ return output_lines;
680
+ }
681
+
682
+ /**
683
+ * @param {ParsedClause} clause
684
+ * @returns {string}
685
+ */
686
+ function formatClauseSummary(clause) {
687
+ const clause_prefix = clause.is_negated ? 'not ' : '';
688
+
689
+ if (clause.term.kind === 'aggregate') {
690
+ return `${clause_prefix}aggregate ${clause.term.aggregate_name}`;
691
+ }
692
+
693
+ return `${clause_prefix}${formatTermSummary(clause.term)}`;
694
+ }
695
+
696
+ /**
697
+ * @param {ParsedTerm} term
698
+ * @returns {string}
699
+ */
700
+ function formatTermSummary(term) {
701
+ if (term.kind === 'field') {
702
+ return `${term.field_name} ${term.operator} ${term.value}`;
703
+ }
704
+
705
+ if (term.kind === 'field_set') {
706
+ return `${term.field_name} ${term.operator} [${term.values.join(', ')}]`;
707
+ }
708
+
709
+ if (term.kind === 'relation') {
710
+ return `${term.relation_name} exists`;
711
+ }
712
+
713
+ if (term.kind === 'relation_target') {
714
+ return `${term.relation_name} = ${term.target_id}`;
715
+ }
716
+
717
+ throw new Error('Expected a non-aggregate query term.');
718
+ }
719
+
720
+ /**
721
+ * @param {ParsedTraversalTerm} traversal
722
+ * @returns {string}
723
+ */
724
+ function formatTraversal(traversal) {
725
+ return `${traversal.direction}:${traversal.relation_name}`;
726
+ }
727
+
728
+ /**
729
+ * @param {{ header: (value: string) => string, label: (value: string) => string }} render_options
730
+ * @param {string} label
731
+ * @param {string} value
732
+ * @returns {string}
733
+ */
734
+ function formatLabeledLine(render_options, label, value) {
735
+ return `${render_options.label(`${label}:`)} ${value}`;
736
+ }
737
+
738
+ /**
739
+ * @param {QuerySource} query_source
740
+ * @returns {string}
741
+ */
742
+ function formatQuerySource(query_source) {
743
+ if (query_source.kind === 'stored_query') {
744
+ return `stored query "${query_source.name}"`;
745
+ }
746
+
747
+ return 'ad hoc query';
748
+ }
749
+
750
+ /**
751
+ * @param {QuerySource} query_source
752
+ * @returns {string}
753
+ */
754
+ function formatQueryDiagnosticPath(query_source) {
755
+ if (query_source.kind === 'stored_query') {
756
+ return `<query:${query_source.name}>`;
757
+ }
758
+
759
+ return '<query>';
760
+ }
761
+
762
+ /**
763
+ * @param {string} diagnostic_path
764
+ * @param {number} column
765
+ * @param {string} message
766
+ * @param {PatramDiagnostic['code']} [code]
767
+ * @returns {PatramDiagnostic}
768
+ */
769
+ function createQueryDiagnostic(diagnostic_path, column, message, code) {
770
+ return {
771
+ code: code ?? 'query.unknown_relation',
772
+ column,
773
+ level: 'error',
774
+ line: 1,
775
+ message,
776
+ path: diagnostic_path,
777
+ };
778
+ }
779
+
780
+ /**
781
+ * @param {PatramDiagnostic} diagnostic
782
+ * @param {string} diagnostic_path
783
+ * @returns {PatramDiagnostic}
784
+ */
785
+ function replaceDiagnosticPath(diagnostic, diagnostic_path) {
786
+ return {
787
+ ...diagnostic,
788
+ path: diagnostic_path,
789
+ };
790
+ }
791
+
792
+ /**
793
+ * @param {string} value
794
+ * @returns {string}
795
+ */
796
+ function identity(value) {
797
+ return value;
798
+ }