diff --git a/src/zql/ast-to-ivm/pipeline-builder.test.ts b/src/zql/ast-to-ivm/pipeline-builder.test.ts index c4890dc..77ffd1d 100644 --- a/src/zql/ast-to-ivm/pipeline-builder.test.ts +++ b/src/zql/ast-to-ivm/pipeline-builder.test.ts @@ -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({ @@ -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; @@ -147,11 +131,9 @@ describe('OR', () => { delete: E; }; - type NoUndefined = T extends undefined ? never : T; - type Case = { name?: string | undefined; - where: NoUndefined; + where: WhereCondition<{fields: E}>; values?: (E | DeleteE)[] | undefined; expected: (E | [v: E, multiplicity: number])[]; }; @@ -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'], }; @@ -427,3 +409,6 @@ describe('OR', () => { }); } }); + +// order-by and limit are properties of the materialize view +// and not a part of the pipeline. diff --git a/src/zql/ast-to-ivm/pipeline-builder.ts b/src/zql/ast-to-ivm/pipeline-builder.ts index 36ba46f..0352183 100644 --- a/src/zql/ast-to-ivm/pipeline-builder.ts +++ b/src/zql/ast-to-ivm/pipeline-builder.ts @@ -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 '>': @@ -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 { + includes(v: T): boolean; +} + +function opIn(l: T, r: Includes) { + return r.includes(l); +} + +function opLike(l: Includes, r: T) { + return l.includes(r); +} + +function opIlike(l: string, r: string) { + return l.toLowerCase().includes(r.toLowerCase()); +} + +function not(f: (l: T, r: T) => boolean) { + return (l: T, r: T) => !f(l, r); +} diff --git a/src/zql/ast/ast.ts b/src/zql/ast/ast.ts index e259893..ba5bcb3 100644 --- a/src/zql/ast/ast.ts +++ b/src/zql/ast/ast.ts @@ -52,13 +52,18 @@ export type Conjunction = { }; export type SimpleOperator = | '=' + | '!=' | '<' | '>' | '>=' | '<=' | 'IN' + | 'NOT IN' | 'LIKE' - | 'ILIKE'; + | 'NOT LIKE' + | 'ILIKE' + | 'NOT ILIKE'; + export type SimpleCondition = // | ConditionList { diff --git a/src/zql/integration.test.ts b/src/zql/integration.test.ts index 6fa6e36..9e68109 100644 --- a/src/zql/integration.test.ts +++ b/src/zql/integration.test.ts @@ -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++) { @@ -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(); +}); diff --git a/src/zql/query/condition-to-string.ts b/src/zql/query/condition-to-string.ts new file mode 100644 index 0000000..cde929e --- /dev/null +++ b/src/zql/query/condition-to-string.ts @@ -0,0 +1,23 @@ +import {EntitySchema} from '../schema/entity-schema.js'; +import {WhereCondition} from './entity-query.js'; + +export function conditionToString( + c: WhereCondition, + 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}`; +} diff --git a/src/zql/query/entity-query.test.ts b/src/zql/query/entity-query.test.ts index c7d8d8a..c3958c9 100644 --- a/src/zql/query/entity-query.test.ts +++ b/src/zql/query/entity-query.test.ts @@ -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'; @@ -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; + expected: WhereCondition; + }[] = [ + { + 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); + }, + ); + } +}); diff --git a/src/zql/query/entity-query.ts b/src/zql/query/entity-query.ts index bb31c08..0b9b21a 100644 --- a/src/zql/query/entity-query.ts +++ b/src/zql/query/entity-query.ts @@ -81,19 +81,19 @@ export type MakeHumanReadable = {} & { let aliasCount = 0; -type WhereExpression = +export type WhereCondition = | { op: 'AND' | 'OR'; - conditions: WhereExpression[]; + conditions: WhereCondition[]; } - | SimpleExpression>; + | SimpleCondition; -type SimpleExpression> = { +type SimpleCondition = { op: SimpleOperator; - field: F; + field: Selectable; value: { type: 'literal'; - value: FieldValue; + value: FieldValue>; }; }; @@ -144,26 +144,26 @@ export class EntityQuery { }); } - where(expr: WhereExpression): EntityQuery; + where(expr: WhereCondition): EntityQuery; where>( field: K, op: SimpleOperator, value: FieldValue, ): EntityQuery; where>( - exprOrField: K | WhereExpression, + exprOrField: K | WhereCondition, op?: SimpleOperator, value?: FieldValue, ): EntityQuery { - let expr: WhereExpression; + let expr: WhereCondition; if (typeof exprOrField === 'string') { expr = expression(exprOrField, op!, value!); } else { expr = exprOrField; } - let cond: WhereExpression; - const where = this.#ast.where as WhereExpression | undefined; + let cond: WhereCondition; + const where = this.#ast.where as WhereCondition | undefined; if (!where) { cond = expr; } else if (where.op === 'AND') { @@ -229,22 +229,22 @@ export function astForTesting(q: WeakKey): AST { type ArrayOfAtLeastTwo = [T, T, ...T[]]; export function or( - ...conditions: ArrayOfAtLeastTwo> -): WhereExpression { + ...conditions: ArrayOfAtLeastTwo> +): WhereCondition { return flatten('OR', conditions); } export function and( - ...conditions: ArrayOfAtLeastTwo> -): WhereExpression { + ...conditions: ArrayOfAtLeastTwo> +): WhereCondition { return flatten('AND', conditions); } function flatten( op: 'AND' | 'OR', - conditions: WhereExpression[], -): WhereExpression { - const flattened: WhereExpression[] = []; + conditions: WhereCondition[], +): WhereCondition { + const flattened: WhereCondition[] = []; for (const c of conditions) { if (c.op === op) { flattened.push(...c.conditions); @@ -260,7 +260,7 @@ export function expression>( field: K, op: SimpleOperator, value: FieldValue, -): WhereExpression { +): WhereCondition { return { op, field, @@ -270,3 +270,55 @@ export function expression>( }, }; } + +export function not( + expr: WhereCondition, +): WhereCondition { + switch (expr.op) { + case 'AND': + return { + op: 'OR', + conditions: expr.conditions.map(not), + }; + case 'OR': + return { + op: 'AND', + conditions: expr.conditions.map(not), + }; + default: + return { + op: negateOperator(expr.op), + field: expr.field, + value: expr.value, + }; + } +} + +function negateOperator(op: SimpleOperator): SimpleOperator { + switch (op) { + case '=': + return '!='; + case '!=': + return '='; + case '<': + return '>='; + case '>': + return '<='; + case '>=': + return '<'; + case '<=': + return '>'; + case 'IN': + return 'NOT IN'; + case 'NOT IN': + return 'IN'; + case 'LIKE': + return 'NOT LIKE'; + case 'NOT LIKE': + return 'LIKE'; + case 'ILIKE': + return 'NOT ILIKE'; + case 'NOT ILIKE': + return 'ILIKE'; + } +}