turbine-orm 0.37.0 → 0.38.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.
@@ -49,6 +49,7 @@
49
49
  import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
50
50
  import { normalizeKeyColumns } from '../schema.js';
51
51
  import { isRelationPickOrderBy } from './filters.js';
52
+ import { ownLookup } from './utils.js';
52
53
  /**
53
54
  * Max parent keys per follow-up query. On Postgres the whole key set travels as
54
55
  * ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit — it
@@ -123,7 +124,7 @@ export function neededParentKeyFields(parentMeta, withClause) {
123
124
  }
124
125
  continue;
125
126
  }
126
- const rel = parentMeta.relations[relName];
127
+ const rel = ownLookup(parentMeta.relations, relName);
127
128
  if (!rel)
128
129
  continue; // unknown relation — the join path throws; let the loader surface it
129
130
  for (const col of localKeyColumns(rel)) {
@@ -161,7 +162,7 @@ export function resolveCountRelations(parentMeta, countSpec) {
161
162
  for (const [relName, enabled] of Object.entries(countSpec)) {
162
163
  if (!enabled)
163
164
  continue;
164
- const rel = parentMeta.relations[relName];
165
+ const rel = ownLookup(parentMeta.relations, relName);
165
166
  if (!rel) {
166
167
  throw new RelationError(`[turbine] Unknown relation "${relName}" in _count on table "${parentMeta.name}". ` +
167
168
  `Available: ${Object.keys(parentMeta.relations).join(', ')}`);
@@ -232,7 +233,7 @@ export async function loadRelationsBatched(ctx, parents, withClause, timeout, de
232
233
  loads.push(loadCounts(ctx, parents, spec));
233
234
  continue;
234
235
  }
235
- const rel = ctx.parentMeta.relations[relName];
236
+ const rel = ownLookup(ctx.parentMeta.relations, relName);
236
237
  if (!rel) {
237
238
  throw new ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
238
239
  `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
@@ -18,7 +18,7 @@ import * as aggMod from './aggregates.js';
18
18
  import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, stripFields, } from './batched-loader.js';
19
19
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, sortedEntries, } from './filters.js';
20
20
  import * as relationsMod from './relations.js';
21
- import { LRUCache, parseDbDate, sqlToPreparedName } from './utils.js';
21
+ import { LRUCache, ownLookup, parseDbDate, sqlToPreparedName } from './utils.js';
22
22
  import * as whereMod from './where.js';
23
23
  import * as writesMod from './writes.js';
24
24
  /**
@@ -856,7 +856,7 @@ export class QueryInterface {
856
856
  !whereObj.NOT &&
857
857
  whereKeys.every((k) => {
858
858
  const v = whereObj[k];
859
- return v !== null && !isWhereOperator(v) && !this.tableMeta.relations[k];
859
+ return v !== null && !isWhereOperator(v) && !ownLookup(this.tableMeta.relations, k);
860
860
  });
861
861
  // Simple path: plain equality, no operators/null/OR
862
862
  if (!args.with && isSimpleWhere) {
@@ -1095,7 +1095,7 @@ export class QueryInterface {
1095
1095
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
1096
1096
  const orderFp = args?.orderBy
1097
1097
  ? Object.entries(args.orderBy)
1098
- .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations[k]?.to)}`)
1098
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, ownLookup(this.tableMeta.relations, k)?.to)}`)
1099
1099
  .join(',')
1100
1100
  : '';
1101
1101
  const cursorFp = args?.cursor
@@ -1681,7 +1681,11 @@ export class QueryInterface {
1681
1681
  }
1682
1682
  /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
1683
1683
  toColumn(field) {
1684
- const mapped = this.tableMeta.columnMap[field];
1684
+ // Prototype-safe lookup: a plain-object `columnMap` would otherwise return
1685
+ // an inherited member (e.g. Object.prototype.constructor) for a field named
1686
+ // "constructor" / "toString" / "__proto__", bypassing the unknown-field
1687
+ // check below and returning a non-string as the column name.
1688
+ const mapped = ownLookup(this.tableMeta.columnMap, field);
1685
1689
  if (mapped)
1686
1690
  return mapped;
1687
1691
  // Fall back to camelToSnake ONLY if that snake_cased name also exists as a
@@ -1691,7 +1695,7 @@ export class QueryInterface {
1691
1695
  // SQL injection and catching typos like `where: { emial: 'x' }` with a
1692
1696
  // clear error instead of a cryptic Postgres "column does not exist".
1693
1697
  const snake = camelToSnake(field);
1694
- if (this.tableMeta.reverseColumnMap?.[snake]) {
1698
+ if (this.tableMeta.reverseColumnMap && ownLookup(this.tableMeta.reverseColumnMap, snake)) {
1695
1699
  return snake;
1696
1700
  }
1697
1701
  if (this.tableMeta.allColumns?.includes(snake)) {
@@ -1786,7 +1790,7 @@ export class QueryInterface {
1786
1790
  // pick.where / pick.orderBy paths). To-one relation orderBy carries the
1787
1791
  // target's global filter once per ordered column.
1788
1792
  if (this.isRelationOrderByValue(dir)) {
1789
- const relDef = this.tableMeta.relations[key];
1793
+ const relDef = ownLookup(this.tableMeta.relations, key);
1790
1794
  if (relDef && isRelationPickOrderBy(dir)) {
1791
1795
  this.collectRelationPickOrderParams(key, relDef, dir, params);
1792
1796
  }
@@ -17,6 +17,7 @@ import { missingIndexForRelation } from '../index-advisor.js';
17
17
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import { resolveCountRelations } from './batched-loader.js';
19
19
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, sortedEntries, } from './filters.js';
20
+ import { ownLookup } from './utils.js';
20
21
  import * as whereMod from './where.js';
21
22
  import * as writesMod from './writes.js';
22
23
  /** Relations already warned about missing FK indexes (once per process, dev only). */
