velocious 1.0.582 → 1.0.583

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.
Files changed (30) hide show
  1. package/README.md +2 -2
  2. package/build/configuration-types.js +2 -0
  3. package/build/database/drivers/base.js +75 -0
  4. package/build/database/query/preloader/belongs-to.js +32 -31
  5. package/build/database/query/preloader/has-many.js +50 -26
  6. package/build/database/query/preloader/has-one.js +20 -12
  7. package/build/database/query/query-data.js +23 -8
  8. package/build/database/query/with-count.js +47 -11
  9. package/build/src/configuration-types.d.ts +10 -0
  10. package/build/src/configuration-types.d.ts.map +1 -1
  11. package/build/src/configuration-types.js +3 -1
  12. package/build/src/database/drivers/base.d.ts +39 -0
  13. package/build/src/database/drivers/base.d.ts.map +1 -1
  14. package/build/src/database/drivers/base.js +70 -1
  15. package/build/src/database/query/preloader/belongs-to.d.ts.map +1 -1
  16. package/build/src/database/query/preloader/belongs-to.js +33 -31
  17. package/build/src/database/query/preloader/has-many.d.ts.map +1 -1
  18. package/build/src/database/query/preloader/has-many.js +48 -28
  19. package/build/src/database/query/preloader/has-one.d.ts.map +1 -1
  20. package/build/src/database/query/preloader/has-one.js +21 -13
  21. package/build/src/database/query/query-data.js +21 -8
  22. package/build/src/database/query/with-count.js +41 -12
  23. package/package.json +2 -1
  24. package/src/configuration-types.js +2 -0
  25. package/src/database/drivers/base.js +75 -0
  26. package/src/database/query/preloader/belongs-to.js +32 -31
  27. package/src/database/query/preloader/has-many.js +50 -26
  28. package/src/database/query/preloader/has-one.js +20 -12
  29. package/src/database/query/query-data.js +23 -8
  30. package/src/database/query/with-count.js +47 -11
package/README.md CHANGED
@@ -23,8 +23,8 @@
23
23
  * Gap-less positional lists with automatic reordering via `actsAsList`, including models with numeric, string, or UUID primary keys (see [docs/acts-as-list.md](docs/acts-as-list.md))
24
24
  * Rails-style nested-attribute writes on frontend-model `save()` (see [docs/nested-attributes.md](docs/nested-attributes.md))
25
25
  * Async-aware test-data factories with inherited traits, graph-first native association autosave, metadata-aware override precedence, callbacks, sequences, and linting (see [docs/factories.md](docs/factories.md))
26
- * Per-row association counts via `.withCount(...)`, including cohort-safe intersected filters and safe batching of structurally identical aggregates, on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
27
- * Consumer-defined per-row SQL aggregates/computations via `.queryData(...)`, with compatible projections sharing a roundtrip while preserving declared alias-overwrite order, on frontend and backend queries (see [docs/query-data.md](docs/query-data.md))
26
+ * Per-row association counts via `.withCount(...)`, including cohort-safe intersected filters, safe batching of structurally identical aggregates, and automatic IN-list chunking for large parent sets, on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
27
+ * Consumer-defined per-row SQL aggregates/computations via `.queryData(...)`, with compatible projections sharing a roundtrip while preserving declared alias-overwrite order and automatic IN-list chunking for large parent sets, on frontend and backend queries (see [docs/query-data.md](docs/query-data.md))
28
28
  * Per-record ability checks via `.abilities(...)` on frontend queries + `record.can(action)` (see [docs/abilities.md](docs/abilities.md))
29
29
  * Translated model attributes with current-locale relationship sorting (see [docs/translations.md](docs/translations.md))
30
30
  * Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
@@ -75,6 +75,8 @@
75
75
  * @property {boolean} [multipleStatements] - (MySQL) Opt in to multi-statement queries so a whole structure SQL dump loads in one round-trip via `StructureSqlLoader`. Off by default; ordinary queries otherwise reject stacked statements.
76
76
  * @property {number} [maxRowsPerInsert] - Maximum rows per `INSERT ... VALUES (...), (...), ...` statement generated by `Record.insertMultiple`. Defaults to 500.
77
77
  * @property {number} [maxInsertSqlBytes] - Maximum serialized SQL size, in bytes, for a single `INSERT ... VALUES (...), (...), ...` statement. Defaults to 1 MiB (1048576).
