@orthacms/utils-server 0.0.0-reserve.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +10 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +24 -0
  6. package/dist/lib/clamp-int.d.ts +8 -0
  7. package/dist/lib/clamp-int.d.ts.map +1 -0
  8. package/dist/lib/clamp-int.js +15 -0
  9. package/dist/lib/filters/filter-exceptions.d.ts +92 -0
  10. package/dist/lib/filters/filter-exceptions.d.ts.map +1 -0
  11. package/dist/lib/filters/filter-exceptions.js +117 -0
  12. package/dist/lib/filters/negation.d.ts +33 -0
  13. package/dist/lib/filters/negation.d.ts.map +1 -0
  14. package/dist/lib/filters/negation.js +59 -0
  15. package/dist/lib/filters/operator-support.d.ts +25 -0
  16. package/dist/lib/filters/operator-support.d.ts.map +1 -0
  17. package/dist/lib/filters/operator-support.js +88 -0
  18. package/dist/lib/filters/own-property.d.ts +28 -0
  19. package/dist/lib/filters/own-property.d.ts.map +1 -0
  20. package/dist/lib/filters/own-property.js +34 -0
  21. package/dist/lib/filters/parse-filter-tree.d.ts +17 -0
  22. package/dist/lib/filters/parse-filter-tree.d.ts.map +1 -0
  23. package/dist/lib/filters/parse-filter-tree.js +107 -0
  24. package/dist/lib/filters/relation-exists.d.ts +35 -0
  25. package/dist/lib/filters/relation-exists.d.ts.map +1 -0
  26. package/dist/lib/filters/relation-exists.js +119 -0
  27. package/dist/lib/filters/resolve-leaf.d.ts +8 -0
  28. package/dist/lib/filters/resolve-leaf.d.ts.map +1 -0
  29. package/dist/lib/filters/resolve-leaf.js +170 -0
  30. package/dist/lib/filters/scalar-op.d.ts +14 -0
  31. package/dist/lib/filters/scalar-op.d.ts.map +1 -0
  32. package/dist/lib/filters/scalar-op.js +66 -0
  33. package/dist/lib/filters/table-helpers.d.ts +67 -0
  34. package/dist/lib/filters/table-helpers.d.ts.map +1 -0
  35. package/dist/lib/filters/table-helpers.js +68 -0
  36. package/dist/lib/filters/tree-to-drizzle.d.ts +35 -0
  37. package/dist/lib/filters/tree-to-drizzle.d.ts.map +1 -0
  38. package/dist/lib/filters/tree-to-drizzle.js +76 -0
  39. package/dist/lib/filters/types.d.ts +217 -0
  40. package/dist/lib/filters/types.d.ts.map +1 -0
  41. package/dist/lib/filters/types.js +35 -0
  42. package/dist/lib/pg-errors.d.ts +43 -0
  43. package/dist/lib/pg-errors.d.ts.map +1 -0
  44. package/dist/lib/pg-errors.js +82 -0
  45. package/package.json +35 -0
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseFilterTree = parseFilterTree;
4
+ const filter_exceptions_1 = require("./filter-exceptions");
5
+ const resolve_leaf_1 = require("./resolve-leaf");
6
+ const DEFAULT_MAX_DEPTH = 3;
7
+ const DEFAULT_MAX_NODES = 50;
8
+ const DEFAULT_MAX_GROUP_DEPTH = 5;
9
+ const DEFAULT_MAX_IN_LIST = 100;
10
+ /**
11
+ * Parse a `filter` payload into a tree the translator can walk. Two
12
+ * input shapes are accepted:
13
+ *
14
+ * 1. `string` — a JSON-encoded tree, so a controller can forward
15
+ * `?filter=<json>` without pre-parsing. Throws `InvalidJson` on
16
+ * malformed input.
17
+ * 2. Object — already-parsed tree (`{ and: [...] }` / `{ or: [...] }` /
18
+ * single rule `{ field, op, value }`). Recursively validated; each
19
+ * leaf flows through the same schema/op/coercion check.
20
+ *
21
+ * Returns `null` when the input is missing, empty, or `{}` so callers
22
+ * can skip the WHERE clause without an empty-array dance.
23
+ */
24
+ function parseFilterTree(rawFilter, schema) {
25
+ if (rawFilter === undefined || rawFilter === null)
26
+ return null;
27
+ let value = rawFilter;
28
+ if (typeof value === 'string') {
29
+ const trimmed = value.trim();
30
+ if (trimmed.length === 0)
31
+ return null;
32
+ try {
33
+ value = JSON.parse(trimmed);
34
+ }
35
+ catch (err) {
36
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidJson, 'filter string is not valid JSON', { reason: err.message });
37
+ }
38
+ }
39
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
40
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidShape, 'expected object');
41
+ }
42
+ const obj = value;
43
+ if (Object.keys(obj).length === 0)
44
+ return null;
45
+ const ctx = {
46
+ nodeCount: 0,
47
+ maxNodes: schema.maxNodes ?? DEFAULT_MAX_NODES,
48
+ maxGroupDepth: schema.maxGroupDepth ?? DEFAULT_MAX_GROUP_DEPTH,
49
+ maxDepth: schema.maxDepth ?? DEFAULT_MAX_DEPTH,
50
+ maxInListLength: schema.maxInListLength ?? DEFAULT_MAX_IN_LIST
51
+ };
52
+ return walkNode(obj, schema, 0, ctx);
53
+ }
54
+ function walkNode(raw, schema, depth, ctx) {
55
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
56
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidNode, 'tree node must be an object');
57
+ }
58
+ ctx.nodeCount += 1;
59
+ if (ctx.nodeCount > ctx.maxNodes) {
60
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.MaxNodesExceeded, `filter exceeds max node count ${ctx.maxNodes}`, { maxNodes: ctx.maxNodes });
61
+ }
62
+ const node = raw;
63
+ // `Object.hasOwn`, not `in`: `parseFilterTree` also accepts an
64
+ // already-parsed object, which a caller could hand over with a prototype
65
+ // that carries these names. Shape detection must read what the payload
66
+ // itself declares.
67
+ const isAnd = Object.hasOwn(node, 'and');
68
+ const isOr = Object.hasOwn(node, 'or');
69
+ if (isAnd && isOr) {
70
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidNode, 'group node must declare exactly one of `and` or `or`');
71
+ }
72
+ if (isAnd || isOr) {
73
+ if (depth >= ctx.maxGroupDepth) {
74
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.GroupDepthExceeded, `group nesting exceeds max depth ${ctx.maxGroupDepth}`, { maxGroupDepth: ctx.maxGroupDepth });
75
+ }
76
+ const combinator = isAnd ? 'and' : 'or';
77
+ const children = node[combinator];
78
+ if (!Array.isArray(children)) {
79
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidNode, `\`${combinator}\` must be an array of nodes`);
80
+ }
81
+ if (children.length === 0) {
82
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidNode, `\`${combinator}\` group must contain at least one child`);
83
+ }
84
+ return {
85
+ kind: 'group',
86
+ combinator,
87
+ children: children.map((c) => walkNode(c, schema, depth + 1, ctx))
88
+ };
89
+ }
90
+ if (Object.hasOwn(node, 'field') && Object.hasOwn(node, 'op')) {
91
+ const field = node.field;
92
+ const op = node.op;
93
+ if (typeof field !== 'string' || field.length === 0) {
94
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidNode, 'rule `field` must be a non-empty string');
95
+ }
96
+ if (typeof op !== 'string' || op.length === 0) {
97
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidNode, 'rule `op` must be a non-empty string');
98
+ }
99
+ const path = field.split('.');
100
+ const leaf = (0, resolve_leaf_1.resolveLeaf)(path, op, node.value, schema, ctx.maxDepth, ctx.maxInListLength);
101
+ return toRule(leaf);
102
+ }
103
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidNode, 'tree node must have `and`, `or`, or (`field` + `op`)');
104
+ }
105
+ function toRule(leaf) {
106
+ return { kind: 'rule', ...leaf };
107
+ }
@@ -0,0 +1,35 @@
1
+ import { type SQL } from 'drizzle-orm';
2
+ import { type DbLike, type TableLike } from './table-helpers';
3
+ import type { ParsedFilter, RelationSchema } from './types';
4
+ /**
5
+ * Build an `EXISTS (...)` subquery that applies `inner` against the
6
+ * relation's target/through table. The `kind` discriminant selects the
7
+ * SQL shape:
8
+ *
9
+ * - `one-to-one` / `one-to-many`: target FK references parent.
10
+ * - `many-to-one`: parent FK references target.
11
+ * - `many-to-many`: join via `through`; add inner join to target when
12
+ * filtering on a target field.
13
+ * - `self-referential`: parent and target are the same physical table,
14
+ * so the target is aliased to give the subquery its own correlation
15
+ * name (an unaliased subquery would bind both correlation sides to the
16
+ * inner scope and silently degrade to "a row that is its own parent").
17
+ *
18
+ * Every branch ANDs the relation's optional `scope` predicate (workspace
19
+ * + soft-delete guard) inside the EXISTS, so a relation filter never
20
+ * traverses rows the root query itself excludes.
21
+ *
22
+ * Every column that lives on the PARENT side (`fk` for `many-to-one` /
23
+ * `self-referential`, and any `parentKey`) is re-resolved against `parent`
24
+ * via {@link rebind}, because `parent` may be an alias of the physical
25
+ * table — see that helper for why an unaliased column silently correlates
26
+ * to the wrong row.
27
+ */
28
+ export declare function relationExists(rel: RelationSchema, parent: TableLike, inner: ParsedFilter, db: DbLike): SQL;
29
+ /**
30
+ * Apply the next segment of a filter path against `currentTable`.
31
+ * If the path has one segment left it resolves to a scalar condition;
32
+ * otherwise it descends through the next nested relation.
33
+ */
34
+ export declare function descend(rel: RelationSchema, currentTable: TableLike, f: ParsedFilter, db: DbLike): SQL;
35
+ //# sourceMappingURL=relation-exists.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relation-exists.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/relation-exists.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,GAAG,EAAE,MAAM,aAAa,CAAC;AAM7D,OAAO,EAIH,KAAK,MAAM,EACX,KAAK,SAAS,EACjB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE5D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,cAAc,CAC1B,GAAG,EAAE,cAAc,EACnB,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE,YAAY,EACnB,EAAE,EAAE,MAAM,GACX,GAAG,CA6GL;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CACnB,GAAG,EAAE,cAAc,EACnB,YAAY,EAAE,SAAS,EACvB,CAAC,EAAE,YAAY,EACf,EAAE,EAAE,MAAM,GACX,GAAG,CAeL"}
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.relationExists = relationExists;
4
+ exports.descend = descend;
5
+ const drizzle_orm_1 = require("drizzle-orm");
6
+ const pg_core_1 = require("drizzle-orm/pg-core");
7
+ const filter_exceptions_1 = require("./filter-exceptions");
8
+ const own_property_1 = require("./own-property");
9
+ const scalar_op_1 = require("./scalar-op");
10
+ const table_helpers_1 = require("./table-helpers");
11
+ const types_1 = require("./types");
12
+ /**
13
+ * Build an `EXISTS (...)` subquery that applies `inner` against the
14
+ * relation's target/through table. The `kind` discriminant selects the
15
+ * SQL shape:
16
+ *
17
+ * - `one-to-one` / `one-to-many`: target FK references parent.
18
+ * - `many-to-one`: parent FK references target.
19
+ * - `many-to-many`: join via `through`; add inner join to target when
20
+ * filtering on a target field.
21
+ * - `self-referential`: parent and target are the same physical table,
22
+ * so the target is aliased to give the subquery its own correlation
23
+ * name (an unaliased subquery would bind both correlation sides to the
24
+ * inner scope and silently degrade to "a row that is its own parent").
25
+ *
26
+ * Every branch ANDs the relation's optional `scope` predicate (workspace
27
+ * + soft-delete guard) inside the EXISTS, so a relation filter never
28
+ * traverses rows the root query itself excludes.
29
+ *
30
+ * Every column that lives on the PARENT side (`fk` for `many-to-one` /
31
+ * `self-referential`, and any `parentKey`) is re-resolved against `parent`
32
+ * via {@link rebind}, because `parent` may be an alias of the physical
33
+ * table — see that helper for why an unaliased column silently correlates
34
+ * to the wrong row.
35
+ */
36
+ function relationExists(rel, parent, inner, db) {
37
+ switch (rel.kind) {
38
+ case types_1.RelationKind.OneToOne:
39
+ case types_1.RelationKind.OneToMany: {
40
+ const parentKey = rel.parentKey
41
+ ? (0, table_helpers_1.rebind)(rel.parentKey, parent)
42
+ : (0, table_helpers_1.primaryKey)(parent);
43
+ const condition = (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(rel.fk, parentKey), rel.scope?.(rel.table), descend(rel, rel.table, inner, db));
44
+ return (0, drizzle_orm_1.exists)(db
45
+ .select({ one: (0, drizzle_orm_1.sql) `1` })
46
+ .from(rel.table)
47
+ .where(condition));
48
+ }
49
+ case types_1.RelationKind.ManyToOne: {
50
+ const targetKey = rel.targetKey ?? (0, table_helpers_1.primaryKey)(rel.table);
51
+ const condition = (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(targetKey, (0, table_helpers_1.rebind)(rel.fk, parent)), rel.scope?.(rel.table), descend(rel, rel.table, inner, db));
52
+ return (0, drizzle_orm_1.exists)(db
53
+ .select({ one: (0, drizzle_orm_1.sql) `1` })
54
+ .from(rel.table)
55
+ .where(condition));
56
+ }
57
+ case types_1.RelationKind.SelfReferential: {
58
+ // Parent and target are the same physical table, so an
59
+ // unaliased subquery would bind BOTH sides of the correlation
60
+ // to the inner scope. The alias gives the inner table its own
61
+ // correlation name; `rel.fk` stays bound to the OUTER row.
62
+ const target = (0, pg_core_1.alias)(rel.table, rel.alias);
63
+ const targetKey = (0, table_helpers_1.columnOf)(target, 'id');
64
+ const condition = (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(targetKey, (0, table_helpers_1.rebind)(rel.fk, parent)), rel.scope?.(target), descend(rel, target, inner, db));
65
+ return (0, drizzle_orm_1.exists)(db
66
+ .select({ one: (0, drizzle_orm_1.sql) `1` })
67
+ .from(target)
68
+ .where(condition));
69
+ }
70
+ case types_1.RelationKind.ManyToMany: {
71
+ const parentKey = rel.parentKey
72
+ ? (0, table_helpers_1.rebind)(rel.parentKey, parent)
73
+ : (0, table_helpers_1.primaryKey)(parent);
74
+ // The junction-only fast path reads the target FK straight off
75
+ // the join row, skipping the target table. A `scope` lives on
76
+ // that target table, so it can only be enforced through the
77
+ // join — the fast path must yield to it, or `relation.id in (…)`
78
+ // would silently bypass the workspace / soft-delete guards that
79
+ // `relation.field` enforces.
80
+ const onlyTargetFk = !rel.scope && inner.path.length === 1 && inner.path[0] === 'id';
81
+ if (onlyTargetFk) {
82
+ return (0, drizzle_orm_1.exists)(db
83
+ .select({ one: (0, drizzle_orm_1.sql) `1` })
84
+ .from(rel.through)
85
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(rel.fk, parentKey), (0, scalar_op_1.scalar)(rel.targetFk, inner.op, inner.value))));
86
+ }
87
+ if (!rel.table) {
88
+ throw new filter_exceptions_1.FilterSchemaException('many-to-many filter on target field requires `table`');
89
+ }
90
+ const targetKey = rel.targetKey ?? (0, table_helpers_1.primaryKey)(rel.table);
91
+ return (0, drizzle_orm_1.exists)(db
92
+ .select({ one: (0, drizzle_orm_1.sql) `1` })
93
+ .from(rel.through)
94
+ .innerJoin(rel.table, (0, drizzle_orm_1.eq)(targetKey, rel.targetFk))
95
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(rel.fk, parentKey), rel.scope?.(rel.table), descend(rel, rel.table, inner, db))));
96
+ }
97
+ default:
98
+ // Unreachable through the `RelationSchema` union, but a schema
99
+ // assembled at runtime can carry an unrecognised `kind`. Falling
100
+ // out of the switch returned `undefined`, and an `undefined`
101
+ // predicate is dropped by drizzle's `and()`/`or()` — the filter
102
+ // would silently widen instead of failing.
103
+ throw new filter_exceptions_1.FilterSchemaException(`relation declares unknown kind "${String(rel.kind)}"`);
104
+ }
105
+ }
106
+ /**
107
+ * Apply the next segment of a filter path against `currentTable`.
108
+ * If the path has one segment left it resolves to a scalar condition;
109
+ * otherwise it descends through the next nested relation.
110
+ */
111
+ function descend(rel, currentTable, f, db) {
112
+ if (f.path.length === 1) {
113
+ return (0, scalar_op_1.scalar)((0, table_helpers_1.columnOf)(currentTable, f.path[0]), f.op, f.value);
114
+ }
115
+ const next = (0, own_property_1.own)(rel.relations, f.path[0]);
116
+ if (!next)
117
+ throw new filter_exceptions_1.FilterSchemaException(`nested relation missing: ${f.path[0]}`);
118
+ return relationExists(next, currentTable, { ...f, path: f.path.slice(1) }, db);
119
+ }
@@ -0,0 +1,8 @@
1
+ import type { FilterSchema, ParsedFilter } from './types';
2
+ /**
3
+ * Walk a single dotted path against the schema, validate the operator,
4
+ * and coerce the value to the declared type. Produces the `ParsedFilter`
5
+ * leaf shape the tree parser tags with `kind: 'rule'`.
6
+ */
7
+ export declare function resolveLeaf(path: string[], op: string, value: unknown, schema: FilterSchema, maxDepth: number, maxInListLength: number): ParsedFilter;
8
+ //# sourceMappingURL=resolve-leaf.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-leaf.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/resolve-leaf.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAqB,MAAM,SAAS,CAAC;AAQ7E;;;;GAIG;AACH,wBAAgB,WAAW,CACvB,IAAI,EAAE,MAAM,EAAE,EACd,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,YAAY,EACpB,QAAQ,EAAE,MAAM,EAChB,eAAe,EAAE,MAAM,GACxB,YAAY,CAkEd"}
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveLeaf = resolveLeaf;
4
+ const filter_exceptions_1 = require("./filter-exceptions");
5
+ const operator_support_1 = require("./operator-support");
6
+ const own_property_1 = require("./own-property");
7
+ const types_1 = require("./types");
8
+ const OPS = Object.values(types_1.FilterOperator);
9
+ /** Canonical 8-4-4-4-12 hex UUID — what Postgres' `uuid` type actually accepts. */
10
+ const UUID_CANONICAL = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ /**
12
+ * Walk a single dotted path against the schema, validate the operator,
13
+ * and coerce the value to the declared type. Produces the `ParsedFilter`
14
+ * leaf shape the tree parser tags with `kind: 'rule'`.
15
+ */
16
+ function resolveLeaf(path, op, value, schema, maxDepth, maxInListLength) {
17
+ if (path.length === 0) {
18
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.EmptyPath, 'empty filter path');
19
+ }
20
+ if (path.length > maxDepth) {
21
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.DepthExceeded, `filter path exceeds max depth ${maxDepth}`, { path: path.join('.'), maxDepth });
22
+ }
23
+ if (!OPS.includes(op)) {
24
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.UnknownOperator, `unknown operator "${op}"`, { op });
25
+ }
26
+ let fields = schema.fields ?? {};
27
+ let relations = schema.relations ?? {};
28
+ for (let i = 0; i < path.length; i++) {
29
+ const seg = path[i];
30
+ const last = i === path.length - 1;
31
+ if (last) {
32
+ const field = (0, own_property_1.own)(fields, seg);
33
+ if (!field) {
34
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.UnknownField, `unknown field "${path.join('.')}"`, { path: path.join('.') });
35
+ }
36
+ assertOperatorAllowed(field, op, path);
37
+ return {
38
+ path,
39
+ op: op,
40
+ value: coerce(value, field, op, path, maxInListLength)
41
+ };
42
+ }
43
+ const rel = (0, own_property_1.own)(relations, seg);
44
+ if (!rel) {
45
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.UnknownRelation, `unknown relation "${seg}" in "${path.join('.')}"`, { segment: seg, path: path.join('.') });
46
+ }
47
+ fields = rel.fields ?? {};
48
+ relations = rel.relations ?? {};
49
+ }
50
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidShape, 'unreachable: path walker ended without a leaf');
51
+ }
52
+ /**
53
+ * Reject an operator the field's column type cannot answer.
54
+ *
55
+ * Runs **before** coercion, so the reported issue is the operator rather than a
56
+ * value the operator would never have accepted anyway — `embargoUntil ilike
57
+ * "%2020%"` should say "ilike is not available on a date field", not "not a
58
+ * date: %2020%". The context carries `path`, `op` and the `allowed` list, so a
59
+ * client can render a per-field issue and offer the operators that do work.
60
+ *
61
+ * An unrecognised declared type is left alone: that is a **schema** bug, and
62
+ * `scalarOf` already raises the 500 (`FilterSchemaException`) that names it.
63
+ * Blaming the client with a 400 here would hide it.
64
+ */
65
+ function assertOperatorAllowed(field, op, path) {
66
+ const allowed = (0, operator_support_1.operatorsFor)(field.type);
67
+ if (!allowed || allowed.includes(op)) {
68
+ return;
69
+ }
70
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.OperatorNotAllowed, `operator "${op}" is not available on "${path.join('.')}" (a ${field.type} field)`, { path: path.join('.'), op, fieldType: field.type, allowed });
71
+ }
72
+ function coerce(raw, field, op, path, maxInListLength) {
73
+ const pathStr = path.join('.');
74
+ if (op === types_1.FilterOperator.Null) {
75
+ if (raw === 'true' || raw === true)
76
+ return true;
77
+ if (raw === 'false' || raw === false)
78
+ return false;
79
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidValue, 'null filter accepts only true|false', { path: pathStr, op, value: raw });
80
+ }
81
+ if (op === types_1.FilterOperator.In || op === types_1.FilterOperator.Nin) {
82
+ const items = Array.isArray(raw)
83
+ ? raw
84
+ : typeof raw === 'string'
85
+ ? raw.split(',')
86
+ : [raw];
87
+ // An empty list would translate to `IN ()` / `NOT IN ()`, which
88
+ // Drizzle emits as `false` / `true` — a silent no-op (matches
89
+ // nothing) or inverted filter (matches everything). Reject it so
90
+ // the client gets a clean 400 instead of a surprising result set.
91
+ if (items.length === 0) {
92
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.EmptyInList, `${op} requires at least one value`, { path: pathStr, op });
93
+ }
94
+ // Cap the list so a single rule (one node, under maxNodes) can't
95
+ // blow up into an arbitrarily large IN clause.
96
+ if (items.length > maxInListLength) {
97
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.MaxInListExceeded, `${op} value list exceeds max length ${maxInListLength}`, { path: pathStr, op, maxInListLength, length: items.length });
98
+ }
99
+ return items.map((v) => scalarOf(v, field, pathStr));
100
+ }
101
+ return scalarOf(raw, field, pathStr);
102
+ }
103
+ function scalarOf(v, field, pathStr) {
104
+ // A filter value has to be a scalar. `String(v)` on anything else produces
105
+ // a plausible-looking string that is then MATCHED AGAINST rather than
106
+ // rejected: `null` → `"null"`, a missing `value` key → `"undefined"`,
107
+ // `{}` → `"[object Object]"`, `[1,2]` → `"1,2"`, and (for a number field)
108
+ // `[]` → `""` → `0`. Each returns 200 with a wrong, usually empty, result
109
+ // set that the client reads as "no matches" instead of "bad request" —
110
+ // the one silent failure mode in a library that 400s every other bad
111
+ // value. A JSON client meaning "is null" wants the `null` operator.
112
+ if (v === null ||
113
+ (typeof v !== 'string' &&
114
+ typeof v !== 'number' &&
115
+ typeof v !== 'boolean')) {
116
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidValue, v === undefined
117
+ ? 'value is required'
118
+ : 'value must be a string, number or boolean', { path: pathStr, expectedType: field.type });
119
+ }
120
+ const s = typeof v === 'string' ? v : String(v);
121
+ switch (field.type) {
122
+ case types_1.ScalarFieldType.String:
123
+ return s;
124
+ case types_1.ScalarFieldType.Number: {
125
+ const n = Number(s);
126
+ if (!Number.isFinite(n)) {
127
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidValue, `not a number: ${s}`, { path: pathStr, expectedType: 'number', value: s });
128
+ }
129
+ return n;
130
+ }
131
+ case types_1.ScalarFieldType.Boolean:
132
+ if (s === 'true')
133
+ return true;
134
+ if (s === 'false')
135
+ return false;
136
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidValue, `not a boolean: ${s}`, { path: pathStr, expectedType: 'boolean', value: s });
137
+ case types_1.ScalarFieldType.Uuid:
138
+ // Canonical 8-4-4-4-12 form — stricter than a loose `[0-9a-f-]{36}`,
139
+ // which would let malformed values (e.g. 36 dashes) pass coercion
140
+ // and then fail Postgres' uuid cast as a 500 instead of a clean 400.
141
+ if (!UUID_CANONICAL.test(s)) {
142
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidValue, `not a uuid: ${s}`, { path: pathStr, expectedType: 'uuid', value: s });
143
+ }
144
+ return s;
145
+ case types_1.ScalarFieldType.Date: {
146
+ const d = new Date(s);
147
+ if (Number.isNaN(d.getTime())) {
148
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidValue, `not a date: ${s}`, { path: pathStr, expectedType: 'date', value: s });
149
+ }
150
+ return d;
151
+ }
152
+ case types_1.ScalarFieldType.Enum:
153
+ if (!field.enumValues?.includes(s)) {
154
+ throw new filter_exceptions_1.FilterException(filter_exceptions_1.FilterErrorCode.InvalidValue, `not in enum: ${s}`, {
155
+ path: pathStr,
156
+ expectedType: 'enum',
157
+ value: s,
158
+ allowed: field.enumValues
159
+ });
160
+ }
161
+ return s;
162
+ default:
163
+ // Not reachable through the type system, but a schema built at
164
+ // runtime (the content plugin derives one per content type) can
165
+ // land here with an unrecognised `type`. Falling out of the switch
166
+ // returned `undefined`, which drizzle renders as the same broken
167
+ // `$1 = ` fragment an inherited field name used to produce.
168
+ throw new filter_exceptions_1.FilterSchemaException(`field "${pathStr}" declares unknown type "${String(field.type)}"`);
169
+ }
170
+ }
@@ -0,0 +1,14 @@
1
+ import { type AnyColumn, type SQL } from 'drizzle-orm';
2
+ import { FilterOperator } from './types';
3
+ /**
4
+ * Translate a single operator + value pair against `col` into a Drizzle
5
+ * SQL fragment. Array-valued ops (`in`/`nin`) expect the caller's value
6
+ * to already be an array; `null` expects a boolean.
7
+ *
8
+ * Negative operators are NULL-inclusive — see {@link negative}. (On a
9
+ * relation path a negative operator never reaches here: the translator
10
+ * rewrites it into `NOT EXISTS(… positive …)` first, which is the only
11
+ * correct reading once the relation can hold more than one row.)
12
+ */
13
+ export declare function scalar(col: AnyColumn, op: FilterOperator, value: unknown): SQL;
14
+ //# sourceMappingURL=scalar-op.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scalar-op.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/scalar-op.ts"],"names":[],"mappings":"AAAA,OAAO,EAeH,KAAK,SAAS,EACd,KAAK,GAAG,EACX,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAiBzC;;;;;;;;;GASG;AACH,wBAAgB,MAAM,CAClB,GAAG,EAAE,SAAS,EACd,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,OAAO,GACf,GAAG,CAqCL"}
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scalar = scalar;
4
+ const drizzle_orm_1 = require("drizzle-orm");
5
+ const filter_exceptions_1 = require("./filter-exceptions");
6
+ const types_1 = require("./types");
7
+ /**
8
+ * A negative predicate that also matches NULL.
9
+ *
10
+ * SQL's three-valued logic makes `col <> 'x'`, `col NOT IN (…)` and
11
+ * `col NOT ILIKE '%x%'` all evaluate to NULL — i.e. *not matched* — when
12
+ * the column is NULL. That reads as a silent data-loss bug to an editor:
13
+ * a publishable type keeps its required fields **nullable** (they're only
14
+ * required to publish), so "Title does not contain foo" would quietly hide
15
+ * every draft whose title is still empty. An empty value is not the value
16
+ * being excluded, so it belongs in the result.
17
+ */
18
+ function negative(col, predicate) {
19
+ return (0, drizzle_orm_1.or)(predicate, (0, drizzle_orm_1.isNull)(col));
20
+ }
21
+ /**
22
+ * Translate a single operator + value pair against `col` into a Drizzle
23
+ * SQL fragment. Array-valued ops (`in`/`nin`) expect the caller's value
24
+ * to already be an array; `null` expects a boolean.
25
+ *
26
+ * Negative operators are NULL-inclusive — see {@link negative}. (On a
27
+ * relation path a negative operator never reaches here: the translator
28
+ * rewrites it into `NOT EXISTS(… positive …)` first, which is the only
29
+ * correct reading once the relation can hold more than one row.)
30
+ */
31
+ function scalar(col, op, value) {
32
+ switch (op) {
33
+ case types_1.FilterOperator.Eq:
34
+ return (0, drizzle_orm_1.eq)(col, value);
35
+ case types_1.FilterOperator.Ne:
36
+ return negative(col, (0, drizzle_orm_1.ne)(col, value));
37
+ case types_1.FilterOperator.Gt:
38
+ return (0, drizzle_orm_1.gt)(col, value);
39
+ case types_1.FilterOperator.Gte:
40
+ return (0, drizzle_orm_1.gte)(col, value);
41
+ case types_1.FilterOperator.Lt:
42
+ return (0, drizzle_orm_1.lt)(col, value);
43
+ case types_1.FilterOperator.Lte:
44
+ return (0, drizzle_orm_1.lte)(col, value);
45
+ case types_1.FilterOperator.In:
46
+ return (0, drizzle_orm_1.inArray)(col, value);
47
+ case types_1.FilterOperator.Nin:
48
+ return negative(col, (0, drizzle_orm_1.notInArray)(col, value));
49
+ case types_1.FilterOperator.Like:
50
+ return (0, drizzle_orm_1.like)(col, String(value));
51
+ case types_1.FilterOperator.Ilike:
52
+ return (0, drizzle_orm_1.ilike)(col, String(value));
53
+ case types_1.FilterOperator.Nilike:
54
+ return negative(col, (0, drizzle_orm_1.notIlike)(col, String(value)));
55
+ case types_1.FilterOperator.Null:
56
+ return value === true ? (0, drizzle_orm_1.isNull)(col) : (0, drizzle_orm_1.isNotNull)(col);
57
+ default:
58
+ // The parser validates `op` against the vocabulary, so this is
59
+ // unreachable — but falling out of the switch returned `undefined`,
60
+ // and drizzle's `and()`/`or()` drop an undefined member, so a
61
+ // future operator added to `FilterOperator` without a case here
62
+ // would silently widen the filter instead of failing the build's
63
+ // intent. Fail loudly instead.
64
+ throw new filter_exceptions_1.FilterSchemaException(`no translation for operator "${String(op)}"`);
65
+ }
66
+ }
@@ -0,0 +1,67 @@
1
+ import { type AnyColumn, type SQLWrapper } from 'drizzle-orm';
2
+ /**
3
+ * A minimal Drizzle-like table shape.
4
+ *
5
+ * We deliberately do NOT import drizzle-orm's `Table`/`PgTable` here:
6
+ * drizzle's generic `Column`/`TableConfig` use a protected field that
7
+ * breaks structural assignment between utils-server and callers'
8
+ * concrete `PgTableWithColumns<…>` types under nodenext resolution.
9
+ * The parser is the runtime whitelist that keeps this safe.
10
+ */
11
+ export type TableLike = object;
12
+ /**
13
+ * A minimal Drizzle-like database shape. Only `.select().from()` and
14
+ * `.innerJoin()` are used; parameter/return types are widened so
15
+ * concrete Drizzle instances (e.g. `NodePgDatabase`) assign here
16
+ * without cross-package nominal friction.
17
+ */
18
+ export type DbLike = {
19
+ select: (...args: any[]) => {
20
+ from: (t: any) => {
21
+ where: (cond: any) => SQLWrapper;
22
+ innerJoin: (t: any, cond: any) => {
23
+ where: (cond: any) => SQLWrapper;
24
+ };
25
+ };
26
+ };
27
+ };
28
+ /**
29
+ * Resolve a column on a table by name, or throw if absent.
30
+ *
31
+ * The lookup is `Object.hasOwn`-guarded: drizzle's column map is a plain
32
+ * object, so `cols['constructor']` would otherwise return `Object` itself and
33
+ * this function would hand a `Function` to drizzle as a `Column`. The parser's
34
+ * whitelist is the primary guard, but this is the site where an escaped name
35
+ * turns into malformed SQL rather than an error, so it guards itself too.
36
+ */
37
+ export declare function columnOf(table: TableLike, name: string): AnyColumn;
38
+ /**
39
+ * Re-resolve a prebuilt column against the table the translator is actually
40
+ * querying.
41
+ *
42
+ * A `RelationSchema`'s parent-side columns (`fk` on a `many-to-one` /
43
+ * `self-referential`, and every `parentKey`) are built once, against the
44
+ * **physical** parent table. That is wrong the moment the parent is an
45
+ * *alias* — which happens for every relation nested under a
46
+ * `self-referential` hop, whose subquery is `FROM t AS qb_x`. An unaliased
47
+ * `t.col` inside that subquery is still resolvable from the OUTERMOST
48
+ * query's `FROM t`, so Postgres silently binds the correlation to the root
49
+ * row instead of the aliased one: `parent.author.name` would filter on the
50
+ * ROOT row's author, and `parent.parent.name` would collapse to
51
+ * `parent.name`. Valid SQL, wrong rows, no error.
52
+ *
53
+ * Rebinding by the column's own DB name against the queried table fixes the
54
+ * correlation. It is a no-op when the parent is the physical table (the
55
+ * lookup finds the identical column), so unaliased paths are unaffected.
56
+ * Only ever call this for columns that live on the parent **by
57
+ * construction** — never for a target-side `fk` (`one-to-many`), where the
58
+ * name could collide with an unrelated parent column.
59
+ */
60
+ export declare function rebind(column: AnyColumn, table: TableLike): AnyColumn;
61
+ /**
62
+ * Resolve a table's primary key (defaults to an `id` column).
63
+ * Callers can override with an explicit `parentKey`/`targetKey` in the
64
+ * relation schema when the table's primary key is named differently.
65
+ */
66
+ export declare function primaryKey(table: TableLike): AnyColumn;
67
+ //# sourceMappingURL=table-helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/filters/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAG/E;;;;;;;;GAQG;AACH,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC;AAG/B;;;;;GAKG;AACH,MAAM,MAAM,MAAM,GAAG;IACjB,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK;QACxB,IAAI,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK;YACd,KAAK,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,UAAU,CAAC;YACjC,SAAS,EAAE,CACP,CAAC,EAAE,GAAG,EACN,IAAI,EAAE,GAAG,KACR;gBACD,KAAK,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,UAAU,CAAC;aACpC,CAAC;SACL,CAAC;KACL,CAAC;CACL,CAAC;AAGF;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,CAMlE;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,GAAG,SAAS,CAOrE;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,CAOtD"}