@zeroxsolutions/db 9.0.1 → 9.1.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.
@@ -14,8 +14,14 @@ export declare class InvalidQuerySort extends Error {
14
14
  * building SQL.
15
15
  *
16
16
  * @param query - the parsed wire query, read for its `filter` / `sort` (a no-op when `undefined`)
17
- * @throws {@link InvalidQueryFilter} naming the first filtered field outside `allow.filterable`
18
- * @throws {@link InvalidQuerySort} naming the first sorted field outside `allow.sortable`
17
+ * @param resolved - the names a resolver answers, which no row key could list
18
+ * @throws {@link InvalidQueryFilter} naming the first filtered field outside `allow.filterable` and
19
+ * `resolved.filterable`
20
+ * @throws {@link InvalidQuerySort} naming the first sorted field outside `allow.sortable` and
21
+ * `resolved.sortable`
19
22
  */
20
- export declare function assertQueryableFields<TSelect>(query: Partial<Pick<GuardedCollectionQuery<FilterValue>, 'filter' | 'sort'>> | undefined, allow: QueryableFields<TSelect>): void;
23
+ export declare function assertQueryableFields<TSelect>(query: Partial<Pick<GuardedCollectionQuery<FilterValue>, 'filter' | 'sort'>> | undefined, allow: QueryableFields<TSelect>, resolved?: {
24
+ filterable?: readonly string[];
25
+ sortable?: readonly string[];
26
+ }): void;
21
27
  //# sourceMappingURL=assert-queryable-fields.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"assert-queryable-fields.d.ts","sourceRoot":"","sources":["../../src/utils/assert-queryable-fields.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,KAAK,EAAc,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAG5E,6EAA6E;AAC7E,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,KAAK,EAAE,MAAM;CAG1B;AAED,yEAAyE;AACzE,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,KAAK,EAAE,MAAM;CAG1B;AAaD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAC3C,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,sBAAsB,CAAC,WAAW,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC,CAAC,GAAG,SAAS,EACxF,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,GAC9B,IAAI,CAUN"}
1
+ {"version":3,"file":"assert-queryable-fields.d.ts","sourceRoot":"","sources":["../../src/utils/assert-queryable-fields.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,KAAK,EAAc,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAG5E,6EAA6E;AAC7E,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,KAAK,EAAE,MAAM;CAG1B;AAED,yEAAyE;AACzE,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,KAAK,EAAE,MAAM;CAG1B;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAC3C,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,sBAAsB,CAAC,WAAW,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC,CAAC,GAAG,SAAS,EACxF,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,EAC/B,QAAQ,GAAE;IAAE,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAO,GAC9E,IAAI,CAUN"}
@@ -28,13 +28,16 @@ function assertFilterNodes(nodes, filterable) {
28
28
  * building SQL.
29
29
  *
30
30
  * @param query - the parsed wire query, read for its `filter` / `sort` (a no-op when `undefined`)
31
- * @throws {@link InvalidQueryFilter} naming the first filtered field outside `allow.filterable`
32
- * @throws {@link InvalidQuerySort} naming the first sorted field outside `allow.sortable`
31
+ * @param resolved - the names a resolver answers, which no row key could list
32
+ * @throws {@link InvalidQueryFilter} naming the first filtered field outside `allow.filterable` and
33
+ * `resolved.filterable`
34
+ * @throws {@link InvalidQuerySort} naming the first sorted field outside `allow.sortable` and
35
+ * `resolved.sortable`
33
36
  */
34
- export function assertQueryableFields(query, allow) {
37
+ export function assertQueryableFields(query, allow, resolved = {}) {
35
38
  // Widened to `string`: the membership check runs against client-supplied names, not keys of TSelect.
36
- const filterable = allow.filterable;
37
- const sortable = allow.sortable;
39
+ const filterable = [...allow.filterable, ...(resolved.filterable ?? [])];
40
+ const sortable = [...allow.sortable, ...(resolved.sortable ?? [])];
38
41
  assertFilterNodes(query?.filter ?? [], filterable);
39
42
  for (const order of query?.sort ?? []) {
40
43
  if (!sortable.includes(order.field)) {
@@ -1,5 +1,5 @@
1
- import type { SQL } from 'drizzle-orm';
2
- import type { FilterTree, FilterTreeGroup, SortField } from '@zeroxsolutions/query';
1
+ import type { Column, SQL } from 'drizzle-orm';
2
+ import type { FilterableOf, FilterTree, FilterTreeGroup, SortField } from '@zeroxsolutions/query';
3
3
  import type { TableConfig as PgTableConfig } from 'drizzle-orm/pg-core';
4
4
  import { PgTable } from 'drizzle-orm/pg-core';
5
5
  import type { SQLiteTable, TableConfig as SQLiteTableConfig } from 'drizzle-orm/sqlite-core';
@@ -95,10 +95,20 @@ export type FilterableTable = PgTable<PgTableConfig> | SQLiteTable<SQLiteTableCo
95
95
  export declare class UnknownQueryField extends Error {
96
96
  constructor(field: string);
97
97
  }
98
- /** What a filter may reach: the table to resolve fields against, and the tree to build. */
98
+ /** An expression for one published path no column holds. `undefined` contributes no condition, like an empty `in`. */
99
+ export type FilterResolver = (leaf: FilterValue) => SQL | undefined;
100
+ /**
101
+ * One resolver for each filter path the permit list `P` grants that is not a column of the table, and none for any
102
+ * other name: a path permitted without an expression, or an expression for a path nobody permits, fails to compile.
103
+ */
104
+ export type FilterResolvers<TTable extends FilterableTable, P> = {
105
+ readonly [F in Exclude<keyof FilterableOf<P> & string, keyof TTable['_']['columns'] & string>]: FilterResolver;
106
+ };
107
+ /** What a filter may reach: the table to resolve fields against, the tree to build, and the paths no column holds. */
99
108
  export interface FilterQuery<TTable extends FilterableTable = FilterableTable> {
100
109
  table: TTable;
101
110
  filter?: FilterNode[] | undefined;
111
+ resolvers?: Readonly<Record<string, FilterResolver>> | undefined;
102
112
  }
103
113
  /**
104
114
  * Builds one SQL condition from a filter tree. Nodes at the root join conjunctively; a group joins
@@ -112,16 +122,20 @@ export interface FilterQuery<TTable extends FilterableTable = FilterableTable> {
112
122
  * buildFilterConditions({ table: assessments, ...parsedQuery });
113
123
  * ```
114
124
  */
115
- export declare function buildFilterConditions<TTable extends FilterableTable>({ table, filter, }: FilterQuery<TTable>): SQL | undefined;
125
+ export declare function buildFilterConditions<TTable extends FilterableTable>({ table, filter, resolvers, }: FilterQuery<TTable>): SQL | undefined;
116
126
  /**
117
127
  * Builds the `order by` clauses for a parsed sort, in the order the clauses were given.
118
128
  *
129
+ * @param options.resolvers - an expression per sort path no column holds, tried before a column
119
130
  * @returns an empty array for an absent or empty `sort`, which orders nothing
120
- * @throws {@link UnknownQueryField} when a clause names a column the table does not hold
131
+ * @throws {@link UnknownQueryField} when a clause names a column the table does not hold and no
132
+ * resolver answers it
121
133
  * @example
122
134
  * ```ts
123
135
  * const orderClauses = buildOrderBy(users, [{ field: 'createdAt', direction: 'desc' }]);
124
136
  * ```
125
137
  */
126
- export declare function buildOrderBy<TTable extends PgTable<PgTableConfig> | SQLiteTable<SQLiteTableConfig>>(table: TTable, sort: SortField[] | undefined): SQL[];
138
+ export declare function buildOrderBy<TTable extends PgTable<PgTableConfig> | SQLiteTable<SQLiteTableConfig>>(table: TTable, sort: SortField[] | undefined, options?: {
139
+ resolvers?: Readonly<Record<string, () => SQL | Column>>;
140
+ }): SQL[];
127
141
  //# sourceMappingURL=filter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"filter.d.ts","sourceRoot":"","sources":["../../src/utils/filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,GAAG,EAAC,MAAM,aAAa,CAAC;AAqB1B,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACpF,OAAO,KAAK,EAAE,WAAW,IAAI,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACxE,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC9C,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,IAAI,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC7F,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;EAgB/B,CAAC;AAIH,iHAAiH;AACjH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BAiC5B,CAAC;AAEH,gGAAgG;AAChG,MAAM,MAAM,WAAW,GAAG,eAAe,CAAC,WAAW,CAAC,CAAC;AAEvD,wEAAwE;AACxE,MAAM,MAAM,UAAU,GAAG,UAAU,CAAC,WAAW,CAAC,CAAC;AAEjD,eAAO,MAAM,iBAAiB,EAAE,CAAC,CAAC,OAAO,CAAC,WAAW,CAKnD,CAAC;AAEH,kFAAkF;AAClF,eAAO,MAAM,gBAAgB,EAAE,CAAC,CAAC,OAAO,CAAC,UAAU,CAGjD,CAAC;AAEH,mEAAmE;AACnE,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,IAAI,WAAW,CAEnE;AAED,qEAAqE;AACrE,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAClE,uEAAuE;AACvE,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D;;;GAGG;AACH,MAAM,WAAW,eAAe,CAAC,OAAO;IACtC,UAAU,EAAE,SAAS,CAAC,MAAM,OAAO,GAAG,MAAM,CAAC,EAAE,CAAC;IAChD,QAAQ,EAAE,SAAS,CAAC,MAAM,OAAO,GAAG,MAAM,CAAC,EAAE,CAAC;CAC/C;AAWD,wDAAwD;AACxD,MAAM,MAAM,eAAe,GAAG,OAAO,CAAC,aAAa,CAAC,GAAG,WAAW,CAAC,iBAAiB,CAAC,CAAC;AAEtF;;;GAGG;AACH,qBAAa,iBAAkB,SAAQ,KAAK;gBAC9B,KAAK,EAAE,MAAM;CAG1B;AAED,2FAA2F;AAC3F,MAAM,WAAW,WAAW,CAAC,MAAM,SAAS,eAAe,GAAG,eAAe;IAC3E,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,UAAU,EAAE,GAAG,SAAS,CAAC;CACnC;AAmGD;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,SAAS,eAAe,EAAE,EACpE,KAAK,EACL,MAAM,GACP,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,GAAG,GAAG,SAAS,CAOvC;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAS,OAAO,CAAC,aAAa,CAAC,GAAG,WAAW,CAAC,iBAAiB,CAAC,EACjG,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,SAAS,EAAE,GAAG,SAAS,GAC5B,GAAG,EAAE,CAgBP"}
1
+ {"version":3,"file":"filter.d.ts","sourceRoot":"","sources":["../../src/utils/filter.ts"],"names":[],"mappings":"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"}
@@ -107,7 +107,14 @@ function textMatch(context, column, pattern) {
107
107
  : sql `${column} like ${pattern} escape '\\'`;
108
108
  }
109
109
  function buildCondition(context, filter) {
110
- const column = context.columns[filter.field];
110
+ // Parenthesized because a resolver may return a bare `a or b`, and `and()` joins its operands without
111
+ // grouping them: beside a sibling or a soft-delete scope it would read `a or (b and deleted_at is null)`.
112
+ if (Object.hasOwn(context.resolvers, filter.field)) {
113
+ const expression = context.resolvers[filter.field]?.(filter);
114
+ return expression === undefined ? undefined : sql `(${expression})`;
115
+ }
116
+ // Own keys only: the field is client-written, and `constructor` would otherwise answer from Object.prototype.
117
+ const column = Object.hasOwn(context.columns, filter.field) ? context.columns[filter.field] : undefined;
111
118
  if (!column)
112
119
  throw new UnknownQueryField(filter.field);
113
120
  switch (filter.operator) {
@@ -179,35 +186,46 @@ function buildGroup(context, group) {
179
186
  * buildFilterConditions({ table: assessments, ...parsedQuery });
180
187
  * ```
181
188
  */
182
- export function buildFilterConditions({ table, filter, }) {
189
+ export function buildFilterConditions({ table, filter, resolvers, }) {
183
190
  if (!filter || filter.length === 0)
184
191
  return undefined;
185
- const conditions = buildNodes({ columns: getTableColumns(table), caseInsensitiveOperator: is(table, PgTable) }, filter);
192
+ const conditions = buildNodes({
193
+ columns: getTableColumns(table),
194
+ caseInsensitiveOperator: is(table, PgTable),
195
+ resolvers: resolvers ?? {},
196
+ }, filter);
186
197
  return conditions.length > 0 ? and(...conditions) : undefined;
187
198
  }
188
199
  /**
189
200
  * Builds the `order by` clauses for a parsed sort, in the order the clauses were given.
190
201
  *
202
+ * @param options.resolvers - an expression per sort path no column holds, tried before a column
191
203
  * @returns an empty array for an absent or empty `sort`, which orders nothing
192
- * @throws {@link UnknownQueryField} when a clause names a column the table does not hold
204
+ * @throws {@link UnknownQueryField} when a clause names a column the table does not hold and no
205
+ * resolver answers it
193
206
  * @example
194
207
  * ```ts
195
208
  * const orderClauses = buildOrderBy(users, [{ field: 'createdAt', direction: 'desc' }]);
196
209
  * ```
197
210
  */
198
- export function buildOrderBy(table, sort) {
211
+ export function buildOrderBy(table, sort, options = {}) {
199
212
  if (!sort || sort.length === 0)
200
213
  return [];
201
214
  const columns = getTableColumns(table);
202
215
  const clauses = [];
203
216
  for (const item of sort) {
204
- const column = columns[item.field];
205
- if (!column)
217
+ const resolvers = options.resolvers ?? {};
218
+ const expression = Object.hasOwn(resolvers, item.field)
219
+ ? resolvers[item.field]?.()
220
+ : Object.hasOwn(columns, item.field)
221
+ ? columns[item.field]
222
+ : undefined;
223
+ if (!expression)
206
224
  throw new UnknownQueryField(item.field);
207
225
  if (item.direction === 'desc')
208
- clauses.push(desc(column));
226
+ clauses.push(desc(expression));
209
227
  else
210
- clauses.push(asc(column));
228
+ clauses.push(asc(expression));
211
229
  }
212
230
  return clauses;
213
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroxsolutions/db",
3
- "version": "9.0.1",
3
+ "version": "9.1.0",
4
4
  "description": "Generic Drizzle toolkit - the id column and audit timestamps a table takes, a base repository, the unit-of-work and transactional outbox impls, and the filter/sort/pagination query helpers, for PostgreSQL (postgres-js) and Cloudflare D1; ships no domain schema. Shared across ZeroXSolutions services.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "tslib": "^2.3.0",
39
39
  "uuid": "^14.0.1",
40
40
  "@zeroxsolutions/cosmic": "0.3.4",
41
- "@zeroxsolutions/query": "0.5.1"
41
+ "@zeroxsolutions/query": "0.6.0"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@testcontainers/postgresql": "^12.1.0",