78
+ * @property {number} [maxInClauseValues] - Maximum values in a single `IN (...)` cohort used by preloads, association counts, and queryData aggregates. Defaults to 999.
79
+ * @property {number} [maxQuerySqlBytes] - Maximum serialized SQL size, in bytes, for a single cohort query used by preloads, association counts, and queryData aggregates. Defaults to 1 MiB (1048576).
78
80
  * @property {string} [password] - Password for the database user.
79
81
  * @property {number} [port] - Database port.
80
82
  * @property {string} [primaryKeyType] - Default type for implicit migration primary keys and references. Defaults to `uuid`.
@@ -806,10 +806,82 @@ export default class VelociousDatabaseDriversBase {
806
806
  return optionalPositiveInteger(this.getArgs().maxInsertSqlBytes, "maxInsertSqlBytes") ?? 1048576
807
807
  }
808
808
 
809
+ /**
810
+ * Maximum values in a single `IN (...)` cohort used by preloads, association
811
+ * counts, and queryData aggregates. The default stays under SQLite's default
812
+ * `MAX_VARIABLE_NUMBER` compile-time limit.
813
+ *
814
+ * Override via `maxInClauseValues` in the database configuration.
815
+ * @returns {number} - Maximum values per IN clause cohort.
816
+ */
817
+ maxInClauseValues() {
818
+ return optionalPositiveInteger(this.getArgs().maxInClauseValues, "maxInClauseValues") ?? 999
819
+ }
820
+
821
+ /**
822
+ * Maximum serialized SQL size, in bytes, for a single cohort query used by
823
+ * preloads, association counts, and queryData aggregates. Cohort chunking
824
+ * stops when the next value would push the generated string over this threshold.
825
+ *
826
+ * Override via `maxQuerySqlBytes` in the database configuration.
827
+ * @returns {number} - Maximum bytes per cohort query.
828
+ */
829
+ maxQuerySqlBytes() {
830
+ return optionalPositiveInteger(this.getArgs().maxQuerySqlBytes, "maxQuerySqlBytes") ?? 1048576
831
+ }
832
+
833
+ /**
834
+ * Splits `values` into cohort chunks that stay within both `maxCount` and
835
+ * `maxBytes` while preserving order.
836
+ *
837
+ * A chunk always contains at least one value, even if that single value exceeds
838
+ * the byte limit, so progress is guaranteed.
839
+ * @template T
840
+ * @param {Array<T>} values - Values to chunk.
841
+ * @param {(values: Array<T>) => string} buildSql - Function that builds the full SQL for a candidate chunk.
842
+ * @param {{maxCount?: number, maxBytes?: number}} [options] - Chunking bounds.
843
+ * @returns {Array<Array<T>>} - Value cohorts.
844
+ */
845
+ chunkValues(values, buildSql, {maxCount = this.maxInClauseValues(), maxBytes = this.maxQuerySqlBytes()} = {}) {
846
+ if (values.length === 0) return []
847
+
848
+ /**
849
+ * Chunks.
850
+ * @type {Array<Array<T>>} */
851
+ const chunks = []
852
+ /**
853
+ * Current chunk.
854
+ * @type {Array<T>} */
855
+ let currentChunk = []
856
+
857
+ for (const value of values) {
858
+ const candidate = [...currentChunk, value]
859
+ const candidateBytes = Buffer.byteLength(buildSql(candidate), "utf8")
860
+
861
+ if (currentChunk.length > 0 && (candidate.length > maxCount || candidateBytes > maxBytes)) {
862
+ chunks.push(currentChunk)
863
+ currentChunk = [value]
864
+ } else {
865
+ currentChunk = candidate
866
+ }
867
+ }
868
+
869
+ if (currentChunk.length > 0) {
870
+ chunks.push(currentChunk)
871
+ }
872
+
873
+ return chunks
874
+ }
875
+
809
876
  /**
810
877
  * Splits `rows` into chunks that stay within both {@link maxRowsPerInsert}
811
878
  * and {@link maxInsertSqlBytes} while preserving order.
812
879
  *
880
+ * Byte accounting is incremental: `buildSql` is called once with `[]` to
881
+ * measure the statement prefix and once per row with `[row]` to measure the
882
+ * row's values tuple. This keeps chunking linear in the number of rows
883
+ * instead of rebuilding the full multi-row SQL for every candidate.
884
+ *
813
885
  * A chunk always contains at least one row, even if that single row exceeds
814
886
  * the byte limit, so progress is guaranteed.
815
887
  * @param {Array<Array<ReturnType<typeof JSON.parse>>>} rows - Rows to insert.
@@ -824,6 +896,9 @@ export default class VelociousDatabaseDriversBase {
824
896
  const prefix = `${emptySql} VALUES `
825
897
  const baseByteLength = Buffer.byteLength(prefix, "utf8")
826
898
 
899
+ /**
900
+ * Current chunk.
901
+ * @type {Array<Array<ReturnType<typeof JSON.parse>>>} */
827
902
  let currentChunk = []
