Skip to content

Commit

Permalink
Add filter to drizzle converter
Browse files Browse the repository at this point in the history
  • Loading branch information
zoriya committed Jan 5, 2025
1 parent 297e003 commit 7b17f8e
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 28 deletions.
46 changes: 38 additions & 8 deletions api/src/controllers/movies.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { and, desc, eq, sql } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { KError } from "~/models/error";
import { isUuid, processLanguages } from "~/models/utils";
import { Genre, isUuid, processLanguages } from "~/models/utils";
import { comment, RemovePrefix } from "~/utils";
import { db } from "../db";
import { shows, showTranslations } from "../db/schema/shows";
import { getColumns } from "../db/schema/utils";
import { bubble } from "../models/examples";
import { Movie, type MovieStatus, MovieTranslation } from "../models/movie";
import { Movie, MovieStatus, MovieTranslation } from "../models/movie";
import { Page } from "~/models/utils/page";
import { type Filter, parseFilters } from "~/models/utils/filter-sql";

// drizzle is bugged and doesn't allow js arrays to be used in raw sql.
export function sqlarr(array: unknown[]) {
Expand Down Expand Up @@ -37,6 +38,20 @@ const getTranslationQuery = (languages: string[]) => {

const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows);

const movieFilters: Filter = {
genres: {
column: shows.genres,
type: "enum",
values: Genre.enum,
isArray: true,
},
rating: { column: shows.rating, type: "int" },
status: { column: shows.status, type: "enum", values: MovieStatus.enum },
runtime: { column: shows.runtime, type: "float" },
airDate: { column: shows.startAir, type: "date" },
originalLanguage: { column: shows.originalLanguage, type: "string" },
};

export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
.model({
movie: Movie,
Expand Down Expand Up @@ -100,8 +115,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
example: "en-us, ja;q=0.5",
description: comment`
List of languages you want the data in.
This follows the Accept-Language offical specification
(https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language).
This follows the [Accept-Language offical specification](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language).
`,
}),
}),
Expand Down Expand Up @@ -135,7 +149,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
.get(
"",
async ({
query: { limit, after, sort },
query: { limit, after, sort, filter },
headers: { "accept-language": languages },
}) => {
const langs = processLanguages(languages);
Expand All @@ -146,6 +160,9 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
if (key === "airDate") return { key: "startAir" as const, desc };
return { key, desc };
});
const filters = parseFilters(filter, movieFilters);

// TODO: Add sql indexes on order keys

const items = await db
.select({
Expand All @@ -156,6 +173,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
})
.from(shows)
.innerJoin(transQ, eq(shows.pk, transQ.pk))
.where(filters)
.orderBy(
...order.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])),
shows.pk,
Expand All @@ -168,6 +186,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
detail: { description: "Get all movies" },
query: t.Object({
sort: t.Array(
// TODO: Add random
t.UnionEnum([
"slug",
"-slug",
Expand All @@ -183,6 +202,18 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
// TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia
{ explode: false, default: ["slug"] },
),
filter: t.Optional(
t.String({
description: comment`
Filters to apply to the query.
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(movieFilters).join(", ")}
`,
example:
"(rating gt 75 and genres has action) or status eq planned",
}),
),
limit: t.Integer({
minimum: 1,
maximum: 250,
Expand All @@ -205,10 +236,9 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
example: "en-us, ja;q=0.5",
description: comment`
List of languages you want the data in.
This follows the Accept-Language offical specification
(https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language).
This follows the [Accept-Language offical specification](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language).
In this request, * is always implied (if no language could satisfy the request, kyoo will use any language available).
In this request, * is always implied (if no language could satisfy the request, kyoo will use any language available.)
`,
}),
}),
Expand Down
2 changes: 1 addition & 1 deletion api/src/db/schema/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
import type { AnySQLiteSelect } from "drizzle-orm/sqlite-core";
import type { WithSubquery } from "drizzle-orm/subquery";
import { db } from "..";
import { CasingCache } from "drizzle-orm/casing";
import type { CasingCache } from "drizzle-orm/casing";

export const schema = pgSchema("kyoo");

Expand Down
2 changes: 1 addition & 1 deletion api/src/models/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { t } from "elysia";
export const KError = t.Object({
status: t.Integer(),
message: t.String(),
details: t.Any(),
details: t.Optional(t.Any()),
});
export type KError = typeof KError.static;
133 changes: 133 additions & 0 deletions api/src/models/utils/filters-sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
and,
type Column,
eq,
gt,
gte,
lt,
lte,
ne,
not,
or,
type SQL,
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);
};

const opMap: Record<Operator, typeof eq> = {
eq: eq,
ne: ne,
gt: gt,
ge: gte,
lt: lt,
le: lte,
has: eq,
};

const toDrizzle = (expr: Expression, config: Filter): SQL | KError => {
switch (expr.type) {
case "op": {
const where = `${expr.property} ${expr.operator} ${expr.value}`;
const prop = config[expr.property];

if (!prop) {
return {
status: 422,
message: comment`
Invalid property: ${expr.property}.
Expected one of ${Object.keys(config).join(", ")}.
`,
details: { in: where },
};
}

if (prop.type !== expr.value.type) {
return {
status: 422,
message: comment`
Invalid value for property ${expr.property}.
Got ${expr.value.type} but expected ${prop.type}.
`,
details: { 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`
Invalid value ${expr.value.value} for property ${expr.property}.
Expected one of ${prop.values.join(", ")} but got ${expr.value.value}.
`,
details: { in: where },
};
}

if (prop.isArray) {
if (expr.operator !== "has" && expr.operator !== "eq") {
return {
status: 422,
message: 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 },
};
}
return sql`${expr.value.value} = any(${prop.column})`;
}
return opMap[expr.operator](prop.column, expr.value.value);
}
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:
return exhaustiveCheck(expr);
}
};

function exhaustiveCheck(v: never): never {
return v;
}
22 changes: 4 additions & 18 deletions api/src/models/utils/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,16 @@ import {
between,
recover,
} from "parjs/combinators";
import type { KError } from "../error";

export type Filter = {
[key: string]: any;
};

type Property = string;
type Value =
export type Property = string;
export type Value =
| { type: "int"; value: number }
| { type: "float"; value: number }
| { type: "date"; value: string }
| { type: "string"; value: string }
| { type: "enum"; value: string };
const operators = ["eq", "ne", "gt", "ge", "lt", "le", "has", "in"] as const;
type Operator = (typeof operators)[number];
const operators = ["eq", "ne", "gt", "ge", "lt", "le", "has"] as const;
export type Operator = (typeof operators)[number];
export type Expression =
| { type: "op"; operator: Operator; property: Property; value: Value }
| { type: "and"; lhs: Expression; rhs: Expression }
Expand Down Expand Up @@ -133,12 +128,3 @@ const not = t(string("not")).pipe(
const brackets = expression.pipe(between("(", ")"));

expr.init(not.pipe(or(brackets, operation)));

export const parseFilter = (
filter: string,
config: Filter,
): Expression | KError => {
const ret = expression.parse(filter);
if (ret.isOk) return ret.value;
return { status: 422, message: `Invalid filter: ${filter}.`, details: ret };
};

0 comments on commit 7b17f8e

Please sign in to comment.