turbine-orm 0.42.0 → 0.43.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.
@@ -236,6 +236,21 @@ function relationFkColumns(model, field) {
236
236
  const fieldsArg = relAttr?.args.find((a) => a.key === 'fields' && a.kind === 'array');
237
237
  return fieldsArg?.items?.map((pf) => fieldColumn(model, pf)) ?? null;
238
238
  }
239
+ /**
240
+ * FK columns for an INVERSE relation field (one carrying no `fields: [...]`),
241
+ * derived by @relation("Name") pairing: the opposing model's same-named field
242
+ * that owns the FK pins the columns. Returns undefined when there is no
243
+ * relation name or no named counterpart. This is how Prisma disambiguates two
244
+ * or more relations to the same target model.
245
+ */
246
+ function pairedInverseFkColumns(model, field, targetModelName, modelsByName) {
247
+ const relName = relationNameOf(field);
248
+ const targetModel = modelsByName.get(targetModelName);
249
+ if (!relName || !targetModel)
250
+ return null;
251
+ const opposing = targetModel.fields.find((f) => f.type === model.name && relationNameOf(f) === relName && (relationFkColumns(targetModel, f)?.length ?? 0) > 0);
252
+ return opposing ? relationFkColumns(targetModel, opposing) : null;
253
+ }
239
254
  function resolveRelation(model, fieldName, targetModelName, isList, modelTable, modelsByName, schema, tableMeta, noDb) {
240
255
  const cardinality = isList ? 'many' : 'one';
241
256
  const base = {
@@ -261,15 +276,7 @@ function resolveRelation(model, fieldName, targetModelName, isList, modelTable,
261
276
  // fall back to the ambiguity handling below when there is no relation name or
262
277
  // the named pair cannot be found.
263
278
  if (!fkColumns || fkColumns.length === 0) {
264
- const relName = relationNameOf(field);
265
- const targetModel = modelsByName.get(targetModelName);
266
- if (relName && targetModel) {
267
- const opposing = targetModel.fields.find((f) => f.type === model.name &&
268
- relationNameOf(f) === relName &&
269
- (relationFkColumns(targetModel, f)?.length ?? 0) > 0);
270
- if (opposing)
271
- fkColumns = relationFkColumns(targetModel, opposing);
272
- }
279
+ fkColumns = pairedInverseFkColumns(model, field, targetModelName, modelsByName);
273
280
  }
274
281
  const candidates = Object.values(tableMeta.relations).filter((def) => {
275
282
  if (targetTable && def.to !== targetTable)
@@ -299,6 +306,36 @@ function resolveRelation(model, fieldName, targetModelName, isList, modelTable,
299
306
  };
300
307
  }
301
308
  if (picked.length > 1) {
309
+ // Elimination pass: an UNNAMED pair can still resolve when every sibling
310
+ // relation field to the same target pins a different candidate (via its own
311
+ // `fields: [...]` or @relation("Name") pairing). Subtract those consumed
312
+ // candidates; exactly one survivor means the unnamed pair is unambiguous by
313
+ // elimination, which is how Prisma itself resolves it.
314
+ const consumed = new Set();
315
+ for (const sibling of model.fields) {
316
+ if (sibling.name === fieldName || sibling.type !== targetModelName)
317
+ continue;
318
+ const sibFks = relationFkColumns(model, sibling) ?? pairedInverseFkColumns(model, sibling, targetModelName, modelsByName);
319
+ if (!sibFks || sibFks.length === 0)
320
+ continue;
321
+ const sibWant = [...sibFks].sort().join(',');
322
+ for (const def of candidates) {
323
+ const fk = Array.isArray(def.foreignKey) ? def.foreignKey : [def.foreignKey];
324
+ if ([...fk].sort().join(',') === sibWant)
325
+ consumed.add(def.name);
326
+ }
327
+ }
328
+ const surviving = picked.filter((def) => !consumed.has(def.name));
329
+ if (surviving.length === 1) {
330
+ const def = surviving[0];
331
+ return {
332
+ ...base,
333
+ turbineName: def.name,
334
+ cardinality,
335
+ junction: def.type === 'manyToMany' ? def.through?.table : undefined,
336
+ status: 'resolved',
337
+ };
338
+ }
302
339
  return {
303
340
  ...base,
304
341
  reason: `ambiguous - ${picked.length} candidate relations match (${picked.map((d) => d.name).join(', ')})`,
@@ -412,9 +412,9 @@ class TurbineClient {
412
412
  }
413
413
  else {
414
414
  const poolConfig = {
415
- max: config.poolSize ?? 10,
416
- idleTimeoutMillis: config.idleTimeoutMs ?? 30_000,
417
- connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
415
+ max: config.poolSize ?? config.max ?? 10,
416
+ idleTimeoutMillis: config.idleTimeoutMs ?? config.idleTimeoutMillis ?? 30_000,
417
+ connectionTimeoutMillis: config.connectionTimeoutMs ?? config.connectionTimeoutMillis ?? 5_000,
418
418
  };
419
419
  // Did the caller supply ANY explicit connection target? If not, and a
420
420
  // DATABASE_URL is present in the environment, fall back to it so
@@ -463,9 +463,9 @@ class TurbineClient {
463
463
  if (typeof replica === 'string') {
464
464
  const replicaPool = new pg_1.default.Pool({
465
465
  connectionString: replica,
466
- max: config.poolSize ?? 10,
467
- idleTimeoutMillis: config.idleTimeoutMs ?? 30_000,
468
- connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
466
+ max: config.poolSize ?? config.max ?? 10,
467
+ idleTimeoutMillis: config.idleTimeoutMs ?? config.idleTimeoutMillis ?? 30_000,
468
+ connectionTimeoutMillis: config.connectionTimeoutMs ?? config.connectionTimeoutMillis ?? 5_000,
469
469
  ...(config.ssl !== undefined ? { ssl: config.ssl } : {}),
470
470
  });
471
471
  replicaPool.on('error', (err) => {
@@ -151,7 +151,16 @@ function decorate(err, prismaErrorCodes) {
151
151
  }
152
152
  return err;
153
153
  }
154
- function buildLookups(mm) {
154
+ const TIME_PG_TYPES = new Set(['time', 'time without time zone', 'timetz', 'time with time zone']);
155
+ /** `HH:MM:SS(.fff)?` (a pg `time` wire value) → Prisma's epoch-day `Date`. */
156
+ function timeStringToDate(v) {
157
+ const m = v.match(/^(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/);
158
+ if (!m)
159
+ return null;
160
+ const ms = m[4] ? Math.round(Number(`0.${m[4]}`) * 1000) : 0;
161
+ return new Date(Date.UTC(1970, 0, 1, Number(m[1]), Number(m[2]), Number(m[3]), ms));
162
+ }
163
+ function buildLookups(ctx, mm) {
155
164
  const reverseFields = {};
156
165
  let identityFields = true;
157
166
  for (const [prismaField, turbineField] of Object.entries(mm.fields)) {
@@ -163,12 +172,17 @@ function buildLookups(mm) {
163
172
  for (const [prismaRel, rel] of Object.entries(mm.relations)) {
164
173
  reverseRelations[rel.name] = { prismaName: prismaRel, cardinality: rel.cardinality };
165
174
  }
166
- return { reverseFields, identityFields, reverseRelations };
175
+ const timeFields = new Set();
176
+ for (const col of ctx.schema.tables[mm.table]?.columns ?? []) {
177
+ if (TIME_PG_TYPES.has(col.pgType))
178
+ timeFields.add(col.field);
179
+ }
180
+ return { reverseFields, identityFields, reverseRelations, timeFields };
167
181
  }
168
182
  function lookupsFor(ctx, mm) {
169
183
  let l = ctx.lookups.get(mm.table);
170
184
  if (!l) {
171
- l = buildLookups(mm);
185
+ l = buildLookups(ctx, mm);
172
186
  ctx.lookups.set(mm.table, l);
173
187
  }
174
188
  return l;
@@ -724,7 +738,11 @@ function reshapeRow(ctx, mm, row) {
724
738
  out[rel.prismaName] = rv;
725
739
  continue;
726
740
  }
727
- out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = val;
741
+ let sv = val;
742
+ if (typeof sv === 'string' && l.timeFields.has(key)) {
743
+ sv = timeStringToDate(sv) ?? sv;
744
+ }
745
+ out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = sv;
728
746
  }
729
747
  return out;
730
748
  }
@@ -756,7 +774,11 @@ function reshapeAggregate(ctx, mm, res) {
756
774
  out[key] = reshapeAggFieldBlock(l, val, false);
757
775
  continue;
758
776
  }
759
- out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = val;
777
+ let sv = val;
778
+ if (typeof sv === 'string' && l.timeFields.has(key)) {
779
+ sv = timeStringToDate(sv) ?? sv;
780
+ }
781
+ out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = sv;
760
782
  }
761
783
  return out;
762
784
  }
@@ -788,7 +810,11 @@ function reshapeGroupRow(ctx, mm, row) {
788
810
  out[key] = reshapeAggFieldBlock(l, val, false);
789
811
  continue;
790
812
  }
791
- out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = val;
813
+ let sv = val;
814
+ if (typeof sv === 'string' && l.timeFields.has(key)) {
815
+ sv = timeStringToDate(sv) ?? sv;
816
+ }
817
+ out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = sv;
792
818
  }
793
819
  return out;
794
820
  }
@@ -868,7 +868,44 @@ function buildRelationFilter(qi, _relName, relDef, filterObj, params, parentTabl
868
868
  const clauses = [];
869
869
  // Correlation: link child table to parent table (supports composite FKs)
870
870
  let correlation;
871
- if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
871
+ if (relDef.type === 'manyToMany') {
872
+ // The target row is related iff a junction row links it to the parent.
873
+ // Direct FK correlation (the other branches) would compile target.pk =
874
+ // parent.pk and silently match nothing, so route through the junction:
875
+ // EXISTS (SELECT 1 FROM junction
876
+ // WHERE junction.targetKey = target.pk AND junction.sourceKey = parent.ref)
877
+ // All bare table names (no aliases), so the scoped sub-where machinery and
878
+ // nested relation filters inside the branch keep their qualification. The
879
+ // fragment binds no params, so collectRelationFilterParams needs no mirror.
880
+ if (!relDef.through) {
881
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
882
+ }
883
+ const qJunction = qi.q(relDef.through.table);
884
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
885
+ const targetPk = targetMeta.primaryKey;
886
+ if (targetPk.length === 0) {
887
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
888
+ `cannot correlate the relation filter through the junction.`);
889
+ }
890
+ if (targetKeys.length !== targetPk.length) {
891
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
892
+ `but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
893
+ }
894
+ const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
895
+ const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
896
+ if (sourceKeys.length !== refKeys.length) {
897
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
898
+ `but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
899
+ }
900
+ const targetLink = targetKeys
901
+ .map((jcol, i) => `${qJunction}.${qi.q(jcol)} = ${qt}.${qi.q(targetPk[i])}`)
902
+ .join(' AND ');
903
+ const parentLink = sourceKeys
904
+ .map((jcol, i) => `${qJunction}.${qi.q(jcol)} = ${qSelf}.${qi.q(refKeys[i])}`)
905
+ .join(' AND ');
906
+ correlation = `EXISTS (SELECT 1 FROM ${qJunction} WHERE ${targetLink} AND ${parentLink})`;
907
+ }
908
+ else if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
872
909
  // parent.pk = child.fk
873
910
  correlation = qi.dialect.buildCorrelation(qt, relDef.foreignKey, qSelf, relDef.referenceKey);
874
911
  }
@@ -231,6 +231,21 @@ function relationFkColumns(model, field) {
231
231
  const fieldsArg = relAttr?.args.find((a) => a.key === 'fields' && a.kind === 'array');
232
232
  return fieldsArg?.items?.map((pf) => fieldColumn(model, pf)) ?? null;
233
233
  }
234
+ /**
235
+ * FK columns for an INVERSE relation field (one carrying no `fields: [...]`),
236
+ * derived by @relation("Name") pairing: the opposing model's same-named field
237
+ * that owns the FK pins the columns. Returns undefined when there is no
238
+ * relation name or no named counterpart. This is how Prisma disambiguates two
239
+ * or more relations to the same target model.
240
+ */
241
+ function pairedInverseFkColumns(model, field, targetModelName, modelsByName) {
242
+ const relName = relationNameOf(field);
243
+ const targetModel = modelsByName.get(targetModelName);
244
+ if (!relName || !targetModel)
245
+ return null;
246
+ const opposing = targetModel.fields.find((f) => f.type === model.name && relationNameOf(f) === relName && (relationFkColumns(targetModel, f)?.length ?? 0) > 0);
247
+ return opposing ? relationFkColumns(targetModel, opposing) : null;
248
+ }
234
249
  function resolveRelation(model, fieldName, targetModelName, isList, modelTable, modelsByName, schema, tableMeta, noDb) {
235
250
  const cardinality = isList ? 'many' : 'one';
236
251
  const base = {
@@ -256,15 +271,7 @@ function resolveRelation(model, fieldName, targetModelName, isList, modelTable,
256
271
  // fall back to the ambiguity handling below when there is no relation name or
257
272
  // the named pair cannot be found.
258
273
  if (!fkColumns || fkColumns.length === 0) {
259
- const relName = relationNameOf(field);
260
- const targetModel = modelsByName.get(targetModelName);
261
- if (relName && targetModel) {
262
- const opposing = targetModel.fields.find((f) => f.type === model.name &&
263
- relationNameOf(f) === relName &&
264
- (relationFkColumns(targetModel, f)?.length ?? 0) > 0);
265
- if (opposing)
266
- fkColumns = relationFkColumns(targetModel, opposing);
267
- }
274
+ fkColumns = pairedInverseFkColumns(model, field, targetModelName, modelsByName);
268
275
  }
269
276
  const candidates = Object.values(tableMeta.relations).filter((def) => {
270
277
  if (targetTable && def.to !== targetTable)
@@ -294,6 +301,36 @@ function resolveRelation(model, fieldName, targetModelName, isList, modelTable,
294
301
  };
295
302
  }
296
303
  if (picked.length > 1) {
304
+ // Elimination pass: an UNNAMED pair can still resolve when every sibling
305
+ // relation field to the same target pins a different candidate (via its own
306
+ // `fields: [...]` or @relation("Name") pairing). Subtract those consumed
307
+ // candidates; exactly one survivor means the unnamed pair is unambiguous by
308
+ // elimination, which is how Prisma itself resolves it.
309
+ const consumed = new Set();
310
+ for (const sibling of model.fields) {
311
+ if (sibling.name === fieldName || sibling.type !== targetModelName)
312
+ continue;
313
+ const sibFks = relationFkColumns(model, sibling) ?? pairedInverseFkColumns(model, sibling, targetModelName, modelsByName);
314
+ if (!sibFks || sibFks.length === 0)
315
+ continue;
316
+ const sibWant = [...sibFks].sort().join(',');
317
+ for (const def of candidates) {
318
+ const fk = Array.isArray(def.foreignKey) ? def.foreignKey : [def.foreignKey];
319
+ if ([...fk].sort().join(',') === sibWant)
320
+ consumed.add(def.name);
321
+ }
322
+ }
323
+ const surviving = picked.filter((def) => !consumed.has(def.name));
324
+ if (surviving.length === 1) {
325
+ const def = surviving[0];
326
+ return {
327
+ ...base,
328
+ turbineName: def.name,
329
+ cardinality,
330
+ junction: def.type === 'manyToMany' ? def.through?.table : undefined,
331
+ status: 'resolved',
332
+ };
333
+ }
297
334
  return {
298
335
  ...base,
299
336
  reason: `ambiguous - ${picked.length} candidate relations match (${picked.map((d) => d.name).join(', ')})`,
package/dist/client.d.ts CHANGED
@@ -158,6 +158,12 @@ export interface TurbineConfig {
158
158
  idleTimeoutMs?: number;
159
159
  /** Connection timeout in ms (default: 5000) */
160
160
  connectionTimeoutMs?: number;
161
+ /** pg-style alias for {@link poolSize}; the explicit field wins when both are set. */
162
+ max?: number;
163
+ /** pg-style alias for {@link idleTimeoutMs}; the explicit field wins when both are set. */
164
+ idleTimeoutMillis?: number;
165
+ /** pg-style alias for {@link connectionTimeoutMs}; the explicit field wins when both are set. */
166
+ connectionTimeoutMillis?: number;
161
167
  /** Enable query logging to console (default: false) */
162
168
  logging?: boolean;
163
169
  /** Default LIMIT applied to findMany() when no limit is specified (opt-in, default: undefined) */
package/dist/client.js CHANGED
@@ -404,9 +404,9 @@ export class TurbineClient {
404
404
  }
405
405
  else {
406
406
  const poolConfig = {
407
- max: config.poolSize ?? 10,
408
- idleTimeoutMillis: config.idleTimeoutMs ?? 30_000,
409
- connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
407
+ max: config.poolSize ?? config.max ?? 10,
408
+ idleTimeoutMillis: config.idleTimeoutMs ?? config.idleTimeoutMillis ?? 30_000,
409
+ connectionTimeoutMillis: config.connectionTimeoutMs ?? config.connectionTimeoutMillis ?? 5_000,
410
410
  };
411
411
  // Did the caller supply ANY explicit connection target? If not, and a
412
412
  // DATABASE_URL is present in the environment, fall back to it so
@@ -455,9 +455,9 @@ export class TurbineClient {
455
455
  if (typeof replica === 'string') {
456
456
  const replicaPool = new pg.Pool({
457
457
  connectionString: replica,
458
- max: config.poolSize ?? 10,
459
- idleTimeoutMillis: config.idleTimeoutMs ?? 30_000,
460
- connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
458
+ max: config.poolSize ?? config.max ?? 10,
459
+ idleTimeoutMillis: config.idleTimeoutMs ?? config.idleTimeoutMillis ?? 30_000,
460
+ connectionTimeoutMillis: config.connectionTimeoutMs ?? config.connectionTimeoutMillis ?? 5_000,
461
461
  ...(config.ssl !== undefined ? { ssl: config.ssl } : {}),
462
462
  });
463
463
  replicaPool.on('error', (err) => {
@@ -147,7 +147,16 @@ function decorate(err, prismaErrorCodes) {
147
147
  }
148
148
  return err;
149
149
  }
150
- function buildLookups(mm) {
150
+ const TIME_PG_TYPES = new Set(['time', 'time without time zone', 'timetz', 'time with time zone']);
151
+ /** `HH:MM:SS(.fff)?` (a pg `time` wire value) → Prisma's epoch-day `Date`. */
152
+ function timeStringToDate(v) {
153
+ const m = v.match(/^(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/);
154
+ if (!m)
155
+ return null;
156
+ const ms = m[4] ? Math.round(Number(`0.${m[4]}`) * 1000) : 0;
157
+ return new Date(Date.UTC(1970, 0, 1, Number(m[1]), Number(m[2]), Number(m[3]), ms));
158
+ }
159
+ function buildLookups(ctx, mm) {
151
160
  const reverseFields = {};
152
161
  let identityFields = true;
153
162
  for (const [prismaField, turbineField] of Object.entries(mm.fields)) {
@@ -159,12 +168,17 @@ function buildLookups(mm) {
159
168
  for (const [prismaRel, rel] of Object.entries(mm.relations)) {
160
169
  reverseRelations[rel.name] = { prismaName: prismaRel, cardinality: rel.cardinality };
161
170
  }
162
- return { reverseFields, identityFields, reverseRelations };
171
+ const timeFields = new Set();
172
+ for (const col of ctx.schema.tables[mm.table]?.columns ?? []) {
173
+ if (TIME_PG_TYPES.has(col.pgType))
174
+ timeFields.add(col.field);
175
+ }
176
+ return { reverseFields, identityFields, reverseRelations, timeFields };
163
177
  }
164
178
  function lookupsFor(ctx, mm) {
165
179
  let l = ctx.lookups.get(mm.table);
166
180
  if (!l) {
167
- l = buildLookups(mm);
181
+ l = buildLookups(ctx, mm);
168
182
  ctx.lookups.set(mm.table, l);
169
183
  }
170
184
  return l;
@@ -720,7 +734,11 @@ function reshapeRow(ctx, mm, row) {
720
734
  out[rel.prismaName] = rv;
721
735
  continue;
722
736
  }
723
- out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = val;
737
+ let sv = val;
738
+ if (typeof sv === 'string' && l.timeFields.has(key)) {
739
+ sv = timeStringToDate(sv) ?? sv;
740
+ }
741
+ out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = sv;
724
742
  }
725
743
  return out;
726
744
  }
@@ -752,7 +770,11 @@ function reshapeAggregate(ctx, mm, res) {
752
770
  out[key] = reshapeAggFieldBlock(l, val, false);
753
771
  continue;
754
772
  }
755
- out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = val;
773
+ let sv = val;
774
+ if (typeof sv === 'string' && l.timeFields.has(key)) {
775
+ sv = timeStringToDate(sv) ?? sv;
776
+ }
777
+ out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = sv;
756
778
  }
757
779
  return out;
758
780
  }
@@ -784,7 +806,11 @@ function reshapeGroupRow(ctx, mm, row) {
784
806
  out[key] = reshapeAggFieldBlock(l, val, false);
785
807
  continue;
786
808
  }
787
- out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = val;
809
+ let sv = val;
810
+ if (typeof sv === 'string' && l.timeFields.has(key)) {
811
+ sv = timeStringToDate(sv) ?? sv;
812
+ }
813
+ out[l.identityFields ? key : (l.reverseFields[key] ?? key)] = sv;
788
814
  }
789
815
  return out;
790
816
  }
@@ -11,7 +11,7 @@
11
11
  * primitives this module needs. See builder.ts for the thin delegating methods.
12
12
  */
13
13
  import { UnsupportedFeatureError, ValidationError } from '../errors.js';
14
- import { camelToSnake } from '../schema.js';
14
+ import { camelToSnake, normalizeKeyColumns } from '../schema.js';
15
15
  import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, isArrayFilter, isColumnRef, isJsonFilter, isUnmatchedPlainObject, isWhereOperator, JSON_RANGE_OPERATORS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
16
16
  import { escapeLike, OPERATOR_KEYS, ownLookup } from './utils.js';
17
17
  import { classifyScalarForSql, fingerprintScalarToken, walkWhere, } from './where-compile.js';
@@ -808,7 +808,44 @@ export function buildRelationFilter(qi, _relName, relDef, filterObj, params, par
808
808
  const clauses = [];
809
809
  // Correlation: link child table to parent table (supports composite FKs)
810
810
  let correlation;
811
- if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
811
+ if (relDef.type === 'manyToMany') {
812
+ // The target row is related iff a junction row links it to the parent.
813
+ // Direct FK correlation (the other branches) would compile target.pk =
814
+ // parent.pk and silently match nothing, so route through the junction:
815
+ // EXISTS (SELECT 1 FROM junction
816
+ // WHERE junction.targetKey = target.pk AND junction.sourceKey = parent.ref)
817
+ // All bare table names (no aliases), so the scoped sub-where machinery and
818
+ // nested relation filters inside the branch keep their qualification. The
819
+ // fragment binds no params, so collectRelationFilterParams needs no mirror.
820
+ if (!relDef.through) {
821
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
822
+ }
823
+ const qJunction = qi.q(relDef.through.table);
824
+ const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
825
+ const targetPk = targetMeta.primaryKey;
826
+ if (targetPk.length === 0) {
827
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
828
+ `cannot correlate the relation filter through the junction.`);
829
+ }
830
+ if (targetKeys.length !== targetPk.length) {
831
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
832
+ `but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
833
+ }
834
+ const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
835
+ const refKeys = normalizeKeyColumns(relDef.referenceKey);
836
+ if (sourceKeys.length !== refKeys.length) {
837
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
838
+ `but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
839
+ }
840
+ const targetLink = targetKeys
841
+ .map((jcol, i) => `${qJunction}.${qi.q(jcol)} = ${qt}.${qi.q(targetPk[i])}`)
842
+ .join(' AND ');
843
+ const parentLink = sourceKeys
844
+ .map((jcol, i) => `${qJunction}.${qi.q(jcol)} = ${qSelf}.${qi.q(refKeys[i])}`)
845
+ .join(' AND ');
846
+ correlation = `EXISTS (SELECT 1 FROM ${qJunction} WHERE ${targetLink} AND ${parentLink})`;
847
+ }
848
+ else if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
812
849
  // parent.pk = child.fk
813
850
  correlation = qi.dialect.buildCorrelation(qt, relDef.foreignKey, qSelf, relDef.referenceKey);
814
851
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {