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

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