828
903
  let currentBytes = 0
829
904
 
@@ -87,25 +87,27 @@ export default class VelociousDatabaseQueryPreloaderBelongsTo {
87
87
  if (foreignKeyValues.size > 0) {
88
88
  await ensureModelClassInitialized(targetModelClass, this.relationship.getConfiguration(), modelsToLoad[0])
89
89
 
90
- /**
91
- * Where args.
92
- * @type {Record<string, string | number | Array<string | number>>} */
93
- const whereArgs = {}
90
+ // Build the query once with scope and selection, then clone it per cohort so
91
+ // the IN-list size stays within driver limits without rebuilding shared state.
92
+ let baseQuery = preloadQueryForModel(modelsToLoad, targetModelClass)
94
93
 
95
- whereArgs[primaryKey] = [...foreignKeyValues]
94
+ baseQuery = this.relationship.applyScope(baseQuery)
95
+ baseQuery = this.selection.applyToQuery({query: baseQuery, targetModelClass, mappingColumns: [primaryKey]})
96
96
 
97
- // Load target models to be preloaded on the given models
98
- let query = preloadQueryForModel(modelsToLoad, targetModelClass).where(whereArgs)
97
+ const driver = baseQuery.driver
98
+ const cohorts = driver.chunkValues([...foreignKeyValues], (chunk) => baseQuery.clone().where({[primaryKey]: chunk}).toSql())
99
99
 
100
- query = this.relationship.applyScope(query)
101
- query = this.selection.applyToQuery({query, targetModelClass, mappingColumns: [primaryKey]})
100
+ for (const cohort of cohorts) {
101
+ const cohortQuery = baseQuery.clone().where({[primaryKey]: cohort})
102
+ const foundTargetModels = await cohortQuery.toArray()
102
103
 
103
- targetModels = await query.toArray()
104
+ targetModels.push(...foundTargetModels)
104
105
 
105
- for (const targetModel of targetModels) {
106
- const primaryKeyValue = /** @type {string | number} */ (targetModel.readColumn(primaryKey))
106
+ for (const targetModel of foundTargetModels) {
107
+ const primaryKeyValue = /** @type {string | number} */ (targetModel.readColumn(primaryKey))
107
108
 
108
- targetModelsById[primaryKeyValue] = targetModel
109
+ targetModelsById[primaryKeyValue] = targetModel
110
+ }
109
111
  }
110
112
  }
111
113
 
@@ -205,33 +207,32 @@ export default class VelociousDatabaseQueryPreloaderBelongsTo {
205
207
 
206
208
  await ensureModelClassInitialized(targetModelClass, configuration, this.models[0])
207
209
 
208
- /**
209
- * Where args.
210
- * @type {Record<string, string | number | Array<string | number>>} */
211
- const whereArgs = {}
210
+ let baseQuery = preloadQueryForModel(this.models, targetModelClass)
212
211
 
213
- whereArgs[primaryKey] = [...foreignKeyValuesByType[targetType]]
212
+ baseQuery = this.relationship.applyScope(baseQuery)
213
+ baseQuery = this.selection.applyToQuery({query: baseQuery, targetModelClass, mappingColumns: [primaryKey]})
214
214
 
215
- let query = preloadQueryForModel(this.models, targetModelClass).where(whereArgs)
215
+ const driver = baseQuery.driver
216
+ const cohorts = driver.chunkValues([...foreignKeyValuesByType[targetType]], (chunk) => baseQuery.clone().where({[primaryKey]: chunk}).toSql())
216
217
 
217
- query = this.relationship.applyScope(query)
218
- query = this.selection.applyToQuery({query, targetModelClass, mappingColumns: [primaryKey]})
218
+ targetModelsByTypeAndId[targetType] = {}
219
219
 
220
- const foundTargetModels = await query.toArray()
220
+ for (const cohort of cohorts) {
221
+ const cohortQuery = baseQuery.clone().where({[primaryKey]: cohort})
222
+ const foundTargetModels = await cohortQuery.toArray()
221
223
 
222
- targetModels.push(...foundTargetModels)
224
+ targetModels.push(...foundTargetModels)
223
225
 
224
- const className = targetModelClass.getModelName()
226
+ const className = targetModelClass.getModelName()
225
227
 
226
- if (!targetModelsByClassName[className]) targetModelsByClassName[className] = []
227
- targetModelsByClassName[className].push(...foundTargetModels)
228
+ if (!targetModelsByClassName[className]) targetModelsByClassName[className] = []
229
+ targetModelsByClassName[className].push(...foundTargetModels)
228
230
 
229
- targetModelsByTypeAndId[targetType] = {}
231
+ for (const targetModel of foundTargetModels) {
232
+ const primaryKeyValue = /** @type {string | number} */ (targetModel.readColumn(primaryKey))
230
233
 
231
- for (const targetModel of foundTargetModels) {
232
- const primaryKeyValue = /** @type {string | number} */ (targetModel.readColumn(primaryKey))
233
-
234
- targetModelsByTypeAndId[targetType][primaryKeyValue] = targetModel
234
+ targetModelsByTypeAndId[targetType][primaryKeyValue] = targetModel
235
+ }
235
236
  }
236
237
  }
237
238
 
@@ -155,10 +155,11 @@ export default class VelociousDatabaseQueryPreloaderHasMany {
155
155
  modelsByPrimaryKeyValue[primaryKeyValue].push(model)
156
156
  }
157
157
 
158
- // Step 1: Query the through table to build parent→target ID mapping
159
- const throughModels = await preloadQueryForModel(modelsToLoad, throughModelClass)
160
- .where({[throughForeignKey]: [...modelsPrimaryKeyValues]})
161
- .toArray()
158
+ // Step 1: Query the through table to build parent→target ID mapping.
159
+ // Chunk the parent PK cohort so the through query's IN-list stays bounded.
160
+ const throughBaseQuery = preloadQueryForModel(modelsToLoad, throughModelClass)
161
+ const throughDriver = throughBaseQuery.driver
162
+ const throughCohorts = throughDriver.chunkValues([...modelsPrimaryKeyValues], (chunk) => throughBaseQuery.clone().where({[throughForeignKey]: chunk}).toSql())
162
163
 
163
164
  /**
164
165
  * Parent to target ids.
@@ -170,28 +171,43 @@ export default class VelociousDatabaseQueryPreloaderHasMany {
170
171
  * @type {Set<string | number>} */
171
172
  const allTargetIds = new Set()
172
173
 
173
- for (const throughModel of throughModels) {
174
- const parentId = /** @type {string | number} */ (throughModel.readColumn(throughForeignKey))
175
- const throughId = /** @type {string | number} */ (throughModel.readColumn(throughModelClass.primaryKey()))
174
+ for (const cohort of throughCohorts) {
175
+ const throughQuery = throughBaseQuery.clone().where({[throughForeignKey]: cohort})
176
+ const throughModels = await throughQuery.toArray()
176
177
 
177
- if (!(parentId in parentToTargetIds)) parentToTargetIds[parentId] = []
178
+ for (const throughModel of throughModels) {
179
+ const parentId = /** @type {string | number} */ (throughModel.readColumn(throughForeignKey))
180
+ const throughId = /** @type {string | number} */ (throughModel.readColumn(throughModelClass.primaryKey()))
178
181
 
179
- parentToTargetIds[parentId].push(throughId)
180
- allTargetIds.add(throughId)
182
+ if (!(parentId in parentToTargetIds)) parentToTargetIds[parentId] = []
183
+
184
+ parentToTargetIds[parentId].push(throughId)
185
+ allTargetIds.add(throughId)
186
+ }
181
187
  }
182
188
 
183
- // Step 2: Load target models by the foreign key that points to the through table
189
+ // Step 2: Load target models by the foreign key that points to the through table.
190
+ // Chunk the target ID cohort so the target query's IN-list stays bounded.
184
191
  /**
185
192
  * Target models.
186
193
  * @type {import("../../record/index.js").default[]} */
187
194
  let targetModels = []
188
195
 
189
196
  if (allTargetIds.size > 0) {
190
- let query = preloadQueryForModel(modelsToLoad, targetModelClass).where({[targetForeignKey]: [...allTargetIds]})
197
+ let targetBaseQuery = preloadQueryForModel(modelsToLoad, targetModelClass)
198
+
199
+ targetBaseQuery = this.relationship.applyScope(targetBaseQuery)
200
+ targetBaseQuery = this.selection.applyToQuery({query: targetBaseQuery, targetModelClass, mappingColumns: [targetForeignKey]})
201
+
202
+ const targetDriver = targetBaseQuery.driver
203
+ const targetCohorts = targetDriver.chunkValues([...allTargetIds], (chunk) => targetBaseQuery.clone().where({[targetForeignKey]: chunk}).toSql())
191
204
 
192
- query = this.relationship.applyScope(query)
193
- query = this.selection.applyToQuery({query, targetModelClass, mappingColumns: [targetForeignKey]})
194
- targetModels = await query.toArray()
205
+ for (const cohort of targetCohorts) {
206
+ const cohortQuery = targetBaseQuery.clone().where({[targetForeignKey]: cohort})
207
+ const foundTargetModels = await cohortQuery.toArray()
208
+
209
+ targetModels.push(...foundTargetModels)
210
+ }
195
211
  }
196
212
 
197
213
  // Step 3: Index target models by their foreign key (maps to through model ID)
@@ -285,27 +301,35 @@ export default class VelociousDatabaseQueryPreloaderHasMany {
285
301
  modelsByPrimaryKeyValue[primaryKeyValue].push(model)
286
302
  }
287
303
 
288
- /**
289
- * Where args.
290
- * @type {Record<string, string | number | Array<string | number>>} */
291
- const whereArgs = {}
304
+ await ensureModelClassInitialized(targetModelClass, this.relationship.getConfiguration(), modelsToLoad[0])
292
305
 
293
- whereArgs[foreignKey] = [...modelsPrimaryKeyValues]
306
+ // Build the query once with the polymorphic type constant (when present),
307
+ // relationship scope, and selection. The parent ID IN-list is cloned per cohort
308
+ // so the generated SQL stays within driver limits.
309
+ let baseQuery = preloadQueryForModel(modelsToLoad, targetModelClass)
294
310
 
295
311
  if (this.relationship.getPolymorphic()) {
296
312
  const typeColumn = this.relationship.getPolymorphicTypeColumn()
297
313
 
298
- whereArgs[typeColumn] = this.relationship.getModelClass().getModelName()
314
+ baseQuery = baseQuery.where({[typeColumn]: this.relationship.getModelClass().getModelName()})
299
315
  }
300
316
 
301
- await ensureModelClassInitialized(targetModelClass, this.relationship.getConfiguration(), modelsToLoad[0])
317
+ baseQuery = this.relationship.applyScope(baseQuery)
318
+ baseQuery = this.selection.applyToQuery({query: baseQuery, targetModelClass, mappingColumns: [foreignKey]})
302
319
 
303
- let query = preloadQueryForModel(modelsToLoad, targetModelClass).where(whereArgs)
320
+ /**
321
+ * Target models.
322
+ * @type {import("../../record/index.js").default[]} */
323
+ const targetModels = []
324
+ const driver = baseQuery.driver
325
+ const cohorts = driver.chunkValues([...modelsPrimaryKeyValues], (chunk) => baseQuery.clone().where({[foreignKey]: chunk}).toSql())
304
326
 
305
- query = this.relationship.applyScope(query)
306
- query = this.selection.applyToQuery({query, targetModelClass, mappingColumns: [foreignKey]})
327
+ for (const cohort of cohorts) {
328
+ const cohortQuery = baseQuery.clone().where({[foreignKey]: cohort})
329
+ const foundTargetModels = await cohortQuery.toArray()
307
330
 
308
- const targetModels = await query.toArray()
331
+ targetModels.push(...foundTargetModels)
332
+ }
309
333
 
310
334
  for (const targetModel of targetModels) {
311
335
  const foreignKeyValue = /** @type {string | number} */ (targetModel.readColumn(foreignKey))
@@ -72,28 +72,36 @@ export default class VelociousDatabaseQueryPreloaderHasOne {
72
72
 
73
73
  if (modelsPrimaryKeyValues.size == 0) return satisfiedTargets
74
74
 
75
- /**
76
- * Where args.
77
- * @type {Record<string, string | number | Array<string | number>>} */
78
- const whereArgs = {}
75
+ await ensureModelClassInitialized(targetModelClass, this.relationship.getConfiguration(), this.models[0])
79
76
 
80
- whereArgs[foreignKey] = [...modelsPrimaryKeyValues]
77
+ // Load target models to be preloaded on the given models.
78
+ // Build the query once with the polymorphic type constant (when present),
79
+ // relationship scope, and selection. The parent ID IN-list is cloned per cohort
80
+ // so the generated SQL stays within driver limits.
81
+ let baseQuery = preloadQueryForModel(this.models, targetModelClass)
81
82
 
82
83
  if (this.relationship.getPolymorphic()) {
83
84
  const typeColumn = this.relationship.getPolymorphicTypeColumn()
84
85
 
85
- whereArgs[typeColumn] = this.relationship.getModelClass().getModelName()
86
+ baseQuery = baseQuery.where({[typeColumn]: this.relationship.getModelClass().getModelName()})
86
87
  }
87
88
 
88
- await ensureModelClassInitialized(targetModelClass, this.relationship.getConfiguration(), this.models[0])
89
+ baseQuery = this.relationship.applyScope(baseQuery)
90
+ baseQuery = this.selection.applyToQuery({query: baseQuery, targetModelClass, mappingColumns: [foreignKey]})
89
91
 
90
- // Load target models to be preloaded on the given models
91
- let query = preloadQueryForModel(this.models, targetModelClass).where(whereArgs)
92
+ /**
93
+ * Target models.
94
+ * @type {import("../../record/index.js").default[]} */
95
+ const targetModels = []
96
+ const driver = baseQuery.driver
97
+ const cohorts = driver.chunkValues([...modelsPrimaryKeyValues], (chunk) => baseQuery.clone().where({[foreignKey]: chunk}).toSql())
92
98
 
93
- query = this.relationship.applyScope(query)
94
- query = this.selection.applyToQuery({query, targetModelClass, mappingColumns: [foreignKey]})
99
+ for (const cohort of cohorts) {
100
+ const cohortQuery = baseQuery.clone().where({[foreignKey]: cohort})
101
+ const foundTargetModels = await cohortQuery.toArray()
95
102
 
96
- const targetModels = await query.toArray()
103
+ targetModels.push(...foundTargetModels)
104
+ }
97
105
 
98
106
  for (const targetModel of targetModels) {
99
107
  const foreignKeyValue = /** @type {string | number} */ (targetModel.readColumn(foreignKey))
@@ -227,7 +227,7 @@ export async function runQueryData({rootModelClass, rootModels, entries}) {
227
227
  }
228
228
 
229
229
  for (const {query} of queryGroups) {
230
- await executeEntryQuery({primaryKey, query, rootModels})
230
+ await executeChunkedEntryQuery({primaryKey, query, rootIds, rootModels})
231
231
  }
232
232
  }
233
233
 
@@ -267,13 +267,6 @@ function prepareEntry({entry, entryIndex, primaryKey, rootIds, rootModelClass, s
267
267
  const rootTable = rootModelClass.tableName()
268
268
  const rootPkSql = `${driver.quoteTable(rootTable)}.${driver.quoteColumn(primaryKey)}`
269
269
 
270
- /**
271
- * Root where.
272
- * @type {Record<string, ReturnType<typeof JSON.parse>>} */
273
- const rootWhere = {}
274
- rootWhere[primaryKey] = rootIds
275
- query.where(rootWhere)
276
-
277
270
  const joinDescriptor = buildNestedJoinDescriptor(entry.chain)
278
271
 
279
272
  if (joinDescriptor !== true) {
@@ -370,3 +363,25 @@ async function executeEntryQuery({primaryKey, query, rootModels}) {
370
363
  }
371
364
  }
372
365
  }
366
+
367
+ /**
368
+ * Executes one compatible queryData group in cohorts so the root ID IN-list
369
+ * stays within driver limits, attaching each selected alias to the matching
370
+ * root record.
371
+ * @param {object} args - Options.
372
+ * @param {string} args.primaryKey - Root model primary key column.
373
+ * @param {import("./model-class-query.js").default} args.query - Prepared grouped query.
374
+ * @param {Array<string | number>} args.rootIds - Root primary-key values.
375
+ * @param {import("../record/index.js").default[]} args.rootModels - Loaded root records.
376
+ * @returns {Promise<void>}
377
+ */
378
+ async function executeChunkedEntryQuery({primaryKey, query, rootIds, rootModels}) {
379
+ const driver = query.driver
380
+ const cohorts = driver.chunkValues(rootIds, (chunk) => query.clone().where({[primaryKey]: chunk}).toSql())
381
+
382
+ for (const cohort of cohorts) {
383
+ const cohortQuery = query.clone().where({[primaryKey]: cohort})
384
+
385
+ await executeEntryQuery({primaryKey, query: cohortQuery, rootModels})
386
+ }
387
+ }
@@ -115,19 +115,19 @@ export async function runWithCount({models, modelClass, entries}) {
115
115
  const queryGroups = new Map()
116
116
 
117
117
  for (const entry of entries) {
118
- const countQuery = queryForEntry({entry, modelClass, parentIds, sourceModel})
119
- const sql = countQuery.toSql()
118
+ const {baseQuery, foreignKey} = queryForEntry({entry, modelClass, sourceModel})
119
+ const sql = baseQuery.toSql()
120
120
  const existingGroup = queryGroups.get(sql)
121
121
 
122
122
  if (existingGroup) {
123
123
  existingGroup.entries.push(entry)
124
124
  } else {
125
- queryGroups.set(sql, {countQuery, entries: [entry]})
125
+ queryGroups.set(sql, {baseQuery, entries: [entry], foreignKey})
126
126
  }
127
127
  }
128
128
 
129
- for (const {countQuery, entries: groupedEntries} of queryGroups.values()) {
130
- const counts = await executeCountQuery(countQuery)
129
+ for (const {baseQuery, entries: groupedEntries, foreignKey} of queryGroups.values()) {
130
+ const counts = await executeChunkedCountQuery({baseQuery, foreignKey, parentIds})
131
131
 
132
132
  for (const entry of groupedEntries) attachCounts({counts, entry, models, primaryKey})
133
133
  }
@@ -135,14 +135,16 @@ export async function runWithCount({models, modelClass, entries}) {
135
135
 
136
136
  /**
137
137
  * Builds the grouped count query for an entry.
138
+ *
139
+ * The returned query does NOT yet filter by parent IDs; callers chunk the
140
+ * parent cohort and apply the foreign-key IN clause per chunk.
138
141
  * @param {object} args - Options.
139
142
  * @param {WithCountEntry} args.entry - Entry being evaluated.
140
143
  * @param {typeof import("../record/index.js").default} args.modelClass - Parent model class.
141
- * @param {Array<string | number>} args.parentIds - Primary keys of the loaded parents.
142
144
  * @param {import("../record/index.js").default} args.sourceModel - Loaded operation owner.
143
- * @returns {import("./model-class-query.js").default} - Prepared count query.
145
+ * @returns {{baseQuery: import("./model-class-query.js").default, foreignKey: string}} - Prepared count query and its foreign key.
144
146
  */
145
- function queryForEntry({entry, modelClass, parentIds, sourceModel}) {
147
+ function queryForEntry({entry, modelClass, sourceModel}) {
146
148
  const relationship = modelClass.getRelationshipByName(entry.relationshipName)
147
149
 
148
150
  if (!relationship) {
@@ -163,7 +165,7 @@ function queryForEntry({entry, modelClass, parentIds, sourceModel}) {
163
165
  /**
164
166
  * Mandatory cohort conditions.
165
167
  * @type {Record<string, ReturnType<typeof JSON.parse>>} */
166
- const mandatoryWhereConditions = {[foreignKey]: parentIds}
168
+ const mandatoryWhereConditions = {}
167
169
 
168
170
  if (relationship.getPolymorphic && relationship.getPolymorphic()) {
169
171
  const typeColumn = relationship.getPolymorphicTypeColumn()
@@ -171,8 +173,12 @@ function queryForEntry({entry, modelClass, parentIds, sourceModel}) {
171
173
  }
172
174
 
173
175
  const baseQuery = sourceModel.queryForModel(targetModelClass)
176
+
174
177
  baseQuery._forceQualifyBaseTable = true
175
- baseQuery.where(mandatoryWhereConditions)
178
+
179
+ if (Object.keys(mandatoryWhereConditions).length > 0) {
180
+ baseQuery.where(mandatoryWhereConditions)
181
+ }
176
182
 
177
183
  if (entry.where) {
178
184
  baseQuery.where(entry.where)
@@ -192,7 +198,7 @@ function queryForEntry({entry, modelClass, parentIds, sourceModel}) {
192
198
  countQuery.select(`${qualifiedForeignKey} AS parent_id`)
193
199
  countQuery.select("COUNT(*) AS count_value")
194
200
 
195
- return countQuery
201
+ return {baseQuery: countQuery, foreignKey}
196
202
  }
197
203
 
198
204
  /**
@@ -219,6 +225,36 @@ async function executeCountQuery(countQuery) {
219
225
  return counts
220
226
  }
221
227
 
228
+ /**
229
+ * Executes a grouped count query in cohorts so the parent ID IN-list stays
230
+ * within driver limits, merging per-parent counts across chunks.
231
+ * @param {object} args - Options.
232
+ * @param {import("./model-class-query.js").default} args.baseQuery - Prepared count query without parent IDs.
233
+ * @param {string} args.foreignKey - Foreign key used to join to the parents.
234
+ * @param {Array<string | number>} args.parentIds - Primary keys of the loaded parents.
235
+ * @returns {Promise<Map<string | number, number>>} - Map of parent pk → count.
236
+ */
237
+ async function executeChunkedCountQuery({baseQuery, foreignKey, parentIds}) {
238
+ const driver = baseQuery.driver
239
+ const cohorts = driver.chunkValues(parentIds, (chunk) => baseQuery.clone().where({[foreignKey]: chunk}).toSql())
240
+
241
+ /**
242
+ * Counts.
243
+ * @type {Map<string | number, number>} */
244
+ const counts = new Map()
245
+
246
+ for (const cohort of cohorts) {
247
+ const cohortQuery = baseQuery.clone().where({[foreignKey]: cohort})
248
+ const cohortCounts = await executeCountQuery(cohortQuery)
249
+
250
+ for (const [parentId, count] of cohortCounts) {
251
+ counts.set(parentId, count)
252
+ }
253
+ }
254
+
255
+ return counts
256
+ }
257
+
222
258
  /**
223
259
  * Attaches one entry's resolved counts to the loaded models.
224
260
  * @param {object} args - Options.
@@ -165,6 +165,14 @@ export type DatabaseConfigurationType = {
165
165
  * - Maximum serialized SQL size, in bytes, for a single `INSERT ... VALUES (...), (...), ...` statement. Defaults to 1 MiB (1048576).
166
166
  */
167
167
  maxInsertSqlBytes?: number;
168
+ /**
169
+ * - Maximum values in a single `IN (...)` cohort used by preloads, association counts, and queryData aggregates. Defaults to 999.
170
+ */
171
+ maxInClauseValues?: number;
172
+ /**
173
+ * - Maximum serialized SQL size, in bytes, for a single cohort query used by preloads, association counts, and queryData aggregates. Defaults to 1 MiB (1048576).
174
+ */
175
+ maxQuerySqlBytes?: number;
168
176
  /**
169
177
  * - Password for the database user.
170
178
  */
@@ -1645,6 +1653,8 @@ export type ConfigurationArgsType = {
1645
1653
  * @property {boolean} [multipleStatements] - (MySQL) Opt in to multi-statement queries so a whole structure SQL dump loads in one round-trip via `StructureSqlLoader`. Off by default; ordinary queries otherwise reject stacked statements.
1646
1654
  * @property {number} [maxRowsPerInsert] - Maximum rows per `INSERT ... VALUES (...), (...), ...` statement generated by `Record.insertMultiple`. Defaults to 500.
1647
1655
  * @property {number} [maxInsertSqlBytes] - Maximum serialized SQL size, in bytes, for a single `INSERT ... VALUES (...), (...), ...` statement. Defaults to 1 MiB (1048576).
1656
+ * @property {number} [maxInClauseValues] - Maximum values in a single `IN (...)` cohort used by preloads, association counts, and queryData aggregates. Defaults to 999.
1657
+ * @property {number} [maxQuerySqlBytes] - Maximum serialized SQL size, in bytes, for a single cohort query used by preloads, association counts, and queryData aggregates. Defaults to 1 MiB (1048576).
1648
1658
  * @property {string} [password] - Password for the database user.
1649
1659
  * @property {number} [port] - Database port.
1650
1660
  * @property {string} [primaryKeyType] - Default type for implicit migration primary keys and references. Defaults to `uuid`.