Skip to content

Commit

Permalink
feat(zql): Add not conditions (#58)
Browse files Browse the repository at this point in the history
`NOT` is implemented using De Morgan's law so it is not represented in
the AST.

This adds a few more SimpleOperators to deal with things like `NOT` `IN`
etc.
  • Loading branch information
arv authored Apr 3, 2024
1 parent 766162a commit 66c9a71
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 51 deletions.
37 changes: 11 additions & 26 deletions src/zql/ast-to-ivm/pipeline-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {makeTestContext} from '../context/context.js';
import {DifferenceStream} from '../ivm/graph/difference-stream.js';
import {Materialite} from '../ivm/materialite.js';
import * as agg from '../query/agg.js';
import {EntityQuery, astForTesting as ast} from '../query/entity-query.js';
import {conditionToString} from '../query/condition-to-string.js';
import {
EntityQuery,
WhereCondition,
astForTesting as ast,
} from '../query/entity-query.js';
import {buildPipeline} from './pipeline-builder.js';

const e1 = z.object({
Expand Down Expand Up @@ -115,27 +120,6 @@ test('Where', () => {
expect(effectRunCount).toBe(1);
});

// order-by and limit are properties of the materialize view
// and not a part of the pipeline.

function conditionToString(c: Condition, paren = false): string {
if (c.op === 'AND' || c.op === 'OR') {
let s = '';
if (paren) {
s += '(';
}
{
const paren = c.op === 'AND' && c.conditions.length > 1;
s += c.conditions.map(c => conditionToString(c, paren)).join(` ${c.op} `);
}
if (paren) {
s += ')';
}
return s;
}
return `${(c as {field: string}).field} ${c.op} ${(c as {value: {value: unknown}}).value.value}`;
}

describe('OR', () => {
type E = {
id: string;
Expand All @@ -147,11 +131,9 @@ describe('OR', () => {
delete: E;
};

type NoUndefined<T> = T extends undefined ? never : T;

type Case = {
name?: string | undefined;
where: NoUndefined<AST['where']>;
where: WhereCondition<{fields: E}>;
values?: (E | DeleteE)[] | undefined;
expected: (E | [v: E, multiplicity: number])[];
};
Expand Down Expand Up @@ -396,7 +378,7 @@ describe('OR', () => {
const ast: AST = {
table: 'items',
select: ['id', 'a', 'b'],
where: c.where,
where: c.where as Condition,
orderBy: [['id'], 'asc'],
};

Expand Down Expand Up @@ -427,3 +409,6 @@ describe('OR', () => {
});
}
});

// order-by and limit are properties of the materialize view
// and not a part of the pipeline.
34 changes: 31 additions & 3 deletions src/zql/ast-to-ivm/pipeline-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ function getOperator(op: SimpleOperator): (l: any, r: any) => boolean {
switch (op) {
case '=':
return (l, r) => l === r;
case '!=':
return (l, r) => l !== r;
case '<':
return (l, r) => l < r;
case '>':
Expand All @@ -310,12 +312,38 @@ function getOperator(op: SimpleOperator): (l: any, r: any) => boolean {
case '<=':
return (l, r) => l <= r;
case 'IN':
return (l, r) => r.includes(l);
return opIn;
case 'NOT IN':
return not(opIn);
case 'LIKE':
return (l, r) => l.includes(r);
return opLike;
case 'NOT LIKE':
return not(opLike);
case 'ILIKE':
return (l, r) => l.toLowerCase().includes(r.toLowerCase());
return opIlike;
case 'NOT ILIKE':
return not(opIlike);
default:
throw new Error(`Operator ${op} not supported`);
}
}

interface Includes<T> {
includes(v: T): boolean;
}

function opIn<T>(l: T, r: Includes<T>) {
return r.includes(l);
}

function opLike<T>(l: Includes<T>, r: T) {
return l.includes(r);
}

function opIlike(l: string, r: string) {
return l.toLowerCase().includes(r.toLowerCase());
}

function not<T>(f: (l: T, r: T) => boolean) {
return (l: T, r: T) => !f(l, r);
}
7 changes: 6 additions & 1 deletion src/zql/ast/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,18 @@ export type Conjunction = {
};
export type SimpleOperator =
| '='
| '!='
| '<'
| '>'
| '>='
| '<='
| 'IN'
| 'NOT IN'
| 'LIKE'
| 'ILIKE';
| 'NOT LIKE'
| 'ILIKE'
| 'NOT ILIKE';

export type SimpleCondition =
// | ConditionList
{
Expand Down
49 changes: 48 additions & 1 deletion src/zql/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {z} from 'zod';
import {generate} from '../generate.js';
import {makeReplicacheContext} from './context/replicache-context.js';
import * as agg from './query/agg.js';
import {EntityQuery, expression, or} from './query/entity-query.js';
import {EntityQuery, expression, not, or} from './query/entity-query.js';

export async function tickAFewTimes(n = 10, time = 0) {
for (let i = 0; i < n; i++) {
Expand Down Expand Up @@ -622,3 +622,50 @@ test('or where', async () => {

await r.close();
});

test('not', async () => {
const {q, r} = setup();
const issues: Issue[] = [
{
id: 'a',
title: 'foo',
status: 'open',
priority: 'high',
assignee: 'charles',
created: Date.now(),
updated: Date.now(),
},
{
id: 'b',
title: 'bar',
status: 'open',
priority: 'medium',
assignee: 'bob',
created: Date.now(),
updated: Date.now(),
},
{
id: 'c',
title: 'baz',
status: 'closed',
priority: 'low',
assignee: 'alice',
created: Date.now(),
updated: Date.now(),
},
] as const;
await Promise.all(issues.map(r.mutate.initIssue));

const stmt = q
.select('id')
.where(not(expression('status', '=', 'closed')))
.prepare();
const rows = await stmt.exec();
expect(rows).toEqual([{id: 'a'}, {id: 'b'}]);

await r.mutate.deleteIssue('a');
const rows2 = await stmt.exec();
expect(rows2).toEqual([{id: 'b'}]);

await r.close();
});
23 changes: 23 additions & 0 deletions src/zql/query/condition-to-string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {EntitySchema} from '../schema/entity-schema.js';
import {WhereCondition} from './entity-query.js';

export function conditionToString<S extends EntitySchema>(
c: WhereCondition<S>,
paren = false,
): string {
if (c.op === 'AND' || c.op === 'OR') {
let s = '';
if (paren) {
s += '(';
}
{
const paren = c.op === 'AND' && c.conditions.length > 1;
s += c.conditions.map(c => conditionToString(c, paren)).join(` ${c.op} `);
}
if (paren) {
s += ')';
}
return s;
}
return `${(c as {field: string}).field} ${c.op} ${(c as {value: {value: unknown}}).value.value}`;
}
106 changes: 105 additions & 1 deletion src/zql/query/entity-query.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import {describe, expect, expectTypeOf, test} from 'vitest';
import {z} from 'zod';
import {AST} from '../ast/ast.js';
import {AST, SimpleOperator} from '../ast/ast.js';
import {makeTestContext} from '../context/context.js';
import * as agg from './agg.js';
import {conditionToString} from './condition-to-string.js';
import {
EntityQuery,
WhereCondition,
and,
astForTesting,
expression,
not,
or,
} from './entity-query.js';

Expand Down Expand Up @@ -665,3 +668,104 @@ describe('ast', () => {
});
});
});

describe('NOT', () => {
describe('Negate Ops', () => {
const cases: {
in: SimpleOperator;
out: SimpleOperator;
}[] = [
{in: '=', out: '!='},
{in: '!=', out: '='},
{in: '<', out: '>='},
{in: '>', out: '<='},
{in: '>=', out: '<'},
{in: '<=', out: '>'},
{in: 'IN', out: 'NOT IN'},
{in: 'NOT IN', out: 'IN'},
{in: 'LIKE', out: 'NOT LIKE'},
{in: 'NOT LIKE', out: 'LIKE'},
{in: 'ILIKE', out: 'NOT ILIKE'},
{in: 'NOT ILIKE', out: 'ILIKE'},
];

for (const c of cases) {
test(`${c.in} -> ${c.out}`, () => {
const q = new EntityQuery<{fields: E1}>(context, 'e1');
expect(ast(q.where(not(expression('a', c.in, 1)))).where).toEqual({
op: c.out,
field: 'a',
value: {type: 'literal', value: 1},
});
});
}
});
});

describe("De Morgan's Law", () => {
type S = {fields: E1};

const cases: {
condition: WhereCondition<S>;
expected: WhereCondition<S>;
}[] = [
{
condition: expression('a', '=', 1),
expected: expression('a', '!=', 1),
},

{
condition: and(expression('a', '!=', 1), expression('a', '<', 2)),
expected: or(expression('a', '=', 1), expression('a', '>=', 2)),
},

{
condition: or(expression('a', '<=', 1), expression('a', '>', 2)),
expected: and(expression('a', '>', 1), expression('a', '<=', 2)),
},

{
condition: or(
and(expression('a', '>=', 1), expression('a', 'IN', 1)),
expression('a', 'NOT IN', 2),
),
expected: and(
or(expression('a', '<', 1), expression('a', 'NOT IN', 1)),
expression('a', 'IN', 2),
),
},

{
condition: and(
or(expression('a', 'NOT IN', 1), expression('a', 'LIKE', 1)),
expression('a', 'NOT LIKE', 2),
),
expected: or(
and(expression('a', 'IN', 1), expression('a', 'NOT LIKE', 1)),
expression('a', 'LIKE', 2),
),
},

{
condition: not(expression('a', 'ILIKE', 1)),
expected: expression('a', 'ILIKE', 1),
},

{
condition: not(expression('a', 'NOT ILIKE', 1)),
expected: expression('a', 'NOT ILIKE', 1),
},
];

for (const c of cases) {
test(
'NOT(' +
conditionToString(c.condition) +
') -> ' +
conditionToString(c.expected),
() => {
expect(not(c.condition)).toEqual(c.expected);
},
);
}
});
Loading

0 comments on commit 66c9a71

Please sign in to comment.