Skip to content

Commit

Permalink
feat: add ANY checks
Browse files Browse the repository at this point in the history
Just pass an array expression into `is.eq` or similar
  • Loading branch information
aleclarson committed Sep 23, 2022
1 parent ed21ab1 commit 2602c8d
Show file tree
Hide file tree
Showing 3 changed files with 87 additions and 40 deletions.
64 changes: 35 additions & 29 deletions src/postgres/check.ts
Expand Up @@ -8,7 +8,13 @@ import {
} from './internal/tokenize'
import { kBoolType } from './internal/type'
import type { Query } from './query'
import type { ExtractNull, QueryInput, Type } from './type'
import type {
ArrayInput,
ExtractNull,
QueryInput,
StringInput,
Type,
} from './type'
import { isBoolExpression } from './typeChecks'
import { t } from './typesBuiltin'

Expand Down Expand Up @@ -53,73 +59,73 @@ export class Check {
readonly left: any,
readonly op: string,
readonly right: any,
readonly isRange?: boolean
readonly isNot?: boolean
) {}
}

export class CheckBuilder<T extends Type = any> {
constructor(
protected wrap: (check: Check) => CheckList,
protected left: any,
protected negated?: boolean
protected isNot?: boolean
) {}

protected check(op: string, right: any) {
return this.wrap(new Check(this.left, op, right, this.isNot))
}

get not() {
return new CheckBuilder<T>(this.wrap, this.left, !this.negated)
return new CheckBuilder<T>(this.wrap, this.left, !this.isNot)
}

/** Inclusive range matching */
between(
min: QueryInput<T>,
max: QueryInput<T>
): CheckList<t.bool | ExtractNull<T>> {
return this.wrap(
new Check(
this.left,
this.negated ? 'NOT BETWEEN' : 'BETWEEN',
[min, max],
true
)
)
return this.check('BETWEEN', [min, max])
}

in(
arr: QueryInput<T>[] | QueryInput<T[]>
): CheckList<t.bool | ExtractNull<T>> {
return this.wrap(new Check(this.left, this.negated ? 'NOT IN' : 'IN', arr))
in(arr: readonly QueryInput<T>[]): CheckList<t.bool | ExtractNull<T>> {
return this.check('IN', arr)
}

like(pattern: StringInput<T>): CheckList<t.bool | ExtractNull<T>> {
return this.check('LIKE', pattern)
}

ilike(pattern: StringInput<T>): CheckList<t.bool | ExtractNull<T>> {
return this.check('ILIKE', pattern)
}
}

export interface CheckBuilder<T> extends CheckMethods<T>, CheckAliases<T> {}

// TODO: let right be null here
type CheckMethods<T> = {
[P in keyof typeof checkMapping]: (
right: QueryInput<T>
right: QueryInput<T> | ArrayInput<T>
) => CheckList<t.bool | ExtractNull<T>>
}

type CheckAliases<T> = {
[P in keyof typeof checkAliases]: (
right: QueryInput<T>
right: QueryInput<T> | ArrayInput<T>
) => CheckList<t.bool | ExtractNull<T>>
}

const checkMapping = {
equalTo: ['=', '!='],
greaterThan: ['>', '<='],
greaterThanOrEqualTo: ['>=', '<'],
lessThan: ['<', '>='],
lessThanOrEqualTo: ['<=', '>'],
like: ['LIKE', 'NOT LIKE'],
ilike: ['ILIKE', 'NOT ILIKE'],
equalTo: '=',
greaterThan: '>',
greaterThanOrEqualTo: '>=',
lessThan: '<',
lessThanOrEqualTo: '<=',
} as const

Object.entries(checkMapping).forEach(([key, [op, negatedOp]]) =>
Object.entries(checkMapping).forEach(([key, op]) =>
Object.defineProperty(CheckBuilder.prototype, key, {
value(this: CheckBuilder, right: any) {
return this.wrap(
new Check(this.left, this.negated ? negatedOp : op, right)
)
return this.wrap(new Check(this.left, op, right, this.isNot))
},
})
)
Expand Down
55 changes: 44 additions & 11 deletions src/postgres/internal/tokenize.ts
Expand Up @@ -16,6 +16,7 @@ import {
} from '../symbols'
import type { RuntimeType } from '../type'
import {
isArrayExpression,
isCheckBuilder,
isExpression,
isExpressionType,
Expand Down Expand Up @@ -118,11 +119,20 @@ export function tokenizeColumn(column: string, table?: string | false): Token {
return table ? { id: [table, column] } : { id: column }
}

const negatedChecks: Record<string, string> = {
'=': '!=',
'>': '<=',
'>=': '<',
'<': '>=',
'<=': '>',
IS: 'IS NOT',
}

export function tokenizeCheck(
{ left, op, right, isRange }: Check,
{ left, op, right, isNot }: Check,
ctx: Query.Context
): TokenArray {
const tokens: TokenArray = []
let tokens: TokenArray = []

left = callProp(left)
right = callProp(right)
Expand All @@ -135,18 +145,41 @@ export function tokenizeCheck(
tokens.push(tokenize(left, ctx))
}

tokens.push(
right === null ? (op == '=' ? 'IS' : op == '!=' ? 'IS NOT' : op) : op
)
const isTuple = op == 'IN'
const isRange = op == 'BETWEEN'
const isAny = !isTuple && (Array.isArray(right) || isArrayExpression(right))

if (isRange) {
if (!isAny) {
if (right === null && op == '=') {
op = 'IS'
}
if (isNot) {
op = negatedChecks[op] || `NOT ${op}`
}
}
tokens.push(op)

if (isAny) {
tokens.push({
concat: [
'ANY(',
// An explicit cast is required if a placeholder is used,
// so we have to serialize JS arrays as Postgres arrays manually.
Array.isArray(right) ? { array: right } : tokenize(right, ctx),
')',
],
})
if (isNot) {
tokens = ['NOT', '(', tokens, ')']
}
} else if (isRange) {
tokens.push(tokenize(right[0], ctx), 'AND', tokenize(right[1], ctx))
} else if (isTuple) {
tokens.push({
tuple: (right as any[]).map(value => tokenize(value, ctx)),
})
} else if (Array.isArray(right)) {
tokens.push(
op == 'AND' || op == 'OR'
? tokenizeExpressionList(right, ' AND ', ctx)
: { tuple: right.map(value => tokenize(value, ctx)) }
)
tokens.push(tokenizeExpressionList(right, ' AND ', ctx))
} else {
tokens.push(tokenize(right, ctx))
}
Expand Down
8 changes: 8 additions & 0 deletions src/postgres/type.ts
Expand Up @@ -81,5 +81,13 @@ export type ExtractNull<T> = T extends Type<infer TypeName>
: never
: never

export type StringInput<T> = Extract<QueryInput<T>, string | null>

export type ArrayInput<T> =
| QueryInput<T>[]
| (T extends Type<infer Name, infer Value>
? Type<`${Name}[]`, Value[]>
: never)

export abstract class SetType<T extends object = any> //
extends Type<`setof<record>`, T[], T[]> {}

0 comments on commit 2602c8d

Please sign in to comment.