@travetto/model-sql 8.0.0-alpha.24 → 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,501 +1,830 @@
1
+ import { Injectable } from '@travetto/di';
1
2
  import {
2
- type ModelType,
3
- type BulkOperation, type BulkResponse, type ModelCrudSupport, type ModelStorageSupport, type ModelBulkSupport, NotFoundError,
4
- ModelRegistryIndex, ExistsError, type OptionalId, type ModelIdSource, ModelExpiryUtil, ModelCrudUtil, ModelStorageUtil, ModelBulkUtil,
3
+ type BulkOperation,
4
+ type BulkResponse,
5
+ type IndexConfig,
6
+ type ModelBulkSupport,
7
+ ModelBulkUtil,
8
+ type ModelCrudProvider,
9
+ type ModelCrudSupport,
10
+ ModelCrudUtil,
11
+ type ModelExpirySupport,
12
+ ModelExpiryUtil,
5
13
  type ModelListOptions,
14
+ ModelRegistryIndex,
15
+ type ModelStorageSupport,
16
+ type ModelType,
17
+ NotFoundError,
18
+ type OptionalId
6
19
  } from '@travetto/model';
7
20
  import {
8
- type ModelIndexedSupport, type KeyedIndexSelection, type KeyedIndexBody, type ModelPageOptions, ModelIndexedUtil,
9
- type SingleItemIndex, type SortedIndexSelection, type ModelPageResult, type SortedIndex, type FullKeyedIndexBody,
10
- type FullKeyedIndexWithPartialBody, ModelIndexedComputedIndex, type ModelIndexedSearchOptions, type SortedIndexSelectionType
21
+ type FullKeyedIndexBody,
22
+ type FullKeyedIndexWithPartialBody,
23
+ type KeyedIndexBody,
24
+ type KeyedIndexSelection,
25
+ ModelIndexedComputedIndex,
26
+ type ModelIndexedSearchOptions,
27
+ type ModelIndexedSupport,
28
+ ModelIndexedUtil,
29
+ type ModelPageOptions,
30
+ type ModelPageResult,
31
+ type SingleItemIndex,
32
+ type SortedIndex,
33
+ type SortedIndexSelection,
34
+ type SortedIndexSelectionType,
35
+ warnIfIndexedUniqueIndex,
36
+ warnIfNonIndexedIndex
11
37
  } from '@travetto/model-indexed';
12
- import { castTo, type Class, JSONUtil } from '@travetto/runtime';
13
- import { DataUtil } from '@travetto/schema';
14
- import type { AsyncContext } from '@travetto/context';
15
- import { Injectable, PostConstruct } from '@travetto/di';
16
38
  import {
17
- type ModelQuery, type ModelQueryCrudSupport, type ModelQueryFacetSupport, type ModelQuerySupport,
18
- type PageableModelQuery, type ValidStringFields, type WhereClauseRaw, QueryVerifier, type ModelQuerySuggestSupport,
19
- ModelQueryUtil, ModelQuerySuggestUtil, ModelQueryCrudUtil, type ModelQueryFacet,
39
+ type ModelQuery,
40
+ type ModelQueryCrudSupport,
41
+ ModelQueryCrudUtil,
42
+ type ModelQueryFacet,
43
+ type ModelQueryFacetSupport,
44
+ type ModelQuerySuggestSupport,
45
+ ModelQuerySuggestUtil,
46
+ type ModelQuerySupport,
47
+ ModelQueryUtil,
48
+ type PageableModelQuery,
49
+ QueryVerifier,
50
+ type ValidStringFields,
51
+ type WhereClause
20
52
  } from '@travetto/model-query';
53
+ import { type Class, castTo, JSONUtil } from '@travetto/runtime';
54
+ import { WorkPool } from '@travetto/worker';
21
55
 
22
- import type { SQLModelConfig } from './config.ts';
23
- import { Connected, ConnectedIterator, Transactional } from './connection/decorator.ts';
24
- import { SQLModelUtil } from './util.ts';
25
- import type { SQLDialect } from './dialect/base.ts';
26
- import { TableManager } from './table-manager.ts';
27
- import type { Connection } from './connection/base.ts';
28
- import type { InsertWrapper } from './internal/types.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';
29
60
 
