@rebasepro/common 0.6.1 → 0.8.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/dist/collections/CollectionRegistry.d.ts +30 -2
- package/dist/collections/default-collections.d.ts +255 -2
- package/dist/data/buildRoutedRebaseData.d.ts +53 -0
- package/dist/data/filter-dialect.d.ts +61 -0
- package/dist/data/query_builder.d.ts +4 -4
- package/dist/data/resolveDataSource.d.ts +43 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.es.js +777 -178
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +793 -176
- package/dist/index.umd.js.map +1 -1
- package/dist/table-classification.d.ts +47 -0
- package/dist/util/builders.d.ts +48 -1
- package/dist/util/callbacks.d.ts +6 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/permissions.d.ts +26 -2
- package/dist/util/policy/evaluatePolicy.d.ts +31 -0
- package/dist/util/policy/index.d.ts +3 -0
- package/dist/util/policy/policyToPostgres.d.ts +10 -0
- package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
- package/dist/util/policy/sqlToPolicy.d.ts +20 -0
- package/dist/util/storage.d.ts +26 -1
- package/package.json +3 -3
- package/src/collections/CollectionRegistry.ts +80 -16
- package/src/collections/default-collections.ts +4 -4
- package/src/data/buildRebaseData.ts +9 -120
- package/src/data/buildRoutedRebaseData.ts +97 -0
- package/src/data/filter-dialect.ts +318 -0
- package/src/data/query_builder.ts +10 -10
- package/src/data/resolveDataSource.ts +79 -0
- package/src/index.ts +4 -1
- package/src/table-classification.ts +109 -0
- package/src/util/builders.ts +78 -1
- package/src/util/callbacks.ts +8 -1
- package/src/util/index.ts +1 -0
- package/src/util/permissions.test.ts +5 -3
- package/src/util/permissions.ts +85 -158
- package/src/util/policy/evaluatePolicy.ts +146 -0
- package/src/util/policy/index.ts +3 -0
- package/src/util/policy/policyToPostgres.ts +85 -0
- package/src/util/policy/securityRuleToConditions.ts +67 -0
- package/src/util/policy/sqlToPolicy.ts +88 -0
- package/src/util/references.ts +1 -1
- package/src/util/relations.ts +8 -9
- package/src/util/resolutions.ts +6 -6
- package/src/util/storage.ts +34 -1
|
@@ -6,128 +6,13 @@ import {
|
|
|
6
6
|
FindResponse,
|
|
7
7
|
Entity,
|
|
8
8
|
EntityValues,
|
|
9
|
-
FilterValues,
|
|
10
9
|
WhereFilterOp,
|
|
11
|
-
WhereFieldValue,
|
|
12
|
-
WhereFilterOpShort,
|
|
13
10
|
LogicalCondition,
|
|
14
11
|
WhereValue
|
|
15
12
|
} from "@rebasepro/types";
|
|
16
13
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
17
14
|
import { QueryBuilder } from "./query_builder";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Convert where-clause filter object to the internal DataDriver FilterValues format.
|
|
21
|
-
*
|
|
22
|
-
* Supports multiple value formats:
|
|
23
|
-
* - PostgREST string: { status: "eq.published", age: "gte.18" }
|
|
24
|
-
* - Equality shorthand: { company_profile_id: null, status: "active", age: 18 }
|
|
25
|
-
* - Tuple syntax: { age: [">=", 18], role: ["in", ["admin", "editor"]] }
|
|
26
|
-
*
|
|
27
|
-
* Internal: { status: ["==", "published"], age: [">=", 18] }
|
|
28
|
-
*/
|
|
29
|
-
function convertWhereToFilter(where?: Record<string, WhereFieldValue>): FilterValues<string> | undefined {
|
|
30
|
-
if (!where) return undefined;
|
|
31
|
-
|
|
32
|
-
const operatorMap: Record<string, WhereFilterOp> = {
|
|
33
|
-
"eq": "==",
|
|
34
|
-
"neq": "!=",
|
|
35
|
-
"gt": ">",
|
|
36
|
-
"gte": ">=",
|
|
37
|
-
"lt": "<",
|
|
38
|
-
"lte": "<=",
|
|
39
|
-
"in": "in",
|
|
40
|
-
"nin": "not-in",
|
|
41
|
-
"not-in": "not-in",
|
|
42
|
-
"cs": "array-contains",
|
|
43
|
-
"csa": "array-contains-any",
|
|
44
|
-
"==": "==",
|
|
45
|
-
"!=": "!=",
|
|
46
|
-
">": ">",
|
|
47
|
-
">=": ">=",
|
|
48
|
-
"<": "<",
|
|
49
|
-
"<=": "<=",
|
|
50
|
-
"array-contains": "array-contains",
|
|
51
|
-
"array-contains-any": "array-contains-any"
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const filter: FilterValues<string> = {};
|
|
55
|
-
|
|
56
|
-
for (const [field, rawValue] of Object.entries(where)) {
|
|
57
|
-
// Handle null → equality
|
|
58
|
-
if (rawValue === null) {
|
|
59
|
-
filter[field] = ["==", null];
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Handle boolean → equality
|
|
64
|
-
if (typeof rawValue === "boolean") {
|
|
65
|
-
filter[field] = ["==", rawValue];
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Handle number → equality
|
|
70
|
-
if (typeof rawValue === "number") {
|
|
71
|
-
filter[field] = ["==", rawValue];
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Handle tuple or array of tuples
|
|
76
|
-
if (Array.isArray(rawValue)) {
|
|
77
|
-
const conditions: [WhereFilterOpShort, unknown][] = Array.isArray(rawValue[0])
|
|
78
|
-
? (rawValue as [WhereFilterOpShort, unknown][])
|
|
79
|
-
: [rawValue as [WhereFilterOpShort, unknown]];
|
|
80
|
-
|
|
81
|
-
const mappedConditions: [WhereFilterOp, unknown][] = conditions.map(([rawOp, val]) => {
|
|
82
|
-
const mappedOp = operatorMap[rawOp] ?? "==";
|
|
83
|
-
return [mappedOp, val];
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
filter[field] = Array.isArray(rawValue[0]) ? mappedConditions : mappedConditions[0];
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Handle PostgREST string format: "op.value"
|
|
91
|
-
if (typeof rawValue === "string") {
|
|
92
|
-
const dotIndex = rawValue.indexOf(".");
|
|
93
|
-
if (dotIndex === -1) {
|
|
94
|
-
// Plain string equality
|
|
95
|
-
filter[field] = ["==", rawValue];
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const op = rawValue.substring(0, dotIndex);
|
|
100
|
-
let value: unknown = rawValue.substring(dotIndex + 1);
|
|
101
|
-
|
|
102
|
-
// Parse list values like "(admin,editor)"
|
|
103
|
-
if (typeof value === "string" && value.startsWith("(") && value.endsWith(")")) {
|
|
104
|
-
value = value.slice(1, -1).split(",").map((v: string) => v.trim());
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Parse null string
|
|
108
|
-
if (value === "null") {
|
|
109
|
-
value = null;
|
|
110
|
-
}
|
|
111
|
-
// Parse boolean strings
|
|
112
|
-
else if (value === "true") {
|
|
113
|
-
value = true;
|
|
114
|
-
} else if (value === "false") {
|
|
115
|
-
value = false;
|
|
116
|
-
}
|
|
117
|
-
// Try to parse numbers
|
|
118
|
-
else if (typeof value === "string" && !isNaN(Number(value)) && value.trim() !== "") {
|
|
119
|
-
value = Number(value);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const mappedOp = operatorMap[op];
|
|
123
|
-
if (mappedOp) {
|
|
124
|
-
filter[field] = [mappedOp, value];
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return Object.keys(filter).length > 0 ? filter : undefined;
|
|
130
|
-
}
|
|
15
|
+
import { deserializeFilter } from "./filter-dialect";
|
|
131
16
|
|
|
132
17
|
/**
|
|
133
18
|
* Parse an orderBy string like "created_at:desc" into [field, direction].
|
|
@@ -147,11 +32,14 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
|
|
|
147
32
|
const accessor: CollectionAccessor<M> = {
|
|
148
33
|
async find(params?: FindParams): Promise<FindResponse<M>> {
|
|
149
34
|
const orderParsed = parseOrderBy(params?.orderBy);
|
|
35
|
+
// Ensure filters are in canonical [op, value] format even if passed as PostgREST strings
|
|
36
|
+
const filter = params?.where ? deserializeFilter(params.where as any) : undefined;
|
|
37
|
+
|
|
150
38
|
const entities = await driver.fetchCollection<M>({
|
|
151
39
|
path: slug,
|
|
152
40
|
limit: params?.limit,
|
|
153
41
|
offset: params?.offset,
|
|
154
|
-
filter
|
|
42
|
+
filter,
|
|
155
43
|
orderBy: orderParsed?.[0],
|
|
156
44
|
order: orderParsed?.[1],
|
|
157
45
|
searchString: params?.searchString
|
|
@@ -208,9 +96,10 @@ values: {} as Record<string, unknown> }
|
|
|
208
96
|
|
|
209
97
|
count: driver.countEntities
|
|
210
98
|
? async (params?: FindParams): Promise<number> => {
|
|
99
|
+
const filter = params?.where ? deserializeFilter(params.where as any) : undefined;
|
|
211
100
|
return driver.countEntities!({
|
|
212
101
|
path: slug,
|
|
213
|
-
filter
|
|
102
|
+
filter
|
|
214
103
|
});
|
|
215
104
|
}
|
|
216
105
|
: undefined,
|
|
@@ -224,7 +113,7 @@ values: {} as Record<string, unknown> }
|
|
|
224
113
|
path: slug,
|
|
225
114
|
limit: params?.limit,
|
|
226
115
|
offset: params?.offset,
|
|
227
|
-
filter:
|
|
116
|
+
filter: params?.where,
|
|
228
117
|
orderBy: orderParsed?.[0],
|
|
229
118
|
order: orderParsed?.[1],
|
|
230
119
|
searchString: params?.searchString,
|
|
@@ -254,7 +143,7 @@ values: {} as Record<string, unknown> }
|
|
|
254
143
|
} : undefined,
|
|
255
144
|
|
|
256
145
|
// Fluent Query Builder
|
|
257
|
-
where(columnOrCondition: string | LogicalCondition, operator?:
|
|
146
|
+
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown) {
|
|
258
147
|
const builder = new QueryBuilder<M>(accessor);
|
|
259
148
|
if (typeof columnOrCondition === "object") {
|
|
260
149
|
return builder.where(columnOrCondition);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { RebaseData, CollectionAccessor } from "@rebasepro/types";
|
|
2
|
+
import { toSnakeCase } from "@rebasepro/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parameters for {@link buildRoutedRebaseData}.
|
|
6
|
+
*/
|
|
7
|
+
export interface RoutedRebaseDataParams {
|
|
8
|
+
/**
|
|
9
|
+
* The default data source. Handles every collection that does not
|
|
10
|
+
* resolve to an entry in `sources` (i.e. server-transport collections,
|
|
11
|
+
* which ride the Rebase client).
|
|
12
|
+
*/
|
|
13
|
+
defaultData: RebaseData;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-data-source {@link RebaseData} instances for direct and custom
|
|
17
|
+
* transports, keyed by data-source key (e.g. `"analytics"`). Server-
|
|
18
|
+
* mediated sources are not listed here — they fall through to
|
|
19
|
+
* `defaultData`.
|
|
20
|
+
*/
|
|
21
|
+
sources: Record<string, RebaseData>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the data-source key for a given collection slug or path.
|
|
25
|
+
* Typically backed by the collection registry + `resolveDataSource`
|
|
26
|
+
* (`resolveDataSource(registry.getCollection(path), defs).key`).
|
|
27
|
+
*
|
|
28
|
+
* Return `undefined` (or a key absent from `sources`) to route to the
|
|
29
|
+
* default data source.
|
|
30
|
+
*/
|
|
31
|
+
resolveKey: (slugOrPath: string) => string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a {@link RebaseData} that routes each collection to the right
|
|
36
|
+
* backend based on its resolved data source.
|
|
37
|
+
*
|
|
38
|
+
* `.collection(path)` (and dynamic `data.products`-style access) resolves the
|
|
39
|
+
* collection's data-source key via `resolveKey` and delegates to the matching
|
|
40
|
+
* entry in `sources`, falling back to `defaultData` when there is no match.
|
|
41
|
+
* Because routing keys off the *path being accessed*, a reference widget
|
|
42
|
+
* inside a Firestore form that points at a Postgres collection is still
|
|
43
|
+
* served by Postgres — routing follows the target, not the ancestor.
|
|
44
|
+
*
|
|
45
|
+
* When `sources` is empty this returns `defaultData` untouched, so the
|
|
46
|
+
* single-driver setup keeps identical behaviour and identity (important for
|
|
47
|
+
* effect dependencies that key off the data instance).
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* const data = buildRoutedRebaseData({
|
|
51
|
+
* defaultData: client.data,
|
|
52
|
+
* sources: { analytics: buildRebaseData(firestoreDriver) },
|
|
53
|
+
* resolveKey: (path) => resolveDataSource(registry.getCollection(path), defs).key
|
|
54
|
+
* });
|
|
55
|
+
* await data.products.find(); // → default (server / Postgres)
|
|
56
|
+
* await data.events.find(); // → Firestore, if `events.dataSource === "analytics"`
|
|
57
|
+
*/
|
|
58
|
+
export function buildRoutedRebaseData({
|
|
59
|
+
defaultData,
|
|
60
|
+
sources,
|
|
61
|
+
resolveKey
|
|
62
|
+
}: RoutedRebaseDataParams): RebaseData {
|
|
63
|
+
|
|
64
|
+
// Fast path: nothing to route → return the default untouched (preserves
|
|
65
|
+
// referential identity for effect dependencies).
|
|
66
|
+
if (!sources || Object.keys(sources).length === 0) {
|
|
67
|
+
return defaultData;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolve(slugOrPath: string): RebaseData {
|
|
71
|
+
const key = resolveKey(slugOrPath);
|
|
72
|
+
if (key && sources[key]) return sources[key];
|
|
73
|
+
return defaultData;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getAccessor(slugOrPath: string): CollectionAccessor {
|
|
77
|
+
return resolve(slugOrPath).collection(slugOrPath);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const target = {
|
|
81
|
+
collection: getAccessor
|
|
82
|
+
} as RebaseData;
|
|
83
|
+
|
|
84
|
+
return new Proxy(target, {
|
|
85
|
+
get(_target, prop: string | symbol) {
|
|
86
|
+
if (prop === "collection") return getAccessor;
|
|
87
|
+
// Ignore Symbol properties (e.g. Symbol.toPrimitive, Symbol.iterator)
|
|
88
|
+
if (typeof prop === "symbol") return undefined;
|
|
89
|
+
// Ignore internal JS properties
|
|
90
|
+
if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return undefined;
|
|
91
|
+
|
|
92
|
+
// Convert camelCase property names to snake_case slugs, mirroring
|
|
93
|
+
// buildRebaseData so dynamic access routes consistently.
|
|
94
|
+
return getAccessor(toSnakeCase(prop));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST wire-format adapter for the unified filter system.
|
|
3
|
+
*
|
|
4
|
+
* This module is the ONLY code in the entire codebase that knows about
|
|
5
|
+
* PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
|
|
6
|
+
* Everything else speaks `FilterValues` exclusively.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
WhereFilterOp,
|
|
13
|
+
FilterValues,
|
|
14
|
+
CANONICAL_TO_REST,
|
|
15
|
+
REST_TO_CANONICAL,
|
|
16
|
+
RestFilterOp,
|
|
17
|
+
toCanonicalOp,
|
|
18
|
+
LogicalCondition,
|
|
19
|
+
FilterCondition
|
|
20
|
+
} from "@rebasepro/types";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Value coercion (querystring → typed JS values)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Coerce a raw querystring value to its natural JS type.
|
|
28
|
+
* - `"true"` / `"false"` → boolean
|
|
29
|
+
* - `"null"` → null
|
|
30
|
+
* - Numeric strings → number
|
|
31
|
+
* - Everything else → string (unchanged)
|
|
32
|
+
*/
|
|
33
|
+
function coerceValue(raw: string): unknown {
|
|
34
|
+
if (raw === "true") return true;
|
|
35
|
+
if (raw === "false") return false;
|
|
36
|
+
if (raw === "null") return null;
|
|
37
|
+
if (raw !== "" && !isNaN(Number(raw))) return Number(raw);
|
|
38
|
+
return raw;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Serialize a JS value to its querystring representation.
|
|
43
|
+
*/
|
|
44
|
+
function stringifyValue(value: unknown): string {
|
|
45
|
+
if (value === null) return "null";
|
|
46
|
+
if (typeof value === "boolean") return String(value);
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Serialize: FilterValues → REST querystring
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Serialize a single condition tuple to a PostgREST dot-string.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* serializeTuple(["==", "active"]) // "eq.active"
|
|
59
|
+
* serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
|
|
60
|
+
* serializeTuple([">=", 18]) // "gte.18"
|
|
61
|
+
*/
|
|
62
|
+
function serializeTuple(tuple: [WhereFilterOp, unknown] | unknown): string {
|
|
63
|
+
// If it's already a string, it might be a PostgREST string (with dot)
|
|
64
|
+
// or a raw value (without dot). In both cases, existing tests expect
|
|
65
|
+
// them to be passed through or treated as simple equality if no dot.
|
|
66
|
+
if (typeof tuple === "string") {
|
|
67
|
+
if (tuple.includes(".")) {
|
|
68
|
+
const dotIndex = tuple.indexOf(".");
|
|
69
|
+
const prefix = tuple.substring(0, dotIndex);
|
|
70
|
+
if ((REST_TO_CANONICAL as any)[prefix]) {
|
|
71
|
+
return tuple;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return tuple;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// If it's NOT a canonical tuple [WhereFilterOp, value], treat as equality.
|
|
78
|
+
if (!Array.isArray(tuple) || tuple.length !== 2 || typeof tuple[0] !== "string" || !(CANONICAL_TO_REST as any)[tuple[0]]) {
|
|
79
|
+
return `eq.${stringifyValue(tuple)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [op, value] = tuple as [WhereFilterOp, unknown];
|
|
83
|
+
const restOp = CANONICAL_TO_REST[op];
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
const items = value.map(stringifyValue).join(",");
|
|
87
|
+
return `${restOp}.(${items})`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return `${restOp}.${stringifyValue(value)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Convert `FilterValues` to a PostgREST-style querystring record.
|
|
95
|
+
*
|
|
96
|
+
* - Single conditions produce a string value.
|
|
97
|
+
* - Multiple conditions on the same field produce a string array (repeated params).
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* serializeFilter({ status: ["==", "active"] })
|
|
101
|
+
* // → { status: "eq.active" }
|
|
102
|
+
*
|
|
103
|
+
* serializeFilter({ age: [[">=", 18], ["<", 65]] })
|
|
104
|
+
* // → { age: ["gte.18", "lt.65"] }
|
|
105
|
+
*/
|
|
106
|
+
export function serializeFilter(
|
|
107
|
+
filter: FilterValues<string> | Record<string, any>
|
|
108
|
+
): Record<string, string | string[]> {
|
|
109
|
+
const result: Record<string, string | string[]> = {};
|
|
110
|
+
|
|
111
|
+
for (const [field, condition] of Object.entries(filter)) {
|
|
112
|
+
if (condition === undefined) continue;
|
|
113
|
+
|
|
114
|
+
// Multiple conditions on the same field: array of tuples
|
|
115
|
+
// We detect this by checking if the first element is also an array.
|
|
116
|
+
if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) {
|
|
117
|
+
result[field] = (condition as any[]).map(serializeTuple);
|
|
118
|
+
} else {
|
|
119
|
+
// Single condition (could be a tuple, a raw value, or an already-serialized string)
|
|
120
|
+
result[field] = serializeTuple(condition);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Deserialize: REST querystring → FilterValues
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
|
|
133
|
+
*
|
|
134
|
+
* If the string doesn't match a known operator prefix, falls back to
|
|
135
|
+
* `["==", originalString]` (treating the whole string as an equality value).
|
|
136
|
+
*/
|
|
137
|
+
function deserializeSingle(raw: string): [WhereFilterOp, unknown] {
|
|
138
|
+
const dotIndex = raw.indexOf(".");
|
|
139
|
+
if (dotIndex === -1) {
|
|
140
|
+
// No dot → equality on the raw value (coerced)
|
|
141
|
+
return ["==", coerceValue(raw)];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const prefix = raw.substring(0, dotIndex);
|
|
145
|
+
const rest = raw.substring(dotIndex + 1);
|
|
146
|
+
|
|
147
|
+
// Check if the prefix is a known REST operator
|
|
148
|
+
const canonicalOp = (REST_TO_CANONICAL as Record<string, WhereFilterOp | undefined>)[prefix];
|
|
149
|
+
if (!canonicalOp) {
|
|
150
|
+
// Not a known operator (e.g., email "user@host.com" or version "1.2.3")
|
|
151
|
+
// Treat the entire string as an equality value
|
|
152
|
+
return ["==", raw];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Parse list values: "(admin,editor)" → ["admin", "editor"]
|
|
156
|
+
if (rest.startsWith("(") && rest.endsWith(")")) {
|
|
157
|
+
const items = rest.slice(1, -1).split(",").map(s => coerceValue(s.trim()));
|
|
158
|
+
return [canonicalOp, items];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return [canonicalOp, coerceValue(rest)];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Convert a PostgREST-style querystring record to `FilterValues`.
|
|
166
|
+
*
|
|
167
|
+
* - String values are parsed as single conditions.
|
|
168
|
+
* - String arrays (repeated query params) become multiple conditions on the same field.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* deserializeFilter({ status: "eq.active" })
|
|
172
|
+
* // → { status: ["==", "active"] }
|
|
173
|
+
*
|
|
174
|
+
* deserializeFilter({ age: ["gte.18", "lt.65"] })
|
|
175
|
+
* // → { age: [[">=", 18], ["<", 65]] }
|
|
176
|
+
*/
|
|
177
|
+
export function deserializeFilter(
|
|
178
|
+
query: Record<string, any>
|
|
179
|
+
): FilterValues<string> {
|
|
180
|
+
const result: FilterValues<string> = {};
|
|
181
|
+
|
|
182
|
+
for (const [field, raw] of Object.entries(query)) {
|
|
183
|
+
if (raw === undefined) continue;
|
|
184
|
+
|
|
185
|
+
// If it's already a canonical tuple [op, value], keep it as is
|
|
186
|
+
if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && toCanonicalOp(raw[0]) === raw[0]) {
|
|
187
|
+
result[field] = raw as [WhereFilterOp, unknown];
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (Array.isArray(raw)) {
|
|
192
|
+
if (raw.length === 0) continue;
|
|
193
|
+
|
|
194
|
+
// Check if it's an array of canonical tuples
|
|
195
|
+
if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && toCanonicalOp(raw[0][0]) === raw[0][0]) {
|
|
196
|
+
result[field] = raw as [WhereFilterOp, unknown][];
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (raw.length === 1) {
|
|
201
|
+
result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
|
|
202
|
+
} else {
|
|
203
|
+
// If the elements are strings, they might be PostgREST dot-strings (repeated params)
|
|
204
|
+
if (typeof raw[0] === "string" && raw[0].includes(".")) {
|
|
205
|
+
result[field] = raw.map(r => typeof r === "string" ? deserializeSingle(r) : (["==", r] as [WhereFilterOp, unknown])) as [WhereFilterOp, unknown][];
|
|
206
|
+
} else {
|
|
207
|
+
// Otherwise assume it's a list of values for an implicit "in" or just multiple conditions
|
|
208
|
+
result[field] = ["in", raw];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} else if (typeof raw === "string") {
|
|
212
|
+
result[field] = deserializeSingle(raw);
|
|
213
|
+
} else {
|
|
214
|
+
result[field] = ["==", raw];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Logical conditions: serialize / deserialize
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
|
|
230
|
+
* // → "status.eq.active"
|
|
231
|
+
*
|
|
232
|
+
* serializeLogicalCondition({ type: "or", conditions: [...] })
|
|
233
|
+
* // → "or(status.eq.active,status.eq.pending)"
|
|
234
|
+
*/
|
|
235
|
+
export function serializeLogicalCondition(
|
|
236
|
+
cond: LogicalCondition | FilterCondition
|
|
237
|
+
): string {
|
|
238
|
+
if ("type" in cond) {
|
|
239
|
+
// LogicalCondition (and/or)
|
|
240
|
+
const inner = (cond.conditions ?? [])
|
|
241
|
+
.map(serializeLogicalCondition)
|
|
242
|
+
.join(",");
|
|
243
|
+
return `${cond.type}(${inner})`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// FilterCondition
|
|
247
|
+
const restOp = (CANONICAL_TO_REST as any)[cond.operator] || "eq";
|
|
248
|
+
if (Array.isArray(cond.value)) {
|
|
249
|
+
const items = cond.value.map(stringifyValue).join(",");
|
|
250
|
+
return `${cond.column}.${restOp}.(${items})`;
|
|
251
|
+
}
|
|
252
|
+
return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Parse a logical condition wire-format string back into a
|
|
257
|
+
* `LogicalCondition` or `FilterCondition`.
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* deserializeLogicalCondition("status.eq.active")
|
|
261
|
+
* // → { column: "status", operator: "==", value: "active" }
|
|
262
|
+
*
|
|
263
|
+
* deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
|
|
264
|
+
* // → { type: "or", conditions: [...] }
|
|
265
|
+
*/
|
|
266
|
+
export function deserializeLogicalCondition(
|
|
267
|
+
str: string
|
|
268
|
+
): LogicalCondition | FilterCondition {
|
|
269
|
+
// Check for logical group: "and(...)" or "or(...)"
|
|
270
|
+
const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
|
|
271
|
+
if (logicalMatch) {
|
|
272
|
+
const type = logicalMatch[1] as "and" | "or";
|
|
273
|
+
const innerStr = logicalMatch[2];
|
|
274
|
+
|
|
275
|
+
// Split on commas that are not inside parentheses
|
|
276
|
+
const conditions: (LogicalCondition | FilterCondition)[] = [];
|
|
277
|
+
let depth = 0;
|
|
278
|
+
let start = 0;
|
|
279
|
+
for (let i = 0; i < innerStr.length; i++) {
|
|
280
|
+
if (innerStr[i] === "(") depth++;
|
|
281
|
+
else if (innerStr[i] === ")") depth--;
|
|
282
|
+
else if (innerStr[i] === "," && depth === 0) {
|
|
283
|
+
conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
|
|
284
|
+
start = i + 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
|
|
288
|
+
|
|
289
|
+
return { type, conditions };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// FilterCondition: "column.op.value"
|
|
293
|
+
const firstDot = str.indexOf(".");
|
|
294
|
+
if (firstDot === -1) {
|
|
295
|
+
return { column: str, operator: "==", value: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const column = str.substring(0, firstDot);
|
|
299
|
+
const rest = str.substring(firstDot + 1);
|
|
300
|
+
|
|
301
|
+
const secondDot = rest.indexOf(".");
|
|
302
|
+
if (secondDot === -1) {
|
|
303
|
+
// "column.value" — treat as equality
|
|
304
|
+
return { column, operator: "==", value: coerceValue(rest) };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const opStr = rest.substring(0, secondDot);
|
|
308
|
+
let valueStr = rest.substring(secondDot + 1);
|
|
309
|
+
const operator = toCanonicalOp(opStr) ?? "==";
|
|
310
|
+
|
|
311
|
+
// Parse list values
|
|
312
|
+
if (valueStr.startsWith("(") && valueStr.endsWith(")")) {
|
|
313
|
+
const items = valueStr.slice(1, -1).split(",").map(s => coerceValue(s.trim()));
|
|
314
|
+
return { column, operator, value: items };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { column, operator, value: coerceValue(valueStr) };
|
|
318
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FindParams, Entity, FindResponse, CollectionAccessor, QueryBuilderInterface,
|
|
1
|
+
import { FindParams, Entity, FindResponse, CollectionAccessor, QueryBuilderInterface, WhereFilterOp, LogicalCondition, WhereValue, FilterCondition } from "@rebasepro/types";
|
|
2
2
|
|
|
3
3
|
export function or(...conditions: (FilterCondition | LogicalCondition)[]): LogicalCondition {
|
|
4
4
|
return { type: "or",
|
|
@@ -10,7 +10,7 @@ export function and(...conditions: (FilterCondition | LogicalCondition)[]): Logi
|
|
|
10
10
|
conditions };
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function cond(column: string, operator:
|
|
13
|
+
export function cond(column: string, operator: WhereFilterOp, value: unknown): FilterCondition {
|
|
14
14
|
return { column,
|
|
15
15
|
operator,
|
|
16
16
|
value };
|
|
@@ -26,9 +26,9 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
26
26
|
* @example
|
|
27
27
|
* client.collection('users').where('age', '>=', 18).find()
|
|
28
28
|
*/
|
|
29
|
-
where<K extends keyof M & string>(column: K, operator:
|
|
29
|
+
where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
|
|
30
30
|
where(logicalCondition: LogicalCondition): this;
|
|
31
|
-
where(columnOrCondition: string | LogicalCondition, operator?:
|
|
31
|
+
where(columnOrCondition: string | LogicalCondition, operator?: WhereFilterOp, value?: unknown): this {
|
|
32
32
|
// Handle LogicalCondition signature
|
|
33
33
|
if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
|
|
34
34
|
this.params.logical = columnOrCondition as LogicalCondition;
|
|
@@ -40,18 +40,18 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const column = columnOrCondition as string;
|
|
43
|
-
const condition: [
|
|
43
|
+
const condition: [WhereFilterOp, unknown] = [operator!, value];
|
|
44
44
|
const existing = this.params.where[column];
|
|
45
45
|
|
|
46
46
|
if (existing === undefined) {
|
|
47
47
|
this.params.where[column] = condition;
|
|
48
48
|
} else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) {
|
|
49
|
-
(this.params.where[column] as [
|
|
49
|
+
(this.params.where[column] as [WhereFilterOp, unknown][]).push(condition);
|
|
50
50
|
} else {
|
|
51
51
|
// Convert existing single tuple/value into array of tuples
|
|
52
|
-
let firstCondition: [
|
|
52
|
+
let firstCondition: [WhereFilterOp, unknown];
|
|
53
53
|
if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") {
|
|
54
|
-
firstCondition = existing as [
|
|
54
|
+
firstCondition = existing as [WhereFilterOp, unknown];
|
|
55
55
|
} else {
|
|
56
56
|
firstCondition = ["==", existing];
|
|
57
57
|
}
|
|
@@ -66,8 +66,8 @@ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unk
|
|
|
66
66
|
* @example
|
|
67
67
|
* client.collection('users').orderBy('createdAt', 'desc').find()
|
|
68
68
|
*/
|
|
69
|
-
orderBy(column: keyof M & string,
|
|
70
|
-
this.params.orderBy = `${column}:${
|
|
69
|
+
orderBy(column: keyof M & string, direction: "asc" | "desc" = "asc"): this {
|
|
70
|
+
this.params.orderBy = `${column}:${direction}`;
|
|
71
71
|
return this;
|
|
72
72
|
}
|
|
73
73
|
|