agnostic-query
Version:
Type-safe fluent builder for portable query schemas. Runtime-agnostic, database-agnostic — the same QuerySchema drives Drizzle, Kysely, db0, or raw SQL.
109 lines (94 loc) • 3.13 kB
text/typescript
import type { ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely';
import { type ExpressionWrapper, sql } from 'kysely';
import type { QueryOrderBy } from '../core/order-by.ts';
import type { SchemaShape } from '../core/schema.ts';
import type {
ComparisonWhere,
QueryWhere,
SetComparisonOp,
UnaryComparisonOp,
} from '../core/where.ts';
import { isComparisonWhere } from '../core/where.ts';
import { fieldToStr } from '../sql/pg.ts';
import type { TSelectQueryBuilder } from './types.ts';
const kyselyOpMap = {
'=': '=',
'>': '>',
'>=': '>=',
'<': '<',
'<=': '<=',
like: 'like',
ilike: 'ilike',
'<@': '<@',
'@>': '@>',
'&&': '&&',
} as const satisfies Record<UnaryComparisonOp | SetComparisonOp, string>;
type Expression<TShape, TableName extends string> = ExpressionBuilder<
{ [k in TableName]: TShape },
TableName
>;
type BuildResult<TShape, TableName extends string> = (
expression: Expression<TShape, TableName>,
) => ExpressionWrapper<{ [k in TableName]: TShape }, TableName, SqlBool>;
const emptyExp = <TShape extends SchemaShape, TableName extends string>(
exp: Expression<TShape, TableName>,
) => exp.and([]);
const build = <TShape extends SchemaShape, TableName extends string>(
where: QueryWhere<TShape>,
exp: Expression<TShape, TableName>,
): ExpressionWrapper<{ [k in TableName]: TShape }, TableName, SqlBool> => {
if (where.op === 'not') {
const inner = build(where.condition, exp);
if (!inner) return emptyExp(exp);
return exp.not(inner);
}
if (where.op === 'and' || where.op === 'or') {
const parts = where.conditions
.map((c) => build(c, exp))
.filter((c): c is NonNullable<typeof c> => c !== undefined);
if (parts.length === 0) return emptyExp(exp);
return where.op === 'and' ? exp.and(parts) : exp.or(parts);
}
if (!isComparisonWhere(where)) return emptyExp(exp);
const target = sql.raw(fieldToStr(where.field));
if (where.op === 'is null') {
return exp.eb(target, 'is', null);
}
if (where.op === 'in') {
return exp.eb(target, 'in', where.values);
}
const op = kyselyOpMap[where.op];
if (where.op === 'like' || where.op === 'ilike') {
return exp.eb(target, op, where.value);
}
return exp.eb(target, op, where.value);
};
export const toKyselyWhere = <
TShape extends Record<string, any>,
TableName extends string,
>(
where?: QueryWhere<TShape> | null,
): BuildResult<TShape, TableName> => {
return (exp: Expression<TShape, TableName>) => {
if (!where) return emptyExp(exp);
return build<TShape, TableName>(where, exp);
};
};
export const toKyselyOrderBy = <
TShape extends Record<string, any>,
TableName extends string,
>(
q: TSelectQueryBuilder<TShape, TableName>,
orderBy?: QueryOrderBy<TShape>[],
) => {
if (!orderBy || orderBy.length === 0) return q;
let currentQuery = q;
for (const order of orderBy) {
// 1. 使用你原生的 fieldToStr 生成 PG 語法字串
const sqlTarget = fieldToStr(order.field);
// 2. 使用 sql.raw 包裝,告訴 Kysely 這是一段原始 SQL
// 3. 連續調用 .orderBy
currentQuery = currentQuery.orderBy(sql.raw(sqlTarget), order.direction);
}
return currentQuery;
};