@everystack/api 0.4.6 → 0.4.7
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 +20 -0
- package/package.json +6 -1
- package/src/handler/errors.ts +39 -0
- package/src/handler/index.ts +207 -74
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to `@everystack/api` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.7] - 2026-10-18
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
- date filters no longer throw; refuse impossible calendar dates; map 22007 and 22008 to 422
|
|
12
|
+
- list fields sort by default; the API refuses an order it cannot apply (#92)
|
|
13
|
+
- datetimes show in the viewer's zone and edit round-trip in it (#94)
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- HandlerError carries a machine-readable code and column on RPC answers
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- hiddenColumns accepts the camelCase of a column's SQL name; an ambiguous alias refuses at init
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- move HandlerError to its own import-free module, @everystack/api/errors
|
|
26
|
+
|
|
7
27
|
## [0.4.6] - 2026-10-16
|
|
8
28
|
|
|
9
29
|
### BREAKING CHANGES
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/api",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7",
|
|
4
4
|
"description": "PostgREST-compatible HTTP handler + typed fetch client for Drizzle ORM",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
"types": "./src/handler/index.ts",
|
|
28
28
|
"default": "./src/handler/index.ts"
|
|
29
29
|
},
|
|
30
|
+
"./errors": {
|
|
31
|
+
"source": "./src/handler/errors.ts",
|
|
32
|
+
"types": "./src/handler/errors.ts",
|
|
33
|
+
"default": "./src/handler/errors.ts"
|
|
34
|
+
},
|
|
30
35
|
"./client": {
|
|
31
36
|
"source": "./src/client/index.ts",
|
|
32
37
|
"types": "./src/client/index.ts",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HandlerError lives in its own module with no imports, so code that only needs to throw one
|
|
3
|
+
* (the admin's RPC factory) does not pull the whole server handler into its bundle.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** An application error code: lower-case words, never a Postgres SQLSTATE or a system errno. */
|
|
7
|
+
const APP_ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
8
|
+
const isAppErrorCode = (value: unknown): value is string => typeof value === 'string' && APP_ERROR_CODE.test(value);
|
|
9
|
+
const isAppErrorColumn = (value: unknown): value is string =>
|
|
10
|
+
typeof value === 'string' && value.length >= 1 && value.length <= 128 && !/[\u0000-\u001f\u007f-\u009f]/.test(value);
|
|
11
|
+
|
|
12
|
+
const appErrorMetadata = new WeakMap<object, { readonly code?: string; readonly column?: string }>();
|
|
13
|
+
const EMPTY_METADATA: { readonly code?: string; readonly column?: string } = Object.freeze(Object.create(null));
|
|
14
|
+
|
|
15
|
+
/** Throw from hooks or RPCs to return a custom status and message; RPCs also expose optional metadata. */
|
|
16
|
+
export class HandlerError extends Error {
|
|
17
|
+
status: number;
|
|
18
|
+
constructor(status: number, message: string, options?: { code?: string; column?: string }) {
|
|
19
|
+
super(message);
|
|
20
|
+
const code = options?.code;
|
|
21
|
+
const column = options?.column;
|
|
22
|
+
if (code !== undefined && !isAppErrorCode(code)) {
|
|
23
|
+
throw new TypeError('HandlerError code must match /^[a-z][a-z0-9_]{0,63}$/');
|
|
24
|
+
}
|
|
25
|
+
if (column !== undefined && !isAppErrorColumn(column)) {
|
|
26
|
+
throw new TypeError('HandlerError column must be 1 to 128 characters with no control characters');
|
|
27
|
+
}
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.name = 'HandlerError';
|
|
30
|
+
// Held outside the instance: only what the author passed here is ever sent, and no property
|
|
31
|
+
// or getter on the error can change it afterwards.
|
|
32
|
+
appErrorMetadata.set(this, Object.freeze({ code, column }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The `code` and `column` given to the constructor. Anything assigned to the error later is not here. */
|
|
36
|
+
static metadataOf(error: HandlerError): { code?: string; column?: string } {
|
|
37
|
+
return appErrorMetadata.get(error) ?? EMPTY_METADATA;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/handler/index.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* // Mount as Expo Router API route or any Web Standard handler
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, arrayContains, arrayContained, arrayOverlaps, and, or, not, asc, desc, sql, getTableColumns, Param, type SQL } from 'drizzle-orm';
|
|
10
|
+
import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, arrayContains, arrayContained, arrayOverlaps, and, or, not, asc, desc, sql, getTableColumns, is, Param, type SQL } from 'drizzle-orm';
|
|
11
|
+
import { getTableConfig, PgTable } from 'drizzle-orm/pg-core';
|
|
11
12
|
import { parseArrayLiteral, ARRAY_LITERAL_MAX_ELEMENTS, ARRAY_LITERAL_MAX_ELEMENT_LENGTH } from './array-literal';
|
|
12
13
|
import { emitAudit, resolvePrincipal, type AuditConfig, type RequestContext } from './audit';
|
|
13
14
|
import { isServiceUnavailable, SERVICE_UNAVAILABLE_MESSAGE } from './service-unavailable';
|
|
@@ -25,15 +26,8 @@ type Schema = Record<string, DrizzleTable>;
|
|
|
25
26
|
|
|
26
27
|
type Handler = (request: Request) => Promise<Response>;
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
status: number;
|
|
31
|
-
constructor(status: number, message: string) {
|
|
32
|
-
super(message);
|
|
33
|
-
this.status = status;
|
|
34
|
-
this.name = 'HandlerError';
|
|
35
|
-
}
|
|
36
|
-
}
|
|
29
|
+
export { HandlerError } from './errors';
|
|
30
|
+
import { HandlerError } from './errors';
|
|
37
31
|
|
|
38
32
|
/**
|
|
39
33
|
* A 5xx system fault surfaced to the observability sink. Distinct from a 4xx
|
|
@@ -177,13 +171,18 @@ function describeError(error: unknown): Pick<HandlerFault, 'message' | 'stack' |
|
|
|
177
171
|
}
|
|
178
172
|
|
|
179
173
|
/** Replace schema-revealing error details with a generic message when hideSchemaErrors is enabled. */
|
|
180
|
-
function sanitizeError(
|
|
174
|
+
function sanitizeError(
|
|
175
|
+
message: string,
|
|
176
|
+
status: number,
|
|
177
|
+
options?: HandlerOptions,
|
|
178
|
+
extra?: { code: string; column: string },
|
|
179
|
+
): Response {
|
|
181
180
|
if (options?.hideSchemaErrors) {
|
|
182
181
|
if (status === 404) return json({ message: 'Not found' }, 404);
|
|
183
182
|
if (status === 403) return json({ message: 'Forbidden' }, 403);
|
|
184
|
-
if (status === 400) return json({ message: 'Bad request' }, 400);
|
|
183
|
+
if (status === 400) return json({ message: 'Bad request', ...extra }, 400);
|
|
185
184
|
}
|
|
186
|
-
return json({ message }, status);
|
|
185
|
+
return json({ message, ...extra }, status);
|
|
187
186
|
}
|
|
188
187
|
|
|
189
188
|
export interface AuthConfig {
|
|
@@ -718,6 +717,48 @@ function transformBodyKeys(body: unknown, table: DrizzleTable): unknown {
|
|
|
718
717
|
*/
|
|
719
718
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?$/;
|
|
720
719
|
|
|
720
|
+
/**
|
|
721
|
+
* The one date rule, shared by a write body and a query-string filter so the two cannot
|
|
722
|
+
* drift: the Date for `value`, or the 400 message naming `key`.
|
|
723
|
+
*/
|
|
724
|
+
function parseDateValue(key: string, value: string | number): Date | { error: string } {
|
|
725
|
+
if (typeof value === 'string' && !ISO_DATE_RE.test(value)) {
|
|
726
|
+
return { error: `Invalid date value for "${key}": expected ISO 8601, got ${JSON.stringify(value)}` };
|
|
727
|
+
}
|
|
728
|
+
const parsed = new Date(value);
|
|
729
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
730
|
+
return { error: `Invalid date value for "${key}": ${JSON.stringify(value)}` };
|
|
731
|
+
}
|
|
732
|
+
if (typeof value === 'string' && !isCalendarDate(value.slice(0, 10))) {
|
|
733
|
+
return { error: `Invalid date value for "${key}": ${value.slice(0, 10)} is not a calendar date` };
|
|
734
|
+
}
|
|
735
|
+
return parsed;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The YYYY-MM-DD digits as written name a real day. `new Date('2026-02-29')` rolls over to
|
|
740
|
+
* 1 March with no error, which would match or store the wrong day; PostgreSQL refuses it.
|
|
741
|
+
* Checked on the digits before any offset is applied.
|
|
742
|
+
*/
|
|
743
|
+
function isCalendarDate(ymd: string): boolean {
|
|
744
|
+
const [year, month, day] = ymd.split('-').map(Number);
|
|
745
|
+
const probe = new Date(0);
|
|
746
|
+
probe.setUTCFullYear(year, month - 1, day);
|
|
747
|
+
return probe.getUTCFullYear() === year && probe.getUTCMonth() === month - 1 && probe.getUTCDate() === day;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* A JSON boolean, array or plain object: never a date, and the date encoder throws on it.
|
|
752
|
+
* A Date or a drizzle SQL/Param a hook set has a class prototype and is not one of these.
|
|
753
|
+
*/
|
|
754
|
+
function isJsonShape(value: unknown): boolean {
|
|
755
|
+
if (typeof value === 'boolean' || Array.isArray(value)) return true;
|
|
756
|
+
if (value === null || typeof value !== 'object') return false;
|
|
757
|
+
// Plain from any realm: a parsed body's Object.prototype need not be this module's.
|
|
758
|
+
const proto = Object.getPrototypeOf(value);
|
|
759
|
+
return proto === null || Object.getPrototypeOf(proto) === null;
|
|
760
|
+
}
|
|
761
|
+
|
|
721
762
|
function coerceDateColumnValues(body: unknown, table: DrizzleTable): unknown {
|
|
722
763
|
if (Array.isArray(body)) return body.map(row => coerceDateColumnValues(row, table));
|
|
723
764
|
if (body === null || typeof body !== 'object') {
|
|
@@ -726,16 +767,14 @@ function coerceDateColumnValues(body: unknown, table: DrizzleTable): unknown {
|
|
|
726
767
|
|
|
727
768
|
const result: Record<string, unknown> = { ...(body as Record<string, unknown>) };
|
|
728
769
|
for (const [key, value] of Object.entries(result)) {
|
|
729
|
-
if (typeof value !== 'string' && typeof value !== 'number') continue;
|
|
770
|
+
if (typeof value !== 'string' && typeof value !== 'number' && !isJsonShape(value)) continue;
|
|
730
771
|
const column = getColumn(table, key);
|
|
731
772
|
if (!column || column.dataType !== 'date') continue;
|
|
732
|
-
if (
|
|
773
|
+
if (isJsonShape(value)) {
|
|
733
774
|
throw new HandlerError(400, `Invalid date value for "${key}": expected ISO 8601, got ${JSON.stringify(value)}`);
|
|
734
775
|
}
|
|
735
|
-
const parsed =
|
|
736
|
-
if (
|
|
737
|
-
throw new HandlerError(400, `Invalid date value for "${key}": ${JSON.stringify(value)}`);
|
|
738
|
-
}
|
|
776
|
+
const parsed = parseDateValue(key, value as string | number);
|
|
777
|
+
if (!(parsed instanceof Date)) throw new HandlerError(400, parsed.error);
|
|
739
778
|
result[key] = parsed;
|
|
740
779
|
}
|
|
741
780
|
return result;
|
|
@@ -952,7 +991,16 @@ function resolveHiddenColumns(schema: Schema, options?: HandlerOptions): Handler
|
|
|
952
991
|
const table = schema[tableName];
|
|
953
992
|
const hidden = byTable.get(table) ?? new Set<string>();
|
|
954
993
|
for (const name of names) {
|
|
955
|
-
|
|
994
|
+
let property = columnPropertyName(table, name);
|
|
995
|
+
if (property === undefined && table) {
|
|
996
|
+
const matches = Object.entries(getTableColumns(table))
|
|
997
|
+
.filter(([, column]) => toCamelCase((column as DrizzleColumn).name) === name);
|
|
998
|
+
if (matches.length > 1) {
|
|
999
|
+
const candidates = matches.map(([key]) => JSON.stringify(key)).join(' and ');
|
|
1000
|
+
throw new Error(`[everystack/api] hiddenColumns.${tableName}: "${name}" is ambiguous: it is the camelCase of columns ${candidates}; name one by its property or SQL name`);
|
|
1001
|
+
}
|
|
1002
|
+
property = matches[0]?.[0];
|
|
1003
|
+
}
|
|
956
1004
|
if (property === undefined) {
|
|
957
1005
|
throw new Error(`[everystack/api] hiddenColumns.${tableName}: "${name}" does not resolve to a column`);
|
|
958
1006
|
}
|
|
@@ -979,6 +1027,8 @@ const INTEGRITY_ERRORS: Record<string, { status: number; message: string }> = {
|
|
|
979
1027
|
'22P02': { status: 422, message: 'Invalid value format' },
|
|
980
1028
|
'22001': { status: 422, message: 'Value is too long' },
|
|
981
1029
|
'22003': { status: 422, message: 'Value is out of range' },
|
|
1030
|
+
'22007': { status: 422, message: 'Invalid date or time format' },
|
|
1031
|
+
'22008': { status: 422, message: 'Date or time value is out of range' },
|
|
982
1032
|
};
|
|
983
1033
|
|
|
984
1034
|
type IntegrityAnswer = { status: number; body: {
|
|
@@ -1297,7 +1347,12 @@ export function createHandler(db: DrizzleDb, rawSchema: Schema, rawOptions?: Han
|
|
|
1297
1347
|
});
|
|
1298
1348
|
}
|
|
1299
1349
|
if (error instanceof HandlerError) {
|
|
1300
|
-
|
|
1350
|
+
const { code, column } = HandlerError.metadataOf(error);
|
|
1351
|
+
return json({
|
|
1352
|
+
message: error.message,
|
|
1353
|
+
...(code !== undefined ? { code } : {}),
|
|
1354
|
+
...(column !== undefined ? { column } : {}),
|
|
1355
|
+
}, error.status);
|
|
1301
1356
|
}
|
|
1302
1357
|
if (unavailable) return serviceUnavailableResponse();
|
|
1303
1358
|
if (hostile) return json({ message: options?.hideSchemaErrors ? 'Internal Server Error' : 'RPC execution failed' }, 500);
|
|
@@ -1720,6 +1775,9 @@ async function handleGet(
|
|
|
1720
1775
|
}
|
|
1721
1776
|
|
|
1722
1777
|
const groupColumns = columns || [];
|
|
1778
|
+
const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, false, groupColumns);
|
|
1779
|
+
if ('error' in order) return sanitizeError(order.error, 400, options, { code: order.code, column: order.column });
|
|
1780
|
+
|
|
1723
1781
|
const selectObj = buildAggregateSelect(table, aggregates, groupColumns);
|
|
1724
1782
|
|
|
1725
1783
|
let query: any = db.select(selectObj).from(table);
|
|
@@ -1737,10 +1795,7 @@ async function handleGet(
|
|
|
1737
1795
|
}
|
|
1738
1796
|
}
|
|
1739
1797
|
|
|
1740
|
-
if (
|
|
1741
|
-
const orderExpr = buildOrderBy(table, orderParam);
|
|
1742
|
-
if (orderExpr) query = query.orderBy(orderExpr);
|
|
1743
|
-
}
|
|
1798
|
+
if (order.expressions.length > 0) query = query.orderBy(...order.expressions);
|
|
1744
1799
|
|
|
1745
1800
|
query = query.limit(limitVal).offset(offsetVal);
|
|
1746
1801
|
|
|
@@ -1775,8 +1830,12 @@ async function handleGet(
|
|
|
1775
1830
|
&& (db as any).query?.[tableName];
|
|
1776
1831
|
|
|
1777
1832
|
let rows: unknown[];
|
|
1833
|
+
const isHidden = (column: string): boolean => isHiddenColumn(schema, tableName, column, options);
|
|
1778
1834
|
|
|
1779
1835
|
if (useRelationalQuery) {
|
|
1836
|
+
const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
|
|
1837
|
+
if ('error' in order) return sanitizeError(order.error, 400, options, { code: order.code, column: order.column });
|
|
1838
|
+
|
|
1780
1839
|
// JOIN path — single query via Drizzle relational API (db.query.findMany)
|
|
1781
1840
|
const queryOptions: Record<string, any> = {
|
|
1782
1841
|
limit: limitVal,
|
|
@@ -1798,14 +1857,14 @@ async function handleGet(
|
|
|
1798
1857
|
queryOptions.where = and(...conditions);
|
|
1799
1858
|
}
|
|
1800
1859
|
|
|
1801
|
-
if (
|
|
1802
|
-
const orderExpr = buildOrderBy(table, orderParam);
|
|
1803
|
-
if (orderExpr) queryOptions.orderBy = orderExpr;
|
|
1804
|
-
}
|
|
1860
|
+
if (order.expressions.length > 0) queryOptions.orderBy = order.expressions;
|
|
1805
1861
|
|
|
1806
1862
|
rows = await (db as any).query[tableName].findMany(queryOptions);
|
|
1807
1863
|
} else {
|
|
1808
1864
|
// Batch path — standard select + separate relation queries
|
|
1865
|
+
const order = orderParam === null ? { expressions: [] } : buildOrderBy(table, orderParam, true, undefined, isHidden);
|
|
1866
|
+
if ('error' in order) return sanitizeError(order.error, 400, options, { code: order.code, column: order.column });
|
|
1867
|
+
|
|
1809
1868
|
let query: any = db.select().from(table);
|
|
1810
1869
|
|
|
1811
1870
|
if (conditions.length === 1) {
|
|
@@ -1814,12 +1873,7 @@ async function handleGet(
|
|
|
1814
1873
|
query = query.where(and(...conditions));
|
|
1815
1874
|
}
|
|
1816
1875
|
|
|
1817
|
-
if (
|
|
1818
|
-
const orderExpr = buildOrderBy(table, orderParam);
|
|
1819
|
-
if (orderExpr) {
|
|
1820
|
-
query = query.orderBy(orderExpr);
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1876
|
+
if (order.expressions.length > 0) query = query.orderBy(...order.expressions);
|
|
1823
1877
|
|
|
1824
1878
|
query = query.limit(limitVal).offset(offsetVal);
|
|
1825
1879
|
rows = await query;
|
|
@@ -2042,6 +2096,34 @@ function splitSelectParam(param: string): string[] {
|
|
|
2042
2096
|
return parts;
|
|
2043
2097
|
}
|
|
2044
2098
|
|
|
2099
|
+
/** Check a reference against the resolved hidden policy, including related tables. */
|
|
2100
|
+
function isHiddenColumn(
|
|
2101
|
+
schema: Schema,
|
|
2102
|
+
sourceTable: string,
|
|
2103
|
+
reference: string,
|
|
2104
|
+
options?: HandlerOptions,
|
|
2105
|
+
): boolean {
|
|
2106
|
+
if (!options?.hiddenColumns) return false;
|
|
2107
|
+
|
|
2108
|
+
// A resolved SQL name can contain path syntax; match the literal name first.
|
|
2109
|
+
if (ownEntry(options.hiddenColumns, sourceTable)?.includes(reference)) return true;
|
|
2110
|
+
|
|
2111
|
+
let targetName = sourceTable;
|
|
2112
|
+
let columnName = parseJsonPath(reference)?.baseColumn ?? reference;
|
|
2113
|
+
// Prefer a real source column. The relation fallback only rejects hidden
|
|
2114
|
+
// references; the filter and order builders do not support relation paths.
|
|
2115
|
+
if (!getColumn(schema[targetName], columnName)) {
|
|
2116
|
+
const parts = columnName.split('.');
|
|
2117
|
+
while (parts.length > 1) {
|
|
2118
|
+
const relation = ownEntry(ownEntry(options.relations, targetName), parts.shift()!);
|
|
2119
|
+
if (!relation || !schema[relation.table]) return false;
|
|
2120
|
+
targetName = relation.table;
|
|
2121
|
+
}
|
|
2122
|
+
columnName = parts[0];
|
|
2123
|
+
}
|
|
2124
|
+
return (ownEntry(options.hiddenColumns, targetName) ?? []).includes(columnName);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2045
2127
|
/** Check explicit query references, including each related table's own policy. */
|
|
2046
2128
|
function requestUsesHiddenColumn(
|
|
2047
2129
|
schema: Schema,
|
|
@@ -2051,22 +2133,7 @@ function requestUsesHiddenColumn(
|
|
|
2051
2133
|
): boolean {
|
|
2052
2134
|
if (!options?.hiddenColumns) return false;
|
|
2053
2135
|
|
|
2054
|
-
const isHidden = (sourceTable: string, reference: string): boolean =>
|
|
2055
|
-
let targetName = sourceTable;
|
|
2056
|
-
let columnName = parseJsonPath(reference)?.baseColumn ?? reference;
|
|
2057
|
-
// Prefer a real source column. The relation fallback only rejects hidden
|
|
2058
|
-
// references; the filter and order builders do not support relation paths.
|
|
2059
|
-
if (!getColumn(schema[targetName], columnName)) {
|
|
2060
|
-
const parts = columnName.split('.');
|
|
2061
|
-
while (parts.length > 1) {
|
|
2062
|
-
const relation = ownEntry(ownEntry(options.relations, targetName), parts.shift()!);
|
|
2063
|
-
if (!relation || !schema[relation.table]) return false;
|
|
2064
|
-
targetName = relation.table;
|
|
2065
|
-
}
|
|
2066
|
-
columnName = parts[0];
|
|
2067
|
-
}
|
|
2068
|
-
return (options.hiddenColumns?.[targetName] ?? []).includes(columnName);
|
|
2069
|
-
};
|
|
2136
|
+
const isHidden = (sourceTable: string, reference: string): boolean => isHiddenColumn(schema, sourceTable, reference, options);
|
|
2070
2137
|
|
|
2071
2138
|
const selectUsesHidden = (sourceTable: string, select: string): boolean => {
|
|
2072
2139
|
// Walk the raw selection: parseSelectParam collapses `*,secret` to a
|
|
@@ -2942,6 +3009,33 @@ function buildArrayCondition(column: DrizzleColumn, filter: Filter): any {
|
|
|
2942
3009
|
return filter.negated ? not(condition) : condition;
|
|
2943
3010
|
}
|
|
2944
3011
|
|
|
3012
|
+
/** PostgREST format: in.(val1,val2,val3) */
|
|
3013
|
+
function parseInList(value: string): string[] {
|
|
3014
|
+
return value.replace(/^\(/, '').replace(/\)$/, '').split(',');
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
const DATE_FILTER_OPERATORS = new Set(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in']);
|
|
3018
|
+
|
|
3019
|
+
/**
|
|
3020
|
+
* A date-mode column's driver encoder calls `value.toISOString()`, so a filter value must
|
|
3021
|
+
* reach drizzle as a Date: a string throws a TypeError out of the handler. Each value goes
|
|
3022
|
+
* through the write path's rule (parseDateValue); `in` yields one Date per member. An
|
|
3023
|
+
* operator with no date meaning (like, ilike) is refused, never handed a Date.
|
|
3024
|
+
*/
|
|
3025
|
+
function dateFilterOperand(filter: Filter): Date | Date[] | { __filterError: string } {
|
|
3026
|
+
if (!DATE_FILTER_OPERATORS.has(filter.operator)) {
|
|
3027
|
+
return { __filterError: `Operator '${filter.operator}' is not supported on date column "${filter.column}"` };
|
|
3028
|
+
}
|
|
3029
|
+
const raw = filter.operator === 'in' ? parseInList(filter.value) : [filter.value];
|
|
3030
|
+
const dates: Date[] = [];
|
|
3031
|
+
for (const member of raw) {
|
|
3032
|
+
const parsed = parseDateValue(filter.column, member);
|
|
3033
|
+
if (!(parsed instanceof Date)) return { __filterError: parsed.error };
|
|
3034
|
+
dates.push(parsed);
|
|
3035
|
+
}
|
|
3036
|
+
return filter.operator === 'in' ? dates : dates[0];
|
|
3037
|
+
}
|
|
3038
|
+
|
|
2945
3039
|
function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
2946
3040
|
if (filter.jsonPath) {
|
|
2947
3041
|
return buildJsonCondition(table, filter);
|
|
@@ -2955,26 +3049,35 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2955
3049
|
}
|
|
2956
3050
|
if (ARRAY_OPERATORS.has(filter.operator)) return buildArrayCondition(column, filter);
|
|
2957
3051
|
|
|
3052
|
+
let value: unknown = filter.value;
|
|
3053
|
+
let values: unknown[] | null = null;
|
|
3054
|
+
if (column.dataType === 'date' && filter.operator !== 'is') {
|
|
3055
|
+
const dated = dateFilterOperand(filter);
|
|
3056
|
+
if (isFilterError(dated)) return dated;
|
|
3057
|
+
if (Array.isArray(dated)) values = dated;
|
|
3058
|
+
else value = dated;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
2958
3061
|
let condition: any = null;
|
|
2959
3062
|
|
|
2960
3063
|
switch (filter.operator) {
|
|
2961
3064
|
case 'eq':
|
|
2962
|
-
condition = eq(column,
|
|
3065
|
+
condition = eq(column, value);
|
|
2963
3066
|
break;
|
|
2964
3067
|
case 'neq':
|
|
2965
|
-
condition = ne(column,
|
|
3068
|
+
condition = ne(column, value);
|
|
2966
3069
|
break;
|
|
2967
3070
|
case 'gt':
|
|
2968
|
-
condition = gt(column,
|
|
3071
|
+
condition = gt(column, value);
|
|
2969
3072
|
break;
|
|
2970
3073
|
case 'gte':
|
|
2971
|
-
condition = gte(column,
|
|
3074
|
+
condition = gte(column, value);
|
|
2972
3075
|
break;
|
|
2973
3076
|
case 'lt':
|
|
2974
|
-
condition = lt(column,
|
|
3077
|
+
condition = lt(column, value);
|
|
2975
3078
|
break;
|
|
2976
3079
|
case 'lte':
|
|
2977
|
-
condition = lte(column,
|
|
3080
|
+
condition = lte(column, value);
|
|
2978
3081
|
break;
|
|
2979
3082
|
case 'like':
|
|
2980
3083
|
// PostgREST uses * for wildcard, SQL uses %
|
|
@@ -2987,12 +3090,9 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
2987
3090
|
if (filter.value === 'null') condition = isNull(column);
|
|
2988
3091
|
else if (filter.value === 'not.null') condition = isNotNull(column);
|
|
2989
3092
|
break;
|
|
2990
|
-
case 'in':
|
|
2991
|
-
|
|
2992
|
-
const values = filter.value.replace(/^\(/, '').replace(/\)$/, '').split(',');
|
|
2993
|
-
condition = inArray(column, values);
|
|
3093
|
+
case 'in':
|
|
3094
|
+
condition = inArray(column, values ?? parseInList(filter.value));
|
|
2994
3095
|
break;
|
|
2995
|
-
}
|
|
2996
3096
|
default:
|
|
2997
3097
|
return null;
|
|
2998
3098
|
}
|
|
@@ -3009,18 +3109,51 @@ function buildCondition(table: DrizzleTable, filter: Filter): any {
|
|
|
3009
3109
|
return condition;
|
|
3010
3110
|
}
|
|
3011
3111
|
|
|
3012
|
-
function buildOrderBy(
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
const
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3112
|
+
function buildOrderBy(
|
|
3113
|
+
table: DrizzleTable,
|
|
3114
|
+
orderParam: string,
|
|
3115
|
+
tieBreak: boolean,
|
|
3116
|
+
groupColumns?: string[],
|
|
3117
|
+
isHidden?: (column: string) => boolean,
|
|
3118
|
+
): { expressions: SQL[] } | { error: string; code: 'invalid_order'; column: string } {
|
|
3119
|
+
const columns = Object.values(getTableColumns(table)) as DrizzleColumn[];
|
|
3120
|
+
const expressions: SQL[] = [];
|
|
3121
|
+
const named = new Set<string>();
|
|
3122
|
+
|
|
3123
|
+
for (const part of orderParam.split(',')) {
|
|
3124
|
+
const dotIndex = part.lastIndexOf('.');
|
|
3125
|
+
const columnName = (dotIndex < 0 ? part : part.slice(0, dotIndex)).trim();
|
|
3126
|
+
const direction = dotIndex < 0 ? '' : part.slice(dotIndex + 1).trim();
|
|
3127
|
+
const column = getColumn(table, columnName);
|
|
3128
|
+
// getColumn also sees table properties and inherited names; only real columns can order rows.
|
|
3129
|
+
if (!column || !columns.includes(column)) {
|
|
3130
|
+
return { error: `Unknown order column: ${columnName}`, code: 'invalid_order', column: columnName };
|
|
3131
|
+
}
|
|
3132
|
+
if (direction !== 'asc' && direction !== 'desc') {
|
|
3133
|
+
return { error: `Invalid order direction for ${columnName}: expected asc or desc`, code: 'invalid_order', column: columnName };
|
|
3134
|
+
}
|
|
3135
|
+
if (groupColumns && !groupColumns.some(name => getColumn(table, name) === column)) {
|
|
3136
|
+
return { error: `Order column must be grouped: ${columnName}`, code: 'invalid_order', column: columnName };
|
|
3137
|
+
}
|
|
3138
|
+
expressions.push(direction === 'desc' ? desc(column) : asc(column));
|
|
3139
|
+
named.add(column.name);
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
// Aggregate groups do not represent individual rows, so their order cannot use the row key.
|
|
3143
|
+
if (tieBreak && is(table, PgTable)) {
|
|
3144
|
+
const config = getTableConfig(table);
|
|
3145
|
+
const primaryColumns = [
|
|
3146
|
+
...config.columns.filter(column => column.primary),
|
|
3147
|
+
...config.primaryKeys.flatMap(key => key.columns),
|
|
3148
|
+
];
|
|
3149
|
+
for (const column of primaryColumns) {
|
|
3150
|
+
if (!named.has(column.name) && !isHidden?.(column.name)) {
|
|
3151
|
+
expressions.push(asc(column));
|
|
3152
|
+
named.add(column.name);
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
return { expressions };
|
|
3024
3157
|
}
|
|
3025
3158
|
|
|
3026
3159
|
const RESERVED_QUERY_PARAMS = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
|