Files
plainleaf/client/runtime_api/translate_query.ts
T

168 lines
4.7 KiB
TypeScript

import { parseExpressionString } from "../space_lua/parse.ts";
import type {
LuaCollectionQuery,
LuaOrderBy,
} from "../space_lua/query_collection.ts";
import type { LuaExpression } from "../space_lua/ast.ts";
export type FilterOp =
| "eq"
| "ne"
| "gt"
| "gte"
| "lt"
| "lte"
| "in"
| "contains"
| "startsWith";
export type FilterValue = string;
export type Filter = { field: string; op: FilterOp; value: FilterValue };
export type OrderKey = { field: string; desc: boolean };
export type ListRequest = {
tag: string;
filters: Filter[];
order: OrderKey[];
limit: number;
offset: number;
select?: string[];
};
export type TranslatedQuery = {
query: LuaCollectionQuery;
equivalentLua: string;
};
const FIELD_PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
export class TranslationError extends Error {
constructor(
public code: string,
public cause: string,
) {
super(`${code}: ${cause}`);
}
}
function validateField(field: string): string {
if (!FIELD_PATH_RE.test(field)) {
throw new TranslationError("bad_field", `invalid field path: ${field}`);
}
return field;
}
export function serializeLuaValue(raw: FilterValue): string {
if (raw.startsWith("num:")) return String(Number(raw.slice(4)));
if (raw.startsWith("str:")) return JSON.stringify(raw.slice(4));
if (raw.startsWith("bool:"))
return raw.slice(5) === "true" ? "true" : "false";
if (/^-?\d+(\.\d+)?$/.test(raw)) return raw;
if (raw === "true" || raw === "false") return raw;
if (raw === "null") return "nil";
return JSON.stringify(raw);
}
function clauseFor(filter: Filter): string {
const f = `_it.${validateField(filter.field)}`;
switch (filter.op) {
case "eq":
return `${f} == ${serializeLuaValue(filter.value)}`;
case "ne":
return `${f} ~= ${serializeLuaValue(filter.value)}`;
case "gt":
return `${f} > ${serializeLuaValue(filter.value)}`;
case "gte":
return `${f} >= ${serializeLuaValue(filter.value)}`;
case "lt":
return `${f} < ${serializeLuaValue(filter.value)}`;
case "lte":
return `${f} <= ${serializeLuaValue(filter.value)}`;
case "startsWith": {
const lit = serializeLuaValue(filter.value);
return `string.sub(${f}, 1, #${lit}) == ${lit}`;
}
case "contains": {
const lit = serializeLuaValue(filter.value);
return `string.find(${f}, ${lit}, 1, true) ~= nil`;
}
case "in": {
const parts = filter.value
.split(",")
.map((p) => p.trim())
.filter(Boolean);
if (parts.length === 0) {
throw new TranslationError(
"bad_query",
"in operator requires at least one value",
);
}
return parts.map((p) => `(${f} == ${serializeLuaValue(p)})`).join(" or ");
}
}
throw new TranslationError(
"unknown_operator",
`unknown operator: ${(filter as any).op}`,
);
}
export function translateListRequest(req: ListRequest): TranslatedQuery {
const tag = req.tag;
if (!tag) throw new TranslationError("bad_query", "tag is required");
// Defensive coercion: callers serializing across the bridge may end up
// with `null` here when no filters/order are present.
const filters = req.filters ?? [];
const order = req.order ?? [];
const rawClauses = filters.map((f) => clauseFor(f));
const whereClauses = rawClauses.map((c) => `(${c})`);
const whereStr =
rawClauses.length === 0
? ""
: rawClauses.length === 1
? rawClauses[0]
: whereClauses.join(" and ");
const orderStr = order
.map((o) => `_it.${validateField(o.field)}${o.desc ? " desc" : ""}`)
.join(", ");
const selectStr = req.select
? `{ ${req.select.map((f) => `${validateField(f)} = _it.${f}`).join(", ")} }`
: "";
const parts: string[] = [`from _it = index.tag(${JSON.stringify(tag)})`];
if (whereStr) parts.push(`where ${whereStr}`);
if (orderStr) parts.push(`order by ${orderStr}`);
if (selectStr) parts.push(`select ${selectStr}`);
parts.push(
req.offset > 0 ? `limit ${req.limit}, ${req.offset}` : `limit ${req.limit}`,
);
const equivalentLua = parts.join(" ");
const query: LuaCollectionQuery = {
objectVariable: "_it",
limit: req.limit,
offset: req.offset,
};
if (whereStr) {
query.where = parseExpressionString(
whereClauses.length === 1 ? whereClauses[0].slice(1, -1) : whereStr,
) as LuaExpression;
}
if (order.length) {
query.orderBy = order.map<LuaOrderBy>((o) => ({
expr: parseExpressionString(`_it.${o.field}`) as LuaExpression,
desc: o.desc,
}));
}
if (req.select?.length) {
query.select = parseExpressionString(selectStr) as LuaExpression;
}
return { query, equivalentLua };
}