30
61
  /**
31
- * Core for SQL Model Source. Should not have any direct queries,
32
- * but should offload all of that to the dialect, so it can be overridden
33
- * 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.
34
65
  */
35
66
  @Injectable()
36
- export class SQLModelService implements
37
- ModelCrudSupport, ModelStorageSupport,
38
- ModelBulkSupport, ModelQuerySupport,
39
- ModelQueryCrudSupport, ModelQueryFacetSupport,
40
- ModelIndexedSupport,
41
- ModelQuerySuggestSupport {
42
-
43
- #manager: TableManager;
44
- #context: AsyncContext;
45
- #dialect: SQLDialect;
46
- idSource: ModelIdSource;
47
-
48
- readonly config: SQLModelConfig;
49
-
50
- get client(): SQLDialect {
51
- return this.#dialect;
52
- }
53
-
54
- constructor(
55
- context: AsyncContext,
56
- config: SQLModelConfig,
57
- dialect: SQLDialect
58
- ) {
59
- this.#context = context;
60
- this.#dialect = dialect;
61
- this.config = config;
62
- }
63
-
64
- /**
65
- * Verify upserted ids for bulk operations
66
- */
67
- async #checkUpsertedIds<T extends ModelType>(
68
- cls: Class<T>,
69
- addedIds: Map<number, string>,
70
- toCheck: Map<string, number>
71
- ): Promise<Map<number, string>> {
72
- // Get all upsert ids
73
- const all = toCheck.size ?
74
- (await this.#exec<ModelType>(
75
- this.#dialect.getSelectRowsByIdsSQL(
76
- SQLModelUtil.classToStack(cls), [...toCheck.keys()], [this.#dialect.idField]
77
- )
78
- )).records : [];
79
-
80
- const allIds = new Set(all.map(type => type.id));
81
-
82
- for (const [id, idx] of toCheck.entries()) {
83
- if (!allIds.has(id)) { // If not found
84
- addedIds.set(idx, id);
67
+ export abstract class BaseSQLModelService<C = unknown>
68
+ implements
69
+ ModelCrudSupport,
70
+ ModelStorageSupport,
71
+ ModelBulkSupport,
72
+ ModelExpirySupport,
73
+ ModelIndexedSupport,
74
+ ModelQuerySupport,
75
+ ModelQueryCrudSupport,
76
+ ModelQueryFacetSupport,
77
+ ModelQuerySuggestSupport
78
+ {
79
+ abstract readonly client: C;
80
+ abstract connection: SQLConnection;
81
+
82
+ idSource = ModelCrudUtil.uuidSource();
83
+
84
+ get dialect(): AbstractANSI99Dialect {
85
+ return this.connection.dialect;
86
+ }
87
+
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
+ }
95
+
96
+ async initialize(): Promise<void> {
97
+ await this.connection.init();
98
+ await this.createStorage();
99
+ ModelExpiryUtil.registerCull(this);
100
+ }
101
+
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);
85
110
  }
86
111
  }
87
-
88
- return addedIds;
112
+ return ModelCrudUtil.load(modelClass, resolvedRecord);
89
113
  }
90
114
 
91
- #exec<T = unknown>(sql: string): Promise<{ records: T[], count: number }> {
92
- return this.#dialect.executeSQL<T>(sql);
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))));
93
117
  }
94
118
 
95
- async #deleteRaw<T extends ModelType>(cls: Class<T>, id: string, where?: WhereClauseRaw<T>, checkExpiry = true): Promise<void> {
96
- castTo<WhereClauseRaw<ModelType>>(where ??= {}).id = id;
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);
97
127
 
98
- const count = await this.#dialect.deleteAndGetCount<ModelType>(cls, {
99
- where: ModelQueryUtil.getWhereClause(cls, where, checkExpiry)
100
- });
101
- if (count === 0) {
102
- throw new NotFoundError(cls, id);
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 };
103
138
  }
