@travetto/model-sql 8.0.0-alpha.26 → 8.0.0-alpha.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-sql",
3
- "version": "8.0.0-alpha.26",
3
+ "version": "8.0.0-alpha.27",
4
4
  "type": "module",
5
5
  "description": "SQL backing for the travetto model module, with real-time modeling support for SQL schemas.",
6
6
  "keywords": [
@@ -28,15 +28,15 @@
28
28
  "directory": "module/model-sql"
29
29
  },
30
30
  "dependencies": {
31
- "@travetto/config": "^8.0.0-alpha.22",
32
- "@travetto/context": "^8.0.0-alpha.20",
33
- "@travetto/model": "^8.0.0-alpha.23",
34
- "@travetto/model-indexed": "^8.0.0-alpha.25",
35
- "@travetto/model-query": "^8.0.0-alpha.24"
31
+ "@travetto/config": "^8.0.0-alpha.23",
32
+ "@travetto/context": "^8.0.0-alpha.21",
33
+ "@travetto/model": "^8.0.0-alpha.24",
34
+ "@travetto/model-indexed": "^8.0.0-alpha.26",
35
+ "@travetto/model-query": "^8.0.0-alpha.25"
36
36
  },
37
37
  "peerDependencies": {
38
- "@travetto/cli": "^8.0.0-alpha.28",
39
- "@travetto/test": "^8.0.0-alpha.21"
38
+ "@travetto/cli": "^8.0.0-alpha.29",
39
+ "@travetto/test": "^8.0.0-alpha.22"
40
40
  },
