Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(minato): support object query, enhance dotted primary #97

Merged
merged 9 commits into from
May 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions packages/core/src/database.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { defineProperty, Dict, filterKeys, makeArray, mapValues, MaybeArray, noop, omit } from 'cosmokit'
import { Context, Service, Spread } from 'cordis'
import { DeepPartial, FlatKeys, FlatPick, Indexable, Keys, randomId, Row, unravel, Values } from './utils.ts'
import { DeepPartial, FlatKeys, FlatPick, getCell, Indexable, Keys, randomId, Row, unravel, Values } from './utils.ts'
import { Selection } from './selection.ts'
import { Field, Model, Relation } from './model.ts'
import { Driver } from './driver.ts'
Expand Down Expand Up @@ -44,10 +44,6 @@ export namespace Join2 {
export type Predicate<S, U extends Input<S>> = (args: Parameters<S, U>) => Eval.Expr<boolean>
}

function getCell(row: any, key: any): any {
return key.split('.').reduce((r, k) => r[k], row)
}

export class Database<S = {}, N = {}, C extends Context = Context> extends Service<undefined, C> {
static [Service.provide] = 'model'
static [Service.immediate] = true
Expand Down Expand Up @@ -476,7 +472,7 @@ export class Database<S = {}, N = {}, C extends Context = Context> extends Servi
const { primary, autoInc, fields } = sel.model
if (!autoInc) {
const keys = makeArray(primary)
if (keys.some(key => !(key in data))) {
if (keys.some(key => getCell(data, key) === undefined)) {
throw new Error('missing primary key')
}
}
Expand Down
10 changes: 5 additions & 5 deletions packages/core/src/model.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Binary, clone, filterKeys, isNullable, makeArray, mapValues, MaybeArray } from 'cosmokit'
import { clone, filterKeys, isNullable, makeArray, mapValues, MaybeArray } from 'cosmokit'
import { Context } from 'cordis'
import { Eval, isEvalExpr, Update } from './eval.ts'
import { DeepPartial, FlatKeys, Flatten, Keys, Row, unravel } from './utils.ts'
import { Eval, Update } from './eval.ts'
import { DeepPartial, FlatKeys, Flatten, isFlat, Keys, Row, unravel } from './utils.ts'
import { Type } from './type.ts'
import { Driver } from './driver.ts'
import { Query } from './query.ts'
Expand Down Expand Up @@ -335,7 +335,7 @@ export class Model<S = any> {
const field = fields.find(field => key.startsWith(field + '.'))
if (field) {
result[key] = value
} else if (!value || typeof value !== 'object' || isEvalExpr(value) || Object.keys(value).length === 0) {
} else if (isFlat(value)) {
if (strict && (typeof value !== 'object' || Object.keys(value).length)) {
throw new TypeError(`unknown field "${key}" in model ${this.name}`)
}
Expand Down Expand Up @@ -367,7 +367,7 @@ export class Model<S = any> {
const field = fields.find(field => fullKey === field || fullKey.startsWith(field + '.'))
if (field) {
node[segments[0]] = value
} else if (!value || typeof value !== 'object' || isEvalExpr(value) || Array.isArray(value) || Binary.is(value) || Object.keys(value).length === 0) {
} else if (isFlat(value)) {
if (strict) {
throw new TypeError(`unknown field "${fullKey}" in model ${this.name}`)
} else {
Expand Down
9 changes: 3 additions & 6 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Extract, isNullable } from 'cosmokit'
import { Eval, executeEval } from './eval.ts'
import { AtomicTypes, Comparable, Flatten, Indexable, isComparable, makeRegExp, Values } from './utils.ts'
import { AtomicTypes, Comparable, Flatten, flatten, getCell, Indexable, isComparable, isFlat, makeRegExp, Values } from './utils.ts'
import { Selection } from './selection.ts'

export type Query<T = any> = Query.Expr<Flatten<T>> | Query.Shorthand<Indexable> | Selection.Callback<T, boolean>
Expand Down Expand Up @@ -148,13 +148,10 @@ export function executeQuery(data: any, query: Query.Expr, ref: string, env: any

// execute field query
try {
return executeFieldQuery(value, getCell(data, key))
const flattenQuery = isFlat(query[key]) ? { [key]: query[key] } : flatten(query[key], `${key}.`)
return Object.entries(flattenQuery).every(([key, value]) => executeFieldQuery(value, getCell(data, key)))
} catch {
return false
}
})
}

function getCell(row: any, key: any): any {
return key.split('.').reduce((r, k) => r[k], row)
}
34 changes: 32 additions & 2 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Intersect, isNullable } from 'cosmokit'
import { Eval } from './eval.ts'
import { Binary, Intersect, isNullable } from 'cosmokit'
import { Eval, isEvalExpr } from './eval.ts'

export type Values<S> = S[keyof S]

Expand Down Expand Up @@ -70,9 +70,21 @@ export function isComparable(value: any): value is Comparable {
return typeof value === 'string'
|| typeof value === 'number'
|| typeof value === 'boolean'
|| typeof value === 'bigint'
|| value instanceof Date
}

export function isFlat(value: any): value is Values<AtomicTypes> {
return !value
|| typeof value !== 'object'
|| isEvalExpr(value)
|| Object.keys(value).length === 0
|| Array.isArray(value)
|| value instanceof Date
|| value instanceof RegExp
|| Binary.isSource(value)
}

const letters = 'abcdefghijklmnopqrstuvwxyz'

export function randomId() {
Expand All @@ -98,6 +110,24 @@ export function unravel(source: object, init?: (value) => any) {
return result
}

export function flatten(source: object, prefix = '', ignore: (value: any) => boolean = isFlat) {
const result = {}
for (const key in source) {
const value = source[key]
if (ignore(value)) {
result[`${prefix}${key}`] = value
} else {
Object.assign(result, flatten(value, `${prefix}${key}.`, ignore))
}
}
return result
}

export function getCell(row: any, key: any): any {
if (key in row) return row[key]
return key.split('.').reduce((r, k) => r === undefined ? undefined : r[k], row)
}

export function isEmpty(value: any) {
if (isNullable(value)) return true
if (typeof value !== 'object') return false
Expand Down
2 changes: 1 addition & 1 deletion packages/memory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export class MemoryDriver extends Driver<MemoryDriver.Config> {
meta.autoInc += 1
data[primary] = meta.autoInc
} else {
const duplicated = await this.database.get(table, pick(data, makeArray(primary)))
const duplicated = await this.database.get(table, pick(model.format(data), makeArray(primary)))
if (duplicated.length) {
throw new RuntimeError('duplicate-entry')
}
Expand Down
14 changes: 9 additions & 5 deletions packages/mongo/src/builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Dict, isNullable, mapValues } from 'cosmokit'
import { Eval, Field, isAggrExpr, isComparable, isEvalExpr, Model, Query, Selection, Type, unravel } from 'minato'
import { Eval, Field, flatten, isAggrExpr, isComparable, isEvalExpr, isFlat, Model, Query, Selection, Type, unravel } from 'minato'
import { Filter, FilterOperators, ObjectId } from 'mongodb'
import MongoDriver from '.'

Expand Down Expand Up @@ -406,10 +406,14 @@ export class Builder {
} else if (key === '$expr') {
additional.push({ $expr: this.eval(value) })
} else {
const actualKey = this.getActualKey(key)
const query = transformFieldQuery(value, actualKey, additional)
if (query === false) return
if (query !== true) filter[actualKey] = query
const ignore = (value: any) => isFlat(value) || value instanceof ObjectId
const flattenQuery = ignore(value) ? { [key]: value } : flatten(value, `${key}.`, ignore)
for (const key in flattenQuery) {
const value = flattenQuery[key], actualKey = this.getActualKey(key)
const query = transformFieldQuery(value, actualKey, additional)
if (query === false) return
if (query !== true) filter[actualKey] = query
}
}
}
if (additional.length) {
Expand Down
4 changes: 2 additions & 2 deletions packages/mysql/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ export class MySQLBuilder extends Builder {
}

this.transformers['boolean'] = {
encode: value => `if(${value}=b'1', 1, 0)`,
decode: value => `if(${value}=1, b'1', b'0')`,
encode: value => `if(${value}=true, 1, 0)`,
decode: value => `if(${value}=1, true, false)`,
load: value => isNullable(value) ? value : !!value,
dump: value => isNullable(value) ? value : value ? 1 : 0,
}
Expand Down
35 changes: 18 additions & 17 deletions packages/postgres/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,28 +46,28 @@ export class PostgresBuilder extends Builder {

this.evalOperators = {
...this.evalOperators,
$select: (args) => `${args.map(arg => this.parseEval(arg, this.getLiteralType(arg))).join(', ')}`,
$select: (args) => `${args.map(arg => this.parseEval(arg, this.transformType(arg))).join(', ')}`,
$if: (args) => {
const type = this.getLiteralType(args[1]) ?? this.getLiteralType(args[2]) ?? 'text'
const type = this.transformType(args[1]) ?? this.transformType(args[2]) ?? 'text'
return `(SELECT CASE WHEN ${this.parseEval(args[0], 'boolean')} THEN ${this.parseEval(args[1], type)} ELSE ${this.parseEval(args[2], type)} END)`
},
$ifNull: (args) => {
const type = args.map(this.getLiteralType).find(x => x) ?? 'text'
const type = args.map(this.transformType).find(x => x) ?? 'text'
return `coalesce(${args.map(arg => this.parseEval(arg, type)).join(', ')})`
},

$regex: ([key, value]) => `${this.parseEval(key)} ~ ${this.parseEval(value)}`,
$regex: ([key, value]) => `(${this.parseEval(key)} ~ ${this.parseEval(value)})`,

// number
$add: (args) => `(${args.map(arg => this.parseEval(arg, 'double precision')).join(' + ')})`,
$multiply: (args) => `(${args.map(arg => this.parseEval(arg, 'double precision')).join(' * ')})`,
$modulo: ([left, right]) => {
const dividend = this.parseEval(left, 'double precision'), divisor = this.parseEval(right, 'double precision')
return `${dividend} - (${divisor} * floor(${dividend} / ${divisor}))`
return `(${dividend} - (${divisor} * floor(${dividend} / ${divisor})))`
},
$log: ([left, right]) => isNullable(right)
? `ln(${this.parseEval(left, 'double precision')})`
: `ln(${this.parseEval(left, 'double precision')}) / ln(${this.parseEval(right, 'double precision')})`,
: `(ln(${this.parseEval(left, 'double precision')}) / ln(${this.parseEval(right, 'double precision')}))`,
$random: () => `random()`,

$or: (args) => {
Expand All @@ -91,8 +91,6 @@ export class PostgresBuilder extends Builder {
else return `(${args.map(arg => this.parseEval(arg, 'bigint')).join(' # ')})`
},

$eq: this.binary('=', 'text'),

$number: (arg) => {
const value = this.parseEval(arg)
const type = Type.fromTerm(arg)
Expand All @@ -109,7 +107,7 @@ export class PostgresBuilder extends Builder {
value => this.isEncoded() ? this.jsonLength(value) : this.asEncoded(`COALESCE(ARRAY_LENGTH(${value}, 1), 0)`, false),
),

$concat: (args) => `${args.map(arg => this.parseEval(arg, 'text')).join('||')}`,
$concat: (args) => `(${args.map(arg => this.parseEval(arg, 'text')).join('||')})`,
}

this.transformers['boolean'] = {
Expand Down Expand Up @@ -167,19 +165,21 @@ export class PostgresBuilder extends Builder {
this.modifiedTable = table
}

protected binary(operator: string, eltype: string = 'double precision') {
protected binary(operator: string, eltype: true | string = 'double precision') {
return ([left, right]) => {
const type = this.getLiteralType(left) ?? this.getLiteralType(right) ?? eltype
const type = this.transformType(left) ?? this.transformType(right) ?? eltype
return `(${this.parseEval(left, type)} ${operator} ${this.parseEval(right, type)})`
}
}

private getLiteralType(expr: any) {
const type = Type.fromTerm(expr)
if (Field.string.includes(type.type) || typeof expr === 'string') return 'text'
else if (Field.number.includes(type.type) || typeof expr === 'number') return 'double precision'
else if (Field.boolean.includes(type.type) || typeof expr === 'boolean') return 'boolean'
private transformType(source: any) {
const type = Type.isType(source) ? source : Type.fromTerm(source)
if (Field.string.includes(type.type) || typeof source === 'string') return 'text'
else if (['integer', 'unsigned', 'bigint'].includes(type.type) || typeof source === 'bigint') return 'bigint'
else if (Field.number.includes(type.type) || typeof source === 'number') return 'double precision'
else if (Field.boolean.includes(type.type) || typeof source === 'boolean') return 'boolean'
else if (type.type === 'json') return 'jsonb'
else if (type.type !== 'expr') return true
}

parseEval(expr: any, outtype: boolean | string = true): string {
Expand Down Expand Up @@ -225,7 +225,8 @@ export class PostgresBuilder extends Builder {
return this.asEncoded(`(${obj} @> ${value})`, false)
}

protected encode(value: string, encoded: boolean, pure: boolean = false, type?: Type, outtype?: string) {
protected encode(value: string, encoded: boolean, pure: boolean = false, type?: Type, outtype?: true | string) {
outtype ??= this.transformType(type)
return this.asEncoded((encoded === this.isEncoded() && !pure) ? value
: encoded ? `to_jsonb(${this.transform(value, type, 'encode')})`
: this.transform(`(jsonb_build_object('v', ${value})->>'v')`, type, 'decode') + `${typeof outtype === 'string' ? `::${outtype}` : ''}`
Expand Down
21 changes: 12 additions & 9 deletions packages/sql-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Dict, isNullable } from 'cosmokit'
import { Driver, Eval, Field, isAggrExpr, isComparable, isEvalExpr, Model, Modifier, Query, randomId, Selection, Type, unravel } from 'minato'
import {
Driver, Eval, Field, flatten, isAggrExpr, isComparable, isEvalExpr, isFlat,
Model, Modifier, Query, randomId, Selection, Type, unravel,
} from 'minato'

export function escapeId(value: string) {
return '`' + value + '`'
Expand Down Expand Up @@ -195,10 +198,6 @@ export class Builder {
}
}

protected unescapeId(value: string) {
return value.slice(1, value.length - 1)
}

protected createNullQuery(key: string, value: boolean) {
return `${key} is ${value ? 'not ' : ''}null`
}
Expand Down Expand Up @@ -231,7 +230,7 @@ export class Builder {
}

protected isJsonQuery(key: string) {
return isSqlJson(this.state.tables![this.state.table!].fields![this.unescapeId(key)]?.type)
return Type.fromTerm(this.state.expr)?.type === 'json' || this.isEncoded(key)
}

protected comparator(operator: string) {
Expand Down Expand Up @@ -296,7 +295,7 @@ export class Builder {
protected encode(value: string, encoded: boolean, pure: boolean = false, type?: Type) {
return this.asEncoded((encoded === this.isEncoded() && !pure) ? value
: encoded ? `cast(${this.transform(value, type, 'encode')} as json)`
: `json_unquote(${this.transform(value, type, 'decode')})`, pure ? undefined : encoded)
: this.transform(`json_unquote(${value})`, type, 'decode'), pure ? undefined : encoded)
}

protected isEncoded(key?: string) {
Expand Down Expand Up @@ -347,7 +346,6 @@ export class Builder {

protected parseFieldQuery(key: string, query: Query.Field) {
const conditions: string[] = []
if (this.modifiedTable) key = `${this.escapeId(this.modifiedTable)}.${key}`

// query shorthand
if (Array.isArray(query)) {
Expand Down Expand Up @@ -383,7 +381,12 @@ export class Builder {
} else if (key === '$expr') {
conditions.push(this.parseEval(query.$expr))
} else {
conditions.push(this.parseFieldQuery(this.escapeId(key), query[key]))
const flattenQuery = isFlat(query[key]) ? { [key]: query[key] } : flatten(query[key], `${key}.`)
for (const key in flattenQuery) {
const model = this.state.tables![this.state.table!] ?? Object.values(this.state.tables!)[0]
const expr = Eval('', [Object.keys(this.state.tables!)[0], key], model.getType(key)!)
conditions.push(this.parseFieldQuery(this.parseEval(expr), flattenQuery[key]))
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/sqlite/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class SQLiteBuilder extends Builder {
protected encode(value: string, encoded: boolean, pure: boolean = false, type?: Type) {
return encoded ? super.encode(value, encoded, pure, type)
: (encoded === this.isEncoded() && !pure) ? value
: this.asEncoded(`(${value} ->> '$')`, pure ? undefined : false)
: this.asEncoded(this.transform(`(${value} ->> '$')`, type, 'decode'), pure ? undefined : false)
}

protected createAggr(expr: any, aggr: (value: string) => string, nonaggr?: (value: string) => string) {
Expand Down
19 changes: 12 additions & 7 deletions packages/sqlite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,9 @@ export class SQLiteDriver extends Driver<SQLiteDriver.Config> {
}

async remove(sel: Selection.Mutable) {
const { query, table } = sel
const filter = this.sql.parseQuery(query)
const { query, table, tables } = sel
const builder = new SQLiteBuilder(this, tables)
const filter = builder.parseQuery(query)
if (filter === '0') return {}
const result = this._run(`DELETE FROM ${escapeId(table)} WHERE ${filter}`, [], () => this._get(`SELECT changes() AS count`))
return { matched: result.count, removed: result.count }
Expand All @@ -330,13 +331,13 @@ export class SQLiteDriver extends Driver<SQLiteDriver.Config> {
}

_update(sel: Selection.Mutable, indexFields: string[], updateFields: string[], update: {}, data: {}) {
const { ref, table } = sel
const model = this.model(table)
const { ref, table, tables, model } = sel
const builder = new SQLiteBuilder(this, tables)
executeUpdate(data, update, ref)
const row = this.sql.dump(data, model)
const row = builder.dump(data, model)
const assignment = updateFields.map((key) => `${escapeId(key)} = ?`).join(',')
const query = Object.fromEntries(indexFields.map(key => [key, row[key]]))
const filter = this.sql.parseQuery(query)
const filter = builder.parseQuery(query)
this._run(`UPDATE ${escapeId(table)} SET ${assignment} WHERE ${filter}`, updateFields.map((key) => row[key] ?? null))
}

Expand Down Expand Up @@ -402,7 +403,11 @@ export class SQLiteDriver extends Driver<SQLiteDriver.Config> {
$or: chunk.map(item => Object.fromEntries(keys.map(key => [key, item[key]]))),
})
for (const item of chunk) {
const row = results.find(row => keys.every(key => deepEqual(row[key], item[key], true)))
const row = results.find(row => {
// flatten key to respect model
row = model.format(row)
return keys.every(key => deepEqual(row[key], item[key], true))
})
if (row) {
this._update(sel, keys, updateFields, item, row)
result.matched++
Expand Down
Loading