139
+
140
+ return result;
104
141
  }
105
142
 
106
- async * #scanTable<T extends ModelType>(
107
- cls: Class<T>,
108
- buildQuery: () => PageableModelQuery<T>,
109
- options?: ModelListOptions & ModelPageOptions<number>
110
- ): AsyncIterable<{ items: T[], nextOffset?: number }> {
111
- const batchSize = options?.batchSizeHint ?? 100;
112
- const maxCount = options?.limit ?? Number.MAX_SAFE_INTEGER;
113
- let offset = options?.offset ?? 0;
114
- let lastOffset = -1;
115
- let produced = 0;
116
- while (offset !== lastOffset && produced < maxCount && !(options?.abort?.aborted)) {
117
- const limit = Math.min(batchSize, maxCount - produced);
118
- lastOffset = offset;
119
- const items = await this.query<T>(cls, {
120
- ...buildQuery(),
121
- limit,
122
- offset
123
- });
124
- offset += items.length;
125
- produced += items.length;
126
- if (items.length) {
127
- yield { items, nextOffset: items.length < limit ? undefined : offset };
128
- }
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;
165
+ }
166
+
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);
129
185
  }
130
186
  }
131
187
 
132
- @PostConstruct()
133
- async initializeClient(): Promise<void> {
134
- await this.#dialect.connection.init?.();
135
- this.idSource = ModelCrudUtil.uuidSource(this.#dialect.ID_LENGTH);
136
- this.#manager = new TableManager(this.#context, this.#dialect);
137
- await ModelStorageUtil.storageInitialization(this);
138
- ModelExpiryUtil.registerCull(this);
139
- }
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 });
140
193
 
141
- get connection(): Connection {
142
- return this.#dialect.connection;
143
- }
194
+ const result = await this.connection.execute<Record<string, unknown>>(sql, parameters);
195
+
196
+ if (result.count === 0) {
197
+ throw new NotFoundError(modelClass, id);
198
+ }
144
199
 
145
- async exportModel<T extends ModelType>(cls: Class<T>): Promise<string> {
146
- return (await this.#manager.exportTables(cls)).join('\n');
200
+ return this.loadSingle(modelClass, result.records[0]);
147
201
  }
148
202
 
149
- async upsertModel(cls: Class): Promise<void> {
150
- await this.#manager.upsertTables(cls);
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;
151
212
  }
152
213
 
153
- async deleteModel(cls: Class): Promise<void> {
154
- await this.#manager.dropTables(cls);
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);
218
+ }
219
+ return preppedItem;
155
220
  }
156
221
 
157
- async truncateModel(cls: Class): Promise<void> {
158
- await this.#manager.truncateTables(cls);
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);
159
224
  }
160
225
 
161
- async createStorage(): Promise<void> { }
162
- async deleteStorage(): Promise<void> { }
226
+ async updatePartial<T extends ModelType>(modelClass: Class<T>, item: Partial<T> & { id: string }, view?: string): Promise<T> {
227
+ ModelCrudUtil.ensureNotSubType(modelClass);
163
228
 
164
- @Transactional()
165
- async create<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
166
- const prepped = await ModelCrudUtil.preStore(cls, item, this);
167
- try {
168
- for (const ins of this.#dialect.getAllInsertSQL(cls, prepped)) {
169
- await this.#exec(ins);
170
- }
171
- } catch (error) {
172
- if (error instanceof ExistsError) {
173
- throw new ExistsError(cls, prepped.id);
174
- } else {
175
- throw error;
176
- }
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);
177
233
  }
178
- return prepped;
179
- }
180
234
 
181
- @Transactional()
182
- async update<T extends ModelType>(cls: Class<T>, item: T): Promise<T> {
183
- await this.#deleteRaw(cls, item.id, {}, true);
184
- return await this.create(cls, item);
235
+ return this.loadSingle(modelClass, result.records[0]);
185
236
  }
186
237
 