41
41
  "peerDependenciesMeta": {
42
42
  "@travetto/cli": {
package/src/dialect.ts CHANGED
@@ -5,7 +5,7 @@ import { isModelQueryIndex, ModelQueryUtil, type SortClause, type WhereClause }
5
5
  import { type Class, castTo, JSONUtil, RuntimeError } from '@travetto/runtime';
6
6
  import { DataUtil, type SchemaFieldConfig, SchemaRegistryIndex } from '@travetto/schema';
7
7
 
8
- import type { JSONSqlPathMode, SchemaContext, TableContext } from './types.ts';
8
+ import type { JSONSqlPathMode, ResolvedPathContext, SchemaContext, TableContext } from './types.ts';
9
9
 
10
10
  export interface TransactionStatements {
11
11
  begin: string;
@@ -64,28 +64,10 @@ export abstract class AbstractANSI99Dialect {
64
64
 
65
65
  abstract getColumnType(fieldConfiguration: SchemaFieldConfig): string;
66
66
  abstract compileJsonIndexPath(columnName: string, jsonPath: string[], mode: JSONSqlPathMode): string;
67
- abstract compileArrayAll(
68
- sqlPath: string,
69
- identifier: string,
70
- value: unknown[],
71
- field: SchemaFieldConfig,
72
- topLevel?: boolean
73
- ): { sql: string; formatted: unknown };
74
- abstract compileArrayEquals(
75
- sqlPath: string,
76
- identifier: string,
77
- values: unknown,
78
- field: SchemaFieldConfig,
79
- topLevel?: boolean
80
- ): { sql: string; formatted: unknown };
81
- abstract compileArrayAny(
82
- sqlPath: string,
83
- identifier: string,
84
- values: unknown[],
85
- field: SchemaFieldConfig,
86
- topLevel?: boolean
87
- ): { sql: string; formatted: unknown };
88
- abstract compileArrayExists(sqlPath: string, identifier: string, field: SchemaFieldConfig, topLevel?: boolean): { sql: string };
67
+ abstract compileArrayAll(context: ResolvedPathContext, identifier: string, value: unknown[]): { sql: string; formatted: unknown };
68
+ abstract compileArrayEquals(context: ResolvedPathContext, identifier: string, values: unknown): { sql: string; formatted: unknown };
69
+ abstract compileArrayAny(context: ResolvedPathContext, identifier: string, values: unknown[]): { sql: string; formatted: unknown };
70
+ abstract compileArrayExists(context: ResolvedPathContext, identifier?: string): { sql: string };
89
71
 
90
72
  abstract getRegexOperator(caseInsensitive: boolean): string;
91
73
  abstract formatRegex(source: string, caseInsensitive: boolean): string;
@@ -94,12 +76,17 @@ export abstract class AbstractANSI99Dialect {
94
76
  compileJsonEquality?(sqlPath: string, identifier: string): string;
95
77
  shiftPlaceholders?(whereSQL: string, offset: number): string;
96
78
 
97
- compileIndexPath(context: TableContext, path: string[], mode: JSONSqlPathMode): string {
79
+ buildSqlPath<T extends ModelType>(tableContext: TableContext<T>, path: string[], mode: JSONSqlPathMode): string {
98
80
  const firstSegment = path[0];
99
81
  const escapedFirst = this.escapeIdentifier(firstSegment);
100
- if (context.simpleFields.has(firstSegment)) {
82
+ if (tableContext.simpleFields.has(firstSegment)) {
101
83
  if (path.length > 1) {
102
- throw new RuntimeError(`Cannot create nested index under column "${firstSegment}" in table "${context.tableName}"`);
84
+ throw new RuntimeError(
85
+ `Cannot traverse nested properties under simple column "${firstSegment}" in table "${tableContext.tableName}"`,
86
+ {
87
+ category: 'data'
88
+ }
89
+ );
103
90
  }
104
91
  return escapedFirst;
105
92
  } else {
@@ -111,6 +98,10 @@ export abstract class AbstractANSI99Dialect {
111
98
  }
112
99
  }
113
100
 
101
+ compileIndexPath(context: TableContext, path: string[], mode: JSONSqlPathMode): string {
102
+ return this.buildSqlPath(context, path, mode);
103
+ }
104
+
114
105
  getCreateIndexSQL(context: TableContext, indexConfig: IndexConfig): string {
115
106
  const { tableName, cls: modelClass } = context;
116
107
  const indexName = ['idx', tableName, indexConfig.name.toLowerCase().replaceAll('-', '_')].join('_');
@@ -272,11 +263,14 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
272
263
  return sortClauses.length ? `ORDER BY ${sortClauses.join(', ')}` : '';
273
264
  }
274
265
 
275
- resolvePath<T extends ModelType>(
266
+ #resolveSchemaPath<T extends ModelType>(
276
267
  tableContext: TableContext<T>,
277
- path: string[],
278
- mode: JSONSqlPathMode
279
- ): { sqlPath: string; leafField?: SchemaFieldConfig } {
268
+ path: string[]
269
+ ): {
270
+ leafField?: SchemaFieldConfig;
271
+ arrayField?: SchemaFieldConfig;
272
+ arraySegmentIndex?: number;
273
+ } {
280
274
  const firstSegment = path[0];
281
275
 
282
276
  if (tableContext.simpleFields.has(firstSegment)) {
@@ -288,34 +282,55 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
288
282
  }
289
283
  );
290
284
  }
291
- const leafField = tableContext.simpleFields.get(firstSegment);
292
- return { sqlPath: this.escapeIdentifier(firstSegment), leafField };
285
+ return { leafField: tableContext.simpleFields.get(firstSegment) };
293
286
  }
294
287
 
295
288
  let currentField: SchemaFieldConfig | undefined = tableContext.complexFields.get(firstSegment);
289
+ let arrayField: SchemaFieldConfig | undefined = currentField?.array ? currentField : undefined;
290
+ let arraySegmentIndex: number | undefined = currentField?.array ? 0 : undefined;
296
291
  let currentClass = currentField?.type;
297
- const jsonPath = path.slice(1);
298
292
 
299
- for (let index = 0; index < jsonPath.length - 1; index++) {
300
- const segment = jsonPath[index];
293
+ for (let pathIndex = 1; pathIndex < path.length; pathIndex += 1) {
294
+ const segment = path[pathIndex];
301
295
  const subclassConfiguration = SchemaRegistryIndex.getOptional(currentClass!)?.get();
302
296
  currentField = subclassConfiguration?.fields[segment];
297
+ if (currentField?.array && !arrayField) {
298
+ arrayField = currentField;
299
+ arraySegmentIndex = pathIndex;
300
+ }
303
301
  currentClass = currentField?.type;
304
302
  }
305
303
 
306
- if (jsonPath.length > 0) {
307
- const leafSegment = jsonPath[jsonPath.length - 1];
308
- const subclassConfiguration = SchemaRegistryIndex.getOptional(currentClass!)?.get();
309
- currentField = subclassConfiguration?.fields[leafSegment];
310
- }
304
+ return {
305
+ leafField: currentField,
306
+ arrayField,
307
+ arraySegmentIndex
308
+ };
309
+ }
311
310
 
312
- let compiledPath = this.compileIndexPath(tableContext, path, mode);
311
+ resolvePath<T extends ModelType>(tableContext: TableContext<T>, path: string[], mode: JSONSqlPathMode): ResolvedPathContext {
312
+ const firstSegment = path[0];
313
313
 
314
- if (currentField && !currentField.array) {
315
- compiledPath = this.castColumn(compiledPath, currentField.type);
314
+ if (tableContext.simpleFields.has(firstSegment)) {
315
+ const { leafField } = this.#resolveSchemaPath(tableContext, path);
316
+ return { sqlPath: this.buildSqlPath(tableContext, path, mode), leafField };
316
317
  }
317
318
 
318
- return { sqlPath: compiledPath, leafField: currentField };
319
+ const { leafField, arrayField, arraySegmentIndex } = this.#resolveSchemaPath(tableContext, path);
320
+ const sqlPath = this.buildSqlPath(tableContext, path, mode);
321
+
322
+ const finalSqlPath = leafField && !leafField.array ? this.castColumn(sqlPath, leafField.type) : sqlPath;
323
+
324
+ const arrayPath = arraySegmentIndex !== undefined ? path.slice(0, arraySegmentIndex + 1) : undefined;
325
+ const subPath = arraySegmentIndex !== undefined ? path.slice(arraySegmentIndex + 1) : undefined;
326
+
327
+ return {
328
+ sqlPath: finalSqlPath,
329
+ leafField,
330
+ arrayField,
331
+ arrayPath,
332
+ subPath
333
+ };
319
334
  }
320
335
 
321
336
  #compileClause<T extends ModelType>(
@@ -349,28 +364,6 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
349
364
  }
350
365
  }
351
366
 
352
- #buildJsonTemplate(queryObject: Record<string, unknown>): Record<string, unknown> {
353
- const template: Record<string, unknown> = {};
354
- for (const [key, value] of Object.entries(queryObject)) {
355
- if (DataUtil.isPlainObject(value)) {
356
- const firstKey = Object.keys(value)[0];
357
- const valueObject = castTo<Record<string, unknown>>(value);
358
- if (firstKey === '$eq') {
359
- template[key] = valueObject.$eq;
360
- } else if (valueObject.$eq !== undefined) {
361
- template[key] = valueObject.$eq;
362
- } else if (!firstKey.startsWith('$')) {
363
- template[key] = this.#buildJsonTemplate(valueObject);
364
- } else {
365
- throw new RuntimeError(`Unsupported operator ${firstKey} in nested array query`, { category: 'data' });
366
- }
367
- } else {
368
- template[key] = value;
369
- }
370
- }
371
- return template;
372
- }
373
-
374
367
  #compileSimple<T extends ModelType>(
375
368
  tableContext: TableContext<T>,
376
369
  item: Record<string, unknown>,
@@ -389,14 +382,11 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
389
382
  const isPlainObject = DataUtil.isPlainObject(value);
390
383
  const firstKey = isPlainObject ? Object.keys(value)[0] : '';
391
384
 
392
- const { leafField } = this.resolvePath(tableContext, currentPath, 'read');
393
385
  const nextIdentificationPath = `${identificationPath}__${index}`;
394
386
 
395
387
  if (isPlainObject) {
396
388
  if (firstKey.startsWith('$')) {
397
389
  clauses.push(this.#compileOperator(tableContext, currentPath, value as Record<string, unknown>, nextIdentificationPath));
398
- } else if (leafField?.array) {
399
- clauses.push(this.#compileOperator(tableContext, currentPath, { $eq: value }, nextIdentificationPath));
400
390
  } else {
401
391
  clauses.push(this.#compileSimple(tableContext, value as Record<string, unknown>, currentPath, nextIdentificationPath));
402
392
  }
@@ -414,7 +404,9 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
414
404
  operation: Record<string, unknown>,
415
405
  identificationPath: IdentificationPath = ''
416
406
  ): QueryClause {
417
- const { sqlPath, leafField } = this.resolvePath(tableContext, path, 'read');
407
+ const resolvedContext = this.resolvePath(tableContext, path, 'read');
408
+ const { sqlPath, leafField, arrayField } = resolvedContext;
409
+ const effectiveArrayField = leafField?.array ? leafField : arrayField;
418
410
  const clauses: QueryClause[] = [];
419
411
 
420
412
  let index = 0;
@@ -432,16 +424,16 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
432
424
 
433
425
  let clause: QueryClause;
434
426
 
435
- if (leafField?.array) {
427
+ if (effectiveArrayField) {
436
428
  if (operator === '$eq' || operator === '$ne') {
437
- const { sql, formatted } = this.compileArrayEquals(sqlPath, identifier, value, leafField, path.length === 1);
429
+ const { sql, formatted } = this.compileArrayEquals(resolvedContext, identifier, value);
438
430
  const finalSql = operator === '$ne' ? `NOT(${sql})` : sql;
439
431
  clause = { parameters: { [identifier]: formatted }, sql: finalSql };
440
432
  } else if (operator === '$in' || operator === '$nin') {
441
433
  if (!Array.isArray(value) || value.length === 0) {
442
434
  clause = operator === '$in' ? { sql: '1=0' } : {};
443
435
  } else {
444
- const { sql, formatted } = this.compileArrayAny(sqlPath, identifier, value, leafField, path.length === 1);
436
+ const { sql, formatted } = this.compileArrayAny(resolvedContext, identifier, value);
445
437
  const finalSql = operator === '$nin' ? `NOT(${sql})` : sql;
446
438
  clause = { sql: finalSql, parameters: { [identifier]: formatted } };
447
439
  }
@@ -449,11 +441,11 @@ CREATE TABLE ${this.escapeIdentifier(context.tableName)} (
449
441
  if (!Array.isArray(value) || value.length === 0) {
450
442
  clause = { sql: '1=0' };
451
443
  } else {
452
- const { sql, formatted } = this.compileArrayAll(sqlPath, identifier, value, leafField, path.length === 1);
444
+ const { sql, formatted } = this.compileArrayAll(resolvedContext, identifier, value);
453
445
  clause = { sql, parameters: { [identifier]: formatted } };
454
446
  }
455
447
  } else if (operator === '$exists') {
456
- const { sql } = this.compileArrayExists(sqlPath, identifier, leafField, path.length === 1);
448
+ const { sql } = this.compileArrayExists(resolvedContext, identifier);
457
449
  const finalSql = !value ? `NOT(${sql})` : sql;
458
450
  clause = { sql: finalSql };
459
451
  } else {
package/src/service.ts CHANGED
@@ -15,7 +15,8 @@ import {
15
15
  type ModelStorageSupport,
16
16
  type ModelType,
17
17
  NotFoundError,
18
- type OptionalId
18
+ type OptionalId,
19
+ UniqueError
19
20
  } from '@travetto/model';
20
21
  import {
21
22
  type FullKeyedIndexBody,
@@ -207,7 +208,14 @@ export abstract class BaseSQLModelService<C = unknown>
207
208
 
208
209
  const { sql, values } = this.dialect.buildInsert(tableContext, rawItem);
209
210
 
210
- await this.connection.execute(sql, values);
211
+ try {
212
+ await this.connection.execute(sql, values);
213
+ } catch (error) {
214
+ if (error instanceof UniqueError && (error.details?.type === 'query' || error.details?.type === 'index')) {
215
+ throw new UniqueError(modelClass, (error.details.constraint as string) ?? 'unknown', error.details);
216
+ }
217
+ throw error;
218
+ }
211
219
  return preppedItem;
212
220
  }
213
221
 
@@ -487,7 +495,7 @@ export abstract class BaseSQLModelService<C = unknown>
487
495
  try {
488
496
  const result = await this.connection.execute(command.sql, command.values);
489
497
  if (command.type === 'update' && result.count === 0) {
490
- counts.error++;
498
+ counts.error += 1;
491
499
  errors.push(new NotFoundError(modelClass, command.identifier!));
492
500
  } else if (command.type === 'delete' && result.count < command.count) {
493
501
  counts.delete += result.count;
package/src/types.ts CHANGED
@@ -15,3 +15,11 @@ export interface TableContext<T extends ModelType = ModelType> extends SchemaCon
15
15
  tableName: string;
16
16
  database?: string;
17
17
  }
18
+
19
+ export interface ResolvedPathContext {
20
+ sqlPath: string;
21
+ leafField?: SchemaFieldConfig;
22
+ arrayField?: SchemaFieldConfig;
23
+ arrayPath?: string[];
24
+ subPath?: string[];
25
+ }
@@ -8,7 +8,7 @@ import { BeforeAll, Suite, Test } from '@travetto/test';
8
8
 
9
9
  import { AbstractANSI99Dialect } from '../../src/dialect.ts';
10
10
  import { SQLModelSchemaUtil } from '../../src/schema.ts';
11
- import type { TableContext } from '../../src/types.ts';
11
+ import type { ResolvedPathContext, TableContext } from '../../src/types.ts';
12
12
 
13
13
  @Model()
14
14
  class User {
@@ -50,20 +50,20 @@ class MockDialect extends AbstractANSI99Dialect {
50
50
  return `$$${index}`;
51
51
  }
52
52
 
53
- compileArrayAll(sqlPath: string, identifier: string, value: unknown[]) {
54
- return { sql: `${sqlPath} ALL ${identifier}`, formatted: value };
53
+ compileArrayAll(context: ResolvedPathContext, identifier: string, value: unknown[]) {
54
+ return { sql: `${context.sqlPath} ALL ${identifier}`, formatted: value };
55
55
  }
56
56
 
57
- compileArrayEquals(sqlPath: string, identifier: string, values: unknown) {
58
- return { sql: `${sqlPath} EQUALS ${identifier}`, formatted: values };
57
+ compileArrayEquals(context: ResolvedPathContext, identifier: string, values: unknown) {
58
+ return { sql: `${context.sqlPath} EQUALS ${identifier}`, formatted: values };
59
59
  }
60
60
 
61
- compileArrayAny(sqlPath: string, identifier: string, values: unknown[]) {
62
- return { sql: `${sqlPath} ANY ${identifier}`, formatted: values };
61
+ compileArrayAny(context: ResolvedPathContext, identifier: string, values: unknown[]) {
62
+ return { sql: `${context.sqlPath} ANY ${identifier}`, formatted: values };
63
63
  }
64
64
 
65
- compileArrayExists(sqlPath: string, identifier: string) {
66
- return { sql: `${sqlPath} IS NOT NULL`, formatted: undefined };
65
+ compileArrayExists(context: ResolvedPathContext, identifier?: string) {
66
+ return { sql: `${context.sqlPath} IS NOT NULL`, formatted: undefined };
67
67
  }
68
68
 
69
69
  getRegexOperator(caseInsensitive: boolean) {