turbine-orm 0.75.0 → 0.76.0
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/README.md +48 -7
- package/dist/cjs/cli/compile-query.d.ts +22 -2
- package/dist/cjs/cli/compile-query.js +49 -5
- package/dist/cjs/cli/config.d.ts +2 -0
- package/dist/cjs/cli/config.js +1 -1
- package/dist/cjs/cli/destructive.js +78 -43
- package/dist/cjs/cli/index.d.ts +95 -1
- package/dist/cjs/cli/index.js +609 -145
- package/dist/cjs/cli/mcp.js +30 -1
- package/dist/cjs/cli/pii-predicate-guard.d.ts +25 -0
- package/dist/cjs/cli/pii-predicate-guard.js +72 -12
- package/dist/cjs/cli/rate-limit.js +38 -1
- package/dist/cjs/cli/studio.js +26 -5
- package/dist/cjs/cli/ui.d.ts +33 -0
- package/dist/cjs/cli/ui.js +53 -7
- package/dist/cjs/client.d.ts +13 -1
- package/dist/cjs/client.js +1 -1
- package/dist/cjs/errors.d.ts +12 -1
- package/dist/cjs/errors.js +11 -2
- package/dist/cjs/generate.d.ts +26 -0
- package/dist/cjs/generate.js +174 -27
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/introspect.d.ts +17 -0
- package/dist/cjs/introspect.js +100 -1
- package/dist/cjs/mssql.d.ts +18 -0
- package/dist/cjs/mssql.js +20 -1
- package/dist/cjs/pipeline.js +44 -6
- package/dist/cjs/powql.js +51 -17
- package/dist/cjs/query/batched-loader.js +3 -3
- package/dist/cjs/query/builder.js +1 -1
- package/dist/cjs/query/relations.d.ts +5 -0
- package/dist/cjs/query/relations.js +141 -69
- package/dist/cjs/query/utils.d.ts +13 -0
- package/dist/cjs/query/utils.js +16 -0
- package/dist/cjs/serverless.d.ts +1 -1
- package/dist/cjs/serverless.js +1 -1
- package/dist/cjs/sqlite.d.ts +33 -1
- package/dist/cjs/sqlite.js +84 -3
- package/dist/cli/compile-query.d.ts +22 -2
- package/dist/cli/compile-query.js +50 -6
- package/dist/cli/config.d.ts +2 -0
- package/dist/cli/config.js +1 -1
- package/dist/cli/destructive.js +78 -43
- package/dist/cli/index.d.ts +95 -1
- package/dist/cli/index.js +604 -147
- package/dist/cli/mcp.js +30 -1
- package/dist/cli/pii-predicate-guard.d.ts +25 -0
- package/dist/cli/pii-predicate-guard.js +73 -13
- package/dist/cli/rate-limit.js +38 -1
- package/dist/cli/studio.js +27 -6
- package/dist/cli/ui.d.ts +33 -0
- package/dist/cli/ui.js +51 -7
- package/dist/client.d.ts +13 -1
- package/dist/client.js +1 -1
- package/dist/errors.d.ts +12 -1
- package/dist/errors.js +11 -2
- package/dist/generate.d.ts +26 -0
- package/dist/generate.js +172 -27
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +17 -0
- package/dist/introspect.js +98 -1
- package/dist/mssql.d.ts +18 -0
- package/dist/mssql.js +20 -1
- package/dist/pipeline.js +44 -6
- package/dist/powql.js +53 -19
- package/dist/query/batched-loader.js +4 -4
- package/dist/query/builder.js +2 -2
- package/dist/query/relations.d.ts +5 -0
- package/dist/query/relations.js +141 -70
- package/dist/query/utils.d.ts +13 -0
- package/dist/query/utils.js +15 -0
- package/dist/serverless.d.ts +1 -1
- package/dist/serverless.js +1 -1
- package/dist/sqlite.d.ts +33 -1
- package/dist/sqlite.js +85 -4
- package/package.json +2 -2
package/dist/query/relations.js
CHANGED
|
@@ -18,7 +18,7 @@ import { normalizeKeyColumns, snakeToCamel } from '../schema.js';
|
|
|
18
18
|
import { resolveCountRelations } from './batched-loader.js';
|
|
19
19
|
import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, MAX_NAMED_ORDER_KEYS, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
|
|
20
20
|
import { assertDirectionToken, assertOrderDirection } from './types.js';
|
|
21
|
-
import { canonicalColumnOrder, ownLookup, relationInProjectionMessage, resolveColumnName, resolveRelation, resolveRelationDef, selectNamesNothingMessage, selectOmitExclusiveMessage, unknownFieldMessage, } from './utils.js';
|
|
21
|
+
import { availableClause, canonicalColumnOrder, ownLookup, relationInProjectionMessage, resolveColumnName, resolveRelation, resolveRelationDef, selectNamesNothingMessage, selectOmitExclusiveMessage, unknownFieldMessage, } from './utils.js';
|
|
22
22
|
import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
|
|
23
23
|
import * as whereMod from './where.js';
|
|
24
24
|
import * as writesMod from './writes.js';
|
|
@@ -175,6 +175,46 @@ export function resolveProjection(qi, table, meta, select, omit, includePii) {
|
|
|
175
175
|
export function resolveColumns(qi, select, omit, includePii) {
|
|
176
176
|
return resolveProjection(qi, qi.table, qi.tableMeta, select, omit, includePii);
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* The one place `with: { posts: true }` becomes an options object.
|
|
180
|
+
*
|
|
181
|
+
* `true` is not a third kind of relation spec, it is shorthand for "include
|
|
182
|
+
* this relation with no options", i.e. `{}`. Every walker over a `with` tree
|
|
183
|
+
* needs the same reading of it, and there are SIX of them: the build path
|
|
184
|
+
* ({@link buildRelationSubquery} / {@link buildManyToManySubquery}), the
|
|
185
|
+
* param-collect mirror ({@link collectRelationSubqueryParams}), the cache
|
|
186
|
+
* fingerprint ({@link withFingerprint}), the decode shape
|
|
187
|
+
* ({@link buildRelationShape}), the projection resolver
|
|
188
|
+
* ({@link resolveTargetColumns}) and the flatten planner.
|
|
189
|
+
*
|
|
190
|
+
* Each of them USED TO spell the shorthand out for itself, with a
|
|
191
|
+
* `spec !== true && spec.x` guard per option on the build side and a single
|
|
192
|
+
* `if (spec === true) return` early exit on the collect side. Those two
|
|
193
|
+
* readings are not the same, and the difference was a live bug: since global
|
|
194
|
+
* filters reached relation subqueries the build path has emitted the target's
|
|
195
|
+
* filter UNCONDITIONALLY (a `with` must never surface rows the filter hides),
|
|
196
|
+
* while the collect path's early exit returned before pushing its params. A
|
|
197
|
+
* value-bearing filter on a relation target therefore compiled a `$N` that no
|
|
198
|
+
* value backed, and `with: { posts: true }` failed at bind time with
|
|
199
|
+
* "bind message supplies N parameters, but prepared statement requires N+1"
|
|
200
|
+
* while the identical query written `with: { posts: {} }` worked.
|
|
201
|
+
*
|
|
202
|
+
* Returning ONE frozen empty options object removes the branch instead of
|
|
203
|
+
* duplicating it: below this call there is no `true` case left to keep in
|
|
204
|
+
* sync, so a future option added to one walker cannot be forgotten by the
|
|
205
|
+
* shorthand in another. Frozen and shared because it is read-only by
|
|
206
|
+
* construction (every consumer only reads `select`/`omit`/`where`/`orderBy`/
|
|
207
|
+
* `limit`/`with`), so one instance serves every call and allocates nothing on
|
|
208
|
+
* the hot path.
|
|
209
|
+
*/
|
|
210
|
+
const EMPTY_WITH_OPTIONS = Object.freeze({});
|
|
211
|
+
/**
|
|
212
|
+
* Read a relation spec as options. See {@link EMPTY_WITH_OPTIONS} for why this
|
|
213
|
+
* is a shared authority rather than a guard repeated per walker.
|
|
214
|
+
*/
|
|
215
|
+
export function relationOptions(spec) {
|
|
216
|
+
return spec === true ? EMPTY_WITH_OPTIONS : spec;
|
|
217
|
+
}
|
|
178
218
|
/**
|
|
179
219
|
* Produce a fingerprint for a `with` clause tree. Recursion mirrors
|
|
180
220
|
* buildSelectWithRelations / buildRelationSubquery.
|
|
@@ -211,11 +251,7 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
|
|
|
211
251
|
parts.push(`unknown:${relName}`);
|
|
212
252
|
continue;
|
|
213
253
|
}
|
|
214
|
-
|
|
215
|
-
parts.push(relName);
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
218
|
-
const opts = spec;
|
|
254
|
+
const opts = relationOptions(spec);
|
|
219
255
|
const subParts = [];
|
|
220
256
|
// select/omit shape
|
|
221
257
|
if (opts.select) {
|
|
@@ -293,8 +329,10 @@ export function collectWithParams(qi, withClause, params, table, flattenPlan) {
|
|
|
293
329
|
* Collect params from a single relation subquery. Mirrors buildRelationSubquery.
|
|
294
330
|
*/
|
|
295
331
|
export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, depth = 0) {
|
|
296
|
-
|
|
297
|
-
|
|
332
|
+
// `true` IS `{}` (see {@link relationOptions}). This used to be an early
|
|
333
|
+
// `return`, which skipped the target's global-filter params the build path
|
|
334
|
+
// always pushes.
|
|
335
|
+
const opts = relationOptions(spec);
|
|
298
336
|
const targetTable = relDef.to;
|
|
299
337
|
const targetMeta = qi.schema.tables[targetTable];
|
|
300
338
|
if (!targetMeta)
|
|
@@ -309,19 +347,19 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
|
|
|
309
347
|
// orderBy params → where params → limit param → nested-with params
|
|
310
348
|
// (always, both paths).
|
|
311
349
|
if (relDef.type === 'manyToMany') {
|
|
312
|
-
const m2mOrderEntries =
|
|
350
|
+
const m2mOrderEntries = opts.orderBy ? orderByEntries(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
313
351
|
if (nativeOrderPath && m2mOrderEntries.length > 0) {
|
|
314
352
|
collectRelationOrderParams(qi, targetTable, targetMeta, m2mOrderEntries, params);
|
|
315
353
|
}
|
|
316
|
-
if (
|
|
317
|
-
whereMod.collectAliasWhereParams(qi, targetTable, targetMeta,
|
|
354
|
+
if (opts.where) {
|
|
355
|
+
whereMod.collectAliasWhereParams(qi, targetTable, targetMeta, opts.where, params);
|
|
318
356
|
}
|
|
319
357
|
whereMod.collectTargetGlobalFilterAlias(qi, targetTable, params);
|
|
320
|
-
if (
|
|
321
|
-
params.push(qi.paginationValue(
|
|
358
|
+
if (opts.limit !== undefined && !qi.dialect.inlineLimitOffset) {
|
|
359
|
+
params.push(qi.paginationValue(opts.limit, 'relation limit'));
|
|
322
360
|
}
|
|
323
|
-
if (
|
|
324
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(
|
|
361
|
+
if (opts.with) {
|
|
362
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
|
|
325
363
|
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
326
364
|
if (!nestedRelDef)
|
|
327
365
|
continue;
|
|
@@ -331,12 +369,12 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
|
|
|
331
369
|
return;
|
|
332
370
|
}
|
|
333
371
|
// Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
|
|
334
|
-
const relOrderEntries =
|
|
372
|
+
const relOrderEntries = opts.orderBy ? orderByEntries(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
335
373
|
const hasOrder = relOrderEntries.length > 0;
|
|
336
|
-
const willWrap = relDef.type === 'hasMany' && (
|
|
374
|
+
const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || hasOrder);
|
|
337
375
|
// Non-wrapped path: nested relations BEFORE where/limit
|
|
338
|
-
if (!willWrap &&
|
|
339
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(
|
|
376
|
+
if (!willWrap && opts.with) {
|
|
377
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
|
|
340
378
|
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
341
379
|
if (!nestedRelDef)
|
|
342
380
|
continue;
|
|
@@ -350,8 +388,8 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
|
|
|
350
388
|
collectRelationOrderParams(qi, targetTable, targetMeta, relOrderEntries, params);
|
|
351
389
|
}
|
|
352
390
|
// where params, mirrors buildAliasWhere push order
|
|
353
|
-
if (
|
|
354
|
-
whereMod.collectAliasWhereParams(qi, targetTable, targetMeta,
|
|
391
|
+
if (opts.where) {
|
|
392
|
+
whereMod.collectAliasWhereParams(qi, targetTable, targetMeta, opts.where, params);
|
|
355
393
|
}
|
|
356
394
|
// Global filter on the target, mirrors targetGlobalFilterAlias in
|
|
357
395
|
// buildRelationSubquery (pushed after spec.where, before limit).
|
|
@@ -360,12 +398,12 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
|
|
|
360
398
|
// buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
|
|
361
399
|
// pushing one here would orphan a param and desync the collect path.
|
|
362
400
|
// `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
|
|
363
|
-
if (relDef.type === 'hasMany' &&
|
|
364
|
-
params.push(qi.paginationValue(
|
|
401
|
+
if (relDef.type === 'hasMany' && opts.limit !== undefined && !qi.dialect.inlineLimitOffset) {
|
|
402
|
+
params.push(qi.paginationValue(opts.limit, 'relation limit'));
|
|
365
403
|
}
|
|
366
404
|
// Wrapped path: nested relations AFTER where/limit (inside inner subquery)
|
|
367
|
-
if (willWrap &&
|
|
368
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(
|
|
405
|
+
if (willWrap && opts.with) {
|
|
406
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
|
|
369
407
|
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
370
408
|
if (!nestedRelDef)
|
|
371
409
|
continue;
|
|
@@ -734,13 +772,12 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
|
|
|
734
772
|
// landing here on such a table is an orderBy VALUE of the wrong shape on a
|
|
735
773
|
// scalar column, which deserves to be named rather than reported as a
|
|
736
774
|
// missing relation.
|
|
737
|
-
const known = Object.keys(ownerMeta.relations);
|
|
738
775
|
const isColumn = resolveColumnName(ownerMeta, relName) !== undefined;
|
|
739
776
|
throw new RelationError(isColumn
|
|
740
777
|
? `[turbine] orderBy on "${ownerTable}.${relName}" got a relation-shaped value, but "${relName}" is a ` +
|
|
741
778
|
`column. Order a column with 'asc' / 'desc' (or { sort, nulls }); the object form is for relations.`
|
|
742
779
|
: `[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
|
|
743
|
-
(
|
|
780
|
+
availableClause(Object.keys(ownerMeta.relations), `"${ownerTable}" has no relations.`));
|
|
744
781
|
}
|
|
745
782
|
// Pick-row ordering (`{ pick, by }`): order by a value from ONE related
|
|
746
783
|
// row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
|
|
@@ -1319,8 +1356,9 @@ export function parseNestedRow(qi, row, table, fromJson = false) {
|
|
|
1319
1356
|
* second implementation.
|
|
1320
1357
|
*/
|
|
1321
1358
|
export function resolveTargetColumns(qi, spec, targetMeta, includePii, targetTable = targetMeta.name) {
|
|
1322
|
-
const
|
|
1323
|
-
const
|
|
1359
|
+
const opts = relationOptions(spec);
|
|
1360
|
+
const select = opts.select;
|
|
1361
|
+
const omit = opts.omit;
|
|
1324
1362
|
return resolveProjection(qi, targetTable, targetMeta, select, omit, includePii) ?? targetMeta.allColumns;
|
|
1325
1363
|
}
|
|
1326
1364
|
/**
|
|
@@ -1475,8 +1513,9 @@ export function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
|
|
|
1475
1513
|
const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
|
|
1476
1514
|
const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? snakeToCamel(col));
|
|
1477
1515
|
const nested = {};
|
|
1478
|
-
|
|
1479
|
-
|
|
1516
|
+
const nestedWith = relationOptions(spec).with;
|
|
1517
|
+
if (nestedWith) {
|
|
1518
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(nestedWith)) {
|
|
1480
1519
|
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
1481
1520
|
if (!nestedRelDef)
|
|
1482
1521
|
continue;
|
|
@@ -1713,15 +1752,13 @@ function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includ
|
|
|
1713
1752
|
: `its correlation column(s) on "${relDef.to}" are not provably unique (no primary key, ` +
|
|
1714
1753
|
'unique constraint or non-partial unique index covers them), so a join could multiply parent rows');
|
|
1715
1754
|
}
|
|
1716
|
-
const opts = spec
|
|
1717
|
-
if (opts)
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
return decline('it declares an `orderBy`');
|
|
1722
|
-
}
|
|
1755
|
+
const opts = relationOptions(spec);
|
|
1756
|
+
if (opts.limit !== undefined)
|
|
1757
|
+
return decline('it declares a `limit`');
|
|
1758
|
+
if (opts.orderBy && orderByEntries(opts.orderBy).some(([, dir]) => dir !== undefined)) {
|
|
1759
|
+
return decline('it declares an `orderBy`');
|
|
1723
1760
|
}
|
|
1724
|
-
const nestedEntries = opts
|
|
1761
|
+
const nestedEntries = opts.with ? sortedEntries(opts.with) : [];
|
|
1725
1762
|
for (const [nestedRelName] of nestedEntries) {
|
|
1726
1763
|
if (nestedRelName === '_count')
|
|
1727
1764
|
return decline('its nested `with` uses `_count`');
|
|
@@ -1922,8 +1959,9 @@ function emitFlattenInner(qi, node, params, innerSelects, innerJoins, innerWhere
|
|
|
1922
1959
|
// than dropping the parent row, matching what the correlated subquery's
|
|
1923
1960
|
// `LIMIT 1` did when it returned NULL.
|
|
1924
1961
|
const filters = innerWhere;
|
|
1925
|
-
|
|
1926
|
-
|
|
1962
|
+
const nodeWhere = relationOptions(node.spec).where;
|
|
1963
|
+
if (nodeWhere) {
|
|
1964
|
+
const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, srcAlias, nodeWhere, params);
|
|
1927
1965
|
if (extra)
|
|
1928
1966
|
filters.push(extra);
|
|
1929
1967
|
}
|
|
@@ -1989,8 +2027,9 @@ function projectFlattenNode(qi, node, outerAlias, selectSink) {
|
|
|
1989
2027
|
}
|
|
1990
2028
|
/** Param-collect mirror of {@link emitFlattenNode}. */
|
|
1991
2029
|
export function collectFlattenNodeParams(qi, node, params) {
|
|
1992
|
-
|
|
1993
|
-
|
|
2030
|
+
const nodeWhere = relationOptions(node.spec).where;
|
|
2031
|
+
if (nodeWhere) {
|
|
2032
|
+
whereMod.collectAliasWhereParams(qi, node.targetTable, node.targetMeta, nodeWhere, params);
|
|
1994
2033
|
}
|
|
1995
2034
|
whereMod.collectTargetGlobalFilterAlias(qi, node.targetTable, params);
|
|
1996
2035
|
for (const slot of node.slots) {
|
|
@@ -2139,7 +2178,7 @@ export function buildSelectWithRelations(qi, table, withClause, params, columnsL
|
|
|
2139
2178
|
const relDef = ownLookup(meta.relations, relName);
|
|
2140
2179
|
if (!relDef) {
|
|
2141
2180
|
throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
|
|
2142
|
-
|
|
2181
|
+
availableClause(Object.keys(meta.relations), `"${table}" has no relations.`));
|
|
2143
2182
|
}
|
|
2144
2183
|
// `relationLoadStrategy: 'flatten'`: an eligible to-one relation becomes a
|
|
2145
2184
|
// LEFT JOIN + prefixed scalar projection instead of a per-parent-row
|
|
@@ -2273,6 +2312,11 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2273
2312
|
const targetMeta = qi.schema.tables[targetTable];
|
|
2274
2313
|
if (!targetMeta)
|
|
2275
2314
|
throw new RelationError(`[turbine] Unknown relation target "${targetTable}"`);
|
|
2315
|
+
// `true` IS `{}`; below this line there is no `true` case, which is what
|
|
2316
|
+
// keeps this walk and collectRelationSubqueryParams reading one shape.
|
|
2317
|
+
// The raw `spec` is still what crosses the dialect seam below, since
|
|
2318
|
+
// RelationSubqueryContext is a published contract.
|
|
2319
|
+
const opts = relationOptions(spec);
|
|
2276
2320
|
// Dev-only: correlated relation loading probes the child table once per parent
|
|
2277
2321
|
// row, so a missing FK index multiplies into per-parent full-table scans (a
|
|
2278
2322
|
// batched-loader ORM pays the same missing index only once, which is why
|
|
@@ -2321,9 +2365,33 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2321
2365
|
depth: currentDepth,
|
|
2322
2366
|
path: currentPath,
|
|
2323
2367
|
quote: (name) => qi.q(name),
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2368
|
+
// The relation target's WHERE, which is the caller's `spec.where` AND the
|
|
2369
|
+
// target table's GLOBAL FILTER. Both, not just the first.
|
|
2370
|
+
//
|
|
2371
|
+
// A dialect that overrides buildRelationSubquery takes over the whole
|
|
2372
|
+
// subquery, so the generic builder returns at this seam BEFORE it would
|
|
2373
|
+
// have applied targetGlobalFilterAlias itself. The override cannot apply
|
|
2374
|
+
// the filter on its own: RelationSubqueryContext deliberately hands it no
|
|
2375
|
+
// BuilderCtx, so `targetGlobalFilterAlias` is not reachable from a dialect
|
|
2376
|
+
// file. That left SQL Server's `FOR JSON PATH` path emitting a correlation
|
|
2377
|
+
// predicate and nothing else, so a `with` returned rows a tenancy or
|
|
2378
|
+
// soft-delete filter is supposed to hide, while the collect mirror still
|
|
2379
|
+
// pushed the filter's params. Wrong rows AND an orphan param, on every
|
|
2380
|
+
// spec shape.
|
|
2381
|
+
//
|
|
2382
|
+
// Folding it in here rather than at each override keeps ONE authority for
|
|
2383
|
+
// "what does this relation's WHERE contain", and lands the params exactly
|
|
2384
|
+
// where collectRelationSubqueryParams already expects them: after
|
|
2385
|
+
// spec.where, before the limit.
|
|
2386
|
+
buildWhere: (whereAlias) => {
|
|
2387
|
+
const userWhere = (opts.where
|
|
2388
|
+
? whereMod.buildAliasWhere(qi, targetTable, targetMeta, whereAlias, opts.where, params)
|
|
2389
|
+
: '') ?? '';
|
|
2390
|
+
const gf = whereMod.targetGlobalFilterAlias(qi, targetTable, whereAlias, params);
|
|
2391
|
+
if (userWhere && gf)
|
|
2392
|
+
return `${userWhere} AND ${gf}`;
|
|
2393
|
+
return userWhere || gf || '';
|
|
2394
|
+
},
|
|
2327
2395
|
recurse: (nRelDef, nSpec, nParent, nDepth, nPath) => buildRelationSubquery(qi, nRelDef, nSpec, params, nParent, aliasCounter, nDepth, nPath, includePii),
|
|
2328
2396
|
});
|
|
2329
2397
|
}
|
|
@@ -2335,8 +2403,8 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2335
2403
|
// An orderBy with no defined entries (`orderBy: {}`) is treated as absent -
|
|
2336
2404
|
// it must neither trigger the wrap (dropping nested relations) nor render a
|
|
2337
2405
|
// dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
|
|
2338
|
-
const relOrderEntries =
|
|
2339
|
-
const willWrap = relDef.type === 'hasMany' &&
|
|
2406
|
+
const relOrderEntries = opts.orderBy ? orderByEntries(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
2407
|
+
const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || relOrderEntries.length > 0);
|
|
2340
2408
|
// manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
|
|
2341
2409
|
// where, orderBy, and select/omit are handled there (the target alias is the
|
|
2342
2410
|
// row source, exactly like hasMany), so short-circuit before the hasMany logic.
|
|
@@ -2344,12 +2412,12 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2344
2412
|
return buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns, includePii);
|
|
2345
2413
|
}
|
|
2346
2414
|
// Nested relations, only in the non-wrapped path (wrapped path builds them separately)
|
|
2347
|
-
if (!willWrap &&
|
|
2348
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(
|
|
2415
|
+
if (!willWrap && opts.with) {
|
|
2416
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
|
|
2349
2417
|
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
2350
2418
|
if (!nestedRelDef) {
|
|
2351
2419
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
2352
|
-
|
|
2420
|
+
availableClause(Object.keys(targetMeta.relations), `"${relDef.to}" has no relations.`));
|
|
2353
2421
|
}
|
|
2354
2422
|
// Recursively build nested subquery, passing THIS alias as the parent reference
|
|
2355
2423
|
const nestedSubquery = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
|
|
@@ -2386,8 +2454,8 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2386
2454
|
}
|
|
2387
2455
|
// Additional filters, full scalar where surface (equality, null, operator
|
|
2388
2456
|
// objects, OR/AND/NOT), properly parameterized against this alias.
|
|
2389
|
-
if (
|
|
2390
|
-
const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, alias,
|
|
2457
|
+
if (opts.where) {
|
|
2458
|
+
const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, alias, opts.where, params);
|
|
2391
2459
|
if (extra)
|
|
2392
2460
|
whereClause += ` AND ${extra}`;
|
|
2393
2461
|
}
|
|
@@ -2404,8 +2472,8 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2404
2472
|
// (and shifts every later placeholder by one). To-one relations ignore limit.
|
|
2405
2473
|
// `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
|
|
2406
2474
|
let limitClause = '';
|
|
2407
|
-
if (relDef.type === 'hasMany' &&
|
|
2408
|
-
limitClause = ` LIMIT ${qi.paginationRef(
|
|
2475
|
+
if (relDef.type === 'hasMany' && opts.limit !== undefined) {
|
|
2476
|
+
limitClause = ` LIMIT ${qi.paginationRef(opts.limit, params, 'relation limit')}`;
|
|
2409
2477
|
}
|
|
2410
2478
|
if (relDef.type === 'hasMany') {
|
|
2411
2479
|
// When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
|
|
@@ -2418,12 +2486,12 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
|
|
|
2418
2486
|
// For the json_build_object, reference the inner alias, only include resolved columns
|
|
2419
2487
|
const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
|
|
2420
2488
|
// Build nested relation subqueries referencing innerAlias
|
|
2421
|
-
if (
|
|
2422
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(
|
|
2489
|
+
if (opts.with) {
|
|
2490
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
|
|
2423
2491
|
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
2424
2492
|
if (!nestedRelDef) {
|
|
2425
2493
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
2426
|
-
|
|
2494
|
+
availableClause(Object.keys(targetMeta.relations), `"${relDef.to}" has no relations.`));
|
|
2427
2495
|
}
|
|
2428
2496
|
const nestedSub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
|
|
2429
2497
|
const fallback = nestedRelDef.type === 'hasMany' ? qi.dialect.emptyJsonArrayLiteral : qi.dialect.nullJsonLiteral;
|
|
@@ -2468,6 +2536,9 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
2468
2536
|
if (!relDef.through) {
|
|
2469
2537
|
throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
|
|
2470
2538
|
}
|
|
2539
|
+
// `true` IS `{}`; see {@link relationOptions}. Same reading as
|
|
2540
|
+
// buildRelationSubquery and collectRelationSubqueryParams' m2m branch.
|
|
2541
|
+
const opts = relationOptions(spec);
|
|
2471
2542
|
const targetTable = relDef.to;
|
|
2472
2543
|
const qTarget = qi.q(targetTable);
|
|
2473
2544
|
const qJunction = qi.q(relDef.through.table);
|
|
@@ -2503,15 +2574,15 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
2503
2574
|
// `orderBy: {}` (no defined entries) is treated as absent: it must not
|
|
2504
2575
|
// render a dangling `ORDER BY `. Param pushes here land BEFORE the
|
|
2505
2576
|
// spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
|
|
2506
|
-
const relOrderEntries =
|
|
2577
|
+
const relOrderEntries = opts.orderBy ? orderByEntries(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
|
|
2507
2578
|
let orderClause = '';
|
|
2508
2579
|
if (relOrderEntries.length > 0) {
|
|
2509
2580
|
orderClause = buildRelationOrderClause(qi, targetTable, targetMeta, talias, relOrderEntries, params);
|
|
2510
2581
|
}
|
|
2511
2582
|
// Additional WHERE filters on the target, full scalar where surface,
|
|
2512
2583
|
// properly parameterized against the target alias.
|
|
2513
|
-
if (
|
|
2514
|
-
const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, talias,
|
|
2584
|
+
if (opts.where) {
|
|
2585
|
+
const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, talias, opts.where, params);
|
|
2515
2586
|
if (extra)
|
|
2516
2587
|
whereClause += ` AND ${extra}`;
|
|
2517
2588
|
}
|
|
@@ -2522,8 +2593,8 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
2522
2593
|
whereClause += ` AND ${gfExtra}`;
|
|
2523
2594
|
// LIMIT, `limit: 0` is honored (LIMIT 0 → empty array)
|
|
2524
2595
|
let limitClause = '';
|
|
2525
|
-
if (
|
|
2526
|
-
limitClause = ` LIMIT ${qi.paginationRef(
|
|
2596
|
+
if (opts.limit !== undefined) {
|
|
2597
|
+
limitClause = ` LIMIT ${qi.paginationRef(opts.limit, params, 'relation limit')}`;
|
|
2527
2598
|
}
|
|
2528
2599
|
const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
|
|
2529
2600
|
// When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
|
|
@@ -2534,12 +2605,12 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
2534
2605
|
`${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
|
|
2535
2606
|
const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
|
|
2536
2607
|
// Nested relations reference the inner alias.
|
|
2537
|
-
if (
|
|
2538
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(
|
|
2608
|
+
if (opts.with) {
|
|
2609
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
|
|
2539
2610
|
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
2540
2611
|
if (!nestedRelDef) {
|
|
2541
2612
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
2542
|
-
|
|
2613
|
+
availableClause(Object.keys(targetMeta.relations), `"${relDef.to}" has no relations.`));
|
|
2543
2614
|
}
|
|
2544
2615
|
const nestedSub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
|
|
2545
2616
|
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
|
@@ -2554,12 +2625,12 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
|
|
|
2554
2625
|
// Simple path: build the json object pairs directly off the target alias,
|
|
2555
2626
|
// including any nested relations (correlated to the target alias).
|
|
2556
2627
|
const jsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, talias);
|
|
2557
|
-
if (
|
|
2558
|
-
for (const [nestedRelName, nestedSpec] of sortedEntries(
|
|
2628
|
+
if (opts.with) {
|
|
2629
|
+
for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
|
|
2559
2630
|
const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
|
|
2560
2631
|
if (!nestedRelDef) {
|
|
2561
2632
|
throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
|
|
2562
|
-
|
|
2633
|
+
availableClause(Object.keys(targetMeta.relations), `"${relDef.to}" has no relations.`));
|
|
2563
2634
|
}
|
|
2564
2635
|
const nestedSub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
|
|
2565
2636
|
const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
|
package/dist/query/utils.d.ts
CHANGED
|
@@ -677,6 +677,19 @@ export declare function unknownFieldMessage(table: string, field: string, meta:
|
|
|
677
677
|
columnMap: Record<string, string>;
|
|
678
678
|
relations?: Record<string, unknown>;
|
|
679
679
|
}): string;
|
|
680
|
+
/**
|
|
681
|
+
* The trailing "Available: a, b, c" clause of a name-not-found error, or
|
|
682
|
+
* `emptySentence` when there is nothing to list.
|
|
683
|
+
*
|
|
684
|
+
* The empty branch is the whole reason this exists. Fifteen error sites
|
|
685
|
+
* interpolated `Object.keys(...).join(', ')` directly, and on a table with no
|
|
686
|
+
* relations (or a schema with no tables) that renders a dangling
|
|
687
|
+
* `Available: ` with nothing after the colon, which reads as a broken error
|
|
688
|
+
* message rather than as an answer. One site already got this right by hand;
|
|
689
|
+
* the other fourteen are now the same function, so the next one cannot get it
|
|
690
|
+
* wrong by omission.
|
|
691
|
+
*/
|
|
692
|
+
export declare function availableClause(names: readonly string[], emptySentence: string): string;
|
|
680
693
|
/**
|
|
681
694
|
* The error text for a RELATION named inside `select` / `omit`.
|
|
682
695
|
*
|
package/dist/query/utils.js
CHANGED
|
@@ -1419,6 +1419,21 @@ export function unknownFieldMessage(table, field, meta) {
|
|
|
1419
1419
|
` Known columns: ${columns.join(', ') || '(none)'}.` +
|
|
1420
1420
|
(relations.length ? ` Known relations (valid in \`where\` and \`with\`): ${relations.join(', ')}.` : ''));
|
|
1421
1421
|
}
|
|
1422
|
+
/**
|
|
1423
|
+
* The trailing "Available: a, b, c" clause of a name-not-found error, or
|
|
1424
|
+
* `emptySentence` when there is nothing to list.
|
|
1425
|
+
*
|
|
1426
|
+
* The empty branch is the whole reason this exists. Fifteen error sites
|
|
1427
|
+
* interpolated `Object.keys(...).join(', ')` directly, and on a table with no
|
|
1428
|
+
* relations (or a schema with no tables) that renders a dangling
|
|
1429
|
+
* `Available: ` with nothing after the colon, which reads as a broken error
|
|
1430
|
+
* message rather than as an answer. One site already got this right by hand;
|
|
1431
|
+
* the other fourteen are now the same function, so the next one cannot get it
|
|
1432
|
+
* wrong by omission.
|
|
1433
|
+
*/
|
|
1434
|
+
export function availableClause(names, emptySentence) {
|
|
1435
|
+
return names.length > 0 ? `Available: ${names.join(', ')}` : emptySentence;
|
|
1436
|
+
}
|
|
1422
1437
|
/**
|
|
1423
1438
|
* The error text for a RELATION named inside `select` / `omit`.
|
|
1424
1439
|
*
|
package/dist/serverless.d.ts
CHANGED
|
@@ -143,7 +143,7 @@ export type TurbineHttpOptions = Omit<TurbineConfig, 'pool'>;
|
|
|
143
143
|
* ```ts
|
|
144
144
|
* import { Pool } from '@neondatabase/serverless';
|
|
145
145
|
* import { turbineHttp } from 'turbine-orm/serverless';
|
|
146
|
-
* import type { TurbineClient } from './generated/turbine';
|
|
146
|
+
* import type { TurbineClient } from './generated/turbine/index.js';
|
|
147
147
|
* import { SCHEMA } from './generated/turbine/metadata.js';
|
|
148
148
|
*
|
|
149
149
|
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
package/dist/serverless.js
CHANGED
|
@@ -123,7 +123,7 @@ import { TurbineClient } from './client.js';
|
|
|
123
123
|
* ```ts
|
|
124
124
|
* import { Pool } from '@neondatabase/serverless';
|
|
125
125
|
* import { turbineHttp } from 'turbine-orm/serverless';
|
|
126
|
-
* import type { TurbineClient } from './generated/turbine';
|
|
126
|
+
* import type { TurbineClient } from './generated/turbine/index.js';
|
|
127
127
|
* import { SCHEMA } from './generated/turbine/metadata.js';
|
|
128
128
|
*
|
|
129
129
|
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
package/dist/sqlite.d.ts
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* - **Case-insensitive matching** uses `COLLATE NOCASE`, which is **ASCII-only**
|
|
35
35
|
* (no Unicode case folding).
|
|
36
36
|
*
|
|
37
|
-
* ## Example, `:memory:` database
|
|
37
|
+
* ## Example, `:memory:` database, from a generated schema
|
|
38
38
|
*
|
|
39
39
|
* ```ts
|
|
40
40
|
* import { turbineSqlite } from 'turbine-orm/sqlite';
|
|
@@ -44,6 +44,38 @@
|
|
|
44
44
|
* const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
|
|
45
45
|
* await db.disconnect();
|
|
46
46
|
* ```
|
|
47
|
+
*
|
|
48
|
+
* ## Example, SQLite with no Postgres anywhere
|
|
49
|
+
*
|
|
50
|
+
* The snippet above needs `turbine generate`, which reads a live **Postgres**
|
|
51
|
+
* catalog, so it is the wrong starting point if SQLite is your only database.
|
|
52
|
+
* Describe the schema in code instead: `schemaToSQL` emits the DDL and
|
|
53
|
+
* `schemaDefToMetadata` derives the runtime metadata (relations included, from
|
|
54
|
+
* the same `references:`), both pure functions with no database involved.
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* import { defineSchema, schemaDefToMetadata, schemaToSQL } from 'turbine-orm';
|
|
58
|
+
* import { sqliteDialect, turbineSqlite } from 'turbine-orm/sqlite';
|
|
59
|
+
*
|
|
60
|
+
* const schema = defineSchema({
|
|
61
|
+
* users: { id: { type: 'serial', primaryKey: true }, email: { type: 'text', notNull: true } },
|
|
62
|
+
* posts: {
|
|
63
|
+
* id: { type: 'serial', primaryKey: true },
|
|
64
|
+
* userId: { type: 'integer', notNull: true, references: 'users.id' },
|
|
65
|
+
* title: { type: 'text', notNull: true },
|
|
66
|
+
* },
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* const db = turbineSqlite(':memory:', schemaDefToMetadata(schema));
|
|
70
|
+
* for (const stmt of schemaToSQL(schema, { dialect: sqliteDialect })) {
|
|
71
|
+
* await db.raw([stmt] as never);
|
|
72
|
+
* }
|
|
73
|
+
*
|
|
74
|
+
* // `db.table(...)`, not `db.users`: the typed property accessors are emitted
|
|
75
|
+
* // by `turbine generate`, and this path skips it.
|
|
76
|
+
* const users = await db.table('users').findMany({ with: { posts: true }, orderBy: { id: 'asc' } });
|
|
77
|
+
* await db.disconnect();
|
|
78
|
+
* ```
|
|
47
79
|
*/
|
|
48
80
|
import type { DatabaseSync } from 'node:sqlite';
|
|
49
81
|
import { type PgCompatPool, type PgCompatPoolClient, TurbineClient } from './client.js';
|