187
- @Transactional()
188
- async upsert<T extends ModelType>(cls: Class<T>, item: OptionalId<T>): Promise<T> {
189
- try {
190
- if (item.id) {
191
- await this.#deleteRaw(cls, item.id, {}, false);
192
- }
193
- } catch (error) {
194
- if (!(error instanceof NotFoundError)) {
195
- throw error;
196
- }
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);
197
247
  }
198
- return await this.create(cls, item);
199
248
  }
200
249
 
201
- @Transactional()
202
- async updatePartial<T extends ModelType>(cls: Class<T>, item: Partial<T> & { id: string }, view?: string): Promise<T> {
203
- const id = item.id;
204
- const final = await ModelCrudUtil.naivePartialUpdate(cls, () => this.get(cls, id), item, view);
205
- return this.update(cls, final);
250
+ async *list<T extends ModelType>(modelClass: Class<T>, options?: ModelListOptions): AsyncIterable<T[]> {
251
+ yield* this.listWithOffset(modelClass, options);
206
252
  }
207
253
 
208
- @Connected()
209
- async get<T extends ModelType>(cls: Class<T>, id: string): Promise<T> {
210
- const result = await this.query(cls, { where: castTo({ id }) });
211
- if (result.length === 1) {
212
- return await ModelCrudUtil.load(cls, result[0]);
213
- }
214
- throw new NotFoundError(cls, id);
215
- }
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);
257
+
258
+ const limit = options?.limit ?? Number.MAX_SAFE_INTEGER;
259
+ const batchSize = Math.min(options?.batchSizeHint ?? 100, limit);
260
+
261
+ let offset = options?.offset ?? 0;
262
+ let produced = 0;
263
+
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 });
267
+
268
+ const result = await this.connection.execute(sql, parameters);
269
+ if (result.count === 0) {
270
+ break;
271
+ }
216
272
 
217
- @ConnectedIterator()
218
- async * list<T extends ModelType>(cls: Class<T>, options?: ModelListOptions): AsyncIterable<T[]> {
219
- for await (const { items } of this.#scanTable(cls, () => ({}), options)) {
273
+ const items = await this.loadMany(modelClass, result.records);
220
274
  yield items;
275
+ produced += items.length;
276
+ offset += items.length;
221
277
  }
222
278
  }
223
279
 
224
- @Transactional()
225
- async delete<T extends ModelType>(cls: Class<T>, id: string): Promise<void> {
226
- await this.#deleteRaw(cls, id, {}, false);
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);
227
283
  }
228
284
 
229
- @Transactional()
230
- async processBulk<T extends ModelType>(cls: Class<T>, operations: BulkOperation<T>[]): Promise<BulkResponse> {
285
+ async dropTable<T extends ModelType>(tableContext: TableContext<T>): Promise<void> {
286
+ const sql = this.dialect.getDropTableSQL(tableContext);
287
+ await this.connection.execute(sql);
288
+ }
231
289
 
232
- const { insertedIds, upsertedIds, existingUpsertedIds } = await ModelBulkUtil.preStore(cls, operations, this);
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
+ }
233
294
 
234
- const addedIds = new Map([...insertedIds.entries(), ...upsertedIds.entries()]);
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);
235
300
 
236
- await this.#checkUpsertedIds(cls,
237
- addedIds,
238
- new Map([...existingUpsertedIds.entries()].map(([key, value]) => [value, key]))
239
- );
301
+ if (!tableExists) {
302
+ const createTableSQL = this.dialect.getCreateTableSQL(tableContext);
303
+ await this.connection.execute(createTableSQL);
240
304
 
241
- const get = <K extends keyof BulkOperation<T>>(key: K): Required<BulkOperation<T>>[K][] =>
242
- operations.map(item => item[key]).filter((item): item is Required<BulkOperation<T>>[K] => !!item);
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
+ }
243
320
 
244
- const getStatements = async (key: keyof BulkOperation<T>): Promise<InsertWrapper[]> =>
245
- (await SQLModelUtil.getInserts(cls, get(key))).filter(wrapper => !!wrapper.records.length);
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
+ }
246
336
 
