@travetto/model-sql 8.0.0-alpha.25 → 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/src/service.ts CHANGED
@@ -1,22 +1,22 @@
1
- import type { AsyncContext } from '@travetto/context';
2
- import { Injectable, PostConstruct } from '@travetto/di';
1
+ import { Injectable } from '@travetto/di';
3
2
  import {
4
3
  type BulkOperation,
5
4
  type BulkResponse,
6
- ExistsError,
5
+ type IndexConfig,
7
6
  type ModelBulkSupport,
8
7
  ModelBulkUtil,
8
+ type ModelCrudProvider,
9
9
  type ModelCrudSupport,
10
10
  ModelCrudUtil,
11
+ type ModelExpirySupport,
11
12
  ModelExpiryUtil,
12
- type ModelIdSource,
13
13
  type ModelListOptions,
14
14
  ModelRegistryIndex,
15
15
  type ModelStorageSupport,
16
- ModelStorageUtil,
17
16
  type ModelType,
18
17
  NotFoundError,
19
- type OptionalId
18
+ type OptionalId,
19
+ UniqueError
20
20
  } from '@travetto/model';
21
21
  import {
22
22
  type FullKeyedIndexBody,
@@ -32,7 +32,9 @@ import {
32
32
  type SingleItemIndex,
33
33
  type SortedIndex,
34
34
  type SortedIndexSelection,
35
- type SortedIndexSelectionType
35
+ type SortedIndexSelectionType,
36
+ warnIfIndexedUniqueIndex,
37
+ warnIfNonIndexedIndex
36
38
  } from '@travetto/model-indexed';
37
39
  import {
38
40
  type ModelQuery,
@@ -47,478 +49,790 @@ import {
47
49
  type PageableModelQuery,
48
50
  QueryVerifier,
49
51
  type ValidStringFields,
50
- type WhereClauseRaw
52
+ type WhereClause
51
53
  } from '@travetto/model-query';
52
54
  import { type Class, castTo, JSONUtil } from '@travetto/runtime';
53
- import { DataUtil } from '@travetto/schema';
55
+ import { WorkPool } from '@travetto/worker';
54
56
 
55
- import type { SQLModelConfig } from './config.ts';
56
- import type { Connection } from './connection/base.ts';
57
- import { Connected, ConnectedIterator, Transactional } from './connection/decorator.ts';
58
- import type { SQLDialect } from './dialect/base.ts';
59
- import type { InsertWrapper } from './internal/types.ts';
60
- import { TableManager } from './table-manager.ts';
61
- import { SQLModelUtil } from './util.ts';
57
+ import type { SQLConnection } from './connection.ts';
58
+ import type { AbstractANSI99Dialect } from './dialect.ts';
59
+ import { SQLModelSchemaUtil } from './schema.ts';
60
+ import type { TableContext } from './types.ts';
62
61
 
63
62
  /**
64
- * Core for SQL Model Source. Should not have any direct queries,
65
- * but should offload all of that to the dialect, so it can be overridden
66
- * as needed.
63
+ * Base SQL Model Service.
64
+ * Implements CRUD, Query, Expiry, Bulk, Indexed, and Suggest operations
65
+ * by delegating to connection and dialect components.
67
66
  */
68
67
  @Injectable()
69
- export class SQLModelService
68
+ export abstract class BaseSQLModelService<C = unknown>
70
69
  implements
71
70
  ModelCrudSupport,
72
71
  ModelStorageSupport,
73
72
  ModelBulkSupport,
73
+ ModelExpirySupport,
74
+ ModelIndexedSupport,
74
75
  ModelQuerySupport,
75
76
  ModelQueryCrudSupport,
76
77
  ModelQueryFacetSupport,
77
- ModelIndexedSupport,
78
78
  ModelQuerySuggestSupport
79
79
  {
80
- #manager: TableManager;
81
- #context: AsyncContext;
82
- #dialect: SQLDialect;
83
- idSource: ModelIdSource;
84
-
85
- readonly config: SQLModelConfig;
86
-
87
- get client(): SQLDialect {
88
- return this.#dialect;
89
- }
90
-
91
- constructor(context: AsyncContext, config: SQLModelConfig, dialect: SQLDialect) {
92
- this.#context = context;
93
- this.#dialect = dialect;
94
- this.config = config;
95
- }
96
-
97
- /**
98
- * Verify upserted ids for bulk operations
99
- */
100
- async #checkUpsertedIds<T extends ModelType>(
101
- cls: Class<T>,
102
- addedIds: Map<number, string>,
103
- toCheck: Map<string, number>
104
- ): Promise<Map<number, string>> {
105
- // Get all upsert ids
106
- const all = toCheck.size
107
- ? (
108
- await this.#exec<ModelType>(
109
- this.#dialect.getSelectRowsByIdsSQL(SQLModelUtil.classToStack(cls), [...toCheck.keys()], [this.#dialect.idField])
110
- )
111
- ).records
112
- : [];
113
-
114
- const allIds = new Set(all.map(type => type.id));
115
-
116
- for (const [id, idx] of toCheck.entries()) {
117
- if (!allIds.has(id)) {
118
- // If not found
119
- addedIds.set(idx, id);
120
- }
121
- }
80
+ abstract readonly client: C;
81
+ abstract connection: SQLConnection;
122
82
 
123
- return addedIds;
124
- }
83
+ idSource = ModelCrudUtil.uuidSource();
125
84
 
126
- #exec<T = unknown>(sql: string): Promise<{ records: T[]; count: number }> {
127
- return this.#dialect.executeSQL<T>(sql);
85
+ get dialect(): AbstractANSI99Dialect {
86
+ return this.connection.dialect;
128
87
  }
129
88
 
130
- async #deleteRaw<T extends ModelType>(cls: Class<T>, id: string, where?: WhereClauseRaw<T>, checkExpiry = true): Promise<void> {
131
- castTo<WhereClauseRaw<ModelType>>((where ??= {})).id = id;
89
+ #whereClause<T extends ModelType>(
90
+ modelClass: Class<T>,
91
+ where?: WhereClause<T>,
92
+ checkExpiry?: boolean
93
+ ): { whereSQL?: string; parameters?: unknown[] } {
94
+ return this.dialect.compileWhere(this.connection.getContext(modelClass), ModelQueryUtil.getWhereClause(modelClass, where), checkExpiry);
95
+ }
132
96
 
133
- const count = await this.#dialect.deleteAndGetCount<ModelType>(cls, {
134
- where: ModelQueryUtil.getWhereClause(cls, where, checkExpiry)
135
- });
136
- if (count === 0) {
137
- throw new NotFoundError(cls, id);
138
- }
97
+ async initialize(): Promise<void> {
98
+ await this.connection.init();
99
+ await this.createStorage();
100
+ ModelExpiryUtil.registerCull(this);
139
101
  }
140
102
 
