patram 0.2.0 → 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.
@@ -5,13 +5,13 @@
5
5
 
6
6
  import { z } from 'zod';
7
7
 
8
- const KIND_NAME_SCHEMA = z.string().min(1);
8
+ const CLASS_NAME_SCHEMA = z.string().min(1);
9
9
  const RELATION_NAME_SCHEMA = z.string().min(1);
10
10
  const CLAIM_TYPE_SCHEMA = z.string().min(1);
11
11
  const KEY_SOURCE_SCHEMA = z.enum(['path', 'value']);
12
12
  const TARGET_SCHEMA = z.enum(['path', 'value']);
13
13
 
14
- const kind_definition_schema = z
14
+ const class_definition_schema = z
15
15
  .object({
16
16
  builtin: z.boolean().optional(),
17
17
  label: z.string().min(1).optional(),
@@ -21,16 +21,16 @@ const kind_definition_schema = z
21
21
  const relation_definition_schema = z
22
22
  .object({
23
23
  builtin: z.boolean().optional(),
24
- from: z.array(KIND_NAME_SCHEMA).min(1),
25
- to: z.array(KIND_NAME_SCHEMA).min(1),
24
+ from: z.array(CLASS_NAME_SCHEMA).min(1),
25
+ to: z.array(CLASS_NAME_SCHEMA).min(1),
26
26
  })
27
27
  .strict();
28
28
 
29
29
  const mapping_node_schema = z
30
30
  .object({
31
+ class: CLASS_NAME_SCHEMA,
31
32
  field: z.string().min(1),
32
33
  key: KEY_SOURCE_SCHEMA.optional(),
33
- kind: KIND_NAME_SCHEMA,
34
34
  })
35
35
  .strict();
36
36
 
@@ -38,7 +38,7 @@ const mapping_emit_schema = z
38
38
  .object({
39
39
  relation: RELATION_NAME_SCHEMA,
40
40
  target: TARGET_SCHEMA,
41
- target_kind: KIND_NAME_SCHEMA,
41
+ target_class: CLASS_NAME_SCHEMA,
42
42
  })
43
43
  .strict();
44
44
 
@@ -53,7 +53,7 @@ const mapping_definition_schema = z
53
53
  export const patramConfigSchema = z
54
54
  .object({
55
55
  $schema: z.url().optional(),
56
- kinds: z.record(KIND_NAME_SCHEMA, kind_definition_schema),
56
+ classes: z.record(CLASS_NAME_SCHEMA, class_definition_schema),
57
57
  mappings: z.record(CLAIM_TYPE_SCHEMA, mapping_definition_schema),
58
58
  relations: z.record(RELATION_NAME_SCHEMA, relation_definition_schema),
59
59
  })
@@ -90,8 +90,8 @@ function validateMappingDefinition(mapping_definition, refinement_context) {
90
90
  * @param {RefinementCtx} refinement_context
91
91
  */
92
92
  function validatePatramConfigReferences(config_json, refinement_context) {
93
- validateRelationKinds(config_json, refinement_context);
94
- validateMappingKinds(config_json, refinement_context);
93
+ validateRelationClasses(config_json, refinement_context);
94
+ validateMappingClasses(config_json, refinement_context);
95
95
  validateMappingRelations(config_json, refinement_context);
96
96
  }
97
97
 
@@ -99,19 +99,19 @@ function validatePatramConfigReferences(config_json, refinement_context) {
99
99
  * @param {PatramConfig} config_json
100
100
  * @param {RefinementCtx} refinement_context
101
101
  */
102
- function validateRelationKinds(config_json, refinement_context) {
102
+ function validateRelationClasses(config_json, refinement_context) {
103
103
  for (const [relation_name, relation_definition] of Object.entries(
104
104
  config_json.relations,
105
105
  )) {
106
- validateReferencedKinds(
106
+ validateReferencedClasses(
107
107
  relation_definition.from,
108
- config_json.kinds,
108
+ config_json.classes,
109
109
  ['relations', relation_name, 'from'],
110
110
  refinement_context,
111
111
  );
112
- validateReferencedKinds(
112
+ validateReferencedClasses(
113
113
  relation_definition.to,
114
- config_json.kinds,
114
+ config_json.classes,
115
115
  ['relations', relation_name, 'to'],
116
116
  refinement_context,
117
117
  );
@@ -122,24 +122,24 @@ function validateRelationKinds(config_json, refinement_context) {
122
122
  * @param {PatramConfig} config_json
123
123
  * @param {RefinementCtx} refinement_context
124
124
  */
125
- function validateMappingKinds(config_json, refinement_context) {
125
+ function validateMappingClasses(config_json, refinement_context) {
126
126
  for (const [mapping_name, mapping_definition] of Object.entries(
127
127
  config_json.mappings,
128
128
  )) {
129
129
  if (mapping_definition.emit) {
130
- validateReferencedKinds(
131
- [mapping_definition.emit.target_kind],
132
- config_json.kinds,
133
- ['mappings', mapping_name, 'emit', 'target_kind'],
130
+ validateReferencedClasses(
131
+ [mapping_definition.emit.target_class],
132
+ config_json.classes,
133
+ ['mappings', mapping_name, 'emit', 'target_class'],
134
134
  refinement_context,
135
135
  );
136
136
  }
137
137
 
138
138
  if (mapping_definition.node) {
139
- validateReferencedKinds(
140
- [mapping_definition.node.kind],
141
- config_json.kinds,
142
- ['mappings', mapping_name, 'node', 'kind'],
139
+ validateReferencedClasses(
140
+ [mapping_definition.node.class],
141
+ config_json.classes,
142
+ ['mappings', mapping_name, 'node', 'class'],
143
143
  refinement_context,
144
144
  );
145
145
  }
@@ -171,25 +171,25 @@ function validateMappingRelations(config_json, refinement_context) {
171
171
  }
172
172
 
173
173
  /**
174
- * @param {string[]} referenced_kinds
175
- * @param {Record<string, unknown>} known_kinds
174
+ * @param {string[]} referenced_classes
175
+ * @param {Record<string, unknown>} known_classes
176
176
  * @param {(string | number)[]} issue_path
177
177
  * @param {RefinementCtx} refinement_context
178
178
  */
179
- function validateReferencedKinds(
180
- referenced_kinds,
181
- known_kinds,
179
+ function validateReferencedClasses(
180
+ referenced_classes,
181
+ known_classes,
182
182
  issue_path,
183
183
  refinement_context,
184
184
  ) {
185
- for (const referenced_kind of referenced_kinds) {
186
- if (known_kinds[referenced_kind]) {
185
+ for (const referenced_class of referenced_classes) {
186
+ if (known_classes[referenced_class]) {
187
187
  continue;
188
188
  }
189
189
 
190
190
  refinement_context.addIssue({
191
191
  code: 'custom',
192
- message: `Unknown kind "${referenced_kind}".`,
192
+ message: `Unknown class "${referenced_class}".`,
193
193
  path: issue_path,
194
194
  });
195
195
  }
@@ -1,4 +1,4 @@
1
- export interface KindDefinition {
1
+ export interface ClassDefinition {
2
2
  builtin?: boolean;
3
3
  label?: string;
4
4
  }
@@ -10,15 +10,15 @@ export interface RelationDefinition {
10
10
  }
11
11
 
12
12
  export interface MappingNodeDefinition {
13
+ class: string;
13
14
  field: string;
14
15
  key?: 'path' | 'value';
15
- kind: string;
16
16
  }
17
17
 
18
18
  export interface MappingEmitDefinition {
19
19
  relation: string;
20
20
  target: 'path' | 'value';
21
- target_kind: string;
21
+ target_class: string;
22
22
  }
23
23
 
24
24
  export interface MappingDefinition {
@@ -28,7 +28,13 @@ export interface MappingDefinition {
28
28
 
29
29
  export interface PatramConfig {
30
30
  $schema?: string;
31
- kinds: Record<string, KindDefinition>;
31
+ classes: Record<string, ClassDefinition>;
32
+ class_schemas?: Record<string, ClassSchemaConfig>;
33
+ fields?: Record<string, MetadataFieldConfig>;
32
34
  mappings: Record<string, MappingDefinition>;
33
35
  relations: Record<string, RelationDefinition>;
34
36
  }
37
+ import type {
38
+ ClassSchemaConfig,
39
+ MetadataFieldConfig,
40
+ } from './load-patram-config.types.ts';
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable max-lines */
2
2
  /**
3
3
  * @import { BuildGraphResult, GraphNode } from './build-graph.types.ts';
4
- * @import { PatramDiagnostic } from './load-patram-config.types.ts';
4
+ * @import { PatramDiagnostic, PatramRepoConfig } from './load-patram-config.types.ts';
5
5
  * @import {
6
6
  * ParsedAggregateTerm,
7
7
  * ParsedClause,
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { parseWhereClause } from './parse-where-clause.js';
17
+ import { getQuerySemanticDiagnostics } from './query-inspection.js';
17
18
 
18
19
  /**
19
20
  * Query graph filtering.
@@ -54,10 +55,16 @@ export const DEFAULT_QUERY_LIMIT = 25;
54
55
  *
55
56
  * @param {BuildGraphResult} graph
56
57
  * @param {string} where_clause
58
+ * @param {PatramRepoConfig | { limit?: number, offset?: number }=} repo_config_or_pagination
57
59
  * @param {{ limit?: number, offset?: number }=} pagination_options
58
60
  * @returns {{ diagnostics: PatramDiagnostic[], nodes: GraphNode[], total_count: number }}
59
61
  */
60
- export function queryGraph(graph, where_clause, pagination_options = {}) {
62
+ export function queryGraph(
63
+ graph,
64
+ where_clause,
65
+ repo_config_or_pagination = {},
66
+ pagination_options = {},
67
+ ) {
61
68
  const parse_result = parseWhereClause(where_clause);
62
69
 
63
70
  if (!parse_result.success) {
@@ -68,6 +75,25 @@ export function queryGraph(graph, where_clause, pagination_options = {}) {
68
75
  };
69
76
  }
70
77
 
78
+ const { pagination_options: resolved_pagination_options, repo_config } =
79
+ resolveQueryGraphOptions(repo_config_or_pagination, pagination_options);
80
+
81
+ if (repo_config) {
82
+ const diagnostics = getQuerySemanticDiagnostics(
83
+ repo_config,
84
+ { kind: 'ad_hoc' },
85
+ parse_result.clauses,
86
+ );
87
+
88
+ if (diagnostics.length > 0) {
89
+ return {
90
+ diagnostics,
91
+ nodes: [],
92
+ total_count: 0,
93
+ };
94
+ }
95
+ }
96
+
71
97
  const evaluation_context = {
72
98
  nodes: graph.nodes,
73
99
  relation_indexes: createRelationIndexes(graph.edges),
@@ -76,7 +102,10 @@ export function queryGraph(graph, where_clause, pagination_options = {}) {
76
102
  const matching_nodes = graph_nodes.filter((graph_node) =>
77
103
  matchesClauses(graph_node, parse_result.clauses, evaluation_context),
78
104
  );
79
- const paginated_nodes = paginateNodes(matching_nodes, pagination_options);
105
+ const paginated_nodes = paginateNodes(
106
+ matching_nodes,
107
+ resolved_pagination_options,
108
+ );
80
109
 
81
110
  return {
82
111
  diagnostics: [],
@@ -176,29 +205,48 @@ function matchesAggregateTerm(graph_node, term, evaluation_context) {
176
205
  * @returns {boolean}
177
206
  */
178
207
  function matchesFieldTerm(graph_node, term) {
179
- const term_key = `${term.field_name}${term.operator}`;
208
+ const field_values = getGraphNodeFieldValues(graph_node, term.field_name);
180
209
 
181
- if (
182
- term_key === 'id=' ||
183
- term_key === 'kind=' ||
184
- term_key === 'path=' ||
185
- term_key === 'status='
186
- ) {
187
- return graph_node[term.field_name] === term.value;
210
+ if (term.operator === '=') {
211
+ return field_values.some((field_value) => field_value === term.value);
188
212
  }
189
213
 
190
- if (term_key === 'id^=') {
191
- return graph_node.id.startsWith(term.value);
214
+ if (term.operator === '!=') {
215
+ return field_values.every((field_value) => field_value !== term.value);
192
216
  }
193
217
 
194
- if (term_key === 'path^=') {
195
- return (graph_node.path ?? '').startsWith(term.value);
218
+ if (term.operator === '^=') {
219
+ return field_values.some((field_value) =>
220
+ field_value.startsWith(term.value),
221
+ );
196
222
  }
197
223
 
198
- if (term_key === 'title~') {
199
- return (graph_node.title ?? '')
200
- .toLocaleLowerCase('en')
201
- .includes(term.value.toLocaleLowerCase('en'));
224
+ if (term.operator === '~') {
225
+ const term_value = term.value.toLocaleLowerCase('en');
226
+
227
+ return field_values.some((field_value) =>
228
+ field_value.toLocaleLowerCase('en').includes(term_value),
229
+ );
230
+ }
231
+
232
+ if (term.operator === '<' || term.operator === '<=') {
233
+ return field_values.some((field_value) =>
234
+ compareFieldValues(
235
+ field_value,
236
+ /** @type {'<' | '<='} */ (term.operator),
237
+ term.value,
238
+ ),
239
+ );
240
+ }
241
+
242
+ if (term.operator === '>' || term.operator === '>=') {
243
+ return field_values.some((field_value) =>
244
+ compareFieldValues(
245
+ field_value,
246
+ /** @type {'>' | '>='} */ (term.operator),
247
+ term.value,
248
+ ),
249
+ );
202
250
  }
203
251
 
204
252
  throw new Error('Unsupported parsed where clause.');
@@ -210,8 +258,10 @@ function matchesFieldTerm(graph_node, term) {
210
258
  * @returns {boolean}
211
259
  */
212
260
  function matchesFieldSetTerm(graph_node, term) {
213
- const actual_value = graph_node[term.field_name];
214
- const is_member = actual_value ? term.values.includes(actual_value) : false;
261
+ const field_values = getGraphNodeFieldValues(graph_node, term.field_name);
262
+ const is_member = field_values.some((field_value) =>
263
+ term.values.includes(field_value),
264
+ );
215
265
 
216
266
  return term.operator === 'in' ? is_member : !is_member;
217
267
  }
@@ -352,7 +402,10 @@ function createDirectionalRelationIndex(graph_edges, source_key, target_key) {
352
402
  * @returns {number}
353
403
  */
354
404
  function compareGraphNodes(left_node, right_node) {
355
- return left_node.id.localeCompare(right_node.id, 'en');
405
+ return (left_node.$id ?? left_node.id).localeCompare(
406
+ right_node.$id ?? right_node.id,
407
+ 'en',
408
+ );
356
409
  }
357
410
 
358
411
  /**
@@ -366,3 +419,169 @@ function paginateNodes(matching_nodes, pagination_options) {
366
419
 
367
420
  return matching_nodes.slice(offset, offset + limit);
368
421
  }
422
+
423
+ /**
424
+ * @param {GraphNode} graph_node
425
+ * @param {string} field_name
426
+ * @returns {string[]}
427
+ */
428
+ function getGraphNodeFieldValues(graph_node, field_name) {
429
+ const structural_value = getStructuralFieldValue(graph_node, field_name);
430
+
431
+ if (structural_value !== undefined) {
432
+ return [structural_value];
433
+ }
434
+
435
+ if (field_name === '$path' || field_name === 'title') {
436
+ return [];
437
+ }
438
+
439
+ return normalizeFieldValues(graph_node[field_name]);
440
+ }
441
+
442
+ /**
443
+ * @param {GraphNode} graph_node
444
+ * @param {string} field_name
445
+ * @returns {string | undefined}
446
+ */
447
+ function getStructuralFieldValue(graph_node, field_name) {
448
+ if (field_name === '$id') {
449
+ return graph_node.$id ?? graph_node.id;
450
+ }
451
+
452
+ if (field_name === '$class') {
453
+ return graph_node.$class ?? graph_node.kind;
454
+ }
455
+
456
+ if (field_name === '$path') {
457
+ return graph_node.$path ?? graph_node.path;
458
+ }
459
+
460
+ if (field_name === 'title') {
461
+ return graph_node.title;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * @param {unknown} field_value
467
+ * @returns {string[]}
468
+ */
469
+ function normalizeFieldValues(field_value) {
470
+ if (Array.isArray(field_value)) {
471
+ return field_value.flatMap(getScalarFieldValue);
472
+ }
473
+
474
+ return getScalarFieldValue(field_value);
475
+ }
476
+
477
+ /**
478
+ * @param {unknown} field_value
479
+ * @returns {string[]}
480
+ */
481
+ function getScalarFieldValue(field_value) {
482
+ if (
483
+ typeof field_value === 'string' ||
484
+ typeof field_value === 'number' ||
485
+ typeof field_value === 'boolean'
486
+ ) {
487
+ return [String(field_value)];
488
+ }
489
+
490
+ return [];
491
+ }
492
+
493
+ /**
494
+ * @param {PatramRepoConfig | { limit?: number, offset?: number }} repo_config_or_pagination
495
+ * @param {{ limit?: number, offset?: number }} pagination_options
496
+ * @returns {{ pagination_options: { limit?: number, offset?: number }, repo_config: PatramRepoConfig | null }}
497
+ */
498
+ function resolveQueryGraphOptions(
499
+ repo_config_or_pagination,
500
+ pagination_options,
501
+ ) {
502
+ if (isRepoConfig(repo_config_or_pagination)) {
503
+ return {
504
+ pagination_options,
505
+ repo_config: repo_config_or_pagination,
506
+ };
507
+ }
508
+
509
+ return {
510
+ pagination_options: repo_config_or_pagination,
511
+ repo_config: null,
512
+ };
513
+ }
514
+
515
+ /**
516
+ * @param {PatramRepoConfig | { limit?: number, offset?: number }} value
517
+ * @returns {value is PatramRepoConfig}
518
+ */
519
+ function isRepoConfig(value) {
520
+ return 'include' in value || 'queries' in value;
521
+ }
522
+
523
+ /**
524
+ * @param {string} left_value
525
+ * @param {'<' | '<=' | '>' | '>='} comparison
526
+ * @param {string} right_value
527
+ * @returns {boolean}
528
+ */
529
+ function compareFieldValues(left_value, comparison, right_value) {
530
+ const numeric_comparison = compareNumericFieldValues(
531
+ left_value,
532
+ comparison,
533
+ right_value,
534
+ );
535
+
536
+ if (numeric_comparison !== null) {
537
+ return numeric_comparison;
538
+ }
539
+
540
+ return compareComparableValues(
541
+ left_value.localeCompare(right_value, 'en'),
542
+ comparison,
543
+ );
544
+ }
545
+
546
+ /**
547
+ * @param {string} left_value
548
+ * @param {'<' | '<=' | '>' | '>='} comparison
549
+ * @param {string} right_value
550
+ * @returns {boolean | null}
551
+ */
552
+ function compareNumericFieldValues(left_value, comparison, right_value) {
553
+ const left_number = Number(left_value);
554
+ const right_number = Number(right_value);
555
+
556
+ if (
557
+ !Number.isFinite(left_number) ||
558
+ !Number.isFinite(right_number) ||
559
+ left_value.trim().length === 0 ||
560
+ right_value.trim().length === 0
561
+ ) {
562
+ return null;
563
+ }
564
+
565
+ return compareComparableValues(left_number - right_number, comparison);
566
+ }
567
+
568
+ /**
569
+ * @param {number} comparison_result
570
+ * @param {'<' | '<=' | '>' | '>='} comparison
571
+ * @returns {boolean}
572
+ */
573
+ function compareComparableValues(comparison_result, comparison) {
574
+ if (comparison === '<') {
575
+ return comparison_result < 0;
576
+ }
577
+
578
+ if (comparison === '<=') {
579
+ return comparison_result <= 0;
580
+ }
581
+
582
+ if (comparison === '>') {
583
+ return comparison_result > 0;
584
+ }
585
+
586
+ return comparison_result >= 0;
587
+ }