@zeroxsolutions/db 9.0.1 → 9.2.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.
- package/CHANGELOG.md +28 -0
- package/README.md +26 -0
- package/dist/utils/assert-queryable-fields.d.ts +9 -3
- package/dist/utils/assert-queryable-fields.d.ts.map +1 -1
- package/dist/utils/assert-queryable-fields.js +8 -5
- package/dist/utils/filter.d.ts +27 -6
- package/dist/utils/filter.d.ts.map +1 -1
- package/dist/utils/filter.js +42 -9
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## 9.2.0 (2026-09-28)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- **db:** resolve a relationship filter against its foreign-key column ([32903ba](https://github.com/zeroxsolutions/zeroxsolutions/commit/32903ba))
|
|
6
|
+
|
|
7
|
+
### ❤️ Thank You
|
|
8
|
+
|
|
9
|
+
- Claude Opus 5.5 (1M context)
|
|
10
|
+
- Lương Văn Tú
|
|
11
|
+
|
|
12
|
+
## 9.1.0 (2026-09-27)
|
|
13
|
+
|
|
14
|
+
### 🚀 Features
|
|
15
|
+
|
|
16
|
+
- **db:** order by a path no column holds, and admit resolved names ([bc10882](https://github.com/zeroxsolutions/zeroxsolutions/commit/bc10882))
|
|
17
|
+
- **db:** resolve a filter path no column holds through its own expression ([4627742](https://github.com/zeroxsolutions/zeroxsolutions/commit/4627742))
|
|
18
|
+
|
|
19
|
+
### 🩹 Fixes
|
|
20
|
+
|
|
21
|
+
- **db:** parenthesize a resolved condition and read resolvers by own key ([56bb1e4](https://github.com/zeroxsolutions/zeroxsolutions/commit/56bb1e4))
|
|
22
|
+
|
|
23
|
+
### ❤️ Thank You
|
|
24
|
+
|
|
25
|
+
- Claude Opus 5.5 (1M context)
|
|
26
|
+
- Claude Sonnet 5
|
|
27
|
+
- Lương Văn Tú
|
|
28
|
+
|
|
1
29
|
## 9.0.1 (2026-09-27)
|
|
2
30
|
|
|
3
31
|
### 🩹 Fixes
|
package/README.md
CHANGED
|
@@ -361,6 +361,32 @@ All 15, as `filterOperatorSchema` declares them:
|
|
|
361
361
|
|
|
362
362
|
A `%` or `_` in a text value is escaped, so it matches literally rather than as a wildcard.
|
|
363
363
|
|
|
364
|
+
#### Relationship Filters
|
|
365
|
+
|
|
366
|
+
A relationship named in a permit list is no column - its id sits in a foreign-key column instead, which
|
|
367
|
+
`buildFilterConditions` cannot resolve on its own. Declare the permit through `permitsFor`'s own
|
|
368
|
+
`relationship(name, operators)` (`@zeroxsolutions/query`), then hand `columnResolver` the foreign-key
|
|
369
|
+
column under that same name in `resolvers`:
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import { permitsFor } from '@zeroxsolutions/query';
|
|
373
|
+
import { buildFilterConditions, columnResolver } from '@zeroxsolutions/db/utils';
|
|
374
|
+
|
|
375
|
+
const placesQueryPermits = permitsFor(placeAttributes, { relationships: places.relationships }).relationship(
|
|
376
|
+
'region',
|
|
377
|
+
['equals', 'in'],
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const condition = buildFilterConditions({
|
|
381
|
+
table: places,
|
|
382
|
+
filter: query.filter,
|
|
383
|
+
resolvers: { region: columnResolver(places.regionId) },
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`columnResolver` answers `equals` and `in` - the only two operators a relationship filter grants -
|
|
388
|
+
comparing the id against the column it closes over, and throws on any other operator.
|
|
389
|
+
|
|
364
390
|
### Order Builder
|
|
365
391
|
|
|
366
392
|
```ts
|
|
@@ -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
|
-
* @
|
|
18
|
-
* @throws {@link
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
32
|
-
* @throws {@link
|
|
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)) {
|
package/dist/utils/filter.d.ts
CHANGED
|
@@ -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,27 @@ export type FilterableTable = PgTable<PgTableConfig> | SQLiteTable<SQLiteTableCo
|
|
|
95
95
|
export declare class UnknownQueryField extends Error {
|
|
96
96
|
constructor(field: string);
|
|
97
97
|
}
|
|
98
|
-
/**
|
|
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
|
+
/**
|
|
108
|
+
* The resolver for a relationship filter: the relationship's name is no column, and the id it compares is held in its
|
|
109
|
+
* foreign-key column. `{ region: columnResolver(places.regionId) }` declares one.
|
|
110
|
+
*
|
|
111
|
+
* @throws Error on an operator other than `equals` and `in`, the only two a relationship filter grants
|
|
112
|
+
*/
|
|
113
|
+
export declare function columnResolver(column: Column): FilterResolver;
|
|
114
|
+
/** What a filter may reach: the table to resolve fields against, the tree to build, and the paths no column holds. */
|
|
99
115
|
export interface FilterQuery<TTable extends FilterableTable = FilterableTable> {
|
|
100
116
|
table: TTable;
|
|
101
117
|
filter?: FilterNode[] | undefined;
|
|
118
|
+
resolvers?: Readonly<Record<string, FilterResolver>> | undefined;
|
|
102
119
|
}
|
|
103
120
|
/**
|
|
104
121
|
* Builds one SQL condition from a filter tree. Nodes at the root join conjunctively; a group joins
|
|
@@ -112,16 +129,20 @@ export interface FilterQuery<TTable extends FilterableTable = FilterableTable> {
|
|
|
112
129
|
* buildFilterConditions({ table: assessments, ...parsedQuery });
|
|
113
130
|
* ```
|
|
114
131
|
*/
|
|
115
|
-
export declare function buildFilterConditions<TTable extends FilterableTable>({ table, filter, }: FilterQuery<TTable>): SQL | undefined;
|
|
132
|
+
export declare function buildFilterConditions<TTable extends FilterableTable>({ table, filter, resolvers, }: FilterQuery<TTable>): SQL | undefined;
|
|
116
133
|
/**
|
|
117
134
|
* Builds the `order by` clauses for a parsed sort, in the order the clauses were given.
|
|
118
135
|
*
|
|
136
|
+
* @param options.resolvers - an expression per sort path no column holds, tried before a column
|
|
119
137
|
* @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
|
|
138
|
+
* @throws {@link UnknownQueryField} when a clause names a column the table does not hold and no
|
|
139
|
+
* resolver answers it
|
|
121
140
|
* @example
|
|
122
141
|
* ```ts
|
|
123
142
|
* const orderClauses = buildOrderBy(users, [{ field: 'createdAt', direction: 'desc' }]);
|
|
124
143
|
* ```
|
|
125
144
|
*/
|
|
126
|
-
export declare function buildOrderBy<TTable extends PgTable<PgTableConfig> | SQLiteTable<SQLiteTableConfig>>(table: TTable, sort: SortField[] | undefined
|
|
145
|
+
export declare function buildOrderBy<TTable extends PgTable<PgTableConfig> | SQLiteTable<SQLiteTableConfig>>(table: TTable, sort: SortField[] | undefined, options?: {
|
|
146
|
+
resolvers?: Readonly<Record<string, () => SQL | Column>>;
|
|
147
|
+
}): SQL[];
|
|
127
148
|
//# 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,
|
|
1
|
+
{"version":3,"file":"filter.d.ts","sourceRoot":"","sources":["../../src/utils/filter.ts"],"names":[],"mappings":"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"}
|
package/dist/utils/filter.js
CHANGED
|
@@ -88,6 +88,21 @@ export class UnknownQueryField extends Error {
|
|
|
88
88
|
super(`No column resolves the field "${field}".`);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The resolver for a relationship filter: the relationship's name is no column, and the id it compares is held in its
|
|
93
|
+
* foreign-key column. `{ region: columnResolver(places.regionId) }` declares one.
|
|
94
|
+
*
|
|
95
|
+
* @throws Error on an operator other than `equals` and `in`, the only two a relationship filter grants
|
|
96
|
+
*/
|
|
97
|
+
export function columnResolver(column) {
|
|
98
|
+
return (leaf) => {
|
|
99
|
+
if (leaf.operator === 'equals')
|
|
100
|
+
return eq(column, leaf.value);
|
|
101
|
+
if (leaf.operator === 'in')
|
|
102
|
+
return leaf.value.length > 0 ? inArray(column, leaf.value) : undefined;
|
|
103
|
+
throw new Error(`A relationship filter answers equals and in, not "${leaf.operator}".`);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
91
106
|
/**
|
|
92
107
|
* The character that makes the next one literal. It is its own escape, so the character class below
|
|
93
108
|
* must list it first: escaping `%` before `\` would re-escape the backslash the first pass added.
|
|
@@ -107,7 +122,14 @@ function textMatch(context, column, pattern) {
|
|
|
107
122
|
: sql `${column} like ${pattern} escape '\\'`;
|
|
108
123
|
}
|
|
109
124
|
function buildCondition(context, filter) {
|
|
110
|
-
|
|
125
|
+
// Parenthesized because a resolver may return a bare `a or b`, and `and()` joins its operands without
|
|
126
|
+
// grouping them: beside a sibling or a soft-delete scope it would read `a or (b and deleted_at is null)`.
|
|
127
|
+
if (Object.hasOwn(context.resolvers, filter.field)) {
|
|
128
|
+
const expression = context.resolvers[filter.field]?.(filter);
|
|
129
|
+
return expression === undefined ? undefined : sql `(${expression})`;
|
|
130
|
+
}
|
|
131
|
+
// Own keys only: the field is client-written, and `constructor` would otherwise answer from Object.prototype.
|
|
132
|
+
const column = Object.hasOwn(context.columns, filter.field) ? context.columns[filter.field] : undefined;
|
|
111
133
|
if (!column)
|
|
112
134
|
throw new UnknownQueryField(filter.field);
|
|
113
135
|
switch (filter.operator) {
|
|
@@ -179,35 +201,46 @@ function buildGroup(context, group) {
|
|
|
179
201
|
* buildFilterConditions({ table: assessments, ...parsedQuery });
|
|
180
202
|
* ```
|
|
181
203
|
*/
|
|
182
|
-
export function buildFilterConditions({ table, filter, }) {
|
|
204
|
+
export function buildFilterConditions({ table, filter, resolvers, }) {
|
|
183
205
|
if (!filter || filter.length === 0)
|
|
184
206
|
return undefined;
|
|
185
|
-
const conditions = buildNodes({
|
|
207
|
+
const conditions = buildNodes({
|
|
208
|
+
columns: getTableColumns(table),
|
|
209
|
+
caseInsensitiveOperator: is(table, PgTable),
|
|
210
|
+
resolvers: resolvers ?? {},
|
|
211
|
+
}, filter);
|
|
186
212
|
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
187
213
|
}
|
|
188
214
|
/**
|
|
189
215
|
* Builds the `order by` clauses for a parsed sort, in the order the clauses were given.
|
|
190
216
|
*
|
|
217
|
+
* @param options.resolvers - an expression per sort path no column holds, tried before a column
|
|
191
218
|
* @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
|
|
219
|
+
* @throws {@link UnknownQueryField} when a clause names a column the table does not hold and no
|
|
220
|
+
* resolver answers it
|
|
193
221
|
* @example
|
|
194
222
|
* ```ts
|
|
195
223
|
* const orderClauses = buildOrderBy(users, [{ field: 'createdAt', direction: 'desc' }]);
|
|
196
224
|
* ```
|
|
197
225
|
*/
|
|
198
|
-
export function buildOrderBy(table, sort) {
|
|
226
|
+
export function buildOrderBy(table, sort, options = {}) {
|
|
199
227
|
if (!sort || sort.length === 0)
|
|
200
228
|
return [];
|
|
201
229
|
const columns = getTableColumns(table);
|
|
202
230
|
const clauses = [];
|
|
203
231
|
for (const item of sort) {
|
|
204
|
-
const
|
|
205
|
-
|
|
232
|
+
const resolvers = options.resolvers ?? {};
|
|
233
|
+
const expression = Object.hasOwn(resolvers, item.field)
|
|
234
|
+
? resolvers[item.field]?.()
|
|
235
|
+
: Object.hasOwn(columns, item.field)
|
|
236
|
+
? columns[item.field]
|
|
237
|
+
: undefined;
|
|
238
|
+
if (!expression)
|
|
206
239
|
throw new UnknownQueryField(item.field);
|
|
207
240
|
if (item.direction === 'desc')
|
|
208
|
-
clauses.push(desc(
|
|
241
|
+
clauses.push(desc(expression));
|
|
209
242
|
else
|
|
210
|
-
clauses.push(asc(
|
|
243
|
+
clauses.push(asc(expression));
|
|
211
244
|
}
|
|
212
245
|
return clauses;
|
|
213
246
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroxsolutions/db",
|
|
3
|
-
"version": "9.0
|
|
3
|
+
"version": "9.2.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.
|
|
41
|
+
"@zeroxsolutions/query": "0.7.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@testcontainers/postgresql": "^12.1.0",
|