141
- async *#scanTable<T extends ModelType>(
142
- cls: Class<T>,
143
- buildQuery: () => PageableModelQuery<T>,
144
- options?: ModelListOptions & ModelPageOptions<number>
145
- ): AsyncIterable<{ items: T[]; nextOffset?: number }> {
146
- const batchSize = options?.batchSizeHint ?? 100;
147
- const maxCount = options?.limit ?? Number.MAX_SAFE_INTEGER;
148
- let offset = options?.offset ?? 0;
149
- let lastOffset = -1;
150
- let produced = 0;
151
- while (offset !== lastOffset && produced < maxCount && !options?.abort?.aborted) {
152
- const limit = Math.min(batchSize, maxCount - produced);
153
- lastOffset = offset;
154
- const items = await this.query<T>(cls, {
155
- ...buildQuery(),
156
- limit,
157
- offset
158
- });
159
- offset += items.length;
160
- produced += items.length;
161
- if (items.length) {
162
- yield { items, nextOffset: items.length < limit ? undefined : offset };
103
+ // Record Deserialization Helpers
104
+ async loadSingle<T extends ModelType>(modelClass: Class<T>, record: Record<string, unknown>): Promise<T> {
105
+ const schemaContext = SQLModelSchemaUtil.getSchemaContext(modelClass);
106
+ const resolvedRecord = { ...record };
107
+ for (const complexFieldName of schemaContext.complexFields.keys()) {
108
+ const value = resolvedRecord[complexFieldName];
109
+ if (typeof value === 'string') {
110
+ resolvedRecord[complexFieldName] = JSONUtil.fromUTF8(value);
163
111
  }
164
112
  }
113
+ return ModelCrudUtil.load(modelClass, resolvedRecord);
165
114
  }
166
115
 
167
- @PostConstruct()
168
- async initializeClient(): Promise<void> {
169
- await this.#dialect.connection.init?.();
170
- this.idSource = ModelCrudUtil.uuidSource(this.#dialect.ID_LENGTH);
171
- this.#manager = new TableManager(this.#context, this.#dialect);
172
- await ModelStorageUtil.storageInitialization(this);
173
- ModelExpiryUtil.registerCull(this);
116
+ async loadMany<T extends ModelType>(modelClass: Class<T>, records: unknown[]): Promise<T[]> {
117
+ return Promise.all(records.map(row => this.loadSingle(modelClass, castTo(row))));
174
118
  }
175
119
 
176
- get connection(): Connection {
177
- return this.#dialect.connection;
178
- }
120
+ async executeUpdatePartial<T extends ModelType>(
121
+ modelClass: Class<T>,
122
+ where: WhereClause<T>,
123
+ data: Partial<T>,
124
+ returning: boolean,
125
+ view?: string
126
+ ): Promise<{ count: number; records: Record<string, unknown>[] }> {
127
+ const preparedData = await ModelCrudUtil.prePartialUpdate(modelClass, data, view);
179
128
 
180
- async exportModel<T extends ModelType>(cls: Class<T>): Promise<string> {
181
- return (await this.#manager.exportTables(cls)).join('\n');
182
- }
129
+ const tableContext = this.connection.getContext(modelClass);
130
+ const { whereSQL, parameters = [] } = this.#whereClause(modelClass, where);
131
+ const { sql, values } = this.dialect.buildPartialUpdate(tableContext, preparedData, whereSQL, parameters, returning);
132
+
133
+ const result = await this.connection.execute<Record<string, unknown>>(sql, values);
134
+
135
+ if (result.count > 0 && returning && !this.dialect.returningSupport) {
136
+ const selectSQL = this.dialect.buildSelect(tableContext, { whereSQL });
137
+ const selectResult = await this.connection.execute<Record<string, unknown>>(selectSQL, parameters);
138
+ return { count: result.count, records: selectResult.records };
139
+ }
183
140
 
184
- async upsertModel(cls: Class): Promise<void> {
185
- await this.#manager.upsertTables(cls);
141
+ return result;
186
142
  }
187
143
 
188
- async deleteModel(cls: Class): Promise<void> {
189
- await this.#manager.dropTables(cls);
144
+ async executeUpdate<T extends ModelType>(
145
+ modelClass: Class<T>,
146
+ where: WhereClause<T>,
147
+ item: T,
148
+ modelSource?: ModelCrudProvider
149
+ ): Promise<T | undefined> {
150
+ ModelCrudUtil.ensureNotSubType(modelClass);
151
+ const preppedItem = await ModelCrudUtil.preStore(modelClass, item, modelSource ?? { idSource: this.idSource });
152
+ const rawItem: Record<string, unknown> = castTo(preppedItem);
153
+
154
+ const tableContext = this.connection.getContext(modelClass);
155
+ const { whereSQL, parameters = [] } = this.#whereClause(modelClass, where);
156
+ const { sql, values } = this.dialect.buildUpdate(tableContext, rawItem, whereSQL, parameters);
157
+
158
+ const result = await this.connection.execute(sql, values);
159
+ if (result.count === 0) {
160
+ return undefined;
161
+ }
162
+ if (result.count > 1) {
163
+ throw new Error(`Multiple items found for update lookup ${modelClass.name}`);
164
+ }
165
+ return preppedItem;
190
166
  }
191
167
 
192
- async truncateModel(cls: Class): Promise<void> {
193
- await this.#manager.truncateTables(cls);
168
+ async executeUpsert<T extends ModelType>(
169
+ modelClass: Class<T>,
170
+ item: OptionalId<T>,
171
+ conflictTarget: string[],
172
+ modelSource?: ModelCrudProvider
173
+ ): Promise<T> {
174
+ ModelCrudUtil.ensureNotSubType(modelClass);
175
+ const preppedItem = await ModelCrudUtil.preStore(modelClass, item, modelSource ?? { idSource: this.idSource });
176
+ const rawItem: Record<string, unknown> = castTo(preppedItem);
177
+ const tableContext = this.connection.getContext(modelClass);
178
+
179
+ const { sql, values } = this.dialect.buildUpsert(tableContext, rawItem, conflictTarget);
180
+
181
+ const result = await this.connection.execute<Record<string, unknown>>(sql, values);
182
+ if (result.records.length > 0) {
183
+ return this.loadSingle(modelClass, result.records[0]);
184
+ } else {
185
+ return this.get(modelClass, rawItem.id as string);
186
+ }
194
187
  }
195
188
 
196
- async createStorage(): Promise<void> {}
197
- async deleteStorage(): Promise<void> {}
189
+ // Crud Support
190
+ async get<T extends ModelType>(modelClass: Class<T>, id: string): Promise<T> {
191
+ const tableContext = this.connection.getContext(modelClass);
192
+ const { whereSQL, parameters } = this.#whereClause(modelClass, castTo({ id }));
193
+ const sql = this.dialect.buildSelect(tableContext, { whereSQL });
198
194
 
199
- @Transactional()
200
- async create<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
201
- const prepped = await ModelCrudUtil.preStore(cls, item, this);
202
- try {
203
- for (const ins of this.#dialect.getAllInsertSQL(cls, prepped)) {
204
- await this.#exec(ins);
205
- }
206
- } catch (error) {
207
- if (error instanceof ExistsError) {
208
- throw new ExistsError(cls, prepped.id);
209
- } else {
210
- throw error;
211
- }
195
+ const result = await this.connection.execute<Record<string, unknown>>(sql, parameters);
196
+
197
+ if (result.count === 0) {
198
+ throw new NotFoundError(modelClass, id);
212
199
  }
213
- return prepped;
214
- }
215
200
 
216
- @Transactional()
217
- async update<T extends ModelType>(cls: Class<T>, item: T): Promise<T> {
218
- await this.#deleteRaw(cls, item.id, {}, true);
219
- return await this.create(cls, item);
201
+ return this.loadSingle(modelClass, result.records[0]);
220
202
  }
221
203
 
222
- @Transactional()
223
- async upsert<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
204
+ async create<T extends ModelType>(modelClass: Class<T>, item: OptionalId<T>, modelSource?: ModelCrudProvider): Promise<T> {
205
+ const preppedItem = await ModelCrudUtil.preStore(modelClass, item, modelSource ?? { idSource: this.idSource });
206
+ const rawItem: Record<string, unknown> = castTo(preppedItem);
207
+ const tableContext = this.connection.getContext(modelClass);
208
+
209
+ const { sql, values } = this.dialect.buildInsert(tableContext, rawItem);
210
+
224
211
  try {
225
- if (item.id) {
226
- await this.#deleteRaw(cls, item.id, {}, false);
227
- }
212
+ await this.connection.execute(sql, values);
228
213
  } catch (error) {
229
- if (!(error instanceof NotFoundError)) {
230
- throw 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);
231
216
  }
217
+ throw error;
218
+ }
219
+ return preppedItem;
220
+ }
221
+
222
+ async update<T extends ModelType>(modelClass: Class<T>, item: T, modelSource?: ModelCrudProvider): Promise<T> {
223
+ const preppedItem = await this.executeUpdate(modelClass, castTo({ id: item.id }), item, modelSource);
224
+ if (!preppedItem) {
225
+ throw new NotFoundError(modelClass, item.id);
232
226
  }
233
- return await this.create(cls, item);
227
+ return preppedItem;
234
228
  }
235
229
 
236
- @Transactional()
237
- async updatePartial<T extends ModelType>(cls: Class<T>, item: Partial<T> & { id: string }, view?: string): Promise<T> {
238
- const id = item.id;
239
- const final = await ModelCrudUtil.naivePartialUpdate(cls, () => this.get(cls, id), item, view);
240
- return this.update(cls, final);
230
+ async upsert<T extends ModelType>(modelClass: Class<T>, item: OptionalId<T>, modelSource?: ModelCrudProvider): Promise<T> {
231
+ return this.executeUpsert(modelClass, item, [this.dialect.escapeIdentifier('id')], modelSource);
241
232
  }
242
233
 
243
- @Connected()
244
- async get<T extends ModelType>(cls: Class<T>, id: string): Promise<T> {
245
- const result = await this.query(cls, { where: castTo({ id }) });
246
- if (result.length === 1) {
247
- return await ModelCrudUtil.load(cls, result[0]);
234
+ async updatePartial<T extends ModelType>(modelClass: Class<T>, item: Partial<T> & { id: string }, view?: string): Promise<T> {
235
+ ModelCrudUtil.ensureNotSubType(modelClass);
236
+
237
+ const result = await this.executeUpdatePartial(modelClass, castTo({ id: item.id }), item, true, view);
238
+
239
+ if (result.count === 0) {
240
+ throw new NotFoundError(modelClass, item.id);
248
241
  }
249
- throw new NotFoundError(cls, id);
242
+
243
+ return this.loadSingle(modelClass, result.records[0]);
250
244
  }
251
245
 
252
- @ConnectedIterator()
253
- async *list<T extends ModelType>(cls: Class<T>, options?: ModelListOptions): AsyncIterable<T[]> {
254
- for await (const { items } of this.#scanTable(cls, () => ({}), options)) {
255
- yield items;
246
+ async delete<T extends ModelType>(modelClass: Class<T>, id: string): Promise<void> {
247
+ ModelCrudUtil.ensureNotSubType(modelClass);
248
+ const tableContext = this.connection.getContext(modelClass);
249
+ const { whereSQL, parameters } = this.#whereClause(modelClass, castTo({ id }), false);
250
+ const sql = this.dialect.buildDelete(tableContext, whereSQL);
251
+
252
+ const result = await this.connection.execute(sql, parameters);
253
+ if (result.count === 0) {
254
+ throw new NotFoundError(modelClass, id);
256
255
  }
257
256
  }
258
257
 
259
- @Transactional()
260
- async delete<T extends ModelType>(cls: Class<T>, id: string): Promise<void> {
261
- await this.#deleteRaw(cls, id, {}, false);
258
+ async *list<T extends ModelType>(modelClass: Class<T>, options?: ModelListOptions): AsyncIterable<T[]> {
259
+ yield* this.listWithOffset(modelClass, options);
262
260
  }
263
261
 
264
- @Transactional()
265
- async processBulk<T extends ModelType>(cls: Class<T>, operations: BulkOperation<T>[]): Promise<BulkResponse> {
266
- const { insertedIds, upsertedIds, existingUpsertedIds } = await ModelBulkUtil.preStore(cls, operations, this);
262
+ async *listWithOffset<T extends ModelType>(modelClass: Class<T>, options?: ModelListOptions & { offset?: number }): AsyncIterable<T[]> {
263
+ const tableContext = this.connection.getContext(modelClass);
264
+ const { whereSQL, parameters } = this.#whereClause(modelClass, undefined);
267
265
 
268
- const addedIds = new Map([...insertedIds.entries(), ...upsertedIds.entries()]);
266
+ const limit = options?.limit ?? Number.MAX_SAFE_INTEGER;
267
+ const batchSize = Math.min(options?.batchSizeHint ?? 100, limit);
269
268
 
270
- await this.#checkUpsertedIds(cls, addedIds, new Map([...existingUpsertedIds.entries()].map(([key, value]) => [value, key])));
269
+ let offset = options?.offset ?? 0;
270
+ let produced = 0;
271
271
 
272
- const get = <K extends keyof BulkOperation<T>>(key: K): Required<BulkOperation<T>>[K][] =>
273
- operations.map(item => item[key]).filter((item): item is Required<BulkOperation<T>>[K] => !!item);
272
+ while (!options?.abort?.aborted && produced < limit) {
273
+ const batchLimit = Math.min(batchSize, limit - produced);
274
+ const sql = this.dialect.buildSelect(tableContext, { whereSQL, limit: batchLimit, offset });
274
275
 
275
- const getStatements = async (key: keyof BulkOperation<T>): Promise<InsertWrapper[]> =>
276
- (await SQLModelUtil.getInserts(cls, get(key))).filter(wrapper => !!wrapper.records.length);
276
+ const result = await this.connection.execute(sql, parameters);
277
+ if (result.count === 0) {
278
+ break;
279
+ }
277
280
 
278
- const deletes = [{ stack: SQLModelUtil.classToStack(cls), ids: get('delete').map(wrapper => wrapper.id) }].filter(
279
- wrapper => !!wrapper.ids.length
280
- );
281
+ const items = await this.loadMany(modelClass, result.records);
282
+ yield items;
283
+ produced += items.length;
284
+ offset += items.length;
285
+ }
286
+ }
281
287
 
282
- const [inserts, upserts, updates] = await Promise.all([getStatements('insert'), getStatements('upsert'), getStatements('update')]);
288
+ async dropIndex<T extends ModelType>(tableContext: TableContext<T>, indexName: string): Promise<void> {
289
+ const sql = this.dialect.getDropIndexSQL(tableContext, indexName);
290
+ await this.connection.execute(sql);
291
+ }
283
292
 
284
- const result = await this.#dialect.bulkProcess(deletes, inserts, upserts, updates);
285
- result.insertedIds = addedIds;
286
- return result;
293
+ async dropTable<T extends ModelType>(tableContext: TableContext<T>): Promise<void> {
294
+ const sql = this.dialect.getDropTableSQL(tableContext);
295
+ await this.connection.execute(sql);
287
296
  }
288
297
 
289
- // Expiry
290
- @Transactional()
291
- async deleteExpired<T extends ModelType>(cls: Class<T>): Promise<number> {
292
- return ModelQueryCrudUtil.deleteExpired(this, cls);
298
+ async truncateTable<T extends ModelType>(tableContext: TableContext<T>): Promise<void> {
299
+ const sql = this.dialect.getTruncateTableSQL(tableContext);
300
+ await this.connection.execute(sql);
293
301
  }
294
302
 
295
- @Connected()
296
- async query<T extends ModelType>(cls: Class<T>, query: PageableModelQuery<T>): Promise<T[]> {
297
- await QueryVerifier.verify(cls, query);
298
- const { records } = await this.#exec<T>(this.#dialect.getQuerySQL(cls, query, ModelQueryUtil.getWhereClause(cls, query.where)));
299
- if (ModelRegistryIndex.has(cls)) {
300
- await this.#dialect.fetchDependents(cls, records, query?.select);
303
+ async upsertTable<T extends ModelType>(tableContext: TableContext<T>): Promise<void> {
304
+ // Storage & Migration Operations
305
+ const query = this.dialect.getTableExistsQuery(tableContext);
306
+ const result = await this.connection.execute(query.sql, query.parameters);
307
+ const tableExists = this.dialect.parseTableExistsResult(result.records);
308
+
309
+ if (!tableExists) {
310
+ const createTableSQL = this.dialect.getCreateTableSQL(tableContext);
311
+ await this.connection.execute(createTableSQL);
312
+
313
+ for (const createIndexSQL of this.dialect.getCreateTableIndexSQLs(tableContext)) {
314
+ await this.connection.execute(createIndexSQL);
315
+ }
316
+ } else {
317
+ const query = this.dialect.getExistingColumnsQuery(tableContext);
318
+ const result = await this.connection.execute(query.sql, query.parameters);
319
+ const existingColumns = this.dialect.parseExistingColumns(result.records);
320
+
321
+ const requestedFieldsMap = new Map<string, string>();
322
+ for (const field of tableContext.simpleFields.values()) {
323
+ requestedFieldsMap.set(field.name, this.dialect.getColumnType(field));
324
+ }
325
+ for (const field of tableContext.complexFields.values()) {
326
+ requestedFieldsMap.set(field.name, this.dialect.getComplexColumnType(field));
327
+ }
328
+
329
+ for (const [columnName, columnType] of requestedFieldsMap.entries()) {
330
+ if (columnName === 'id') {
331
+ continue;
332
+ }
333
+ if (!existingColumns.has(columnName)) {
334
+ const addColumnSQL = this.dialect.getAddColumnSQL(tableContext, columnName, columnType);
335
+ await this.connection.execute(addColumnSQL);
336
+ } else if (this.dialect.getAlterColumnTypeSQL) {
337
+ const existingType = existingColumns.get(columnName)!;
338
+ const alterColumnSQL = this.dialect.getAlterColumnTypeSQL(tableContext, columnName, columnType, existingType);
339
+ if (alterColumnSQL) {
340
+ await this.connection.execute(alterColumnSQL);
341
+ }
342
+ }
343
+ }
344
+
345
+ const indexQuery = this.dialect.getExistingIndexesQuery(tableContext);
346
+ const indexResult = await this.connection.execute(indexQuery.sql, indexQuery.parameters);
347
+ const existingIndexes = this.dialect.parseExistingIndexes(indexResult.records);
348
+
349
+ const modelIndexes = ModelRegistryIndex.getIndices(tableContext.cls) || [];
350
+
351
+ const definedIndexes = new Map<string, IndexConfig>();
352
+ for (const indexConfig of modelIndexes) {
353
+ const indexName = ['idx', tableContext.tableName, indexConfig.name.toLowerCase().replaceAll('-', '_')].join('_');
354
+ definedIndexes.set(indexName, indexConfig);
355
+ }
356
+
357
+ for (const [indexName, indexDefinition] of existingIndexes.entries()) {
358
+ if (!definedIndexes.has(indexName)) {
359
+ await this.dropIndex(tableContext, indexName);
360
+ } else {
361
+ const indexConfig = definedIndexes.get(indexName)!;
362
+ const expectedSQL = this.dialect.getCreateIndexSQL(tableContext, indexConfig);
363
+
364
+ if (indexDefinition) {
365
+ const normalizedExisting = this.dialect.normalizeIndexDefinition(indexDefinition);
366
+ const normalizedExpected = this.dialect.normalizeIndexDefinition(expectedSQL);
367
+
368
+ if (normalizedExisting !== normalizedExpected) {
369
+ await this.dropIndex(tableContext, indexName);
370
+ await this.connection.execute(expectedSQL);
371
+ }
372
+ }
373
+ }
374
+ }
375
+
376
+ for (const [indexName, indexConfig] of definedIndexes.entries()) {
377
+ if (!existingIndexes.has(indexName)) {
378
+ const createIndexSQL = this.dialect.getCreateIndexSQL(tableContext, indexConfig);
379
+ await this.connection.execute(createIndexSQL);
380
+ }
381
+ }
301
382
  }
383
+ }
302
384
 
303
- const cleaned = SQLModelUtil.cleanResults<T>(this.#dialect, records);
304
- return await Promise.all(cleaned.map(item => ModelCrudUtil.load(cls, item)));
385
+ // Storage Support
386
+ async createStorage(): Promise<void> {
387
+ for (const modelClass of ModelRegistryIndex.getClasses()) {
388
+ warnIfIndexedUniqueIndex(this, modelClass, ModelRegistryIndex.getIndices(modelClass));
389
+ warnIfNonIndexedIndex(this, modelClass, ModelRegistryIndex.getIndices(modelClass));
390
+ const tableContext = this.connection.getContext(modelClass);
391
+ await this.upsertTable(tableContext);
392
+ }
305
393
  }
306
394
 
307
- @Connected()
308
- async queryOne<T extends ModelType>(cls: Class<T>, builder: ModelQuery<T>, failOnMany = true): Promise<T> {
309
- const results = await this.query<T>(cls, { ...builder, limit: failOnMany ? 2 : 1 });
310
- return ModelQueryUtil.verifyGetSingleCounts<T>(cls, failOnMany, results, builder.where);
395
+ async deleteStorage(): Promise<void> {
396
+ for (const modelClass of ModelRegistryIndex.getClasses()) {
397
+ const tableContext = this.connection.getContext(modelClass);
398
+ await this.dropTable(tableContext);
399
+ }
311
400
  }
312
401
 
313
- @Connected()
314
- async queryCount<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>): Promise<number> {
315
- await QueryVerifier.verify(cls, query);
316
- return this.#dialect.getCountForQuery(cls, query);
402
+ async deleteModel(modelClass: Class): Promise<void> {
403
+ const tableContext = this.connection.getContext(modelClass);
404
+ await this.dropTable(tableContext);
317
405
  }
318
406
 
319
- @Connected()
320
- @Transactional()
321
- async updateByQuery<T extends ModelType>(cls: Class<T>, item: T, query: ModelQuery<T>): Promise<T> {
322
- await QueryVerifier.verify(cls, query);
323
- const where = ModelQueryUtil.getWhereClause(cls, query.where);
324
- where.id = item.id;
325
- await this.#deleteRaw(cls, item.id, where, true);
326
- return await this.create(cls, item);
407
+ async upsertModel(modelClass: Class): Promise<void> {
408
+ const tableContext = this.connection.getContext(modelClass);
409
+ await this.upsertTable(tableContext);
327
410
  }
328
411
 
329
- @Connected()
330
- @Transactional()
331
- async updatePartialByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, data: Partial<T>): Promise<number> {
332
- await QueryVerifier.verify(cls, query);
333
- const item = await ModelCrudUtil.prePartialUpdate(cls, data);
334
- const { count } = await this.#exec(
335
- this.#dialect.getUpdateSQL(SQLModelUtil.classToStack(cls), item, ModelQueryUtil.getWhereClause(cls, query.where))
336
- );
337
- return count;
412
+ async truncateModel<T extends ModelType>(modelClass: Class<T>): Promise<void> {
413
+ const tableContext = this.connection.getContext(modelClass);
414
+ await this.truncateTable(tableContext);
338
415
  }
339
416
 
340
- @Connected()
341
- @Transactional()
342
- async deleteByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>): Promise<number> {
343
- await QueryVerifier.verify(cls, query);
344
- const { count } = await this.#exec(
345
- this.#dialect.getDeleteSQL(SQLModelUtil.classToStack(cls), ModelQueryUtil.getWhereClause(cls, query.where, false))
417
+ // Bulk Support
418
+ async processBulk<T extends ModelType>(modelClass: Class<T>, operations: BulkOperation<T>[]): Promise<BulkResponse> {
419
+ const { insertedIds, upsertedIds, operations: preppedOperations } = await ModelBulkUtil.preStore(modelClass, operations, this);
420
+ const addedIdentifiers = new Map([...insertedIds.entries(), ...upsertedIds.entries()]);
421
+
422
+ const counts = {
423
+ update: 0,
424
+ insert: 0,
425
+ upsert: 0,
426
+ delete: 0,
427
+ error: 0
428
+ };
429
+ const errors: unknown[] = [];
430
+
431
+ // Process the inbound bulk request into groups: inserts, deletes, updates, and upserts
432
+ const inserts: OptionalId<T>[] = [];
433
+ const deletes: string[] = [];
434
+ const updates: T[] = [];
435
+ const upserts: { upsert?: OptionalId<T> }[] = [];
436
+
437
+ for (const operation of preppedOperations) {
438
+ if ('insert' in operation && operation.insert) {
439
+ inserts.push(operation.insert);
440
+ } else if ('delete' in operation && operation.delete) {
441
+ deletes.push(operation.delete.id);
442
+ } else if ('update' in operation && operation.update) {
443
+ updates.push(operation.update);
444
+ } else if ('upsert' in operation && operation.upsert) {
445
+ upserts.push(operation);
446
+ }
447
+ }
448
+
449
+ type SqlCommand = {
450
+ type: 'insert' | 'delete' | 'update' | 'upsert';
451
+ sql: string;
452
+ values: unknown[];
453
+ count: number;
454
+ identifier?: string;
455
+ };
456
+
457
+ const commands: SqlCommand[] = [];
458
+ const batchSize = 100;
459
+ const tableContext = this.connection.getContext(modelClass);
460
+
461
+ // Convert inserts into SQL statements with a fixed batch size
462
+ for (let index = 0; index < inserts.length; index += batchSize) {
463
+ const subBatch = inserts.slice(index, index + batchSize);
464
+ const rawItems: Record<string, unknown>[] = castTo(subBatch);
465
+ const { sql, values } = this.dialect.buildInsertAll(tableContext, rawItems);
466
+ commands.push({ type: 'insert', sql, values, count: subBatch.length });
467
+ }
468
+
469
+ // Convert deletes into SQL statements with a fixed batch size
470
+ for (let index = 0; index < deletes.length; index += batchSize) {
471
+ const subBatch = deletes.slice(index, index + batchSize);
472
+ const { whereSQL, parameters = [] } = this.#whereClause(modelClass, castTo({ id: { $in: subBatch } }), false);
473
+ const sql = this.dialect.buildDelete(tableContext, whereSQL);
474
+ commands.push({ type: 'delete', sql, values: parameters, count: subBatch.length });
475
+ }
476
+
477
+ // Convert updates into SQL statements with a fixed batch size
478
+ for (let index = 0; index < updates.length; index += batchSize) {
479
+ const subBatch = updates.slice(index, index + batchSize);
480
+ const rawItems: Record<string, unknown>[] = castTo(subBatch);
481
+ const { sql, values } = this.dialect.buildUpdateAll(tableContext, rawItems);
482
+ commands.push({ type: 'update', sql, values, count: subBatch.length });
483
+ }
484
+
485
+ // Generate upsert statements from other operations
486
+ for (const operation of upserts) {
487
+ const rawItem: Record<string, unknown> = castTo(operation.upsert);
488
+ const { sql, values } = this.dialect.buildUpsert(tableContext, rawItem, [this.dialect.escapeIdentifier('id')]);
489
+ commands.push({ type: 'upsert', sql, values, count: 1 });
490
+ }
491
+
492
+ // Run the final list of SQL commands through a workpool
493
+ await WorkPool.run(
494
+ async command => {
495
+ try {
496
+ const result = await this.connection.execute(command.sql, command.values);
497
+ if (command.type === 'update' && result.count === 0) {
498
+ counts.error += 1;
499
+ errors.push(new NotFoundError(modelClass, command.identifier!));
500
+ } else if (command.type === 'delete' && result.count < command.count) {
501
+ counts.delete += result.count;
502
+ const missingCount = command.count - result.count;
503
+ counts.error += missingCount;
504
+ errors.push(new NotFoundError(modelClass, `Bulk delete missed ${missingCount} record(s)`));
505
+ } else {
506
+ counts[command.type] += command.count;
507
+ }
508
+ } catch (error) {
509
+ counts.error += command.count;
510
+ errors.push(error);
511
+ }
512
+ },
513
+ commands,
514
+ { max: 8 }
346
515
  );
347
- return count;
516
+
517
+ return {
518
+ errors,
519
+ insertedIds: addedIdentifiers,
520
+ counts
521
+ };
348
522
  }
349
523
 
350
- @Connected()
351
- async suggestByQuery<T extends ModelType>(
352
- cls: Class<T>,
353
- field: ValidStringFields<T>,
354
- prefix?: string,
355
- query?: PageableModelQuery<T>
356
- ): Promise<T[]> {
357
- await QueryVerifier.verify(cls, query);
358
- const resolvedQuery = ModelQuerySuggestUtil.getSuggestQuery<T>(cls, field, prefix, query);
359
- const results = await this.query<T>(cls, resolvedQuery);
360
- return ModelQuerySuggestUtil.combineSuggestResults(cls, field, prefix, results, (a, b) => b, query?.limit);
524
+ // Expiry Support
525
+ async deleteExpired<T extends ModelType>(modelClass: Class<T>): Promise<number> {
526
+ return ModelQueryCrudUtil.deleteExpired(this, modelClass);
361
527
  }
362
528
 
363
- @Connected()
364
- async suggestValuesByQuery<T extends ModelType>(
365
- cls: Class<T>,
366
- field: ValidStringFields<T>,
367
- prefix?: string,
368
- query?: PageableModelQuery<T>
369
- ): Promise<string[]> {
370
- await QueryVerifier.verify(cls, query);
371
- const resolvedQuery = ModelQuerySuggestUtil.getSuggestFieldQuery(cls, field, prefix, query);
372
- const results = await this.query(cls, resolvedQuery);
373
-
374
- const modelTypeField: ValidStringFields<ModelType> = castTo(field);
375
- return ModelQuerySuggestUtil.combineSuggestResults(cls, modelTypeField, prefix, results, result => result, query?.limit);
376
- }
377
-
378
- @Connected()
379
- async facetByQuery<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, query?: ModelQuery<T>): Promise<ModelQueryFacet[]> {
380
- await QueryVerifier.verify(cls, query);
381
- const col = this.#dialect.identifier(field);
382
- const ttl = this.#dialect.identifier('count');
383
- const key = this.#dialect.identifier('key');
384
- const sql = [`SELECT ${col} as ${key}, COUNT(${col}) as ${ttl}`, this.#dialect.getFromSQL(cls)];
385
- sql.push(this.#dialect.getWhereSQL(cls, ModelQueryUtil.getWhereClause(cls, query?.where)));
386
- sql.push(`GROUP BY ${col}`, `ORDER BY ${ttl} DESC`);
387
-
388
- const results = await this.#exec<{ key: string; count: number }>(sql.join('\n'));
389
- return results.records.map(result => {
390
- result.count = DataUtil.coerceType(result.count, Number);
391
- return result;
392
- });
529
+ // Indexed Support
530
+ validateIndexResult<T extends ModelType>(
531
+ modelClass: Class<T>,
532
+ result: { count: number },
533
+ indexConfig: SingleItemIndex<T>,
534
+ computed: ModelIndexedComputedIndex<T>
535
+ ): void {
536
+ if (result.count === 0) {
537
+ throw new NotFoundError(`${modelClass.name} Index=${indexConfig}`, computed.getKey());
538
+ }
539
+ if (result.count > 1) {
540
+ throw new Error(`Multiple items found for index lookup ${modelClass.name} Index=${indexConfig}`);
541
+ }
393
542
  }
394
543
 
395
- // Indexed support
396
- @Connected()
397
544
  async getByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
398
- cls: Class<T>,
399
- idx: SingleItemIndex<T, K, S>,
545
+ modelClass: Class<T>,
546
+ indexConfig: SingleItemIndex<T, K, S>,
400
547
  body: FullKeyedIndexBody<T, K, S>
401
548
  ): Promise<T> {
402
- const computed = ModelIndexedComputedIndex.get(idx, body).validate({ sort: true });
403
- const results = await this.query(cls, castTo({ where: computed.project({ sort: true, includeId: true }) }));
404
- if (results.length !== 1) {
405
- throw new NotFoundError(`${cls.name}: ${idx}`, computed.getKey({ sort: true }));
406
- }
407
- return results[0];
549
+ ModelCrudUtil.ensureNotSubType(modelClass);
550
+ const computed = ModelIndexedComputedIndex.get(indexConfig, body).validate({ sort: true });
551
+ const where: WhereClause<T> = castTo(computed.project({ sort: true, includeId: true }));
552
+
553
+ const tableContext = this.connection.getContext(modelClass);
554
+ const { whereSQL, parameters } = this.#whereClause(modelClass, where);
555
+ const sql = this.dialect.buildSelect(tableContext, { whereSQL });
556
+
557
+ const result = await this.connection.execute<Record<string, unknown>>(sql, parameters);
558
+ this.validateIndexResult(modelClass, result, indexConfig, computed);
559
+
560
+ return this.loadSingle(modelClass, result.records[0]);
408
561
  }
409
562
 
410
- @Connected()
411
- @Transactional()
412
563
  async deleteByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
413
- cls: Class<T>,
414
- idx: SingleItemIndex<T, K, S>,
564
+ modelClass: Class<T>,
565
+ indexConfig: SingleItemIndex<T, K, S>,
415
566
  body: FullKeyedIndexBody<T, K, S>
416
567
  ): Promise<void> {
417
- const computed = ModelIndexedComputedIndex.get(idx, body).validate({ sort: true });
418
- const count = await this.deleteByQuery(cls, castTo({ where: computed.project({ sort: true, includeId: true }) }));
419
- if (count === 0) {
420
- throw new NotFoundError(`${cls.name}: ${idx}`, computed.getKey({ sort: true }));
421
- }
568
+ ModelCrudUtil.ensureNotSubType(modelClass);
569
+ const computed = ModelIndexedComputedIndex.get(indexConfig, body).validate({ sort: true });
570
+ const where: WhereClause<T> = castTo(computed.project({ sort: true, includeId: true }));
571
+
572
+ const tableContext = this.connection.getContext(modelClass);
573
+ const { whereSQL, parameters } = this.#whereClause(modelClass, where);
574
+ const sql = this.dialect.buildDelete(tableContext, whereSQL);
575
+
576
+ const result = await this.connection.execute(sql, parameters);
577
+ this.validateIndexResult(modelClass, result, indexConfig, computed);
422
578
  }
423
579
 
424
- @Connected()
425
- @Transactional()
426
- upsertByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
427
- cls: Class<T>,
428
- idx: SingleItemIndex<T, K, S>,
580
+ async upsertByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
581
+ modelClass: Class<T>,
582
+ indexConfig: SingleItemIndex<T, K, S>,
429
583
  body: OptionalId<T>
430
584
  ): Promise<T> {
431
- return ModelIndexedUtil.naiveUpsert(this, cls, idx, body);
585
+ return ModelIndexedUtil.naiveUpsert(this, modelClass, indexConfig, body);
432
586
  }
433
587
 
434
- @Connected()
435
- @Transactional()
436
- updateByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
437
- cls: Class<T>,
438
- idx: SingleItemIndex<T, K, S>,
588
+ async updateByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
589
+ modelClass: Class<T>,
590
+ indexConfig: SingleItemIndex<T, K, S>,
439
591
  body: T
440
592
  ): Promise<T> {
441
- return ModelIndexedUtil.naiveUpdate(this, cls, idx, body);
593
+ const computed = ModelIndexedComputedIndex.get(indexConfig, castTo(body)).validate({ sort: true });
594
+ const where: WhereClause<T> = castTo(computed.project({ sort: true, includeId: true }));
595
+
596
+ const preppedItem = await this.executeUpdate(modelClass, where, body, this);
597
+ if (!preppedItem) {
598
+ throw new NotFoundError(`${modelClass.name} Index=${indexConfig}`, computed.getKey());
599
+ }
600
+ return preppedItem;
442
601
  }
443
602
 
444
- @Connected()
445
- @Transactional()
446
603
  async updatePartialByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
447
- cls: Class<T>,
448
- idx: SingleItemIndex<T, K, S>,
604
+ modelClass: Class<T>,
605
+ indexConfig: SingleItemIndex<T, K, S>,
449
606
  body: FullKeyedIndexWithPartialBody<T, K, S>
450
607
  ): Promise<T> {
451
- const item = await ModelCrudUtil.naivePartialUpdate(cls, () => this.getByIndex(cls, idx, castTo(body)), castTo(body));
452
- return this.update(cls, item);
453
- }
608
+ ModelCrudUtil.ensureNotSubType(modelClass);
454
609
 
455
- @Connected()
456
- async pageByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
457
- cls: Class<T>,
458
- idx: SortedIndex<T, K, S>,
459
- body: KeyedIndexBody<T, K>,
460
- options?: ModelPageOptions
461
- ): Promise<ModelPageResult<T>> {
462
- const computed = ModelIndexedComputedIndex.get(idx, body).validate();
463
- const offset = options?.offset ? JSONUtil.fromBase64<number>(options.offset) : 0;
610
+ const computed = ModelIndexedComputedIndex.get(indexConfig, castTo(body)).validate({ sort: true });
611
+ const where: WhereClause<T> = castTo(computed.project({ sort: true, includeId: true }));
464
612
 
465
- const baseQuery = castTo<ModelQuery<T>>({
466
- where: computed.project(),
467
- sort: idx.sortTemplate.map(part => ({ [part.path.join('.')]: part.value }))
468
- });
613
+ const result = await this.executeUpdatePartial(modelClass, where, castTo(body), true);
614
+ this.validateIndexResult(modelClass, result, indexConfig, computed);
469
615
 
470
- const items: T[] = [];
471
- let nextOffset: number | undefined;
472
- for await (const batch of this.#scanTable<T>(cls, () => baseQuery, { limit: 100, ...options, offset })) {
473
- items.push(...batch.items);
474
- nextOffset = batch.nextOffset;
475
- }
476
- return { items, nextOffset: nextOffset ? JSONUtil.toBase64(nextOffset) : undefined };
616
+ return this.loadSingle(modelClass, result.records[0]);
477
617
  }
478
618
 
479
- @ConnectedIterator()
480
619
  async *listByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
481
- cls: Class<T>,
482
- idx: SortedIndex<T, K, S>,
620
+ modelClass: Class<T>,
621
+ indexConfig: SortedIndex<T, K, S>,
483
622
  body: KeyedIndexBody<T, K>,
484
- options?: ModelListOptions
623
+ options?: ModelListOptions & { offset?: number }
485
624
  ): AsyncIterable<T[]> {
486
- const computed = ModelIndexedComputedIndex.get(idx, body).validate();
487
- const baseQuery = castTo<ModelQuery<T>>({
488
- where: computed.project(),
489
- sort: idx.sortTemplate.map(part => ({ [part.path.join('.')]: part.value }))
490
- });
491
- for await (const { items } of this.#scanTable<T>(cls, () => baseQuery, options)) {
625
+ const computed = ModelIndexedComputedIndex.get(indexConfig, body).validate();
626
+ const where: WhereClause<T> = castTo(computed.project());
627
+
628
+ const tableContext = this.connection.getContext(modelClass);
629
+ const sortSQL = this.dialect.buildIndexSort(tableContext, indexConfig);
630
+
631
+ const limit = options?.limit ?? Number.MAX_SAFE_INTEGER;
632
+ const batchSize = Math.min(options?.batchSizeHint ?? 100, limit);
633
+
634
+ let offset = options?.offset ?? 0;
635
+ let produced = 0;
636
+
637
+ const { whereSQL, parameters } = this.#whereClause(modelClass, where);
638
+
639
+ while (!options?.abort?.aborted && produced < limit) {
640
+ const batchLimit = Math.min(batchSize, limit - produced);
641
+ const sql = this.dialect.buildSelect(tableContext, { whereSQL, sortSQL, limit: batchLimit, offset });
642
+
643
+ const result = await this.connection.execute(sql, parameters);
644
+ if (result.count === 0) {
645
+ break;
646
+ }
647
+
648
+ const items = await this.loadMany(modelClass, result.records);
492
649
  yield items;
650
+ produced += items.length;
651
+ offset += items.length;
652
+ }
653
+ }
654
+
655
+ async pageByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
656
+ modelClass: Class<T>,
657
+ indexConfig: SortedIndex<T, K, S>,
658
+ body: KeyedIndexBody<T, K>,
659
+ options?: ModelPageOptions
660
+ ): Promise<ModelPageResult<T>> {
661
+ const listOptions = {
662
+ limit: options?.limit,
663
+ offset: options?.offset ? Number(options.offset) : 0
664
+ };
665
+
666
+ const items: T[] = [];
667
+ let nextOffset = listOptions.offset ?? 0;
668
+
669
+ for await (const batch of this.listByIndex(modelClass, indexConfig, body, listOptions)) {
670
+ items.push(...batch);
671
+ nextOffset += batch.length;
493
672
  }
673
+
674
+ return {
675
+ items,
676
+ nextOffset: items.length === options?.limit ? String(nextOffset) : undefined
677
+ };
494
678
  }
495
679
 
496
- @Connected()
497
680
  async suggestByIndex<
498
681
  T extends ModelType,
499
682
  S extends SortedIndexSelection<T>,
500
683
  K extends KeyedIndexSelection<T>,
501
684
  B extends SortedIndexSelectionType<T, S> & string
502
- >(cls: Class<T>, idx: SortedIndex<T, K, S>, body: KeyedIndexBody<T, K>, prefix: B, options?: ModelIndexedSearchOptions): Promise<T[]> {
503
- const items: T[] = [];
504
- const computed = ModelIndexedComputedIndex.get(idx, body).validate();
505
- const nested: Record<string, unknown> = {};
506
- let current = nested;
507
- for (const key of idx.sortTemplate[0].path.slice(0, -1)) {
508
- current = current[key] = {};
685
+ >(
686
+ modelClass: Class<T>,
687
+ indexConfig: SortedIndex<T, K, S>,
688
+ body: KeyedIndexBody<T, K>,
689
+ prefix: B,
690
+ options?: ModelIndexedSearchOptions
691
+ ): Promise<T[]> {
692
+ const computed = ModelIndexedComputedIndex.get(indexConfig, body).validate();
693
+ const where: WhereClause<T> = castTo(computed.project());
694
+
695
+ const tableContext = this.connection.getContext(modelClass);
696
+ const { whereSQL, parameters = [] } = this.#whereClause(modelClass, where);
697
+
698
+ const prefixFieldPath = indexConfig.sortTemplate[0].path;
699
+ const { sqlPath } = this.dialect.resolvePath(tableContext, prefixFieldPath, 'read');
700
+
701
+ const placeholder = this.dialect.getPlaceholder(parameters.length + 1);
702
+ parameters.push(`${prefix}%`);
703
+
704
+ const likeOp = this.dialect.suggestLikeOperator ?? 'LIKE';
705
+ const conditions = [`${sqlPath} ${likeOp} ${placeholder}`];
706
+ if (whereSQL) {
707
+ conditions.push(whereSQL);
509
708
  }
510
- current[idx.sortTemplate[0].path.at(-1)!] = { $regex: ModelIndexedUtil.getSuggestRegex(prefix) };
511
709
 
512
- const baseQuery = castTo<ModelQuery<T>>({
513
- where: {
514
- $and: [computed.project(), nested]
515
- }
710
+ const sql = this.dialect.buildSelect(tableContext, { whereSQL: conditions.join(' AND '), limit: options?.limit ?? 10 });
711
+ const result = await this.connection.execute(sql, parameters);
712
+
713
+ return this.loadMany(modelClass, result.records);
714
+ }
715
+
716
+ // Query Support
717
+ async query<T extends ModelType>(modelClass: Class<T>, query: PageableModelQuery<T>): Promise<T[]> {
718
+ await QueryVerifier.verify(modelClass, query);
719
+ const tableContext = this.connection.getContext(modelClass);
720
+ const { whereSQL, parameters = [] } = this.#whereClause(modelClass, query.where);
721
+ const sortSQL = this.dialect.compileSort(tableContext, query.sort);
722
+
723
+ const sql = this.dialect.buildSelect(tableContext, {
724
+ whereSQL,
725
+ sortSQL,
726
+ limit: query.limit,
727
+ offset: query.offset
728
+ });
729
+ const result = await this.connection.execute(sql, parameters);
730
+
731
+ return this.loadMany(modelClass, result.records);
732
+ }
733
+
734
+ async queryOne<T extends ModelType>(modelClass: Class<T>, query: ModelQuery<T>, failOnMany = true): Promise<T> {
735
+ const limit = failOnMany ? 2 : 1;
736
+ const items = await this.query<T>(modelClass, { ...query, limit });
737
+ return ModelQueryUtil.verifyGetSingleCounts<T>(modelClass, failOnMany, items, query.where);
738
+ }
739
+
740
+ async queryCount<T extends ModelType>(modelClass: Class<T>, query: ModelQuery<T>): Promise<number> {
741
+ await QueryVerifier.verify(modelClass, query);
742
+ const tableContext = this.connection.getContext(modelClass);
743
+ const { whereSQL, parameters = [] } = this.#whereClause(modelClass, query.where);
744
+ const sql = this.dialect.buildCount(tableContext, whereSQL);
745
+
746
+ const result = await this.connection.execute<{ total: string | number }>(sql, parameters);
747
+ return Number(result.records[0]?.total ?? 0);
748
+ }
749
+
750
+ // Query Crud Support
751
+ async updateByQuery<T extends ModelType>(
752
+ modelClass: Class<T>,
753
+ item: T,
754
+ query: ModelQuery<T>,
755
+ modelSource?: ModelCrudProvider
756
+ ): Promise<T> {
757
+ await QueryVerifier.verify(modelClass, query);
758
+ ModelCrudUtil.ensureNotSubType(modelClass);
759
+ const preppedItem = await ModelCrudUtil.preStore(modelClass, item, modelSource ?? { idSource: this.idSource });
760
+ const rawItem: Record<string, unknown> = castTo(preppedItem);
761
+
762
+ const tableContext = this.connection.getContext(modelClass);
763
+ const combinedWhere: WhereClause<T> = castTo({
764
+ $and: [{ id: preppedItem.id }, ...(query.where ? [query.where] : [])]
516
765
  });
766
+ const { whereSQL, parameters = [] } = this.#whereClause(modelClass, combinedWhere);
767
+
768
+ const { sql, values } = this.dialect.buildUpdate(tableContext, rawItem, whereSQL, parameters);
517
769
 
518
- for await (const batch of this.#scanTable<T>(cls, () => baseQuery, { limit: 10, ...options })) {
519
- items.push(...batch.items);
770
+ const result = await this.connection.execute(sql, values);
771
+ if (result.count === 0) {
772
+ throw new NotFoundError(modelClass, `Query: ${JSONUtil.toUTF8(query.where)}`);
520
773
  }
521
774
 
522
- return items;
775
+ return preppedItem;
776
+ }
777
+
778
+ async updatePartialByQuery<T extends ModelType>(modelClass: Class<T>, query: ModelQuery<T>, data: Partial<T>): Promise<number> {
779
+ await QueryVerifier.verify(modelClass, query);
780
+ const result = await this.executeUpdatePartial(modelClass, query.where!, data, false);
781
+ return result.count;
782
+ }
783
+
784
+ async deleteByQuery<T extends ModelType>(modelClass: Class<T>, query: ModelQuery<T>): Promise<number> {
785
+ await QueryVerifier.verify(modelClass, query);
786
+ const tableContext = this.connection.getContext(modelClass);
787
+ const { whereSQL, parameters = [] } = this.#whereClause(modelClass, query.where, false);
788
+
789
+ const sql = this.dialect.buildDelete(tableContext, whereSQL);
790
+
791
+ const result = await this.connection.execute(sql, parameters);
792
+ return result.count;
793
+ }
794
+
795
+ // Suggest Support
796
+ async suggestValuesByQuery<T extends ModelType>(
797
+ modelClass: Class<T>,
798
+ field: ValidStringFields<T>,
799
+ prefix?: string,
800
+ query?: PageableModelQuery<T>
801
+ ): Promise<string[]> {
802
+ const resolvedQuery = ModelQuerySuggestUtil.getSuggestFieldQuery<T>(modelClass, field, prefix, query);
803
+ const results = await this.query<T>(modelClass, resolvedQuery);
804
+ return ModelQuerySuggestUtil.combineSuggestResults<T, string>(modelClass, field, prefix, results, value => value, query?.limit);
805
+ }
806
+
807
+ async suggestByQuery<T extends ModelType>(
808
+ modelClass: Class<T>,
809
+ field: ValidStringFields<T>,
810
+ prefix?: string,
811
+ query?: PageableModelQuery<T>
812
+ ): Promise<T[]> {
813
+ const resolvedQuery = ModelQuerySuggestUtil.getSuggestQuery<T>(modelClass, field, prefix, query);
814
+ const results = await this.query<T>(modelClass, resolvedQuery);
815
+ return ModelQuerySuggestUtil.combineSuggestResults<T, T>(modelClass, field, prefix, results, (_, value) => value, query?.limit);
816
+ }
817
+
818
+ // Facet Support
819
+ async facetByQuery<T extends ModelType>(
820
+ modelClass: Class<T>,
821
+ field: ValidStringFields<T>,
822
+ query?: ModelQuery<T>
823
+ ): Promise<ModelQueryFacet[]> {
824
+ await QueryVerifier.verify(modelClass, query);
825
+ const tableContext = this.connection.getContext(modelClass);
826
+ const { whereSQL, parameters } = this.#whereClause(modelClass, query?.where);
827
+ const { sqlPath } = this.dialect.resolvePath(tableContext, String(field).split('.'), 'read');
828
+
829
+ const sql = this.dialect.buildFacet(tableContext, sqlPath, whereSQL);
830
+
831
+ const result = await this.connection.execute<{ key: string; count: string | number }>(sql, parameters);
832
+
833
+ return result.records.map(record => ({
834
+ key: record.key,
835
+ count: Number(record.count)
836
+ }));
523
837
  }
524
838
  }