Skip to content

Commit

Permalink
refa: separate builder class
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Feb 10, 2024
1 parent cdf8309 commit 3a356d2
Show file tree
Hide file tree
Showing 8 changed files with 608 additions and 586 deletions.
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"postgres"
],
"dependencies": {
"cordis": "^3.9.1",
"cordis": "^3.9.2",
"cosmokit": "^1.5.2"
}
}
44 changes: 27 additions & 17 deletions packages/core/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,36 @@ type TableType<S, T extends TableLike<S>> =
: T extends Selection<infer U> ? U
: never

type TableMap1<S, M extends readonly Keys<S>[]> = Intersect<
| M extends readonly (infer K extends Keys<S>)[]
? { [P in K]: TableType<S, P> }
: never
>
export namespace Join1 {
export type Input<S> = readonly Keys<S>[]

export type Output<S, U extends Input<S>> = Intersect<
| U extends readonly (infer K extends Keys<S>)[]
? { [P in K]: TableType<S, P> }
: never
>

type TableMap2<S, U extends Dict<TableLike<S>>> = {
[K in keyof U]: TableType<S, U[K]>
type Parameters<S, U extends Input<S>> =
| U extends readonly [infer K extends Keys<S>, ...infer R]
? [Row<S[K]>, ...Parameters<S, Extract<R, Input<S>>>]
: []

export type Predicate<S, U extends Input<S>> = (...args: Parameters<S, U>) => Eval.Expr<boolean>
}

type JoinParameters<S, U extends readonly Keys<S>[]> =
| U extends readonly [infer K extends Keys<S>, ...infer R]
? [Row<S[K]>, ...JoinParameters<S, Extract<R, readonly Keys<S>[]>>]
: []
export namespace Join2 {
export type Input<S> = Dict<TableLike<S>>

type JoinCallback1<S, U extends readonly Keys<S>[]> = (...args: JoinParameters<S, U>) => Eval.Expr<boolean>
export type Output<S, U extends Input<S>> = {
[K in keyof U]: TableType<S, U[K]>
}

type JoinCallback2<S, U extends Dict<TableLike<S>>> = (args: {
[K in keyof U]: Row<TableType<S, U[K]>>
}) => Eval.Expr<boolean>
type Parameters<S, U extends Input<S>> = {
[K in keyof U]: Row<TableType<S, U[K]>>
}

export type Predicate<S, U extends Input<S>> = (args: Parameters<S, U>) => Eval.Expr<boolean>
}

const kTransaction = Symbol('transaction')

Expand Down Expand Up @@ -105,8 +115,8 @@ export class Database<S = any, C extends Context = Context> extends Service<C> {
return new Selection(this.getDriver(table), table, query)
}

join<U extends readonly Keys<S>[]>(tables: U, callback?: JoinCallback1<S, U>, optional?: boolean[]): Selection<TableMap1<S, U>>
join<U extends Dict<TableLike<S>>>(tables: U, callback?: JoinCallback2<S, U>, optional?: Dict<boolean, Keys<U>>): Selection<TableMap2<S, U>>
join<U extends Join1.Input<S>>(tables: U, callback?: Join1.Predicate<S, U>, optional?: boolean[]): Selection<Join1.Output<S, U>>
join<U extends Join2.Input<S>>(tables: U, callback?: Join2.Predicate<S, U>, optional?: Dict<boolean, Keys<U>>): Selection<Join2.Output<S, U>>
join(tables: any, query?: any, optional?: any) {
if (Array.isArray(tables)) {
const sel = new Selection(this.getDriver(tables[0]), Object.fromEntries(tables.map((name) => [name, this.select(name)])))
Expand Down
170 changes: 170 additions & 0 deletions packages/mysql/src/builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { Builder, escapeId, isBracketed } from '@minatojs/sql-utils'
import { Dict, Time } from 'cosmokit'
import { Field, isEvalExpr, Model, randomId, Selection } from 'minato'

export const DEFAULT_DATE = new Date('1970-01-01')

export interface Compat {
maria?: boolean
maria105?: boolean
mysql57?: boolean
}

export class MySQLBuilder extends Builder {
// eslint-disable-next-line no-control-regex
protected escapeRegExp = /[\0\b\t\n\r\x1a'"\\]/g
protected escapeMap = {
'\0': '\\0',
'\b': '\\b',
'\t': '\\t',
'\n': '\\n',
'\r': '\\r',
'\x1a': '\\Z',
'\"': '\\\"',
'\'': '\\\'',
'\\': '\\\\',
}

prequeries: string[] = []

constructor(tables?: Dict<Model>, private compat: Compat = {}) {
super(tables)

this.evalOperators.$sum = (expr) => this.createAggr(expr, value => `ifnull(sum(${value}), 0)`, undefined, value => `ifnull(minato_cfunc_sum(${value}), 0)`)
this.evalOperators.$avg = (expr) => this.createAggr(expr, value => `avg(${value})`, undefined, value => `minato_cfunc_avg(${value})`)
this.evalOperators.$min = (expr) => this.createAggr(expr, value => `min(${value})`, undefined, value => `minato_cfunc_min(${value})`)
this.evalOperators.$max = (expr) => this.createAggr(expr, value => `max(${value})`, undefined, value => `minato_cfunc_max(${value})`)

this.define<string[], string>({
types: ['list'],
dump: value => value.join(','),
load: value => value ? value.split(',') : [],
})

this.define<object, string>({
types: ['json'],
dump: value => JSON.stringify(value),
load: value => typeof value === 'string' ? JSON.parse(value) : value,
})

this.define<Date, any>({
types: ['time'],
dump: value => value,
load: (value) => {
if (!value || typeof value === 'object') return value
const time = new Date(DEFAULT_DATE)
const [h, m, s] = value.split(':')
time.setHours(parseInt(h))
time.setMinutes(parseInt(m))
time.setSeconds(parseInt(s))
return time
},
})
}

escape(value: any, field?: Field) {
if (value instanceof Date) {
value = Time.template('yyyy-MM-dd hh:mm:ss', value)
} else if (value instanceof RegExp) {
value = value.source
} else if (!field && !!value && typeof value === 'object') {
return `json_extract(${this.quote(JSON.stringify(value))}, '$')`
}
return super.escape(value, field)
}

protected jsonQuote(value: string, pure: boolean = false) {
if (pure) return this.compat.maria ? `json_extract(json_object('v', ${value}), '$.v')` : `cast(${value} as json)`
const res = this.state.sqlType === 'raw' ? (this.compat.maria ? `json_extract(json_object('v', ${value}), '$.v')` : `cast(${value} as json)`) : value
this.state.sqlType = 'json'
return res
}

protected createAggr(expr: any, aggr: (value: string) => string, nonaggr?: (value: string) => string, compat?: (value: string) => string) {
if (!this.state.group && compat && (this.compat.mysql57 || this.compat.maria)) {
return compat(this.parseEval(expr, false))
} else {
return super.createAggr(expr, aggr, nonaggr)
}
}

protected groupArray(value: string) {
if (!this.compat.maria) return super.groupArray(value)
const res = this.state.sqlType === 'json' ? `concat('[', group_concat(${value}), ']')`
: `concat('[', group_concat(json_extract(json_object('v', ${value}), '$.v')), ']')`
this.state.sqlType = 'json'
return `ifnull(${res}, json_array())`
}

protected parseSelection(sel: Selection) {
if (!this.compat.maria && !this.compat.mysql57) return super.parseSelection(sel)
const { args: [expr], ref, table, tables } = sel
const restore = this.saveState({ wrappedSubquery: true, tables })
const inner = this.get(table as Selection, true, true) as string
const output = this.parseEval(expr, false)
const refFields = this.state.refFields
restore()
let query: string
if (!(sel.args[0] as any).$) {
query = `(SELECT ${output} AS value FROM ${inner} ${isBracketed(inner) ? ref : ''})`
} else {
query = `(ifnull((SELECT ${this.groupArray(output)} AS value FROM ${inner} ${isBracketed(inner) ? ref : ''}), json_array()))`
}
if (Object.keys(refFields ?? {}).length) {
const funcname = `minato_tfunc_${randomId()}`
const decls = Object.values(refFields ?? {}).map(x => `${x} JSON`).join(',')
const args = Object.keys(refFields ?? {}).map(x => this.state.refFields?.[x] ?? x).map(x => this.jsonQuote(x, true)).join(',')
query = this.state.sqlType === 'json' ? `ifnull(${query}, json_array())` : this.jsonQuote(query)
this.prequeries.push(`DROP FUNCTION IF EXISTS ${funcname}`)
this.prequeries.push(`CREATE FUNCTION ${funcname} (${decls}) RETURNS JSON DETERMINISTIC RETURN ${query}`)
this.state.sqlType = 'json'
return `${funcname}(${args})`
} else return query
}

toUpdateExpr(item: any, key: string, field?: Field, upsert?: boolean) {
const escaped = escapeId(key)

// update directly
if (key in item) {
if (!isEvalExpr(item[key]) && upsert) {
return `VALUES(${escaped})`
} else if (isEvalExpr(item[key])) {
return this.parseEval(item[key])
} else {
return this.escape(item[key], field)
}
}

// prepare nested layout
const jsonInit = {}
for (const prop in item) {
if (!prop.startsWith(key + '.')) continue
const rest = prop.slice(key.length + 1).split('.')
if (rest.length === 1) continue
rest.reduce((obj, k) => obj[k] ??= {}, jsonInit)
}

// update with json_set
const valueInit = `ifnull(${escaped}, '{}')`
let value = valueInit

// json_set cannot create deeply nested property when non-exist
// therefore we merge a layout to it
if (Object.keys(jsonInit).length !== 0) {
value = `json_merge(${value}, ${this.quote(JSON.stringify(jsonInit))})`
}

for (const prop in item) {
if (!prop.startsWith(key + '.')) continue
const rest = prop.slice(key.length + 1).split('.')
value = `json_set(${value}, '$${rest.map(key => `."${key}"`).join('')}', ${this.parseEval(item[prop])})`
}

if (value === valueInit) {
return escaped
} else {
return value
}
}
}
Loading

0 comments on commit 3a356d2

Please sign in to comment.