@nozbe/watermelondb
Version:
Build powerful React Native and React web apps that scale from hundreds to tens of thousands of records and remain fast
243 lines (212 loc) • 8.49 kB
JavaScript
// @flow
/* eslint-disable no-use-before-define */
import { invariant } from '../../../utils/common'
import type { SerializedQuery, QueryAssociation } from '../../../Query'
import type {
NonNullValues,
Operator,
Where,
ComparisonRight,
Comparison,
SortBy,
QueryDescription,
} from '../../../QueryDescription'
import * as Q from '../../../QueryDescription'
import { type TableName, type ColumnName } from '../../../Schema'
import encodeValue from '../encodeValue'
import type { SQL, SQLiteArg } from '../index'
function mapJoin<T>(array: T[], mapper: (T) => string, joiner: string): string {
// NOTE: DO NOT try to optimize this by concatenating strings together. In non-JIT JSC,
// concatenating strings is extremely slow (5000ms vs 120ms on 65K sample)
return array.map(mapper).join(joiner)
}
const encodeValues: (NonNullValues) => string = (values) =>
`(${mapJoin((values: any[]), encodeValue, ', ')})`
const getComparisonRight = (table: TableName<any>, comparisonRight: ComparisonRight): string => {
if (comparisonRight.values) {
return encodeValues(comparisonRight.values)
} else if (comparisonRight.column) {
return `"${table}"."${comparisonRight.column}"`
}
return typeof comparisonRight.value !== 'undefined' ? encodeValue(comparisonRight.value) : 'null'
}
// Note: it's necessary to use `is` / `is not` for NULL comparisons to work correctly
// See: https://sqlite.org/lang_expr.html
const operators: { [Operator]: string } = {
eq: 'is',
notEq: 'is not',
gt: '>',
gte: '>=',
weakGt: '>', // For non-column comparison case
lt: '<',
lte: '<=',
oneOf: 'in',
notIn: 'not in',
between: 'between',
like: 'like',
notLike: 'not like',
}
const encodeComparison = (table: TableName<any>, comparison: Comparison) => {
const { operator } = comparison
if (operator === 'between') {
const { right } = comparison
return right.values
? `between ${encodeValue(right.values[0])} and ${encodeValue(right.values[1])}`
: ''
}
return `${operators[operator]} ${getComparisonRight(table, comparison.right)}`
}
const encodeWhere =
(table: TableName<any>, associations: QueryAssociation[]) =>
(where: Where): string => {
switch (where.type) {
case 'and':
return `(${encodeAndOr(associations, 'and', table, where.conditions)})`
case 'or':
return `(${encodeAndOr(associations, 'or', table, where.conditions)})`
case 'where':
return encodeWhereCondition(associations, table, where.left, where.comparison)
case 'on':
if (process.env.NODE_ENV !== 'production') {
invariant(
associations.some(({ to }) => to === where.table),
'To nest Q.on inside Q.and/Q.or you must explicitly declare Q.experimentalJoinTables at the beginning of the query',
)
}
return `(${encodeAndOr(associations, 'and', where.table, where.conditions)})`
case 'sql':
return where.expr
default:
throw new Error(`Unknown clause ${where.type}`)
}
}
const encodeWhereCondition = (
associations: QueryAssociation[],
table: TableName<any>,
left: ColumnName,
comparison: Comparison,
): string => {
const { operator } = comparison
// if right operand is a value, we can use simple comparison
// if a column, we must check for `not null > null`
if (operator === 'weakGt' && comparison.right.column) {
return encodeWhere(
table,
associations,
)(
Q.or(
// $FlowFixMe
Q.where(left, Q.gt(Q.column(comparison.right.column))),
Q.and(Q.where(left, Q.notEq(null)), Q.where((comparison.right: any).column, null)),
),
)
} else if (operator === 'includes') {
return `instr("${table}"."${left}", ${getComparisonRight(table, comparison.right)})`
}
return `"${table}"."${left}" ${encodeComparison(table, comparison)}`
}
const encodeAndOr = (
associations: QueryAssociation[],
op: string,
table: TableName<any>,
conditions: Where[],
) => {
if (conditions.length) {
return mapJoin(conditions, encodeWhere(table, associations), ` ${op} `)
}
return ''
}
const andJoiner = ' and '
const encodeConditions = (
table: TableName<any>,
description: QueryDescription,
associations: QueryAssociation[],
): string => {
const clauses = mapJoin(description.where, encodeWhere(table, associations), andJoiner)
return clauses.length ? ` where ${clauses}` : ''
}
// If query contains `on()` conditions on tables with which the primary table has a has-many
// relation, then we need to add `distinct` on the query to ensure there are no duplicates
const encodeMethod = (
table: TableName<any>,
countMode: boolean,
needsDistinct: boolean,
): string => {
if (countMode) {
return needsDistinct
? `select count(distinct "${table}"."id") as "count" from "${table}"`
: `select count(*) as "count" from "${table}"`
}
return needsDistinct
? `select distinct "${table}".* from "${table}"`
: `select "${table}".* from "${table}"`
}
const encodeAssociation =
(description: QueryDescription) =>
({ from: mainTable, to: joinedTable, info: association }: QueryAssociation): string => {
// TODO: We have a problem here. For all of eternity, WatermelonDB Q.ons were encoded using JOIN
// However, this precludes many legitimate use cases for Q.ons once you start nesting them
// (e.g. get tasks where X or has a tag assignment that Y -- if there is no tag assignment, this will
// fail to join)
// LEFT JOIN needs to be used to address this… BUT technically that's a breaking change. I never
// considered a possiblity of making a query like `Q.on(relation_id, x != 'bla')`. Before this would
// only match if there IS a relation, but with LEFT JOIN it would also match if record does not have
// this relation. I don't know if there are legitimate use cases where this would change anything
// so I need more time to think about whether this breaking change is OK to make or if we need to
// do something more clever/add option/whatever.
// so for now, i'm making an extreeeeemelyyyy bad hack to make sure that there's no breaking change
// for existing code and code with nested Q.ons probably works (with caveats)
const usesOldJoinStyle = description.where.some(
(clause) => clause.type === 'on' && clause.table === joinedTable,
)
const joinKeyword = usesOldJoinStyle ? ' join ' : ' left join '
const joinBeginning = `${joinKeyword}"${joinedTable}" on "${joinedTable}".`
return association.type === 'belongs_to'
? `${joinBeginning}"id" = "${mainTable}"."${association.key}"`
: `${joinBeginning}"${association.foreignKey}" = "${mainTable}"."id"`
}
const encodeJoin = (description: QueryDescription, associations: QueryAssociation[]): string =>
associations.length ? associations.map(encodeAssociation(description)).join('') : ''
const encodeOrderBy = (table: TableName<any>, sortBys: SortBy[]) => {
if (sortBys.length === 0) {
return ''
}
const orderBys = sortBys
.map((sortBy) => {
return `"${table}"."${sortBy.sortColumn}" ${sortBy.sortOrder}`
})
.join(', ')
return ` order by ${orderBys}`
}
const encodeLimitOffset = (limit: ?number, offset: ?number) => {
if (!limit) {
return ''
}
const optionalOffsetStmt = offset ? ` offset ${offset}` : ''
return ` limit ${limit}${optionalOffsetStmt}`
}
const encodeQuery = (query: SerializedQuery, countMode: boolean = false): [SQL, SQLiteArg[]] => {
const { table, description, associations } = query
// TODO: Test if encoding a `select x.id from x` query speeds up queryIds() calls
if (description.sql) {
const { sql, values } = description.sql
return [sql, values]
}
const hasToManyJoins = associations.some(({ info }) => info.type === 'has_many')
if (process.env.NODE_ENV !== 'production') {
description.take &&
invariant(
!countMode,
'take/skip is not currently supported with counting. Please contribute to fix this!',
)
invariant(!description.lokiTransform, 'unsafeLokiTransform not supported with SQLite')
}
const sql =
encodeMethod(table, countMode, hasToManyJoins) +
encodeJoin(description, associations) +
encodeConditions(table, description, associations) +
encodeOrderBy(table, description.sortBy) +
encodeLimitOffset(description.take, description.skip)
return [sql, []]
}
export default encodeQuery