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.
527 lines (483 loc) • 13.5 kB
text/typescript
import { describe, expect, it } from 'bun:test';
import { aq, type QuerySchema } from './index.ts';
import type { QueryWhere } from './where.ts';
import { findWhere } from './where.ts';
type DemoShape = {
id: number;
name: string;
age: number;
status: string;
role: string;
tags: { id: number; name: string }[];
category: string[];
address: {
city: {
name: string;
};
};
};
describe('aq builder', () => {
it('toJSON returns empty schema initially', () => {
const result = aq<DemoShape>().toJSON();
expect(result.where).toBeUndefined();
expect(result).toEqual({});
});
it('string shorthand where', () => {
const result = aq<DemoShape>().where('name', '=', 'Alice').toJSON();
expect(result.where).toEqual({
field: ['name'],
op: '=',
value: 'Alice',
});
});
it('tuple path where', () => {
const result = aq<DemoShape>().where(['name'], '=', 'Bob').toJSON();
expect(result.where).toEqual({
field: ['name'],
op: '=',
value: 'Bob',
});
});
it('where with is null operator', () => {
const result = aq<DemoShape>().where('name', 'is null').toJSON();
expect(result.where).toEqual({
field: ['name'],
op: 'is null',
});
});
it('where with in operator', () => {
const result = aq<DemoShape>().where('status', 'in', ['a', 'b']).toJSON();
expect(result.where).toEqual({
field: ['status'],
op: 'in',
values: ['a', 'b'],
});
});
it('chaining wheres creates AND', () => {
const result = aq<DemoShape>()
.where('name', '=', 'Alice')
.where('age', '>', 18)
.toJSON();
expect(result.where).toEqual({
op: 'and',
conditions: [
{ field: ['name'], op: '=', value: 'Alice' },
{ field: ['age'], op: '>', value: 18 },
],
});
});
it('three chained wheres flatten into single AND', () => {
const result = aq<DemoShape>()
.where('name', '=', 'Alice')
.where('age', '>', 18)
.where('status', '=', 'active')
.toJSON();
expect(result.where).toEqual({
op: 'and',
conditions: [
{ field: ['name'], op: '=', value: 'Alice' },
{ field: ['age'], op: '>', value: 18 },
{ field: ['status'], op: '=', value: 'active' },
],
});
});
it('callbacks: or', () => {
const result = aq<DemoShape>()
.where(({ or, where }) =>
or([where('name', '=', '3'), where('name', '=', '4')]),
)
.toJSON();
expect(result.where).toEqual({
op: 'or',
conditions: [
{ field: ['name'], op: '=', value: '3' },
{ field: ['name'], op: '=', value: '4' },
],
});
});
it('callbacks: and', () => {
const result = aq<DemoShape>()
.where(({ and, where }) =>
and([where('age', '>=', 18), where('age', '<', 65)]),
)
.toJSON();
expect(result.where).toEqual({
op: 'and',
conditions: [
{ field: ['age'], op: '>=', value: 18 },
{ field: ['age'], op: '<', value: 65 },
],
});
});
it('callbacks: not', () => {
const result = aq<DemoShape>()
.where(({ not, where }) => not(where('role', '=', 'banned')))
.toJSON();
expect(result.where).toEqual({
op: 'not',
condition: { field: ['role'], op: '=', value: 'banned' },
});
});
it('mix simple and callback', () => {
const result = aq<DemoShape>()
.where('status', '=', 'active')
.where(({ or, where }) =>
or([where('name', '=', 'admin'), where('name', '=', 'mod')]),
)
.toJSON();
expect(result.where).toEqual({
op: 'and',
conditions: [
{ field: ['status'], op: '=', value: 'active' },
{
op: 'or',
conditions: [
{ field: ['name'], op: '=', value: 'admin' },
{ field: ['name'], op: '=', value: 'mod' },
],
},
],
});
});
it('callback first then simple where', () => {
const result = aq<DemoShape>()
.where(({ or, where }) =>
or([where('name', '=', 'x'), where('name', '=', 'y')]),
)
.where('age', '>=', 10)
.toJSON();
expect(result.where).toEqual({
op: 'and',
conditions: [
{
op: 'or',
conditions: [
{ field: ['name'], op: '=', value: 'x' },
{ field: ['name'], op: '=', value: 'y' },
],
},
{ field: ['age'], op: '>=', value: 10 },
],
});
});
it('nested callbacks: and within or', () => {
const result = aq<DemoShape>()
.where(({ or, and, where }) =>
or([
and([where('role', '=', 'admin'), where('status', '=', 'active')]),
where('age', '>', 30),
]),
)
.toJSON();
expect(result.where).toEqual({
op: 'or',
conditions: [
{
op: 'and',
conditions: [
{ field: ['role'], op: '=', value: 'admin' },
{ field: ['status'], op: '=', value: 'active' },
],
},
{ field: ['age'], op: '>', value: 30 },
],
});
});
it('deeply nested: and > or > and', () => {
const result = aq<DemoShape>()
.where(({ or, and, where }) =>
and([
or([
and([where('name', '=', 'a'), where('status', '=', 'x')]),
and([where('name', '=', 'b'), where('status', '=', 'y')]),
]),
where('age', '>=', 18),
]),
)
.toJSON();
expect(result.where).toEqual({
op: 'and',
conditions: [
{
op: 'or',
conditions: [
{
op: 'and',
conditions: [
{ field: ['name'], op: '=', value: 'a' },
{ field: ['status'], op: '=', value: 'x' },
],
},
{
op: 'and',
conditions: [
{ field: ['name'], op: '=', value: 'b' },
{ field: ['status'], op: '=', value: 'y' },
],
},
],
},
{ field: ['age'], op: '>=', value: 18 },
],
});
});
it('nested not within and within or', () => {
const result = aq<DemoShape>()
.where(({ or, and, not, where }) =>
or([
and([not(where('status', '=', 'banned')), where('age', '>=', 18)]),
where('role', '=', 'admin'),
]),
)
.toJSON();
expect(result.where).toEqual({
op: 'or',
conditions: [
{
op: 'and',
conditions: [
{ op: 'not', condition: { field: ['status'], op: '=', value: 'banned' } },
{ field: ['age'], op: '>=', value: 18 },
],
},
{ field: ['role'], op: '=', value: 'admin' },
],
});
});
it('double not: not(not(...))', () => {
const result = aq<DemoShape>()
.where(({ not, where }) =>
not(not(where('status', '=', 'active'))),
)
.toJSON();
expect(result.where).toEqual({
op: 'not',
condition: {
op: 'not',
condition: { field: ['status'], op: '=', value: 'active' },
},
});
});
it('in operator in callback', () => {
const result = aq<DemoShape>()
.where(({ or, where }) =>
or([where('status', 'in', ['a', 'b']), where('status', '=', 'c')]),
)
.toJSON();
expect(result.where).toEqual({
op: 'or',
conditions: [
{ field: ['status'], op: 'in', values: ['a', 'b'] },
{ field: ['status'], op: '=', value: 'c' },
],
});
});
it('toJSON returns the full schema', () => {
const schema: QuerySchema<DemoShape> = {
limit: 10,
offset: 0,
orderBy: [{ field: ['name'], direction: 'asc' }],
};
const result = aq<DemoShape>(schema).where('name', '=', 'test').toJSON();
expect(result.limit).toBe(10);
expect(result.offset).toBe(0);
expect(result.orderBy).toEqual([{ field: ['name'], direction: 'asc' }]);
expect(result.where).toEqual({ field: ['name'], op: '=', value: 'test' });
});
it('orderBy is undefined when not set', () => {
const result = aq<DemoShape>().toJSON();
expect(result.orderBy).toBeUndefined();
});
it('orderBy defaults to asc', () => {
const result = aq<DemoShape>().orderBy('name').toJSON();
expect(result.orderBy).toEqual([{ field: ['name'], direction: 'asc' }]);
});
it('orderBy with desc direction', () => {
const result = aq<DemoShape>().orderBy('name', 'desc').toJSON();
expect(result.orderBy).toEqual([{ field: ['name'], direction: 'desc' }]);
});
it('chaining orderBy appends entries', () => {
const result = aq<DemoShape>()
.orderBy('name', 'asc')
.orderBy('age', 'desc')
.toJSON();
expect(result.orderBy).toEqual([
{ field: ['name'], direction: 'asc' },
{ field: ['age'], direction: 'desc' },
]);
});
it('orderBy with initial schema appends', () => {
const schema: QuerySchema<DemoShape> = {
orderBy: [{ field: ['name'], direction: 'asc' }],
};
const result = aq<DemoShape>(schema).orderBy('age', 'desc').toJSON();
expect(result.orderBy).toEqual([
{ field: ['name'], direction: 'asc' },
{ field: ['age'], direction: 'desc' },
]);
});
it('where and orderBy can be chained together', () => {
const result = aq<DemoShape>()
.where('name', '=', 'Alice')
.orderBy('age', 'desc')
.toJSON();
expect(result.where).toEqual({ field: ['name'], op: '=', value: 'Alice' });
expect(result.orderBy).toEqual([{ field: ['age'], direction: 'desc' }]);
});
it('where accepts a QueryWhere object directly', () => {
const where: QuerySchema<DemoShape>['where'] = {
field: ['name'],
op: '=',
value: 'Alice',
};
const result = aq<DemoShape>().where(where).toJSON();
expect(result.where).toEqual({ field: ['name'], op: '=', value: 'Alice' });
});
it('where with QueryWhere object appends via AND', () => {
const existing: QuerySchema<DemoShape>['where'] = {
field: ['name'],
op: '=',
value: 'Alice',
};
const extra: QuerySchema<DemoShape>['where'] = {
field: ['age'],
op: '>',
value: 18,
};
const result = aq<DemoShape>().where(existing).where(extra).toJSON();
expect(result.where).toEqual({
op: 'and',
conditions: [
{ field: ['name'], op: '=', value: 'Alice' },
{ field: ['age'], op: '>', value: 18 },
],
});
});
it('where with QueryWhere in callback', () => {
const roleWhere: QuerySchema<DemoShape>['where'] = {
field: ['role'],
op: '=',
value: 'admin',
};
const result = aq<DemoShape>()
.where(({ or, where }) =>
or([where('name', '=', 'Alice'), where(roleWhere)]),
)
.toJSON();
expect(result.where).toEqual({
op: 'or',
conditions: [
{ field: ['name'], op: '=', value: 'Alice' },
{ field: ['role'], op: '=', value: 'admin' },
],
});
});
it('where with QueryWhere using in operator', () => {
const statusWhere: QuerySchema<DemoShape>['where'] = {
field: ['status'],
op: 'in',
values: ['active', 'pending'],
};
const result = aq<DemoShape>().where(statusWhere).toJSON();
expect(result.where).toEqual({
field: ['status'],
op: 'in',
values: ['active', 'pending'],
});
});
it('where mix QueryWhere object and simple where', () => {
const nameWhere: QuerySchema<DemoShape>['where'] = {
field: ['name'],
op: '=',
value: 'Alice',
};
const result = aq<DemoShape>()
.where(nameWhere)
.where('age', '>', 18)
.toJSON();
expect(result.where).toEqual({
op: 'and',
conditions: [
{ field: ['name'], op: '=', value: 'Alice' },
{ field: ['age'], op: '>', value: 18 },
],
});
});
it('where(null) is a no-op', () => {
const result = aq<DemoShape>()
.where(null)
.where('name', '=', 'Alice')
.toJSON();
expect(result.where).toEqual({ field: ['name'], op: '=', value: 'Alice' });
});
it('where(undefined) is a no-op', () => {
const result = aq<DemoShape>()
.where(undefined)
.where('name', '=', 'Alice')
.toJSON();
expect(result.where).toEqual({ field: ['name'], op: '=', value: 'Alice' });
});
it('where() with no args is a no-op', () => {
const result = aq<DemoShape>()
.where()
.where('name', '=', 'Alice')
.toJSON();
expect(result.where).toEqual({ field: ['name'], op: '=', value: 'Alice' });
});
it('null/undefined where does not break chaining', () => {
const result = aq<DemoShape>()
.where('status', '=', 'active')
.where(null)
.where('age', '>', 18)
.where(undefined)
.toJSON();
expect(result.where).toEqual({
op: 'and',
conditions: [
{ field: ['status'], op: '=', value: 'active' },
{ field: ['age'], op: '>', value: 18 },
],
});
});
it('not with null condition produces undefined where', () => {
const result = aq<DemoShape>()
.where(({ not, where }) =>
not(where(null!)),
)
.toJSON();
expect(result.where).toBeUndefined();
});
});
// === Compile-time overload resolution tests ===
// These are never executed — only typechecked by tsgo --noEmit
function expectType<T>(_v: T): void {}
function _typeTests() {
// 2-arg 'is null' compiles
aq<DemoShape>().where('name', 'is null');
// 3-arg 'is null' should error (PredicateOp → never in ComparisonWhereValue)
// @ts-expect-error
aq<DemoShape>().where('name', 'is null', 'x');
// 'in' on array field should error
// @ts-expect-error
aq<DemoShape>().where('tags', 'in', [[1]]);
// 'in' on array field via tuple path should error
// @ts-expect-error
aq<DemoShape>().where(['tags'], 'in', [[1]]);
// findWhere find('address', '=')?.value → { city: { name: string } } | undefined
const where2 = {} as QueryWhere<DemoShape>;
expectType<{ city: { name: string } } | undefined>(
findWhere(where2).find('address', '=')?.value,
);
// findWhere find('tags', '@>')?.value → { id: number; name: string }[] | undefined
expectType<{ id: number; name: string }[] | undefined>(
findWhere(where2).find('tags', '@>')?.value,
);
// findWhere find('id', 'in')?.values → number[] | undefined
expectType<number[] | undefined>(
findWhere(where2).find('id', 'in')?.values,
);
// findWhere find('name', 'like') returns UnaryComparisonWhere (has .value)
expectType<string | undefined>(
findWhere(where2).find('name', 'like')?.value,
);
}