UNPKG

@rikishi/watermelondb

Version:

Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast

526 lines (461 loc) 17.5 kB
// @flow /* eslint-disable no-use-before-define */ import { unique } from '../utils/fp' // don't import whole `utils` to keep worker size small import invariant from '../utils/common/invariant' import logger from '../utils/common/logger' import checkName from '../utils/fp/checkName' import deepFreeze from '../utils/common/deepFreeze' import type { $RE } from '../types' import { type TableName, type ColumnName, columnName } from '../Schema' export type NonNullValue = number | string | boolean export type NonNullValues = number[] | string[] | boolean[] export type Value = NonNullValue | null export type CompoundValue = Value | Value[] export type Operator = | 'eq' | 'notEq' | 'gt' | 'gte' | 'weakGt' | 'lt' | 'lte' | 'oneOf' | 'notIn' | 'between' | 'like' | 'notLike' | 'includes' export type ColumnDescription = $RE<{ column: ColumnName, type?: symbol }> export type ComparisonRight = | $RE<{ value: Value }> | $RE<{ values: NonNullValues }> | ColumnDescription export type Comparison = $RE<{ operator: Operator, right: ComparisonRight, type?: symbol }> export type WhereDescription = $RE<{ type: 'where', left: ColumnName, comparison: Comparison, }> export type SqlExpr = $RE<{ type: 'sql', expr: string }> export type LokiExpr = $RE<{ type: 'loki', expr: any }> export type Where = WhereDescription | And | Or | On | SqlExpr | LokiExpr export type And = $RE<{ type: 'and', conditions: Where[] }> export type Or = $RE<{ type: 'or', conditions: Where[] }> export type On = $RE<{ type: 'on', table: TableName<any>, conditions: Where[], }> export type SortOrder = 'asc' | 'desc' export const asc: SortOrder = 'asc' export const desc: SortOrder = 'desc' export type SortBy = $RE<{ type: 'sortBy', sortColumn: ColumnName, sortOrder: SortOrder, }> export type Take = $RE<{ type: 'take', count: number, }> export type Skip = $RE<{ type: 'skip', count: number, }> export type JoinTables = $RE<{ type: 'joinTables', tables: TableName<any>[], }> export type NestedJoinTable = $RE<{ type: 'nestedJoinTable', from: TableName<any>, to: TableName<any>, }> export type LokiTransformFunction = (rawLokiRecords: any[], loki: any) => any[] export type LokiTransform = $RE<{ type: 'lokiTransform', function: LokiTransformFunction, }> export type SqlQuery = $RE<{ type: 'sqlQuery', sql: string, values: Value[], }> export type Clause = | Where | SortBy | Take | Skip | JoinTables | NestedJoinTable | LokiTransform | SqlQuery type NestedJoinTableDef = $RE<{ from: TableName<any>, to: TableName<any> }> export type QueryDescription = $RE<{ where: Where[], joinTables: TableName<any>[], nestedJoinTables: NestedJoinTableDef[], sortBy: SortBy[], take?: number, skip?: number, lokiTransform?: LokiTransformFunction, sql?: SqlQuery, }> const columnSymbol = Symbol('Q.column') const comparisonSymbol = Symbol('QueryComparison') // 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` 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`) } // 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 } } 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', ) } } export function and(...clauses: Where[]): And { validateConditions(clauses) return { type: 'and', conditions: clauses } } export function or(...clauses: Where[]): Or { validateConditions(clauses) return { type: 'or', conditions: clauses } } 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 syncStatusColumn = columnName('_status') const extractClauses: (Clause[]) => QueryDescription = (clauses) => { const query = { where: [], joinTables: [], nestedJoinTables: [], sortBy: [] } clauses.forEach((clause) => { const { type } = clause switch (type) { case 'where': case 'and': case 'or': case 'sql': case 'loki': query.where.push(clause) break case 'on': // $FlowFixMe query.joinTables.push(clause.table) query.where.push(clause) break case 'sortBy': query.sortBy.push(clause) break case 'take': // $FlowFixMe query.take = clause.count break case 'skip': // $FlowFixMe query.skip = clause.count break case 'joinTables': // $FlowFixMe query.joinTables.push(...clause.tables) break case 'nestedJoinTable': // $FlowFixMe query.nestedJoinTables.push({ from: clause.from, to: clause.to }) break case 'lokiTransform': // $FlowFixMe query.lokiTransform = clause.function break case 'sqlQuery': // $FlowFixMe query.sql = clause if (process.env.NODE_ENV !== 'production') { invariant( clauses.every((_clause) => ['sqlQuery', 'joinTables', 'nestedJoinTable'].includes(_clause.type), ), 'Cannot use Q.unsafeSqlQuery with other clauses, except for Q.experimentalJoinTables and Q.experimentalNestedJoin (Did you mean Q.unsafeSqlExpr?)', ) } break default: throw new Error('Invalid Query clause passed') } }) query.joinTables = unique(query.joinTables) // In the past, multiple separate top-level Q.ons were the only supported syntax and were automatically merged per-table to produce optimal code // We used to have a special case to avoid regressions, but it added complexity and had a side effect of rearranging the query suboptimally // We won't support this anymore, but will warn about suboptimal queries // TODO: Remove after 2022-01-01 if (process.env.NODE_ENV !== 'production') { const onsEncountered = {} query.where.forEach((clause) => { if (clause.type === 'on') { const table = (clause.table: string) if (onsEncountered[table]) { logger.warn( `Found multiple Q.on('${table}', ...) clauses in a query. This is a performance bug - use a single Q.on('${table}', [condition1, condition1]) to produce a better performing query`, ) } onsEncountered[table] = true } }) } // $FlowFixMe: Flow is too dumb to realize that it is valid return query } export function buildQueryDescription(clauses: Clause[]): QueryDescription { const query = extractClauses(clauses) if (process.env.NODE_ENV !== 'production') { invariant(!(query.skip && !query.take), 'cannot skip without take') deepFreeze(query) } return query } const whereNotDeleted = where(syncStatusColumn, notEq('deleted')) function conditionsWithoutDeleted(conditions: Where[]): Where[] { return conditions.map(queryWithoutDeletedImpl) } function queryWithoutDeletedImpl(clause: Where): Where { if (clause.type === 'and') { return { type: 'and', conditions: conditionsWithoutDeleted(clause.conditions) } } else if (clause.type === 'or') { return { type: 'or', conditions: conditionsWithoutDeleted(clause.conditions) } } else if (clause.type === 'on') { const onClause: On = clause return { type: 'on', table: onClause.table, conditions: conditionsWithoutDeleted(onClause.conditions).concat(whereNotDeleted), } } return clause } export function queryWithoutDeleted(query: QueryDescription): QueryDescription { const { where: whereConditions } = query const newQuery = { ...query, where: conditionsWithoutDeleted(whereConditions).concat(whereNotDeleted), } if (process.env.NODE_ENV !== 'production') { deepFreeze(newQuery) } return newQuery }