247
- const deletes = [{ stack: SQLModelUtil.classToStack(cls), ids: get('delete').map(wrapper => wrapper.id) }]
248
- .filter(wrapper => !!wrapper.ids.length);
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);
249
340
 
250
- const [inserts, upserts, updates] = await Promise.all([
251
- getStatements('insert'),
252
- getStatements('upsert'),
253
- getStatements('update')
254
- ]);
341
+ const modelIndexes = ModelRegistryIndex.getIndices(tableContext.cls) || [];
255
342
 
256
- const result = await this.#dialect.bulkProcess(deletes, inserts, upserts, updates);
257
- result.insertedIds = addedIds;
258
- return result;
259
- }
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
+ }
260
367
 
261
- // Expiry
262
- @Transactional()
263
- async deleteExpired<T extends ModelType>(cls: Class<T>): Promise<number> {
264
- return ModelQueryCrudUtil.deleteExpired(this, cls);
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
+ }
374
+ }
265
375
  }
266
376
 
267
- @Connected()
268
- async query<T extends ModelType>(cls: Class<T>, query: PageableModelQuery<T>): Promise<T[]> {
269
- await QueryVerifier.verify(cls, query);
270
- const { records } = await this.#exec<T>(this.#dialect.getQuerySQL(cls, query, ModelQueryUtil.getWhereClause(cls, query.where)));
271
- if (ModelRegistryIndex.has(cls)) {
272
- await this.#dialect.fetchDependents(cls, records, query && query.select);
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);
273
384
  }
385
+ }
274
386
 
275
- const cleaned = SQLModelUtil.cleanResults<T>(this.#dialect, records);
276
- return await Promise.all(cleaned.map(item => ModelCrudUtil.load(cls, item)));
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
+ }
277
392
  }
278
393
 
279
- @Connected()
280
- async queryOne<T extends ModelType>(cls: Class<T>, builder: ModelQuery<T>, failOnMany = true): Promise<T> {
281
- const results = await this.query<T>(cls, { ...builder, limit: failOnMany ? 2 : 1 });
282
- return ModelQueryUtil.verifyGetSingleCounts<T>(cls, failOnMany, results, builder.where);
394
+ async deleteModel(modelClass: Class): Promise<void> {
395
+ const tableContext = this.connection.getContext(modelClass);
396
+ await this.dropTable(tableContext);
283
397
  }
284
398
 
285
- @Connected()
286
- async queryCount<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>): Promise<number> {
287
- await QueryVerifier.verify(cls, query);
288
- return this.#dialect.getCountForQuery(cls, query);
399
+ async upsertModel(modelClass: Class): Promise<void> {
400
+ const tableContext = this.connection.getContext(modelClass);
401
+ await this.upsertTable(tableContext);
289
402
  }
290
403
 
291
- @Connected()
292
- @Transactional()
293
- async updateByQuery<T extends ModelType>(cls: Class<T>, item: T, query: ModelQuery<T>): Promise<T> {
294
- await QueryVerifier.verify(cls, query);
295
- const where = ModelQueryUtil.getWhereClause(cls, query.where);
296
- where.id = item.id;
297
- await this.#deleteRaw(cls, item.id, where, true);
298
- return await this.create(cls, item);
404
+ async truncateModel<T extends ModelType>(modelClass: Class<T>): Promise<void> {
405
+ const tableContext = this.connection.getContext(modelClass);
406
+ await this.truncateTable(tableContext);
299
407
  }
300
408
 
301
- @Connected()
302
- @Transactional()
303
- async updatePartialByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>, data: Partial<T>): Promise<number> {
304
- await QueryVerifier.verify(cls, query);
305
- const item = await ModelCrudUtil.prePartialUpdate(cls, data);
306
- const { count } = await this.#exec(this.#dialect.getUpdateSQL(SQLModelUtil.classToStack(cls), item, ModelQueryUtil.getWhereClause(cls, query.where)));
307
- return count;
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 }
507
+ );
508
+
509
+ return {
510
+ errors,
511
+ insertedIds: addedIdentifiers,
512
+ counts
513
+ };
308
514
  }