@@ -317,10 +318,10 @@ export function buildOrderBy(qi, orderBy, params, lateralSink) {
317
318
  // are validated in the relation branch below, so skip them here.
318
319
  if (process.env.NODE_ENV !== 'production') {
319
320
  for (const [key, value] of Object.entries(orderBy)) {
320
- if (isRelationOrderByValue(qi, value) && qi.tableMeta.relations[key])
321
+ if (isRelationOrderByValue(qi, value) && ownLookup(qi.tableMeta.relations, key))
321
322
  continue;
322
323
  const snakeKey = camelToSnake(key);
323
- if (!qi.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in qi.tableMeta.columnMap)) {
324
+ if (!qi.tableMeta.columns.some((c) => c.name === snakeKey) && !Object.hasOwn(qi.tableMeta.columnMap, key)) {
324
325
  console.warn(`[turbine] Unknown orderBy field "${key}" for table "${qi.tableMeta.name}". ` +
325
326
  'This will cause a runtime error.');
326
327
  }
@@ -402,7 +403,7 @@ export function nullsSuffix(qi, nulls) {
402
403
  * camelCase-named DB columns like "sortOrder").
403
404
  */
404
405
  export function resolveOrderByColumn(_qi, table, meta, key) {
405
- const col = meta.columnMap[key] ?? camelToSnake(key);
406
+ const col = ownLookup(meta.columnMap, key) ?? camelToSnake(key);
406
407
  if (!meta.allColumns.includes(col)) {
407
408
  throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
408
409
  `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
@@ -520,7 +521,7 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
520
521
  .map(([col, dirValue]) => {
521
522
  // columnMap-first resolution (camelToSnake fallback): mirrors the
522
523
  // scalar orderBy path so camelCase-named DB columns resolve here too.
523
- const snakeCol = targetMeta.columnMap[col] ?? camelToSnake(col);
524
+ const snakeCol = ownLookup(targetMeta.columnMap, col) ?? camelToSnake(col);
524
525
  if (!targetMeta.allColumns.includes(snakeCol)) {
525
526
  throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
526
527
  }
@@ -988,7 +989,7 @@ export function resolveTargetColumns(qi, spec, targetMeta, includePii) {
988
989
  // opt-in and comes back regardless of the query's `includePii`.
989
990
  const selectedFields = Object.entries(spec.select)
990
991
  .filter(([, v]) => v)
991
- .map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k));
992
+ .map(([k]) => ownLookup(targetMeta.columnMap, k) ?? camelToSnake(k));
992
993
  return selectedFields.filter((col) => targetMeta.allColumns.includes(col));
993
994
  }
994
995
  // Default / omit-only relation projection: PII columns are excluded unless
@@ -998,7 +999,7 @@ export function resolveTargetColumns(qi, spec, targetMeta, includePii) {
998
999
  if (spec !== true && spec.omit) {
999
1000
  const omittedFields = new Set(Object.entries(spec.omit)
1000
1001
  .filter(([, v]) => v)
1001
- .map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k)));
1002
+ .map(([k]) => ownLookup(targetMeta.columnMap, k) ?? camelToSnake(k)));
1002
1003
  return targetMeta.allColumns.filter((col) => !omittedFields.has(col) && !(hasPii && piiCols.has(col)));
1003
1004
  }
1004
1005
  if (hasPii) {
@@ -13,6 +13,16 @@
13
13
  * quoteIdent('user name') → '"user name"'
14
14
  */
15
15
  export declare function quoteIdent(name: string): string;
16
+ /**
17
+ * Prototype-safe own-property read for the plain metadata maps (columnMap,
18
+ * relations, reverseColumnMap). These are constructed as plain objects, so a
19
+ * bare `map[key]` for a user-supplied field name like "constructor",
20
+ * "toString", or "__proto__" returns an inherited member from
21
+ * `Object.prototype` — a truthy value that slips past validation and produces a
22
+ * cryptic `TypeError` instead of a clean `ValidationError`. Returns `undefined`
23
+ * unless `key` is an OWN enumerable/non-enumerable property.
24
+ */
25
+ export declare function ownLookup<T>(map: Record<string, T>, key: string): T | undefined;
16
26
  /**
17
27
  * Escape single quotes for use as string keys in json_build_object().
18
28
  * Doubles single quotes per SQL quoting rules.
@@ -18,6 +18,18 @@
18
18
  export function quoteIdent(name) {
19
19
  return `"${name.replace(/"/g, '""')}"`;
20
20
  }
21
+ /**
22
+ * Prototype-safe own-property read for the plain metadata maps (columnMap,
23
+ * relations, reverseColumnMap). These are constructed as plain objects, so a
24
+ * bare `map[key]` for a user-supplied field name like "constructor",
25
+ * "toString", or "__proto__" returns an inherited member from
26
+ * `Object.prototype` — a truthy value that slips past validation and produces a
27
+ * cryptic `TypeError` instead of a clean `ValidationError`. Returns `undefined`
28
+ * unless `key` is an OWN enumerable/non-enumerable property.
29
+ */
30
+ export function ownLookup(map, key) {
31
+ return Object.hasOwn(map, key) ? map[key] : undefined;
32
+ }
21
33
  /**
22
34
  * Escape single quotes for use as string keys in json_build_object().
23
35
  * Doubles single quotes per SQL quoting rules.
@@ -34,6 +34,7 @@
34
34
  * step.
35
35
  */
36
36
  import { findArrayUniqueKey, findJsonUniqueKey, fingerprintArrayFilterShape, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isWhereOperator, sortedKeys, VECTOR_DISTANCE_COMPARATORS, } from './filters.js';
37
+ import { ownLookup } from './utils.js';
37
38
  /** True when a normalized relation filter carries at least one cardinality key. */
38
39
  function isRelationFilterObj(filterObj) {
39
40
  return ('some' in filterObj || 'every' in filterObj || 'none' in filterObj || 'is' in filterObj || 'isNot' in filterObj);
@@ -73,7 +74,7 @@ export function walkWhere(host, where) {
73
74
  events.push({ kind: 'not', condition: value });
74
75
  continue;
75
76
  }
76
- const relDef = host.tableMeta.relations[key];
77
+ const relDef = ownLookup(host.tableMeta.relations, key);
77
78
  if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
78
79
  const filterObj = host.normalizeRelationFilter(relDef, value);
79
80
  if (isRelationFilterObj(filterObj)) {
@@ -13,7 +13,7 @@
13
13
  import { UnsupportedFeatureError, ValidationError } from '../errors.js';
14
14
  import { camelToSnake } 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
- import { escapeLike, OPERATOR_KEYS } from './utils.js';
16
+ import { escapeLike, OPERATOR_KEYS, ownLookup } from './utils.js';
17
17
  import { classifyScalarForSql, fingerprintScalarToken, walkWhere, } from './where-compile.js';
18
18
  /**
19
19
  * Produce a value-invariant fingerprint of a where clause.
@@ -663,7 +663,7 @@ export function buildScopedWhere(qi, scope, where, params) {
663
663
  */
664
664
  export function buildScopedScalarClause(qi, scope, field, value, params, clauses) {
665
665
  const meta = scope.meta;
666
- const col = meta.columnMap[field] ?? camelToSnake(field);
666
+ const col = ownLookup(meta.columnMap, field) ?? camelToSnake(field);
667
667
  if (!meta.allColumns.includes(col))
668
668
  throw scope.unknownColumn(field);
669
669
  const qCol = `${scope.qualifier}${qi.q(col)}`;
@@ -740,7 +740,7 @@ export function collectScopedScalarParams(qi, scope, field, value, params) {
740
740
  if (value === null)
741
741
  return;
742
742
  const meta = scope.meta;
743
- const col = meta.columnMap[field] ?? camelToSnake(field);
743
+ const col = ownLookup(meta.columnMap, field) ?? camelToSnake(field);
744
744
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
745
745
  const colType = pgTypeForColumn(qi, meta, col);
746
746
  if (isJsonColumnType(qi, colType)) {
@@ -1011,7 +1011,7 @@ export function resolveColumnRef(_qi, ref, ctx, mode) {
1011
1011
  `Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
1012
1012
  `for lower(a) = lower(b).`);
1013
1013
  }
1014
- const col = ctx.meta.columnMap[ref.col] ?? camelToSnake(ref.col);
1014
+ const col = ownLookup(ctx.meta.columnMap, ref.col) ?? camelToSnake(ref.col);
1015
1015
  if (!ctx.meta.allColumns.includes(col)) {
1016
1016
  throw new ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
1017
1017
  `Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.37.0",
3
+ "version": "0.38.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": {