turbine-orm 0.52.0 → 0.53.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.
@@ -718,23 +718,29 @@ export interface CountArgs<T, R extends object = {}> {
718
718
  skipGlobalFilters?: SkipGlobalFilters;
719
719
  }
720
720
  /**
721
- * Numeric comparison operators usable inside a `having` filter. A bare number
721
+ * Comparison operators usable inside a `having` aggregate filter. A bare value
722
722
  * is shorthand for equality (`COUNT(*) = $n`); the operator object supports
723
- * range and inequality comparisons. Mirrors the numeric subset of
723
+ * range and inequality comparisons. Mirrors the comparison subset of
724
724
  * {@link WhereOperator} so the same SQL machinery can be reused.
725
+ *
726
+ * `V` is the operand type: `number` for `_sum` / `_avg` / `_count`, the
727
+ * column's own type for `_min` / `_max` (those return a stored cell, so
728
+ * `MIN("title") > 'm'` is as valid as `MIN("views") > 10`).
725
729
  */
726
- export interface HavingNumericOperator {
727
- equals?: number;
728
- not?: number;
729
- gt?: number;
730
- gte?: number;
731
- lt?: number;
732
- lte?: number;
733
- in?: number[];
734
- notIn?: number[];
730
+ export interface HavingComparisonOperator<V = number> {
731
+ equals?: V;
732
+ not?: V;
733
+ gt?: V;
734
+ gte?: V;
735
+ lt?: V;
736
+ lte?: V;
737
+ in?: V[];
738
+ notIn?: V[];
735
739
  }
736
- /** A single having predicate value: a bare number (equality) or an operator object. */
737
- export type HavingFilter = number | HavingNumericOperator;
740
+ /** The number-operand spelling, kept as the historical public name. */
741
+ export type HavingNumericOperator = HavingComparisonOperator<number>;
742
+ /** A single having predicate value: a bare value (equality) or an operator object. */
743
+ export type HavingFilter<V = number> = V | HavingComparisonOperator<V>;
738
744
  /**
739
745
  * Per-field aggregate filters inside a {@link HavingClause}. Each aggregate
740
746
  * function maps to a {@link HavingFilter} comparison on that field.
@@ -742,33 +748,52 @@ export type HavingFilter = number | HavingNumericOperator;
742
748
  * @example
743
749
  * viewCount: { _sum: { gt: 100 }, _avg: { lte: 50 } }
744
750
  */
745
- export interface HavingAggregateFilter {
751
+ export interface HavingAggregateFilter<V = unknown> {
746
752
  _sum?: HavingFilter;
747
753
  _avg?: HavingFilter;
748
- _min?: HavingFilter;
749
- _max?: HavingFilter;
754
+ _min?: HavingFilter<V>;
755
+ _max?: HavingFilter<V>;
750
756
  _count?: HavingFilter;
751
757
  }
758
+ /**
759
+ * One field entry of a {@link HavingClause}. Following Prisma, a field accepts
760
+ * BOTH an aggregate filter (`{ _sum: { gt: 100 } }`) and a scalar filter on the
761
+ * grouped value itself (`{ not: null }`, `{ in: ['a', 'b'] }`, or a bare value
762
+ * as equality shorthand), including both in the same object, which are ANDed.
763
+ *
764
+ * A scalar filter is only legal on a column listed in `by`: the group's value
765
+ * is constant within the group, so it compiles into HAVING against the bare
766
+ * group key. On any other column it throws {@link ValidationError} E003, since
767
+ * a non-grouped column cannot be referenced in HAVING at all.
768
+ */
769
+ export type HavingFieldFilter<V = unknown> = (HavingAggregateFilter<V> & WhereOperator<V>) | WhereValue<V>;
752
770
  /**
753
771
  * HAVING clause for `groupBy`, filters whole groups by their aggregate values
754
- * (the SQL `HAVING` clause). Follows Prisma's shape: each aggregable field maps
755
- * to a {@link HavingAggregateFilter} (`field → aggregate → operator → value`),
756
- * and the special top-level `_count` key (no field) filters on `COUNT(*)`.
772
+ * and by the grouped values themselves (the SQL `HAVING` clause). Follows
773
+ * Prisma's shape: each field maps to a {@link HavingFieldFilter}, the special
774
+ * top-level `_count` key (no field) filters on `COUNT(*)`, and `AND` / `OR` /
775
+ * `NOT` combine predicates at any depth.
757
776
  *
758
- * Implemented as a mapped type so the special `_count` key can carry a
759
- * {@link HavingFilter} while every entity field carries a
760
- * {@link HavingAggregateFilter}, without the index-signature conflict an
761
- * intersection type would produce when `T` is a broad `Record<string, unknown>`.
777
+ * Implemented as a mapped type so the special keys can carry their own value
778
+ * types while every entity field carries a {@link HavingFieldFilter}, without
779
+ * the index-signature conflict an intersection type would produce when `T` is
780
+ * a broad `Record<string, unknown>`.
762
781
  *
763
782
  * @example
764
783
  * // groups with more than 5 rows whose summed viewCount is at least 100
765
784
  * having: { _count: { gt: 5 }, viewCount: { _sum: { gte: 100 } } }
785
+ * @example
786
+ * // by: ['typeId'] , drop the NULL group, keep busy groups
787
+ * having: { typeId: { not: null }, _count: { gt: 1 } }
766
788
  */
767
789
  export type HavingClause<T> = {
768
790
  /** Filter on `COUNT(*)` for the whole group. */
769
791
  _count?: HavingFilter;
792
+ AND?: HavingClause<T> | HavingClause<T>[];
793
+ OR?: HavingClause<T>[];
794
+ NOT?: HavingClause<T> | HavingClause<T>[];
770
795
  } & {
771
- [K in keyof T & string]?: HavingAggregateFilter;
796
+ [K in keyof T & string]?: HavingFieldFilter<T[K]>;
772
797
  };
773
798
  /**
774
799
  * A JSON-path group key in {@link GroupByArgs.by}: groups by the value
@@ -23,6 +23,36 @@ export declare function quoteIdent(name: string): string;
23
23
  * unless `key` is an OWN enumerable/non-enumerable property.
24
24
  */
25
25
  export declare function ownLookup<T>(map: Record<string, T>, key: string): T | undefined;
26
+ /** The metadata a key needs to be resolved to a column. `TableMetadata` fits. */
27
+ export interface ColumnNameSource {
28
+ columnMap: Record<string, string>;
29
+ reverseColumnMap?: Record<string, string>;
30
+ allColumns?: string[];
31
+ }
32
+ /**
33
+ * Resolve a user-supplied key to its unquoted column name, or `undefined` when
34
+ * the key names no column on the table.
35
+ *
36
+ * THE key-resolution rule, in one place. `QueryInterface.toColumn` is this
37
+ * function plus the E003 throw, so every SQL builder resolves keys through it,
38
+ * and the value-side passes (write coercion, the `updatedAt` injector, the
39
+ * nested-write foreign-key merge) call it directly rather than re-deriving the
40
+ * rule. They used to read `columnMap` alone, which knows only the FIELD
41
+ * spelling, so a key spelled as the snake_case COLUMN, which the SQL builders
42
+ * accept and which is the natural spelling on an introspected schema, produced
43
+ * correct SQL with an unprocessed value: byte-identical statement, silently
44
+ * different bound param.
45
+ *
46
+ * The rule: the field map first, else `camelToSnake(key)` accepted ONLY when
47
+ * that name is a real column. `camelToSnake` is idempotent on an already-snake
48
+ * string, which is what makes the column spelling legal; arbitrary strings
49
+ * still fail to resolve, so identifier validation is unchanged.
50
+ *
51
+ * Prototype-safe: both maps are plain objects, so a key like "constructor" or
52
+ * "__proto__" would otherwise return an inherited member and pass for a column
53
+ * name (see {@link ownLookup}).
54
+ */
55
+ export declare function resolveColumnName(meta: ColumnNameSource, key: string): string | undefined;
26
56
  /**
27
57
  * Escape single quotes for use as string keys in json_build_object().
28
58
  * Doubles single quotes per SQL quoting rules.
@@ -4,7 +4,7 @@
4
4
  * Standalone utility functions and classes used by the query builder.
5
5
  */
6
6
  import pg from 'pg';
7
- import { localDateTimeKind, timeOfDayKind } from '../schema.js';
7
+ import { camelToSnake, localDateTimeKind, timeOfDayKind } from '../schema.js';
8
8
  // ---------------------------------------------------------------------------
9
9
  // Identifier quoting, prevents SQL injection via table/column names
10
10
  // ---------------------------------------------------------------------------
@@ -32,6 +32,40 @@ export function quoteIdent(name) {
32
32
  export function ownLookup(map, key) {
33
33
  return Object.hasOwn(map, key) ? map[key] : undefined;
34
34
  }
35
+ /**
36
+ * Resolve a user-supplied key to its unquoted column name, or `undefined` when
37
+ * the key names no column on the table.
38
+ *
39
+ * THE key-resolution rule, in one place. `QueryInterface.toColumn` is this
40
+ * function plus the E003 throw, so every SQL builder resolves keys through it,
41
+ * and the value-side passes (write coercion, the `updatedAt` injector, the
42
+ * nested-write foreign-key merge) call it directly rather than re-deriving the
43
+ * rule. They used to read `columnMap` alone, which knows only the FIELD
44
+ * spelling, so a key spelled as the snake_case COLUMN, which the SQL builders
45
+ * accept and which is the natural spelling on an introspected schema, produced
46
+ * correct SQL with an unprocessed value: byte-identical statement, silently
47
+ * different bound param.
48
+ *
49
+ * The rule: the field map first, else `camelToSnake(key)` accepted ONLY when
50
+ * that name is a real column. `camelToSnake` is idempotent on an already-snake
51
+ * string, which is what makes the column spelling legal; arbitrary strings
52
+ * still fail to resolve, so identifier validation is unchanged.
53
+ *
54
+ * Prototype-safe: both maps are plain objects, so a key like "constructor" or
55
+ * "__proto__" would otherwise return an inherited member and pass for a column
56
+ * name (see {@link ownLookup}).
57
+ */
58
+ export function resolveColumnName(meta, key) {
59
+ const mapped = ownLookup(meta.columnMap, key);
60
+ if (mapped)
61
+ return mapped;
62
+ const snake = camelToSnake(key);
63
+ if (meta.reverseColumnMap && ownLookup(meta.reverseColumnMap, snake))
64
+ return snake;
65
+ if (meta.allColumns?.includes(snake))
66
+ return snake;
67
+ return undefined;
68
+ }
35
69
  /**
36
70
  * Escape single quotes for use as string keys in json_build_object().
37
71
  * Doubles single quotes per SQL quoting rules.
@@ -58,6 +58,13 @@ export declare const WARN_NS: {
58
58
  readonly unorderedPage: "unorderedPage";
59
59
  /** PowDB `emitLinks` DDL skips (name/column collision, endpoint drift). */
60
60
  readonly powdbLinks: "powdbLinks";
61
+ /**
62
+ * A key on the object passed as `TurbineConfig` that is not part of the
63
+ * config surface (client.ts `warnUnknownConfigKeys`). Keyed on the unknown
64
+ * key name, so a process that builds many clients from the same misspelled
65
+ * config says it once.
66
+ */
67
+ readonly unknownConfigKey: "unknownConfigKey";
61
68
  /**
62
69
  * `relationLoadStrategy: 'flatten'` was asked for but a relation stayed on the
63
70
  * correlated-subquery path (relations.ts `planFlattenWith`, builder.ts
@@ -93,6 +93,13 @@ export const WARN_NS = {
93
93
  unorderedPage: 'unorderedPage',
94
94
  /** PowDB `emitLinks` DDL skips (name/column collision, endpoint drift). */
95
95
  powdbLinks: 'powdbLinks',
96
+ /**
97
+ * A key on the object passed as `TurbineConfig` that is not part of the
98
+ * config surface (client.ts `warnUnknownConfigKeys`). Keyed on the unknown
99
+ * key name, so a process that builds many clients from the same misspelled
100
+ * config says it once.
101
+ */
102
+ unknownConfigKey: 'unknownConfigKey',
96
103
  /**
97
104
  * `relationLoadStrategy: 'flatten'` was asked for but a relation stayed on the
98
105
  * correlated-subquery path (relations.ts `planFlattenWith`, builder.ts
@@ -112,7 +112,11 @@ export declare function piiFields(_qi: BuilderCtx, meta: TableMetadata): string[
112
112
  * `Date` and lands in UTC on every engine.
113
113
  *
114
114
  * An explicit value always wins, including an explicit `null`: naming the
115
- * column is a statement of intent.
115
+ * column is a statement of intent. "Named" is decided by resolving each data
116
+ * key to its COLUMN, not by matching the field spelling: the SET list resolves
117
+ * the caller's key the same way, so a key spelled as the snake_case column used
118
+ * to be missed here and the injected field assigned the same column a second
119
+ * time (`SET "updated_at" = $1, "updated_at" = $2`, PostgreSQL 42701).
116
120
  */
117
121
  export declare function applyUpdatedAtColumns(qi: BuilderCtx, data: Record<string, unknown>): Record<string, unknown>;
118
122
  export declare function writeReturningColumns(qi: BuilderCtx): ReturningSelection;
@@ -14,7 +14,7 @@ import { NotFoundError, OptimisticLockError, UnsupportedFeatureError, Validation
14
14
  import { camelToSnake, snakeToCamel } from '../schema.js';
15
15
  import { expandCompoundUniqueWhere } from './compound-unique.js';
16
16
  import { isUnmatchedPlainObject, UPDATE_OPERATOR_KEYS } from './filters.js';
17
- import { coerceTemporalValue, ownLookup } from './utils.js';
17
+ import { coerceTemporalValue, resolveColumnName } from './utils.js';
18
18
  import * as whereMod from './where.js';
19
19
  /**
20
20
  * Normalize one `data` value before it is bound as a write param.
@@ -42,10 +42,14 @@ export function coerceWriteValue(qi, key, value) {
42
42
  // Cheap shape check first: the common path costs one check and no lookup.
43
43
  if (!(value instanceof Date) && !Array.isArray(value))
44
44
  return value;
45
- // Non-throwing column resolution: this runs on the cache-HIT param-collect
46
- // path too, where an unknown key must not turn into a different error than
47
- // the build path already raises.
48
- const column = ownLookup(qi.tableMeta.columnMap, key);
45
+ // THE key-resolution rule, shared with `toColumn` (the SQL side of this very
46
+ // statement) so the two can never disagree about which column a key names:
47
+ // resolving through `columnMap` alone missed the snake_case COLUMN spelling
48
+ // that the SQL builders accept, so those writes bound an unprocessed value
49
+ // under an identical statement. Non-throwing, because this also runs on the
50
+ // cache-HIT param-collect path, where an unknown key must not turn into a
51
+ // different error than the build path already raises.
52
+ const column = resolveColumnName(qi.tableMeta, key);
49
53
  if (!column)
50
54
  return value;
51
55
  // Metadata generated by an older Turbine still carries per-column types
@@ -694,22 +698,43 @@ export function piiFields(_qi, meta) {
694
698
  * `Date` and lands in UTC on every engine.
695
699
  *
696
700
  * An explicit value always wins, including an explicit `null`: naming the
697
- * column is a statement of intent.
701
+ * column is a statement of intent. "Named" is decided by resolving each data
702
+ * key to its COLUMN, not by matching the field spelling: the SET list resolves
703
+ * the caller's key the same way, so a key spelled as the snake_case column used
704
+ * to be missed here and the injected field assigned the same column a second
705
+ * time (`SET "updated_at" = $1, "updated_at" = $2`, PostgreSQL 42701).
698
706
  */
699
707
  export function applyUpdatedAtColumns(qi, data) {
700
708
  const tagged = qi.tableMeta.columns.filter((c) => c.updatedAt);
701
709
  if (tagged.length === 0)
702
710
  return data;
711
+ const named = namedColumns(qi.tableMeta, data);
703
712
  let out = null;
704
713
  const now = new Date();
705
714
  for (const col of tagged) {
706
- if (Object.hasOwn(data, col.field) && data[col.field] !== undefined)
715
+ if (named.has(col.name))
707
716
  continue;
708
717
  out ??= { ...data };
709
718
  out[col.field] = now;
710
719
  }
711
720
  return out ?? data;
712
721
  }
722
+ /**
723
+ * The set of COLUMNS a `data` object names, under any accepted spelling of each
724
+ * key. A key set to `undefined` names nothing (see {@link definedKeys}), and a
725
+ * key that resolves to no column is left to the SQL builder's own E003.
726
+ */
727
+ function namedColumns(meta, data) {
728
+ const out = new Set();
729
+ for (const key of Object.keys(data)) {
730
+ if (data[key] === undefined)
731
+ continue;
732
+ const column = resolveColumnName(meta, key);
733
+ if (column)
734
+ out.add(column);
735
+ }
736
+ return out;
737
+ }
713
738
  export function writeReturningColumns(qi) {
714
739
  const piiCols = piiColumns(qi, qi.tableMeta);
715
740
  if (piiCols.size === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.52.0",
3
+ "version": "0.53.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": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",