309
515
 
310
- @Connected()
311
- @Transactional()
312
- async deleteByQuery<T extends ModelType>(cls: Class<T>, query: ModelQuery<T>): Promise<number> {
313
- await QueryVerifier.verify(cls, query);
314
- const { count } = await this.#exec(this.#dialect.getDeleteSQL(SQLModelUtil.classToStack(cls), ModelQueryUtil.getWhereClause(cls, query.where, false)));
315
- return count;
516
+ // Expiry Support
517
+ async deleteExpired<T extends ModelType>(modelClass: Class<T>): Promise<number> {
518
+ return ModelQueryCrudUtil.deleteExpired(this, modelClass);
316
519
  }
317
520
 
318
- @Connected()
319
- async suggestByQuery<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, prefix?: string, query?: PageableModelQuery<T>): Promise<T[]> {
320
- await QueryVerifier.verify(cls, query);
321
- const resolvedQuery = ModelQuerySuggestUtil.getSuggestQuery<T>(cls, field, prefix, query);
322
- const results = await this.query<T>(cls, resolvedQuery);
323
- return ModelQuerySuggestUtil.combineSuggestResults(cls, field, prefix, results, (a, b) => b, query?.limit);
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
+ }
324
534
  }
325
535
 
326
- @Connected()
327
- async suggestValuesByQuery<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, prefix?: string, query?: PageableModelQuery<T>): Promise<string[]> {
328
- await QueryVerifier.verify(cls, query);
329
- const resolvedQuery = ModelQuerySuggestUtil.getSuggestFieldQuery(cls, field, prefix, query);
330
- const results = await this.query(cls, resolvedQuery);
536
+ async getByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
537
+ modelClass: Class<T>,
538
+ indexConfig: SingleItemIndex<T, K, S>,
539
+ body: FullKeyedIndexBody<T, K, S>
540
+ ): Promise<T> {
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 }));
331
544
 
332
- const modelTypeField: ValidStringFields<ModelType> = castTo(field);
333
- return ModelQuerySuggestUtil.combineSuggestResults(cls, modelTypeField, prefix, results, result => result, query?.limit);
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]);
334
553
  }
335
554
 
336
- @Connected()
337
- async facetByQuery<T extends ModelType>(cls: Class<T>, field: ValidStringFields<T>, query?: ModelQuery<T>): Promise<ModelQueryFacet[]> {
338
- await QueryVerifier.verify(cls, query);
339
- const col = this.#dialect.identifier(field);
340
- const ttl = this.#dialect.identifier('count');
341
- const key = this.#dialect.identifier('key');
342
- const sql = [
343
- `SELECT ${col} as ${key}, COUNT(${col}) as ${ttl}`,
344
- this.#dialect.getFromSQL(cls),
345
- ];
346
- sql.push(
347
- this.#dialect.getWhereSQL(cls, ModelQueryUtil.getWhereClause(cls, query?.where))
348
- );
349
- sql.push(
350
- `GROUP BY ${col}`,
351
- `ORDER BY ${ttl} DESC`
352
- );
555
+ async deleteByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
556
+ modelClass: Class<T>,
557
+ indexConfig: SingleItemIndex<T, K, S>,
558
+ body: FullKeyedIndexBody<T, K, S>
559
+ ): Promise<void> {
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 }));
353
563
 
354
- const results = await this.#exec<{ key: string, count: number }>(sql.join('\n'));
355
- return results.records.map(result => {
356
- result.count = DataUtil.coerceType(result.count, Number);
357
- return result;
358
- });
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);
359
570
  }
360
571
 
