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.
257 lines (230 loc) • 6.19 kB
text/typescript
import type {
OperationNode as KyselyOperationNode,
SelectQueryBuilder,
} from 'kysely';
import type { QuerySchema } from '../core/index';
import type { SchemaShape } from '../core/schema';
import type { FieldPath, FieldPathByShape } from '../core/schema.ts';
import type { QueryWhere } from '../core/where';
import type { TSelectQueryBuilder } from './types.ts';
const opReverseMap = {
'=': '=',
'>': '>',
'>=': '>=',
'<': '<',
'<=': '<=',
like: 'like',
ilike: 'ilike',
in: 'in',
} as const;
export type KyselyNodeUnaryOp =
| '='
| '>'
| '>='
| '<'
| '<='
// | 'not'
| 'like'
| 'ilike';
type IdentifierNode = {
kind: 'IdentifierNode';
name: string;
};
type ColumnNode = {
kind: 'ColumnNode';
column: IdentifierNode;
};
type ReferenceNode = {
kind: 'ReferenceNode';
column: ColumnNode;
};
type BinaryOperationNode =
| {
kind: 'BinaryOperationNode';
leftOperand: ReferenceNode;
operator: OperatorNode;
rightOperand: ValueNode;
}
| {
kind: 'BinaryOperationNode';
leftOperand: ReferenceNode;
operator: {
kind: 'OperatorNode';
operator: 'in';
};
rightOperand: PrimitiveValueListNode;
};
type OperatorNode = {
kind: 'OperatorNode';
operator: KyselyNodeUnaryOp;
};
type ValueNode = {
kind: 'ValueNode';
value: any;
};
type PrimitiveValueListNode = {
kind: 'PrimitiveValueListNode';
values: any[];
};
type AndNode = {
kind: 'AndNode';
left: BinaryOperationNode | AndNode | ParensNode;
right: BinaryOperationNode;
};
type OrNode = {
kind: 'OrNode';
left: BinaryOperationNode;
right: BinaryOperationNode;
};
type ParensNode = {
kind: 'ParensNode';
node: OrNode | AndNode;
};
type RawNode = {
kind: 'RawNode';
sqlFragments: string[];
parameters: any[];
};
type UnaryOperationNode = {
kind: 'UnaryOperationNode';
operator: {
kind: 'OperatorNode';
operator: 'not';
};
operand: BinaryOperationNode;
};
export type OperationNode =
| ParensNode
| OrNode
| UnaryOperationNode
| BinaryOperationNode
| AndNode
| ReferenceNode
| RawNode;
const parseRawSqlField = (sql: string): FieldPath => {
const rootMatch = sql.match(/^"((?:[^"]|"")*)"(.*)$/);
if (!rootMatch) return ['unknown_path'];
const root = rootMatch[1].replace(/""/g, '"');
const tail = rootMatch[2];
if (!tail) return [root];
if (/^\[\d+\]/.test(tail)) {
const indices = [...tail.matchAll(/\[(\d+)\]/g)].map(
(m) => Number(m[1]) - 1,
);
return [root, ...indices];
}
const segments: (string | number)[] = [];
for (const m of tail.matchAll(/->>?(?:'((?:[^']|'')*)'|(\d+))/g)) {
if (m[1] !== undefined) segments.push(m[1].replace(/''/g, "'"));
else if (m[2] !== undefined) segments.push(Number(m[2]));
}
if (segments.length === 0) return ['unknown_path'];
return [root, ...segments];
};
const parseFieldFromNode = <TShape extends SchemaShape>(
node: OperationNode,
): FieldPathByShape<TShape> => {
if (node?.kind === 'ReferenceNode' && node.column?.kind === 'ColumnNode') {
return [node.column.column.name] as FieldPathByShape<TShape>;
}
if (node?.kind === 'RawNode' && node.sqlFragments.length === 1) {
return parseRawSqlField(node.sqlFragments[0]) as FieldPathByShape<TShape>;
}
return ['unknown_path'] as FieldPathByShape<TShape>;
};
const parseWhere = <TShape extends SchemaShape>(
node?: OperationNode,
): QueryWhere<TShape> | undefined => {
if (!node) return;
// console.log('type parseWhereNode =', JSON.stringify(node, null, 2));
if (node.kind === 'AndNode' || node.kind === 'OrNode') {
const left = parseWhere(node.left);
const right = parseWhere(node.right);
const conditions = [left, right].filter(
(c): c is QueryWhere<TShape> => !!c,
);
if (conditions.length === 0) return;
if (conditions.length === 1) return conditions[0];
return { op: node.kind === 'AndNode' ? 'and' : 'or', conditions };
}
if (node.kind === 'ParensNode') {
return parseWhere<TShape>(node.node);
}
if (node.kind === 'UnaryOperationNode' && node.operator?.operator === 'not') {
const inner = parseWhere<TShape>(node.operand);
if (!inner) return;
return { op: 'not', condition: inner };
}
if (node.kind === 'BinaryOperationNode') {
const field = parseFieldFromNode<TShape>(node.leftOperand);
if (field.length === 0) return;
if (!node.operator?.operator) return;
if (node.operator?.operator === 'in') {
return {
field: field,
op: 'in',
values: (node.rightOperand as PrimitiveValueListNode)?.values ?? [],
};
}
return {
field,
op: opReverseMap[node.operator.operator],
value: (node.rightOperand as ValueNode)?.value,
};
}
return;
};
const flattenLogic = <TShape extends SchemaShape>(
where: QueryWhere<TShape>,
): QueryWhere<TShape> => {
if (where.op === 'and' || where.op === 'or') {
const flat: QueryWhere<TShape>[] = [];
for (const c of where.conditions) {
const flattened = flattenLogic<TShape>(c);
if (flattened.op === where.op) {
flat.push(...flattened.conditions);
} else {
flat.push(flattened);
}
}
return { op: where.op, conditions: flat };
}
if (where.op === 'not') {
return { op: 'not', condition: flattenLogic(where.condition) };
}
return where;
};
export const fromKysely = <
TShape extends SchemaShape,
TableName extends string,
>(
queryBuilder: TSelectQueryBuilder<TShape, TableName>,
): QuerySchema<TShape> => {
const node = queryBuilder.toOperationNode();
const result: QuerySchema<TShape> = {};
if (node.limit) {
const limitValue = (node.limit.limit as any).value;
if (typeof limitValue === 'number') result.limit = limitValue;
}
if (node.offset) {
const offsetValue = (node.offset.offset as any).value;
if (typeof offsetValue === 'number') result.offset = offsetValue;
}
if (node.orderBy && node.orderBy.items.length > 0) {
result.orderBy = node.orderBy.items.map((item) => {
const field = parseFieldFromNode<TShape>(item.orderBy as OperationNode);
return {
field,
direction:
(item.direction as RawNode)?.sqlFragments?.[0] === 'desc'
? 'desc'
: 'asc',
};
});
}
const parsed = parseWhere<TShape>(node.where?.where as OperationNode);
if (parsed) {
result.where = flattenLogic<TShape>(parsed);
}
return result;
};