@voidbase-cloud/voidbase 0.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.
- package/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/COMPAT.md +43 -0
- package/LICENSE +21 -0
- package/NOTICE +8 -0
- package/README.md +124 -0
- package/bin/voidbase.ts +158 -0
- package/crons/every-minute.ts +13 -0
- package/db/migrations/20260905175935_large_swarm.sql +87 -0
- package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
- package/db/migrations/20260905190723_solid_toro.sql +1 -0
- package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
- package/db/migrations/meta/20260905175935_snapshot.json +599 -0
- package/db/migrations/meta/20260905185720_snapshot.json +703 -0
- package/db/migrations/meta/20260905190723_snapshot.json +710 -0
- package/db/migrations/meta/20260905213340_snapshot.json +781 -0
- package/db/migrations/meta/_journal.json +34 -0
- package/db/schema.ts +130 -0
- package/docs/deploy.md +153 -0
- package/docs/differences.md +88 -0
- package/docs/hooks.md +84 -0
- package/docs/migrating.md +29 -0
- package/docs/perf.md +53 -0
- package/docs/platform.md +208 -0
- package/docs/releasing.md +38 -0
- package/env.ts +23 -0
- package/hooks-plugin.ts +237 -0
- package/package.json +134 -0
- package/queues/jobs.ts +13 -0
- package/routes/api/[...path].ts +19 -0
- package/scripts/bench-realtime.ts +46 -0
- package/scripts/bench.ts +39 -0
- package/scripts/ci-suites.sh +27 -0
- package/scripts/dev.sh +29 -0
- package/scripts/export.ts +70 -0
- package/scripts/seed-app-user.sh +14 -0
- package/scripts/seed-d1.ts +17 -0
- package/scripts/seed-reference.sh +29 -0
- package/scripts/starter.sh +22 -0
- package/scripts/sync-app.ts +22 -0
- package/scripts/sync-panel.ts +66 -0
- package/src/cloud/rest.ts +297 -0
- package/src/node/assets.ts +22 -0
- package/src/node/bundle.ts +88 -0
- package/src/node/cloud-init.ts +51 -0
- package/src/node/d1.ts +44 -0
- package/src/node/deploy-cf.ts +179 -0
- package/src/node/index.ts +5 -0
- package/src/node/panel.ts +21 -0
- package/src/node/serve.ts +125 -0
- package/src/node/storage.ts +51 -0
- package/src/platform/node/env.ts +4 -0
- package/src/platform/node/hooks.ts +19 -0
- package/src/platform/node/log.ts +7 -0
- package/src/platform/node/migrations.ts +5 -0
- package/src/platform/node/photon.ts +1 -0
- package/src/platform/node/sockets.ts +22 -0
- package/src/platform/node/sse.ts +23 -0
- package/src/platform/workers/env.ts +3 -0
- package/src/platform/workers/hooks.ts +2 -0
- package/src/platform/workers/log.ts +1 -0
- package/src/platform/workers/migrations.ts +1 -0
- package/src/platform/workers/photon.ts +1 -0
- package/src/platform/workers/sockets.ts +3 -0
- package/src/platform/workers/sse.ts +1 -0
- package/src/server/api.ts +27 -0
- package/src/server/app.ts +582 -0
- package/src/server/auth-extra.ts +113 -0
- package/src/server/auth-flows.ts +186 -0
- package/src/server/auth-response.ts +111 -0
- package/src/server/auth.ts +187 -0
- package/src/server/backups.ts +234 -0
- package/src/server/batch.ts +123 -0
- package/src/server/bootstrap.ts +71 -0
- package/src/server/collections/auth-option-shape.json +71 -0
- package/src/server/collections/ddl.ts +127 -0
- package/src/server/collections/fields.ts +120 -0
- package/src/server/collections/model.ts +185 -0
- package/src/server/collections/oauth2-providers.json +1 -0
- package/src/server/collections/scaffolds.json +210 -0
- package/src/server/collections/service.ts +392 -0
- package/src/server/collections/system.json +605 -0
- package/src/server/collections/system.ts +19 -0
- package/src/server/collections/validate.ts +239 -0
- package/src/server/crc32.ts +13 -0
- package/src/server/crons.ts +100 -0
- package/src/server/crypto.ts +26 -0
- package/src/server/db.ts +37 -0
- package/src/server/errors.ts +53 -0
- package/src/server/files-api.ts +52 -0
- package/src/server/filter/compile.ts +420 -0
- package/src/server/filter/lexer.ts +107 -0
- package/src/server/filter/parser.ts +49 -0
- package/src/server/hardening.ts +136 -0
- package/src/server/hooks/index.ts +147 -0
- package/src/server/hooks/migrations.ts +58 -0
- package/src/server/hooks/node-async-hooks.d.ts +7 -0
- package/src/server/hooks/record.ts +152 -0
- package/src/server/hooks/runtime.ts +344 -0
- package/src/server/hooks/virtual-migrations.d.ts +4 -0
- package/src/server/hooks/virtual.d.ts +7 -0
- package/src/server/hub.ts +91 -0
- package/src/server/ids.ts +22 -0
- package/src/server/jobs.ts +84 -0
- package/src/server/jwt.ts +61 -0
- package/src/server/logs.ts +144 -0
- package/src/server/mail/index.ts +99 -0
- package/src/server/mail/message.ts +43 -0
- package/src/server/mail/smtp.ts +82 -0
- package/src/server/mail/templates.ts +168 -0
- package/src/server/oauth2/index.ts +198 -0
- package/src/server/oauth2/providers.ts +153 -0
- package/src/server/password.ts +17 -0
- package/src/server/realtime/hub-client.ts +50 -0
- package/src/server/realtime/index.ts +239 -0
- package/src/server/records/expand.ts +129 -0
- package/src/server/records/files.ts +69 -0
- package/src/server/records/json.ts +23 -0
- package/src/server/records/picker.ts +80 -0
- package/src/server/records/service.ts +598 -0
- package/src/server/records/thumbs.ts +148 -0
- package/src/server/records/values.ts +295 -0
- package/src/server/settings-api.ts +104 -0
- package/src/server/settings.ts +215 -0
- package/src/server/sql.ts +61 -0
- package/src/server/static.ts +17 -0
- package/src/server/storage/s3.ts +118 -0
- package/src/server/types.ts +25 -0
- package/src/server/webauthn.ts +168 -0
- package/tsconfig.json +36 -0
- package/tsconfig.node.json +27 -0
- package/types/pb_data.d.ts +24438 -0
- package/vite.config.ts +10 -0
- package/void.json +12 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// Compiles PocketBase filter/rule expressions to SQLite SQL, mirroring tools/search/filter.go and
|
|
2
|
+
// core/record_field_resolver*.go: relation joins, back-relations, json paths, :each/:length/:lower,
|
|
3
|
+
// @request.*, @collection.*, datetime macros, geoDistance, multi-match ("all values") semantics.
|
|
4
|
+
import { isMultiple, type Field } from "../collections/fields";
|
|
5
|
+
import type { Collection } from "../collections/model";
|
|
6
|
+
import { ident } from "../db";
|
|
7
|
+
import { nowString } from "../ids";
|
|
8
|
+
import type { Sign, Token } from "./lexer";
|
|
9
|
+
import { parseFilter, type Expr } from "./parser";
|
|
10
|
+
|
|
11
|
+
export class FilterError extends Error {}
|
|
12
|
+
|
|
13
|
+
export interface AuthInfo {
|
|
14
|
+
collection: Collection;
|
|
15
|
+
row: Record<string, unknown>; // raw table row (hidden fields included)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RequestInfo {
|
|
19
|
+
auth: AuthInfo | null;
|
|
20
|
+
method: string;
|
|
21
|
+
query: Record<string, string>;
|
|
22
|
+
headers: Record<string, string>; // lowercase keys, "-" replaced by "_"
|
|
23
|
+
body: Record<string, unknown>;
|
|
24
|
+
context: string; // default | oauth2 | otp | password | realtime | protectedFile | expand | batch
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CompileOptions {
|
|
28
|
+
base: Collection;
|
|
29
|
+
baseTable?: string; // table or CTE name to select from (defaults to the collection name)
|
|
30
|
+
collections: Map<string, Collection>; // by id and by name
|
|
31
|
+
request: RequestInfo | null;
|
|
32
|
+
allowHiddenFields: boolean;
|
|
33
|
+
aliasSuffix?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Join {
|
|
37
|
+
table: string; // table name or json_each(...) expression
|
|
38
|
+
alias: string;
|
|
39
|
+
on: string; // may reference {P} for the parent alias and {A} for this alias
|
|
40
|
+
params: unknown[];
|
|
41
|
+
multi: boolean; // the join can yield several rows per base row
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Resolved {
|
|
45
|
+
sql: string;
|
|
46
|
+
params: unknown[];
|
|
47
|
+
nullFallback?: "auto" | "disabled" | "enforced";
|
|
48
|
+
joins: Join[];
|
|
49
|
+
multiValue?: { valueSql: string; joins: Join[]; params: unknown[] }; // for "all values must match" checks
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Compiled {
|
|
53
|
+
where: string;
|
|
54
|
+
params: unknown[];
|
|
55
|
+
joins: Join[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MACROS: Record<string, () => unknown> = {
|
|
59
|
+
"@now": () => nowString(),
|
|
60
|
+
"@yesterday": () => nowString(new Date(Date.now() - 86400000)),
|
|
61
|
+
"@tomorrow": () => nowString(new Date(Date.now() + 86400000)),
|
|
62
|
+
"@second": () => new Date().getUTCSeconds(),
|
|
63
|
+
"@minute": () => new Date().getUTCMinutes(),
|
|
64
|
+
"@hour": () => new Date().getUTCHours(),
|
|
65
|
+
"@day": () => new Date().getUTCDate(),
|
|
66
|
+
"@month": () => new Date().getUTCMonth() + 1,
|
|
67
|
+
"@weekday": () => new Date().getUTCDay(),
|
|
68
|
+
"@year": () => new Date().getUTCFullYear(),
|
|
69
|
+
"@todayStart": () => nowString(new Date()).slice(0, 10) + " 00:00:00.000Z",
|
|
70
|
+
"@todayEnd": () => nowString(new Date()).slice(0, 10) + " 23:59:59.999Z",
|
|
71
|
+
"@monthStart": () => nowString(new Date()).slice(0, 7) + "-01 00:00:00.000Z",
|
|
72
|
+
"@monthEnd": () => { const d = new Date(); return nowString(new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0))).slice(0, 10) + " 23:59:59.999Z"; },
|
|
73
|
+
"@yearStart": () => nowString(new Date()).slice(0, 4) + "-01-01 00:00:00.000Z",
|
|
74
|
+
"@yearEnd": () => nowString(new Date()).slice(0, 4) + "-12-31 23:59:59.999Z",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const REQUEST_AUTH_STATIC = new Set(["id", "collectionId", "collectionName", "email", "emailVisibility", "verified"]);
|
|
78
|
+
|
|
79
|
+
export const jsonEach = (col: string) =>
|
|
80
|
+
`json_each(CASE WHEN iif(json_valid(${col}), json_type(${col})='array', FALSE) THEN ${col} ELSE json_array(${col}) END)`;
|
|
81
|
+
export const jsonArrayLength = (col: string) =>
|
|
82
|
+
`json_array_length(CASE WHEN iif(json_valid(${col}), json_type(${col})='array', FALSE) THEN ${col} ELSE (CASE WHEN ${col} = '' OR ${col} IS NULL THEN json_array() ELSE json_array(${col}) END) END)`;
|
|
83
|
+
export function jsonExtract(col: string, path: string): string {
|
|
84
|
+
const p = path && !path.startsWith("[") ? "." + path : path;
|
|
85
|
+
return `(CASE WHEN json_valid(${col}) THEN JSON_EXTRACT(${col}, '$${p}') ELSE JSON_EXTRACT(json_object('pb', ${col}), '$.pb${p}') END)`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isArrayable = (f: Field) => f.type === "select" || f.type === "file" || f.type === "relation";
|
|
89
|
+
const col = (alias: string, name: string) => `${ident(alias)}.${ident(name)}`;
|
|
90
|
+
let seq = 0;
|
|
91
|
+
const uniq = () => (++seq).toString(36);
|
|
92
|
+
|
|
93
|
+
export function compileFilter(filter: string, opts: CompileOptions): Compiled {
|
|
94
|
+
const ast = parseFilter(filter);
|
|
95
|
+
const c = new Compiler(opts);
|
|
96
|
+
const out = c.group(ast);
|
|
97
|
+
return { where: out.sql, params: out.params, joins: c.joins };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Sort: plain fields, @rowid, @random (relation-path sorting lands with the records milestone polish).
|
|
101
|
+
export function compileSort(sort: string, base: Collection, allowHidden: boolean): string {
|
|
102
|
+
if (!sort.trim()) return "";
|
|
103
|
+
const parts: string[] = [];
|
|
104
|
+
for (const raw of sort.split(",")) {
|
|
105
|
+
const s = raw.trim();
|
|
106
|
+
if (!s) continue;
|
|
107
|
+
const desc = s.startsWith("-");
|
|
108
|
+
const name = s.replace(/^[+-]/, "");
|
|
109
|
+
if (name === "@rowid") parts.push(`${ident(base.name)}.${base.type === "view" ? "id" : "rowid"} ${desc ? "DESC" : "ASC"}`);
|
|
110
|
+
else if (name === "@random") parts.push("RANDOM()");
|
|
111
|
+
else {
|
|
112
|
+
const f = (base.fields as Field[]).find((x) => x.name === name);
|
|
113
|
+
if (!f || (f.hidden && !allowHidden)) throw new FilterError(`invalid sort field "${name}"`);
|
|
114
|
+
parts.push(`${col(base.name, f.name)} ${desc ? "DESC" : "ASC"}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return parts.length ? `ORDER BY ${parts.join(", ")}` : "";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class Compiler {
|
|
121
|
+
joins: Join[] = [];
|
|
122
|
+
private baseAlias: string;
|
|
123
|
+
constructor(private o: CompileOptions) {
|
|
124
|
+
this.baseAlias = o.baseTable ?? o.base.name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
group(e: Expr): { sql: string; params: unknown[] } {
|
|
128
|
+
if (e.kind === "cmp") return this.cmp(e.op, e.left, e.right);
|
|
129
|
+
// PocketBase folds left to right: ((a && b) || c)
|
|
130
|
+
let sql = "";
|
|
131
|
+
const params: unknown[] = [];
|
|
132
|
+
e.items.forEach((item, i) => {
|
|
133
|
+
const r = this.group(item.expr);
|
|
134
|
+
const part = item.expr.kind === "group" ? `(${r.sql})` : r.sql;
|
|
135
|
+
sql = i === 0 ? part : `(${sql} ${item.join === "&&" ? "AND" : "OR"} ${part})`;
|
|
136
|
+
params.push(...r.params);
|
|
137
|
+
});
|
|
138
|
+
return { sql, params };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private addJoin(j: Join) {
|
|
142
|
+
if (!this.joins.some((x) => x.alias === j.alias)) this.joins.push(j);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
cmp(op: Sign, leftTok: Token, rightTok: Token): { sql: string; params: unknown[] } {
|
|
146
|
+
// tools/search/filter.go wraps resolver failures with the operand they came from
|
|
147
|
+
const operand = (side: "left" | "right", tok: Token): Resolved => {
|
|
148
|
+
try { return this.resolve(tok); } catch (e) { throw e instanceof FilterError ? new FilterError(`invalid ${side} operand "${tok.literal}" - ${e.message}`) : e; }
|
|
149
|
+
};
|
|
150
|
+
const left = operand("left", leftTok);
|
|
151
|
+
const right = operand("right", rightTok);
|
|
152
|
+
for (const j of [...left.joins, ...right.joins]) this.addJoin(j);
|
|
153
|
+
const base = buildCmp(op, left.sql, left, right.sql, right);
|
|
154
|
+
const params = [...base.params];
|
|
155
|
+
let sql = base.sql;
|
|
156
|
+
const any = op.startsWith("?");
|
|
157
|
+
if (!any) {
|
|
158
|
+
// "all values must match": no row of the multi-valued side may fail the comparison
|
|
159
|
+
for (const [side, other, otherSql] of [[left, right, right.sql], [right, left, left.sql]] as const) {
|
|
160
|
+
if (!side.multiValue) continue;
|
|
161
|
+
const inner = side === left ? buildCmp(op, side.multiValue.valueSql, side, otherSql, other) : buildCmp(op, otherSql, other, side.multiValue.valueSql, side);
|
|
162
|
+
const mmJoins = side.multiValue.joins.map((j) => renderJoin(j)).join(" ");
|
|
163
|
+
sql = `(${sql} AND NOT EXISTS (SELECT 1 FROM ${ident(this.baseAlias)} AS ${ident("__mm_" + this.baseAlias)} ${mmJoins} WHERE ${col("__mm_" + this.baseAlias, "id")} = ${col(this.baseAlias, "id")} AND NOT (${inner.sql})))`;
|
|
164
|
+
params.push(...side.multiValue.params, ...inner.params);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { sql, params };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
resolve(t: Token): Resolved {
|
|
171
|
+
switch (t.type) {
|
|
172
|
+
case "text":
|
|
173
|
+
return { sql: "?", params: [t.literal], nullFallback: "auto", joins: [] };
|
|
174
|
+
case "number":
|
|
175
|
+
return { sql: "?", params: [Number(t.literal)], nullFallback: "auto", joins: [] };
|
|
176
|
+
case "function":
|
|
177
|
+
return this.func(t);
|
|
178
|
+
case "identifier":
|
|
179
|
+
return this.identifier(t.literal);
|
|
180
|
+
default:
|
|
181
|
+
throw new FilterError(`unexpected token ${t.literal}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private func(t: Token): Resolved {
|
|
186
|
+
if (t.literal !== "geoDistance") throw new FilterError(`unknown function "${t.literal}"`);
|
|
187
|
+
const args = (t.meta ?? []).map((a) => this.resolve(a));
|
|
188
|
+
if (args.length !== 4) throw new FilterError("geoDistance expects 4 arguments");
|
|
189
|
+
const [lonA, latA, lonB, latB] = args as [Resolved, Resolved, Resolved, Resolved];
|
|
190
|
+
const sql = `(6371 * acos(cos(radians(${latA.sql})) * cos(radians(${latB.sql})) * cos(radians(${lonB.sql}) - radians(${lonA.sql})) + sin(radians(${latA.sql})) * sin(radians(${latB.sql}))))`;
|
|
191
|
+
return { sql, params: [...latA.params, ...latB.params, ...lonB.params, ...lonA.params, ...latA.params, ...latB.params], nullFallback: "disabled", joins: args.flatMap((a) => a.joins) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private identifier(name: string): Resolved {
|
|
195
|
+
const lower = name.toLowerCase();
|
|
196
|
+
if (lower === "null") return { sql: "NULL", params: [], joins: [] };
|
|
197
|
+
if (lower === "true") return { sql: "1", params: [], joins: [] };
|
|
198
|
+
if (lower === "false") return { sql: "0", params: [], joins: [] };
|
|
199
|
+
if (MACROS[name]) return { sql: "?", params: [MACROS[name]!()], nullFallback: "auto", joins: [] };
|
|
200
|
+
if (name.startsWith("@request.")) return this.request(name);
|
|
201
|
+
if (name.startsWith("@collection.")) return this.collectionRef(name);
|
|
202
|
+
if (name.startsWith("@")) throw new FilterError(`unknown identifier "${name}"`);
|
|
203
|
+
return this.path(this.o.base, this.baseAlias, name.split("."), false);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private request(name: string): Resolved {
|
|
207
|
+
const req = this.o.request;
|
|
208
|
+
const parts = name.split(".");
|
|
209
|
+
const kind = parts[1];
|
|
210
|
+
if (kind === "method") return { sql: "?", params: [req?.method ?? ""], nullFallback: "auto", joins: [] };
|
|
211
|
+
if (kind === "context") return { sql: "?", params: [req?.context ?? "default"], nullFallback: "auto", joins: [] };
|
|
212
|
+
if (kind === "query" || kind === "headers") {
|
|
213
|
+
const key = parts.slice(2).join(".");
|
|
214
|
+
const v = kind === "query" ? req?.query[key] : req?.headers[key.toLowerCase().replace(/-/g, "_")];
|
|
215
|
+
return v === undefined ? { sql: "NULL", params: [], joins: [] } : { sql: "?", params: [v], nullFallback: "auto", joins: [] };
|
|
216
|
+
}
|
|
217
|
+
if (kind === "body") return this.requestBody(parts.slice(2).join("."));
|
|
218
|
+
if (kind === "auth") return this.requestAuth(parts.slice(2));
|
|
219
|
+
throw new FilterError(`unknown identifier "${name}"`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private requestBody(rest: string): Resolved {
|
|
223
|
+
const [field, modifier] = rest.split(":") as [string, string | undefined];
|
|
224
|
+
const body = this.o.request?.body ?? {};
|
|
225
|
+
const has = field in body;
|
|
226
|
+
if (modifier === "isset") return { sql: has ? "1" : "0", params: [], joins: [] };
|
|
227
|
+
const v = body[field];
|
|
228
|
+
if (modifier === "length") {
|
|
229
|
+
const arr = Array.isArray(v) ? v : v === undefined || v === null || v === "" ? [] : [v];
|
|
230
|
+
return { sql: "?", params: [arr.length], nullFallback: "auto", joins: [] };
|
|
231
|
+
}
|
|
232
|
+
if (modifier === "each") {
|
|
233
|
+
const arr = Array.isArray(v) ? v : v === undefined || v === null || v === "" ? [] : [v];
|
|
234
|
+
const alias = `__je_body_${field}_${uniq()}`;
|
|
235
|
+
const join: Join = { table: `json_each(?)`, alias, on: "1=1", params: [JSON.stringify(arr)], multi: true };
|
|
236
|
+
return { sql: col(alias, "value"), params: [], nullFallback: "auto", joins: [join], multiValue: { valueSql: col(alias, "value"), joins: [join], params: [] } };
|
|
237
|
+
}
|
|
238
|
+
if (modifier === "lower") return { sql: "LOWER(?)", params: [v == null ? "" : String(v)], nullFallback: "auto", joins: [] };
|
|
239
|
+
if (v === undefined || v === null) return { sql: "NULL", params: [], joins: [] };
|
|
240
|
+
if (typeof v === "object") return { sql: "?", params: [JSON.stringify(v)], nullFallback: "auto", joins: [] };
|
|
241
|
+
return { sql: "?", params: [typeof v === "boolean" ? (v ? 1 : 0) : v], nullFallback: "auto", joins: [] };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private requestAuth(parts: string[]): Resolved {
|
|
245
|
+
const auth = this.o.request?.auth;
|
|
246
|
+
if (!auth) return { sql: "NULL", params: [], joins: [] };
|
|
247
|
+
const [first] = parts;
|
|
248
|
+
if (parts.length === 1 && first) {
|
|
249
|
+
const [field, modifier] = first.split(":") as [string, string | undefined];
|
|
250
|
+
let v: unknown;
|
|
251
|
+
if (field === "collectionId") v = auth.collection.id;
|
|
252
|
+
else if (field === "collectionName") v = auth.collection.name;
|
|
253
|
+
else {
|
|
254
|
+
const f = (auth.collection.fields as Field[]).find((x) => x.name === field);
|
|
255
|
+
if (!f && !REQUEST_AUTH_STATIC.has(field)) throw new FilterError(`unknown @request.auth field "${field}"`);
|
|
256
|
+
v = auth.row[field];
|
|
257
|
+
if (f && isMultiple(f) && typeof v === "string") { try { v = JSON.parse(v); } catch { v = [v]; } }
|
|
258
|
+
if (f?.type === "bool") v = v ? 1 : 0;
|
|
259
|
+
}
|
|
260
|
+
if (modifier === "lower") return { sql: "LOWER(?)", params: [v == null ? "" : String(v)], nullFallback: "auto", joins: [] };
|
|
261
|
+
if (modifier === "length") return { sql: "?", params: [Array.isArray(v) ? v.length : v ? 1 : 0], nullFallback: "auto", joins: [] };
|
|
262
|
+
if (v === undefined || v === null) return { sql: "NULL", params: [], joins: [] };
|
|
263
|
+
return { sql: "?", params: [typeof v === "object" ? JSON.stringify(v) : v], nullFallback: "auto", joins: [] };
|
|
264
|
+
}
|
|
265
|
+
// nested: join the auth collection on the authenticated id, then walk the path
|
|
266
|
+
const alias = `__auth_${auth.collection.name}`;
|
|
267
|
+
const join: Join = { table: auth.collection.name, alias, on: `${col(alias, "id")} = ?`, params: [String(auth.row.id)], multi: false };
|
|
268
|
+
const res = this.path(auth.collection, alias, parts, true);
|
|
269
|
+
return { ...res, joins: [join, ...res.joins] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private collectionRef(name: string): Resolved {
|
|
273
|
+
// @collection.NAME[:alias].field.path
|
|
274
|
+
const parts = name.split(".");
|
|
275
|
+
const [collName, aliasName] = (parts[1] ?? "").split(":") as [string, string | undefined];
|
|
276
|
+
const collection = this.o.collections.get(collName);
|
|
277
|
+
if (!collection) throw new FilterError(`failed to load collection "${collName}" from field path "${name}"`);
|
|
278
|
+
const alias = `__collection_${aliasName ?? collName}`;
|
|
279
|
+
const join: Join = { table: collection.name, alias, on: "1=1", params: [], multi: true };
|
|
280
|
+
const res = this.path(collection, alias, parts.slice(2), true);
|
|
281
|
+
const joins = [join, ...res.joins];
|
|
282
|
+
return { ...res, joins, multiValue: { valueSql: res.sql, joins, params: [] } };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Walk field.path[.path] from `collection` (aliased `alias`); relation segments add LEFT JOINs.
|
|
286
|
+
private path(collection: Collection, alias: string, parts: string[], allowHidden: boolean): Resolved {
|
|
287
|
+
const joins: Join[] = [];
|
|
288
|
+
let multi = false;
|
|
289
|
+
let cur = collection;
|
|
290
|
+
let curAlias = alias;
|
|
291
|
+
for (let i = 0; i < parts.length; i++) {
|
|
292
|
+
const raw = parts[i]!;
|
|
293
|
+
const isLast = i === parts.length - 1;
|
|
294
|
+
const [prop, rawModifier] = raw.split(":") as [string, string | undefined];
|
|
295
|
+
let modifier = rawModifier;
|
|
296
|
+
const fields = cur.fields as Field[];
|
|
297
|
+
const field = fields.find((f) => f.name === prop);
|
|
298
|
+
if (field && field.hidden && !this.o.allowHiddenFields && !allowHidden) throw new FilterError(`unknown field "${prop}"`);
|
|
299
|
+
|
|
300
|
+
// back-relation: otherCollection_via_relField
|
|
301
|
+
const via = /^(\w+)_via_(\w+)$/.exec(prop);
|
|
302
|
+
if (!field && via && !isLast) {
|
|
303
|
+
// record_field_resolver_runner.go back-relation checks, in PocketBase's order and wording
|
|
304
|
+
const other = this.o.collections.get(via[1]!);
|
|
305
|
+
if (!other) throw new FilterError(`failed to load back relation field "${prop}" collection`);
|
|
306
|
+
const backField = (other.fields as Field[]).find((f) => f.name === via[2]);
|
|
307
|
+
if (!backField) throw new FilterError(`missing back relation field "${via[2]}"`);
|
|
308
|
+
if (backField.type !== "relation") throw new FilterError(`invalid back relation field "${via[2]}"`);
|
|
309
|
+
if (backField.hidden && !this.o.allowHiddenFields) throw new FilterError(`non-filterable back relation field "${backField.name}"`);
|
|
310
|
+
if (backField.collectionId !== cur.id) throw new FilterError(`invalid collection reference of a back relation field "${backField.name}"`);
|
|
311
|
+
const relField = backField;
|
|
312
|
+
const newAlias = `${curAlias}_${prop}`;
|
|
313
|
+
const on = isMultiple(relField)
|
|
314
|
+
? `${col(curAlias, "id")} IN (SELECT value FROM ${jsonEach(col(newAlias, relField.name))})`
|
|
315
|
+
: `${col(newAlias, relField.name)} = ${col(curAlias, "id")}`;
|
|
316
|
+
const hasUnique = cur.indexes.some(() => false); // uniqueness of the back field is not tracked here
|
|
317
|
+
joins.push({ table: other.name, alias: newAlias, on, params: [], multi: true });
|
|
318
|
+
multi = multi || !hasUnique;
|
|
319
|
+
cur = other;
|
|
320
|
+
curAlias = newAlias;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (!field) throw new FilterError(`unknown field "${prop}"`);
|
|
324
|
+
|
|
325
|
+
if (!isLast) {
|
|
326
|
+
if (field.type === "relation") {
|
|
327
|
+
const rel = this.o.collections.get(String(field.collectionId));
|
|
328
|
+
if (!rel) throw new FilterError(`missing relation collection for "${prop}"`);
|
|
329
|
+
const newAlias = `${curAlias}_${prop}`;
|
|
330
|
+
const on = isMultiple(field)
|
|
331
|
+
? `${col(newAlias, "id")} IN (SELECT value FROM ${jsonEach(col(curAlias, prop))})`
|
|
332
|
+
: `${col(newAlias, "id")} = ${col(curAlias, prop)}`;
|
|
333
|
+
joins.push({ table: rel.name, alias: newAlias, on, params: [], multi: isMultiple(field) });
|
|
334
|
+
multi = multi || isMultiple(field);
|
|
335
|
+
cur = rel;
|
|
336
|
+
curAlias = newAlias;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (field.type === "json" || field.type === "geoPoint") {
|
|
340
|
+
const jsonPath = parts.slice(i + 1).join(".");
|
|
341
|
+
const sql = jsonExtract(col(curAlias, prop), jsonPath);
|
|
342
|
+
return { sql, params: [], nullFallback: "auto", joins, multiValue: multi ? { valueSql: sql, joins, params: [] } : undefined };
|
|
343
|
+
}
|
|
344
|
+
throw new FilterError(`invalid path "${parts.join(".")}"`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// last segment
|
|
348
|
+
const c = col(curAlias, prop);
|
|
349
|
+
if (modifier === "length" && !isArrayable(field)) modifier = ""; // PocketBase ignores :length on single-value fields
|
|
350
|
+
if (modifier === "length") {
|
|
351
|
+
const sql = jsonArrayLength(c);
|
|
352
|
+
return { sql, params: [], nullFallback: "auto", joins, multiValue: multi ? { valueSql: sql, joins, params: [] } : undefined };
|
|
353
|
+
}
|
|
354
|
+
if (modifier === "each") {
|
|
355
|
+
if (!isArrayable(field)) throw new FilterError(`":each" modifier is not supported on "${prop}"`);
|
|
356
|
+
const je = `__je_${curAlias}_${prop}`;
|
|
357
|
+
joins.push({ table: jsonEach(c), alias: je, on: "1=1", params: [], multi: true });
|
|
358
|
+
const sql = col(je, "value");
|
|
359
|
+
return { sql, params: [], nullFallback: "auto", joins, multiValue: { valueSql: sql, joins, params: [] } };
|
|
360
|
+
}
|
|
361
|
+
let sql = c;
|
|
362
|
+
if (modifier === "lower") sql = `LOWER(${c})`;
|
|
363
|
+
else if (modifier) throw new FilterError(`unknown modifier ":${modifier}"`);
|
|
364
|
+
else if (field.type === "json") sql = jsonExtract(c, "");
|
|
365
|
+
return { sql, params: [], nullFallback: "auto", joins, multiValue: multi ? { valueSql: sql, joins, params: [] } : undefined };
|
|
366
|
+
}
|
|
367
|
+
throw new FilterError("empty field path");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function renderJoin(j: Join): string {
|
|
372
|
+
const table = j.table.startsWith("json_each(") ? j.table : ident(j.table);
|
|
373
|
+
return `LEFT JOIN ${table} AS ${ident(j.alias)} ON ${j.on}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Operators mirror buildResolversExpr + resolveEqualExpr in tools/search/filter.go.
|
|
377
|
+
function buildCmp(op: Sign, leftSql: string, left: Resolved, rightSql: string, right: Resolved): { sql: string; params: unknown[] } {
|
|
378
|
+
const bare = op.replace("?", "") as Sign;
|
|
379
|
+
switch (bare) {
|
|
380
|
+
case "=": case "!=": {
|
|
381
|
+
const e = equal(bare === "=", leftSql, left, rightSql, right);
|
|
382
|
+
const lp = e.useLeft ? (e.dupLeft ? [...left.params, ...left.params] : left.params) : [];
|
|
383
|
+
const rp = e.useRight ? (e.dupRight ? [...right.params, ...right.params] : right.params) : [];
|
|
384
|
+
return { sql: e.sql, params: [...lp, ...rp] };
|
|
385
|
+
}
|
|
386
|
+
case "~": case "!~": {
|
|
387
|
+
const not = bare === "!~" ? "NOT " : "";
|
|
388
|
+
if (right.params.length === 0) return { sql: `${leftSql} ${not}LIKE ('%' || ${rightSql} || '%') ESCAPE '\\'`, params: [...left.params] };
|
|
389
|
+
return { sql: `${leftSql} ${not}LIKE ${rightSql} ESCAPE '\\'`, params: [...left.params, ...right.params.map(wrapLike)] };
|
|
390
|
+
}
|
|
391
|
+
case "<": case "<=": case ">": case ">=":
|
|
392
|
+
return { sql: `${leftSql} ${bare} ${rightSql}`, params: [...left.params, ...right.params] };
|
|
393
|
+
default:
|
|
394
|
+
throw new FilterError(`unknown operator ${op}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function equal(eq: boolean, l: string, left: Resolved, r: string, right: Resolved): { sql: string; useLeft: boolean; useRight: boolean; dupLeft?: boolean; dupRight?: boolean } {
|
|
399
|
+
const eqOp = eq ? "=" : "IS NOT";
|
|
400
|
+
const nullEqOp = eq ? "IS" : "IS NOT";
|
|
401
|
+
const concat = eq ? "OR" : "AND";
|
|
402
|
+
const nullExpr = eq ? "IS NULL" : "IS NOT NULL";
|
|
403
|
+
if (left.nullFallback === "disabled" || right.nullFallback === "disabled") return { sql: `${l} ${nullEqOp} ${r}`, useLeft: true, useRight: true };
|
|
404
|
+
const isEmptyIdent = (s: string) => ["", "null", "''", '""'].includes(s.toLowerCase());
|
|
405
|
+
const isEmptyParam = (x: Resolved) => x.nullFallback === "auto" && x.params.length === 1 && (x.params[0] === "" || x.params[0] === null);
|
|
406
|
+
const leftEmpty = isEmptyIdent(l) || isEmptyParam(left);
|
|
407
|
+
const rightEmpty = isEmptyIdent(r) || isEmptyParam(right);
|
|
408
|
+
if (leftEmpty && rightEmpty) return { sql: `'' ${eqOp} ''`, useLeft: false, useRight: false };
|
|
409
|
+
const known = (s: string, x: Resolved) => x.nullFallback !== "enforced" && ["1", "0", "true", "false"].includes(s.toLowerCase());
|
|
410
|
+
if (known(l, left) || known(r, right)) return { sql: `${leftEmpty ? "''" : l} ${eqOp} ${rightEmpty ? "''" : r}`, useLeft: !leftEmpty, useRight: !rightEmpty };
|
|
411
|
+
if (leftEmpty) return { sql: `('' ${eqOp} ${r} ${concat} ${r} ${nullExpr})`, useLeft: false, useRight: true, dupRight: true };
|
|
412
|
+
if (rightEmpty) return { sql: `(${l} ${eqOp} '' ${concat} ${l} ${nullExpr})`, useLeft: true, useRight: false, dupLeft: true };
|
|
413
|
+
return { sql: `COALESCE(${l}, '') ${eqOp} COALESCE(${r}, '')`, useLeft: true, useRight: true };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function wrapLike(v: unknown): string {
|
|
417
|
+
const s = String(v);
|
|
418
|
+
if (/(^|[^\\])%/.test(s)) return s;
|
|
419
|
+
return "%" + s.replace(/([\\%_])/g, "\\$1") + "%";
|
|
420
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Tokenizer for PocketBase's filter language (fexpr grammar).
|
|
2
|
+
export type TokenType = "identifier" | "text" | "number" | "sign" | "join" | "group" | "function";
|
|
3
|
+
export interface Token {
|
|
4
|
+
type: TokenType;
|
|
5
|
+
literal: string;
|
|
6
|
+
// group: nested tokens; function: argument tokens
|
|
7
|
+
meta?: Token[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SIGNS = ["?!=", "?!~", "?>=", "?<=", "?=", "?~", "?>", "?<", "!=", "!~", ">=", "<=", "=", "~", ">", "<"] as const;
|
|
11
|
+
export type Sign = (typeof SIGNS)[number];
|
|
12
|
+
|
|
13
|
+
export class FilterSyntaxError extends Error {}
|
|
14
|
+
|
|
15
|
+
const isIdentStart = (c: string) => /[A-Za-z_@#]/.test(c);
|
|
16
|
+
const isIdentChar = (c: string) => /[\w.:@]/.test(c);
|
|
17
|
+
|
|
18
|
+
export function tokenize(input: string): Token[] {
|
|
19
|
+
const out: Token[] = [];
|
|
20
|
+
let i = 0;
|
|
21
|
+
const n = input.length;
|
|
22
|
+
while (i < n) {
|
|
23
|
+
const c = input[i]!;
|
|
24
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; }
|
|
25
|
+
if (c === "/" && input[i + 1] === "/") { while (i < n && input[i] !== "\n") i++; continue; }
|
|
26
|
+
if (c === "(") {
|
|
27
|
+
const end = matchParen(input, i);
|
|
28
|
+
out.push({ type: "group", literal: input.slice(i + 1, end), meta: tokenize(input.slice(i + 1, end)) });
|
|
29
|
+
i = end + 1;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (c === "&" && input[i + 1] === "&") { out.push({ type: "join", literal: "&&" }); i += 2; continue; }
|
|
33
|
+
if (c === "|" && input[i + 1] === "|") { out.push({ type: "join", literal: "||" }); i += 2; continue; }
|
|
34
|
+
if (c === "'" || c === '"') {
|
|
35
|
+
const q = c;
|
|
36
|
+
let j = i + 1;
|
|
37
|
+
let s = "";
|
|
38
|
+
while (j < n && input[j] !== q) {
|
|
39
|
+
if (input[j] === "\\" && j + 1 < n) { s += input[j + 1]; j += 2; continue; }
|
|
40
|
+
s += input[j]; j++;
|
|
41
|
+
}
|
|
42
|
+
if (j >= n) throw new FilterSyntaxError("unterminated string");
|
|
43
|
+
out.push({ type: "text", literal: s });
|
|
44
|
+
i = j + 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (/[0-9]/.test(c) || (c === "-" && /[0-9]/.test(input[i + 1] ?? ""))) {
|
|
48
|
+
let j = i + 1;
|
|
49
|
+
while (j < n && /[0-9.]/.test(input[j]!)) j++;
|
|
50
|
+
out.push({ type: "number", literal: input.slice(i, j) });
|
|
51
|
+
i = j;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const sign = SIGNS.find((s) => input.startsWith(s, i));
|
|
55
|
+
if (sign) { out.push({ type: "sign", literal: sign }); i += sign.length; continue; }
|
|
56
|
+
if (isIdentStart(c)) {
|
|
57
|
+
let j = i + 1;
|
|
58
|
+
while (j < n && isIdentChar(input[j]!)) j++;
|
|
59
|
+
const name = input.slice(i, j);
|
|
60
|
+
if (input[j] === "(") {
|
|
61
|
+
const end = matchParen(input, j);
|
|
62
|
+
const args = splitArgs(input.slice(j + 1, end)).map((a) => {
|
|
63
|
+
const t = tokenize(a);
|
|
64
|
+
if (t.length !== 1) throw new FilterSyntaxError(`invalid function argument "${a}"`);
|
|
65
|
+
return t[0]!;
|
|
66
|
+
});
|
|
67
|
+
out.push({ type: "function", literal: name, meta: args });
|
|
68
|
+
i = end + 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out.push({ type: "identifier", literal: name });
|
|
72
|
+
i = j;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
throw new FilterSyntaxError(`unexpected character "${c}" at ${i}`);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function matchParen(input: string, open: number): number {
|
|
81
|
+
let depth = 0;
|
|
82
|
+
let quote = "";
|
|
83
|
+
for (let i = open; i < input.length; i++) {
|
|
84
|
+
const c = input[i]!;
|
|
85
|
+
if (quote) { if (c === "\\") i++; else if (c === quote) quote = ""; continue; }
|
|
86
|
+
if (c === "'" || c === '"') { quote = c; continue; }
|
|
87
|
+
if (c === "(") depth++;
|
|
88
|
+
else if (c === ")") { depth--; if (depth === 0) return i; }
|
|
89
|
+
}
|
|
90
|
+
throw new FilterSyntaxError("unbalanced parentheses");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function splitArgs(s: string): string[] {
|
|
94
|
+
const out: string[] = [];
|
|
95
|
+
let depth = 0, quote = "", cur = "";
|
|
96
|
+
for (let i = 0; i < s.length; i++) {
|
|
97
|
+
const c = s[i]!;
|
|
98
|
+
if (quote) { cur += c; if (c === "\\") { cur += s[++i] ?? ""; } else if (c === quote) quote = ""; continue; }
|
|
99
|
+
if (c === "'" || c === '"') { quote = c; cur += c; continue; }
|
|
100
|
+
if (c === "(") depth++;
|
|
101
|
+
if (c === ")") depth--;
|
|
102
|
+
if (c === "," && depth === 0) { out.push(cur.trim()); cur = ""; continue; }
|
|
103
|
+
cur += c;
|
|
104
|
+
}
|
|
105
|
+
if (cur.trim()) out.push(cur.trim());
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Parses tokens into PocketBase-style expression groups: a flat list of (join, expr) pairs,
|
|
2
|
+
// where expr is either a comparison or a nested group. Folding order is decided by the compiler.
|
|
3
|
+
import { FilterSyntaxError, tokenize, type Sign, type Token } from "./lexer";
|
|
4
|
+
|
|
5
|
+
export type Expr =
|
|
6
|
+
| { kind: "cmp"; op: Sign; left: Token; right: Token }
|
|
7
|
+
| { kind: "group"; items: ExprItem[] };
|
|
8
|
+
export interface ExprItem { join: "&&" | "||"; expr: Expr }
|
|
9
|
+
|
|
10
|
+
const OPERAND = new Set(["identifier", "text", "number", "function"]);
|
|
11
|
+
|
|
12
|
+
export function parseFilter(input: string): Expr {
|
|
13
|
+
const tokens = tokenize(input);
|
|
14
|
+
return parseGroup(tokens);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseGroup(tokens: Token[]): Expr {
|
|
18
|
+
const items: ExprItem[] = [];
|
|
19
|
+
let i = 0;
|
|
20
|
+
let join: "&&" | "||" = "&&";
|
|
21
|
+
let expectJoin = false;
|
|
22
|
+
while (i < tokens.length) {
|
|
23
|
+
const t = tokens[i]!;
|
|
24
|
+
if (expectJoin) {
|
|
25
|
+
if (t.type !== "join") throw new FilterSyntaxError(`expected && or || near "${t.literal}"`);
|
|
26
|
+
join = t.literal as "&&" | "||";
|
|
27
|
+
expectJoin = false;
|
|
28
|
+
i++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (t.type === "group") {
|
|
32
|
+
if (!t.meta || t.meta.length === 0) throw new FilterSyntaxError("empty group");
|
|
33
|
+
items.push({ join, expr: parseGroup(t.meta) });
|
|
34
|
+
i++;
|
|
35
|
+
expectJoin = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!OPERAND.has(t.type)) throw new FilterSyntaxError(`unexpected token "${t.literal}"`);
|
|
39
|
+
const op = tokens[i + 1];
|
|
40
|
+
const right = tokens[i + 2];
|
|
41
|
+
if (!op || op.type !== "sign") throw new FilterSyntaxError(`expected operator after "${t.literal}"`);
|
|
42
|
+
if (!right || !OPERAND.has(right.type)) throw new FilterSyntaxError(`expected value after "${op.literal}"`);
|
|
43
|
+
items.push({ join, expr: { kind: "cmp", op: op.literal as Sign, left: t, right } });
|
|
44
|
+
i += 3;
|
|
45
|
+
expectJoin = true;
|
|
46
|
+
}
|
|
47
|
+
if (!expectJoin) throw new FilterSyntaxError("incomplete expression");
|
|
48
|
+
return { kind: "group", items };
|
|
49
|
+
}
|