361
- // Indexed support
362
- @Connected()
363
- async getByIndex<
364
- T extends ModelType,
365
- K extends KeyedIndexSelection<T>,
366
- S extends SortedIndexSelection<T>
367
- >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<T> {
368
- const computed = ModelIndexedComputedIndex.get(idx, body).validate({ sort: true });
369
- const results = await this.query(cls, castTo({ where: computed.project({ sort: true, includeId: true }) }));
370
- if (results.length !== 1) {
371
- throw new NotFoundError(`${cls.name}: ${idx}`, computed.getKey({ sort: true }));
372
- }
373
- return results[0];
572
+ async upsertByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
573
+ modelClass: Class<T>,
574
+ indexConfig: SingleItemIndex<T, K, S>,
575
+ body: OptionalId<T>
576
+ ): Promise<T> {
577
+ return ModelIndexedUtil.naiveUpsert(this, modelClass, indexConfig, body);
374
578
  }
375
579
 
376
- @Connected()
377
- @Transactional()
378
- async deleteByIndex<
379
- T extends ModelType,
380
- K extends KeyedIndexSelection<T>,
381
- S extends SortedIndexSelection<T>
382
- >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<void> {
383
- const computed = ModelIndexedComputedIndex.get(idx, body).validate({ sort: true });
384
- const count = await this.deleteByQuery(cls, castTo({ where: computed.project({ sort: true, includeId: true }) }));
385
- if (count === 0) {
386
- throw new NotFoundError(`${cls.name}: ${idx}`, computed.getKey({ sort: true }));
580
+ async updateByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
581
+ modelClass: Class<T>,
582
+ indexConfig: SingleItemIndex<T, K, S>,
583
+ body: T
584
+ ): Promise<T> {
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());
387
591
  }
592
+ return preppedItem;
388
593
  }
389
594
 
390
- @Connected()
391
- @Transactional()
392
- upsertByIndex<
393
- T extends ModelType,
394
- K extends KeyedIndexSelection<T>,
395
- S extends SortedIndexSelection<T>
396
- >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: OptionalId<T>): Promise<T> {
397
- return ModelIndexedUtil.naiveUpsert(this, cls, idx, body);
398
- }
595
+ async updatePartialByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
596
+ modelClass: Class<T>,
597
+ indexConfig: SingleItemIndex<T, K, S>,
598
+ body: FullKeyedIndexWithPartialBody<T, K, S>
599
+ ): Promise<T> {
600
+ ModelCrudUtil.ensureNotSubType(modelClass);
399
601
 
400
- @Connected()
401
- @Transactional()
402
- updateByIndex<
403
- T extends ModelType,
404
- K extends KeyedIndexSelection<T>,
405
- S extends SortedIndexSelection<T>
406
- >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: T): Promise<T> {
407
- return ModelIndexedUtil.naiveUpdate(this, cls, idx, body);
408
- }
602
+ const computed = ModelIndexedComputedIndex.get(indexConfig, castTo(body)).validate({ sort: true });
603
+ const where: WhereClause<T> = castTo(computed.project({ sort: true, includeId: true }));
409
604
 
410
- @Connected()
411
- @Transactional()
412
- async updatePartialByIndex<
413
- T extends ModelType,
414
- K extends KeyedIndexSelection<T>,
415
- S extends SortedIndexSelection<T>
416
- >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexWithPartialBody<T, K, S>): Promise<T> {
417
- const item = await ModelCrudUtil.naivePartialUpdate(cls, () => this.getByIndex(cls, idx, castTo(body)), castTo(body));
418
- return this.update(cls, item);
605
+ const result = await this.executeUpdatePartial(modelClass, where, castTo(body), true);
606
+ this.validateIndexResult(modelClass, result, indexConfig, computed);
607
+
608
+ return this.loadSingle(modelClass, result.records[0]);
419
609
  }
420
610
 
