@nozbe/watermelondb
Version:
Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast
323 lines (275 loc) • 11.5 kB
JavaScript
// @flow
/* eslint-disable no-use-before-define */
// don't import whole `utils` to keep worker size small
import invariant from '../utils/common/invariant'
import checkName from '../utils/fp/checkName'
import fromArrayOrSpread, { type ArrayOrSpreadFn } from '../utils/fp/arrayOrSpread'
import { type TableName, type ColumnName } from '../Schema'
import type {
NonNullValue,
NonNullValues,
Value,
ColumnDescription,
ComparisonRight,
Comparison,
WhereDescription,
SqlExpr,
LokiExpr,
Where,
And,
Or,
On,
SortOrder,
SortBy,
Take,
Skip,
JoinTables,
NestedJoinTable,
LokiTransformFunction,
LokiTransform,
SqlQuery,
} from './type'
// Note: These operators are designed to match SQLite semantics
// to ensure that iOS, Android, web, and Query observation yield exactly the same results
//
// - `true` and `false` are equal to `1` and `0`
// (JS uses true/false, but SQLite uses 1/0)
// - `null`, `undefined`, and missing fields are equal
// (SQLite queries return null, but newly created records might lack fields)
// - You can only compare columns to values/other columns of the same type
// (e.g. string to int comparisons are not allowed)
// - numeric comparisons (<, <=, >, >=, between) with null on either side always return false
// e.g. `null < 2 == false`
// - `null` on the right-hand-side of IN/NOT IN is not allowed
// e.g. `Q.in([null, 'foo', 'bar'])`
// - `null` on the left-hand-side of IN/NOT IN will always return false
// e.g. `null NOT IN (1, 2, 3) == false`
// Equals (weakly)
// Note:
// - (null == undefined) == true
// - (1 == true) == true
// - (0 == false) == true
export function eq(valueOrColumn: Value | ColumnDescription): Comparison {
return { operator: 'eq', right: _valueOrColumn(valueOrColumn), type: comparisonSymbol }
}
// Not equal (weakly)
// Note:
// - (null != undefined) == false
// - (1 != true) == false
// - (0 != false) == false
export function notEq(valueOrColumn: Value | ColumnDescription): Comparison {
return { operator: 'notEq', right: _valueOrColumn(valueOrColumn), type: comparisonSymbol }
}
// Greater than (SQLite semantics)
// Note:
// - (5 > null) == false
export function gt(valueOrColumn: NonNullValue | ColumnDescription): Comparison {
return { operator: 'gt', right: _valueOrColumn(valueOrColumn), type: comparisonSymbol }
}
// Greater than or equal (SQLite semantics)
// Note:
// - (5 >= null) == false
export function gte(valueOrColumn: NonNullValue | ColumnDescription): Comparison {
return { operator: 'gte', right: _valueOrColumn(valueOrColumn), type: comparisonSymbol }
}
// Greater than (JavaScript semantics)
// Note:
// - (5 > null) == true
export function weakGt(valueOrColumn: NonNullValue | ColumnDescription): Comparison {
return { operator: 'weakGt', right: _valueOrColumn(valueOrColumn), type: comparisonSymbol }
}
// Less than (SQLite semantics)
// Note:
// - (null < 5) == false
export function lt(valueOrColumn: NonNullValue | ColumnDescription): Comparison {
return { operator: 'lt', right: _valueOrColumn(valueOrColumn), type: comparisonSymbol }
}
// Less than or equal (SQLite semantics)
// Note:
// - (null <= 5) == false
export function lte(valueOrColumn: NonNullValue | ColumnDescription): Comparison {
return { operator: 'lte', right: _valueOrColumn(valueOrColumn), type: comparisonSymbol }
}
// Value in a set (SQLite IN semantics)
// Note:
// - `null` in `values` is not allowed!
export function oneOf(values: NonNullValues): Comparison {
invariant(Array.isArray(values), `argument passed to oneOf() is not an array`)
Object.freeze(values) // even in production, because it's an easy mistake to make
return { operator: 'oneOf', right: { values }, type: comparisonSymbol }
}
// Value not in a set (SQLite NOT IN semantics)
// Note:
// - `null` in `values` is not allowed!
// - (null NOT IN (1, 2, 3)) == false
export function notIn(values: NonNullValues): Comparison {
invariant(Array.isArray(values), `argument passed to notIn() is not an array`)
Object.freeze(values) // even in production, because it's an easy mistake to make
return { operator: 'notIn', right: { values }, type: comparisonSymbol }
}
// Number is between two numbers (greater than or equal left, and less than or equal right)
export function between(left: number, right: number): Comparison {
invariant(
typeof left === 'number' && typeof right === 'number',
'Values passed to Q.between() are not numbers',
)
const values: number[] = [left, right]
return { operator: 'between', right: { values }, type: comparisonSymbol }
}
export function like(value: string): Comparison {
invariant(typeof value === 'string', 'Value passed to Q.like() is not a string')
return { operator: 'like', right: { value }, type: comparisonSymbol }
}
export function notLike(value: string): Comparison {
invariant(typeof value === 'string', 'Value passed to Q.notLike() is not a string')
return { operator: 'notLike', right: { value }, type: comparisonSymbol }
}
const nonLikeSafeRegexp = /[^a-zA-Z0-9]/g
export function sanitizeLikeString(value: string): string {
invariant(typeof value === 'string', 'Value passed to Q.sanitizeLikeString() is not a string')
return value.replace(nonLikeSafeRegexp, '_')
}
export function includes(value: string): Comparison {
invariant(typeof value === 'string', 'Value passed to Q.includes() is not a string')
return { operator: 'includes', right: { value }, type: comparisonSymbol }
}
export function column(name: ColumnName): ColumnDescription {
invariant(typeof name === 'string', 'Name passed to Q.column() is not a string')
return { column: checkName(name), type: columnSymbol }
}
function _valueOrComparison(arg: Value | Comparison): Comparison {
if (arg === null || typeof arg !== 'object') {
return _valueOrComparison(eq(arg))
}
invariant(
arg.type === comparisonSymbol,
'Invalid Comparison passed to Query builder. You seem to be passing unsanitized user data to Query builder!',
)
const { operator, right } = arg
return { operator, right }
}
export function where(left: ColumnName, valueOrComparison: Value | Comparison): WhereDescription {
return { type: 'where', left: checkName(left), comparison: _valueOrComparison(valueOrComparison) }
}
export function unsafeSqlExpr(sql: string): SqlExpr {
if (process.env.NODE_ENV !== 'production') {
invariant(typeof sql === 'string', 'Value passed to Q.unsafeSqlExpr is not a string')
}
return { type: 'sql', expr: sql }
}
export function unsafeLokiExpr(expr: any): LokiExpr {
if (process.env.NODE_ENV !== 'production') {
invariant(
expr && typeof expr === 'object' && !Array.isArray(expr),
'Value passed to Q.unsafeLokiExpr is not an object',
)
}
return { type: 'loki', expr }
}
export function unsafeLokiTransform(fn: LokiTransformFunction): LokiTransform {
return { type: 'lokiTransform', function: fn }
}
export const and: ArrayOrSpreadFn<Where, And> = (...args): And => {
const clauses = fromArrayOrSpread<Where>(args, 'Q.and()', 'Where')
validateConditions(clauses)
return { type: 'and', conditions: clauses }
}
export const or: ArrayOrSpreadFn<Where, Or> = (...args): Or => {
const clauses = fromArrayOrSpread<Where>(args, 'Q.or()', 'Where')
validateConditions(clauses)
return { type: 'or', conditions: clauses }
}
export const asc: SortOrder = 'asc'
export const desc: SortOrder = 'desc'
export function sortBy(sortColumn: ColumnName, sortOrder: SortOrder = asc): SortBy {
invariant(
sortOrder === 'asc' || sortOrder === 'desc',
`Invalid sortOrder argument received in Q.sortBy (valid: asc, desc)`,
)
return { type: 'sortBy', sortColumn: checkName(sortColumn), sortOrder }
}
export function take(count: number): Take {
invariant(typeof count === 'number', 'Value passed to Q.take() is not a number')
return { type: 'take', count }
}
export function skip(count: number): Skip {
invariant(typeof count === 'number', 'Value passed to Q.skip() is not a number')
return { type: 'skip', count }
}
// Note: we have to write out three separate meanings of OnFunction because of a Babel bug
// (it will remove the parentheses, changing the meaning of the flow type)
type _OnFunctionColumnValue = (TableName<any>, ColumnName, Value) => On
type _OnFunctionColumnComparison = (TableName<any>, ColumnName, Comparison) => On
type _OnFunctionWhere = (TableName<any>, Where) => On
type _OnFunctionWhereList = (TableName<any>, Where[]) => On
type OnFunction = _OnFunctionColumnValue &
_OnFunctionColumnComparison &
_OnFunctionWhere &
_OnFunctionWhereList
// Use: on('tableName', 'left_column', 'right_value')
// or: on('tableName', 'left_column', gte(10))
// or: on('tableName', where('left_column', 'value')))
// or: on('tableName', or(...))
// or: on('tableName', [where(...), where(...)])
export const on: OnFunction = (table, leftOrClauseOrList, valueOrComparison) => {
if (typeof leftOrClauseOrList === 'string') {
invariant(valueOrComparison !== undefined, 'illegal `undefined` passed to Q.on')
return on(table, [where(leftOrClauseOrList, valueOrComparison)])
}
const clauseOrList: Where | Where[] = (leftOrClauseOrList: any)
if (Array.isArray(clauseOrList)) {
const conditions: Where[] = clauseOrList
validateConditions(conditions)
return {
type: 'on',
table: checkName(table),
conditions,
}
} else if (clauseOrList && clauseOrList.type === 'and') {
return on(table, clauseOrList.conditions)
}
return on(table, [clauseOrList])
}
export function experimentalJoinTables(tables: TableName<any>[]): JoinTables {
invariant(Array.isArray(tables), 'experimentalJoinTables expected an array')
return { type: 'joinTables', tables: tables.map(checkName) }
}
export function experimentalNestedJoin(from: TableName<any>, to: TableName<any>): NestedJoinTable {
return { type: 'nestedJoinTable', from: checkName(from), to: checkName(to) }
}
export function unsafeSqlQuery(sql: string, values: Value[] = []): SqlQuery {
if (process.env.NODE_ENV !== 'production') {
invariant(typeof sql === 'string', 'Value passed to Q.unsafeSqlQuery is not a string')
invariant(
Array.isArray(values),
'Placeholder values passed to Q.unsafeSqlQuery are not an array',
)
}
return { type: 'sqlQuery', sql, values }
}
const columnSymbol = Symbol('Q.column')
const comparisonSymbol = Symbol('QueryComparison')
function _valueOrColumn(arg: Value | ColumnDescription): ComparisonRight {
if (arg === null || typeof arg !== 'object') {
invariant(arg !== undefined, 'Cannot compare to undefined in a Query. Did you mean null?')
return { value: arg }
}
if (typeof arg.column === 'string') {
invariant(
arg.type === columnSymbol,
'Invalid { column: } object passed to Watermelon query. You seem to be passing unsanitized user data to Query builder!',
)
return { column: arg.column }
}
throw new Error(`Invalid value passed to query`)
}
const acceptableClauses = ['where', 'and', 'or', 'on', 'sql', 'loki']
const isAcceptableClause = (clause: Where) => acceptableClauses.includes(clause.type)
const validateConditions = (clauses: Where[]) => {
if (process.env.NODE_ENV !== 'production') {
invariant(
clauses.every(isAcceptableClause),
'Q.and(), Q.or(), Q.on() can only contain: Q.where, Q.and, Q.or, Q.on, Q.unsafeSqlExpr, Q.unsafeLokiExpr clauses',
)
}
}