Handle filter parsing with typebox

This commit is contained in:
Zoe Roux
2025-01-06 00:51:35 +01:00
parent efbec85b67
commit 3d6912b60d
10 changed files with 141 additions and 73 deletions
+6
View File
@@ -6,3 +6,9 @@ export const KError = t.Object({
details: t.Optional(t.Any()),
});
export type KError = typeof KError.static;
export class KErrorT extends Error {
constructor(message: string, details?: any) {
super(JSON.stringify({ code: "KError", status: 422, message, details }));
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { Column } from "drizzle-orm";
import { t } from "elysia";
import { comment } from "~/utils";
import { expression } from "./parser";
import { toDrizzle } from "./to-sql";
import { KErrorT } from "~/models/error";
export type FilterDef = {
[key: string]:
| {
column: Column;
type: "int" | "float" | "date" | "string";
isArray?: boolean;
}
| { column: Column; type: "enum"; values: string[]; isArray?: boolean };
};
export const Filter = ({
def,
description = "Filters to apply to the query.",
}: { def: FilterDef; description?: string }) =>
t
.Transform(
t.String({
description: comment`
${description}
This is based on [odata's filter specification](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter).
Filters available: ${Object.keys(def).join(", ")}
`,
example: "(rating gt 75 and genres has action) or status eq planned",
}),
)
.Decode((filter) => {
return parseFilters(filter, def);
})
.Encode(() => {
throw new Error("Can't encode filters");
});
export const parseFilters = (filter: string | undefined, config: FilterDef) => {
if (!filter) return undefined;
const ret = expression.parse(filter);
if (!ret.isOk) {
throw new KErrorT(`Invalid filter: ${filter}.`, ret);
}
return toDrizzle(ret.value, config);
};
@@ -1,6 +1,5 @@
import {
and,
type Column,
eq,
gt,
gte,
@@ -13,29 +12,9 @@ import {
sql,
} from "drizzle-orm";
import { comment } from "~/utils";
import type { KError } from "../error";
import { type Expression, expression, type Operator } from "./filters";
export type Filter = {
[key: string]:
| {
column: Column;
type: "int" | "float" | "date" | "string";
isArray?: boolean;
}
| { column: Column; type: "enum"; values: string[]; isArray?: boolean };
};
export const parseFilters = (filter: string | undefined, config: Filter) => {
if (!filter) return undefined;
const ret = expression.parse(filter);
if (!ret.isOk) {
throw new Error("todo");
// return { status: 422, message: `Invalid filter: ${filter}.`, details: ret }
}
return toDrizzle(ret.value, config);
};
import type { FilterDef } from "./index";
import type { Expression, Operator } from "./parser";
import { KErrorT } from "~/models/error";
const opMap: Record<Operator, typeof eq> = {
eq: eq,
@@ -47,58 +26,54 @@ const opMap: Record<Operator, typeof eq> = {
has: eq,
};
const toDrizzle = (expr: Expression, config: Filter): SQL | KError => {
export const toDrizzle = (expr: Expression, config: FilterDef): SQL => {
switch (expr.type) {
case "op": {
const where = `${expr.property} ${expr.operator} ${expr.value}`;
const where = `${expr.property} ${expr.operator} ${expr.value.value}`;
const prop = config[expr.property];
if (!prop) {
return {
status: 422,
message: comment`
throw new KErrorT(
comment`
Invalid property: ${expr.property}.
Expected one of ${Object.keys(config).join(", ")}.
`,
details: { in: where },
};
{ in: where },
);
}
if (prop.type !== expr.value.type) {
return {
status: 422,
message: comment`
throw new KErrorT(
comment`
Invalid value for property ${expr.property}.
Got ${expr.value.type} but expected ${prop.type}.
`,
details: { in: where },
};
{ in: where },
);
}
if (
prop.type === "enum" &&
(expr.value.type === "enum" || expr.value.type === "string") &&
!prop.values.includes(expr.value.value)
) {
return {
status: 422,
message: comment`
throw new KErrorT(
comment`
Invalid value ${expr.value.value} for property ${expr.property}.
Expected one of ${prop.values.join(", ")} but got ${expr.value.value}.
`,
details: { in: where },
};
{ in: where },
);
}
if (prop.isArray) {
if (expr.operator !== "has" && expr.operator !== "eq") {
return {
status: 422,
message: comment`
throw new KErrorT(
comment`
Property ${expr.property} is an array but you wanted to use the
operator ${expr.operator}. Only "has" is supported ("eq" is also aliased to "has")
`,
details: { in: where },
};
{ in: where },
);
}
return sql`${expr.value.value} = any(${prop.column})`;
}
@@ -107,20 +82,15 @@ const toDrizzle = (expr: Expression, config: Filter): SQL | KError => {
case "and": {
const lhs = toDrizzle(expr.lhs, config);
const rhs = toDrizzle(expr.rhs, config);
if ("status" in lhs) return lhs;
if ("status" in rhs) return rhs;
return and(lhs, rhs)!;
}
case "or": {
const lhs = toDrizzle(expr.lhs, config);
const rhs = toDrizzle(expr.rhs, config);
if ("status" in lhs) return lhs;
if ("status" in rhs) return rhs;
return or(lhs, rhs)!;
}
case "not": {
const lhs = toDrizzle(expr.expression, config);
if ("status" in lhs) return lhs;
return not(lhs);
}
default:
+2
View File
@@ -3,3 +3,5 @@ export * from "./genres";
export * from "./image";
export * from "./language";
export * from "./resource";
export * from "./filters";
export * from "./page";