421
- @Connected()
422
- async pageByIndex<
423
- T extends ModelType,
424
- K extends KeyedIndexSelection<T>,
425
- S extends SortedIndexSelection<T>
426
- >(
427
- cls: Class<T>,
428
- idx: SortedIndex<T, K, S>,
611
+ async *listByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
612
+ modelClass: Class<T>,
613
+ indexConfig: SortedIndex<T, K, S>,
429
614
  body: KeyedIndexBody<T, K>,
430
- options?: ModelPageOptions
431
- ): Promise<ModelPageResult<T>> {
432
- const computed = ModelIndexedComputedIndex.get(idx, body).validate();
433
- const offset = options?.offset ? JSONUtil.fromBase64<number>(options.offset) : 0;
615
+ options?: ModelListOptions & { offset?: number }
616
+ ): AsyncIterable<T[]> {
617
+ const computed = ModelIndexedComputedIndex.get(indexConfig, body).validate();
618
+ const where: WhereClause<T> = castTo(computed.project());
434
619
 
435
- const baseQuery = castTo<ModelQuery<T>>({
436
- where: computed.project(),
437
- sort: idx.sortTemplate.map(part => ({ [part.path.join('.')]: part.value })),
438
- });
620
+ const tableContext = this.connection.getContext(modelClass);
621
+ const sortSQL = this.dialect.buildIndexSort(tableContext, indexConfig);
439
622
 
440
- const items: T[] = [];
441
- let nextOffset: number | undefined;
442
- for await (const batch of this.#scanTable<T>(cls, () => baseQuery, { limit: 100, ...options, offset })) {
443
- items.push(...batch.items);
444
- nextOffset = batch.nextOffset;
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);
641
+ yield items;
642
+ produced += items.length;
643
+ offset += items.length;
445
644
  }
446
- return { items, nextOffset: nextOffset ? JSONUtil.toBase64(nextOffset) : undefined };
447
645
  }
448
646
 
449
- @ConnectedIterator()
450
- async * listByIndex<
451
- T extends ModelType,
452
- K extends KeyedIndexSelection<T>,
453
- S extends SortedIndexSelection<T>
454
- >(
455
- cls: Class<T>,
456
- idx: SortedIndex<T, K, S>,
647
+ async pageByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
648
+ modelClass: Class<T>,
649
+ indexConfig: SortedIndex<T, K, S>,
457
650
  body: KeyedIndexBody<T, K>,
458
- options?: ModelListOptions
459
- ): AsyncIterable<T[]> {
460
- const computed = ModelIndexedComputedIndex.get(idx, body).validate();
461
- const baseQuery = castTo<ModelQuery<T>>({
462
- where: computed.project(),
463
- sort: idx.sortTemplate.map(part => ({ [part.path.join('.')]: part.value })),
464
- });
465
- for await (const { items } of this.#scanTable<T>(cls, () => baseQuery, options)) {
466
- yield items;
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;
467
664
  }
665
+
666
+ return {
667
+ items,
668
+ nextOffset: items.length === options?.limit ? String(nextOffset) : undefined
669
+ };
468
670
  }
469
671
 
470
- @Connected()
471
672
  async suggestByIndex<
472
673
  T extends ModelType,
473
674
  S extends SortedIndexSelection<T>,
474
675
  K extends KeyedIndexSelection<T>,
475
676
  B extends SortedIndexSelectionType<T, S> & string
476
- >(cls: Class<T>, idx: SortedIndex<T, K, S>, body: KeyedIndexBody<T, K>, prefix: B, options?: ModelIndexedSearchOptions): Promise<T[]> {
477
- const items: T[] = [];
478
- const computed = ModelIndexedComputedIndex.get(idx, body).validate();
479
- const nested: Record<string, unknown> = {};
480
- let current = nested;
481
- for (const key of idx.sortTemplate[0].path.slice(0, -1)) {
482
- 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);
483
700
  }
484
- current[idx.sortTemplate[0].path.at(-1)!] = { $regex: ModelIndexedUtil.getSuggestRegex(prefix) };
485
-
486
- const baseQuery = castTo<ModelQuery<T>>({
487
- where: {
488
- $and: [
489
- computed.project(),
490
- nested
491
- ]
492
- },
701
+
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
493
720
  });
721
+ const result = await this.connection.execute(sql, parameters);
722
+
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);
494
737
 
495
- for await (const batch of this.#scanTable<T>(cls, () => baseQuery, { limit: 10, ...options })) {
496
- items.push(...batch.items);
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)}`);
497
765
  }
498
766
 
499
- 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
+ }));
500
829
  }
501
- }
830
+ }