diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5d979ece..18467519 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -42,7 +42,7 @@ jobs: - name: Unit Test run: yarn test:json mysql - name: Report Coverage - if: ${{ matrix.node-version == 16 && matrix.mysql-image == 'mysql:5.7' }} + if: ${{ matrix.node-version == 16 }} uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -50,16 +50,20 @@ jobs: name: codecov mongo: + name: ${{ matrix.mongo-image }} (${{ matrix.node-version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: + mongo-image: + - mongo:6.0 + - mongo:latest node-version: [16, 18, 20] services: mongo: - image: mongo + image: ${{ matrix.mongo-image }} ports: - 27017:27017 # https://stackoverflow.com/questions/66317184/github-actions-cannot-connect-to-mongodb-service diff --git a/packages/core/src/driver.ts b/packages/core/src/driver.ts index b4df705a..d69b8e77 100644 --- a/packages/core/src/driver.ts +++ b/packages/core/src/driver.ts @@ -114,14 +114,14 @@ export class Database { join>>(tables: U, callback?: JoinCallback2, optional?: Dict>): Selection> 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, name]))) + const sel = new Selection(this.getDriver(tables[0]), Object.fromEntries(tables.map((name) => [name, this.select(name)]))) if (typeof query === 'function') { sel.args[0].having = Eval.and(query(...tables.map(name => sel.row[name]))) } sel.args[0].optional = Object.fromEntries(tables.map((name, index) => [name, optional?.[index]])) return new Selection(this.getDriver(sel), sel) } else { - const sel = new Selection(this.getDriver(tables[0]), tables) + const sel = new Selection(this.getDriver(Object.values(tables)[0]), valueMap(tables, (t: TableLike) => typeof t === 'string' ? this.select(t) : t)) if (typeof query === 'function') { sel.args[0].having = Eval.and(query(sel.row)) } diff --git a/packages/core/src/eval.ts b/packages/core/src/eval.ts index c27a22a3..55a40dee 100644 --- a/packages/core/src/eval.ts +++ b/packages/core/src/eval.ts @@ -1,5 +1,5 @@ -import { defineProperty, isNullable } from 'cosmokit' -import { Comparable, Flatten, isComparable, makeRegExp } from './utils' +import { defineProperty, Dict, isNullable, valueMap } from 'cosmokit' +import { Comparable, Flatten, isComparable, makeRegExp, Row } from './utils' export function isEvalExpr(value: any): value is Eval.Expr { return value && Object.keys(value).some(key => key.startsWith('$')) @@ -93,6 +93,19 @@ export namespace Eval { max(value: Number): Expr min(value: Number): Expr count(value: Any): Expr + length(value: Any): Expr + + // json + sum(value: (number | Expr)[] | Expr): Expr + avg(value: (number | Expr)[] | Expr): Expr + max(value: (number | Expr)[] | Expr): Expr + min(value: (number | Expr)[] | Expr): Expr + size(value: (Any | Expr)[] | Expr): Expr + length(value: any[] | Expr): Expr + + object>(fields: T): Expr + object(row: Row.Cell): Expr + array(value: Expr): Expr } } @@ -165,12 +178,42 @@ Eval.or = multary('or', (args, data) => args.some(arg => executeEval(data, arg)) Eval.not = unary('not', (value, data) => !executeEval(data, value)) // aggregation -Eval.sum = unary('sum', (expr, table) => table.reduce((prev, curr) => prev + executeAggr(expr, curr), 0)) -Eval.avg = unary('avg', (expr, table) => table.reduce((prev, curr) => prev + executeAggr(expr, curr), 0) / table.length) -Eval.max = unary('max', (expr, table) => Math.max(...table.map(data => executeAggr(expr, data)))) -Eval.min = unary('min', (expr, table) => Math.min(...table.map(data => executeAggr(expr, data)))) +Eval.sum = unary('sum', (expr, table) => Array.isArray(table) + ? table.reduce((prev, curr) => prev + executeAggr(expr, curr), 0) + : Array.from(executeEval(table, expr)).reduce((prev, curr) => prev + curr, 0)) +Eval.avg = unary('avg', (expr, table) => { + if (Array.isArray(table)) return table.reduce((prev, curr) => prev + executeAggr(expr, curr), 0) / table.length + else { + const array = Array.from(executeEval(table, expr)) + return array.reduce((prev, curr) => prev + curr, 0) / array.length + } +}) +Eval.max = unary('max', (expr, table) => Array.isArray(table) + ? Math.max(...table.map(data => executeAggr(expr, data))) + : Math.max(...Array.from(executeEval(table, expr)))) +Eval.min = unary('min', (expr, table) => Array.isArray(table) + ? Math.min(...table.map(data => executeAggr(expr, data))) + : Math.min(...Array.from(executeEval(table, expr)))) Eval.count = unary('count', (expr, table) => new Set(table.map(data => executeAggr(expr, data))).size) - +defineProperty(Eval, 'length', unary('length', (expr, table) => Array.isArray(table) + ? table.map(data => executeAggr(expr, data)).length + : Array.from(executeEval(table, expr)).length)) + +operators.$object = (field, table) => valueMap(field, value => executeAggr(value, table)) +Eval.object = (fields) => { + if (fields.$model) { + const modelFields = Object.keys(fields.$model.fields) + const prefix: string = fields.$prefix + return Eval('object', Object.fromEntries(modelFields + .filter(path => path.startsWith(prefix)) + .map(k => [k.slice(prefix.length), fields[k.slice(prefix.length)]]), + )) + } + return Eval('object', fields) as any +} +Eval.array = unary('array', (expr, table) => Array.isArray(table) + ? table.map(data => executeAggr(expr, data)) + : Array.from(executeEval(table, expr))) export { Eval as $ } type MapUneval = { diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 07dd6324..55d6ad49 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -188,7 +188,7 @@ export class Model { const field = fields.find(field => fullKey === field || fullKey.startsWith(field + '.')) if (field) { node[segments[0]] = this.resolveValue(key, value) - } else if (!value || typeof value !== 'object' || isEvalExpr(value) || Object.keys(value).length === 0) { + } else if (!value || typeof value !== 'object' || isEvalExpr(value) || Array.isArray(value) || Object.keys(value).length === 0) { if (strict) { throw new TypeError(`unknown field "${fullKey}" in model ${this.name}`) } else { diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index 05972387..0c773fc9 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -11,7 +11,7 @@ export interface Modifier { limit: number offset: number sort: [Eval.Expr, Direction][] - group: string[] + group?: string[] having: Eval.Expr fields?: Dict optional: Dict @@ -22,17 +22,19 @@ namespace Executable { export interface Payload { type: Action - table: string | Selection | Dict + table: string | Selection | Dict ref: string query: Query.Expr args: any[] } } -const createRow = (ref: string, expr = {}, prefix = '') => new Proxy(expr, { +const createRow = (ref: string, expr = {}, prefix = '', model?: Model) => new Proxy(expr, { get(target, key) { + if (key === '$prefix') return prefix + if (key === '$model') return model if (typeof key === 'symbol' || key in target || key.startsWith('$')) return Reflect.get(target, key) - return createRow(ref, Eval('', [ref, `${prefix}${key}`]), `${prefix}${key}.`) + return createRow(ref, Eval('', [ref, `${prefix}${key}`]), `${prefix}${key}.`, model) }, }) @@ -45,15 +47,15 @@ class Executable { constructor(driver: Driver, payload: Executable.Payload) { Object.assign(this, payload) - const expr = {} + defineProperty(this, 'model', driver.model(this.table)) + const expr = { $model: this.model } if (typeof payload.table !== 'string' && !(payload.table instanceof Selection)) { for (const key in payload.table) { - expr[key] = createRow(key) + expr[key] = createRow(key, {}, '', this.model) } } defineProperty(this, 'driver', driver) - defineProperty(this, 'row', createRow(this.ref, expr)) - defineProperty(this, 'model', driver.model(this.table)) + defineProperty(this, 'row', createRow(this.ref, expr, '', this.model)) } protected resolveQuery(query?: Query): Query.Expr @@ -130,13 +132,13 @@ export interface Selection extends Executable.Payload { export class Selection extends Executable { public tables: Dict = {} - constructor(driver: Driver, table: string | Selection | Dict, query?: Query) { + constructor(driver: Driver, table: string | Selection | Dict, query?: Query) { super(driver, { type: 'get', ref: randomId(), table, query: null as never, - args: [{ sort: [], limit: Infinity, offset: 0, group: [], having: Eval.and(), optional: {} }], + args: [{ sort: [], limit: Infinity, offset: 0, group: undefined, having: Eval.and(), optional: {} }], }) this.tables[this.ref] = this.model this.query = this.resolveQuery(query) diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index b20bec28..430bf48a 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -1,5 +1,5 @@ import { clone, Dict, makeArray, noop, omit, pick, valueMap } from 'cosmokit' -import { Database, Driver, Eval, executeEval, executeQuery, executeSort, executeUpdate, isEvalExpr, RuntimeError, Selection } from '@minatojs/core' +import { Database, Driver, Eval, executeEval, executeQuery, executeSort, executeUpdate, RuntimeError, Selection } from '@minatojs/core' export namespace MemoryDriver { export interface Config {} @@ -47,12 +47,9 @@ export class MemoryDriver extends Driver { const { ref, query, table, args, model } = sel const { fields, group, having } = sel.args[0] const data = this.table(table, having).filter(row => executeQuery(row, query, ref)) - if (!group.length && fields && Object.values(args[0].fields ?? {}).some(x => isAggrExpr(x))) { - return [valueMap(fields!, (expr) => executeEval(data.map(row => ({ [ref]: row })), expr))] - } const branches: { index: Dict; table: any[] }[] = [] - const groupFields = group.length ? pick(fields!, group) : fields + const groupFields = group ? pick(fields!, group) : fields for (let row of executeSort(data, args[0], ref)) { row = model.format(row, false) for (const key in model.fields) { @@ -64,7 +61,7 @@ export class MemoryDriver extends Driver { index = valueMap(groupFields!, (expr) => executeEval({ [ref]: row }, expr)) } let branch = branches.find((branch) => { - if (!groupFields) return false + if (!group || !groupFields) return false for (const key in groupFields) { if (branch.index[key] !== index[key]) return false } @@ -77,7 +74,7 @@ export class MemoryDriver extends Driver { branch.table.push(row) } return branches.map(({ index, table }) => { - if (group.length) { + if (group) { if (having) { const value = executeEval(table.map(row => ({ [ref]: row, _: row })), having) if (!value) return @@ -168,17 +165,4 @@ export class MemoryDriver extends Driver { } } -const nonAggrKeys = ['$'] -const aggrKeys = ['$sum', '$avg', '$min', '$max', '$count'] - -function isAggrExpr(value: any) { - if (!isEvalExpr(value)) return false - for (const [key, args] of Object.entries(value)) { - if (!key.startsWith('$')) continue - if (nonAggrKeys.includes(key)) return false - if (aggrKeys.includes(key) || ((Array.isArray(args) ? args : [args]).some(x => isAggrExpr(x)))) return true - } - return false -} - export default MemoryDriver diff --git a/packages/mongo/src/utils.ts b/packages/mongo/src/utils.ts index f8b0852c..2822c4ed 100644 --- a/packages/mongo/src/utils.ts +++ b/packages/mongo/src/utils.ts @@ -72,7 +72,7 @@ function transformFieldQuery(query: Query.FieldQuery, key: string, filters: Filt return result } -const aggrKeys = ['$sum', '$avg', '$min', '$max', '$count'] +const aggrKeys = ['$sum', '$avg', '$min', '$max', '$count', '$length', '$array'] export class Transformer { private counter = 0 @@ -114,6 +114,18 @@ export class Transformer { return { $cond: expr.$if.map(val => this.eval(val, group)) } } + if (expr.$object || expr.$array) { + return this.transformEvalExpr(expr.$object || expr.$array) + } + + if (expr.$length) { + return { $size: this.eval(expr.$length) } + } + + if (expr.$nin) { + return { $not: { $in: expr.$nin.map(val => this.eval(val, group)) } } + } + return valueMap(expr as any, (value) => { if (Array.isArray(value)) { return value.map(val => this.eval(val, group)) @@ -141,16 +153,24 @@ export class Transformer { return expr } - for (const type of aggrKeys) { - if (!expr[type]) continue - const key = this.createKey() - const value = this.transformAggr(expr[type]) - if (type !== '$count') { - group![key] = { [type]: value } - return '$' + key - } else { - group![key] = { $addToSet: value } - return { $size: '$' + key } + if (group) { + for (const type of aggrKeys) { + if (!expr[type]) continue + const key = this.createKey() + const value = this.transformAggr(expr[type]) + if (type === '$count') { + group![key] = { $addToSet: value } + return { $size: '$' + key } + } else if (type === '$length') { + group![key] = { $push: value } + return { $size: '$' + key } + } else if (type === '$array') { + group![key] = { $push: value } + return '$' + key + } else { + group![key] = { [type]: value } + return '$' + key + } } } @@ -222,7 +242,7 @@ export class Transformer { } // groupBy, having, fields - if (group.length) { + if (group) { const $group: Dict = { _id: {} } const $project: Dict = { _id: 0 } stages.push({ $group }) @@ -242,10 +262,9 @@ export class Transformer { stages.push({ $project }) $group['_id'] = model.parse($group['_id'], false) } else if (fields) { - const $group: Dict = { _id: null } - const $project = valueMap(fields, (expr) => this.eval(expr, $group)) + const $project = valueMap(fields, (expr) => this.eval(expr)) $project._id = 0 - stages.push(...Object.keys($group).length === 1 ? [] : [{ $group }], { $project }) + stages.push({ $project }) } else { const $project: Dict = { _id: 0 } for (const key in model.fields) { diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts index c48e0be7..129f9d62 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -68,6 +68,12 @@ function createIndex(keys: string | string[]) { return makeArray(keys).map(escapeId).join(', ') } +interface Compat { + maria?: boolean + maria105?: boolean + mysql57?: boolean +} + interface ColumnInfo { COLUMN_NAME: string IS_NULLABLE: 'YES' | 'NO' @@ -104,14 +110,25 @@ class MySQLBuilder extends Builder { '\\': '\\\\', } - constructor(tables?: Dict) { + constructor(tables?: Dict, 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 => `(0+min(${value}))`, undefined, value => `(0+minato_cfunc_min(${value}))`) + this.evalOperators.$max = (expr) => this.createAggr(expr, value => `(0+max(${value}))`, undefined, value => `(0+minato_cfunc_max(${value}))`) + this.define({ types: ['list'], dump: value => value.join(','), load: value => value ? value.split(',') : [], }) + + this.define({ + types: ['json'], + dump: value => JSON.stringify(value), + load: value => typeof value === 'string' ? JSON.parse(value) : value, + }) } escape(value: any, field?: Field) { @@ -123,6 +140,31 @@ class MySQLBuilder extends Builder { 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)) { + const value = compat(this.parseEval(expr, false)) + this.state.sqlType = 'raw' + return value + } else { + return super.createAggr(expr, aggr, nonaggr) + } + } + + protected groupArray(value: string) { + if (!this.compat.maria105) 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())` + } + toUpdateExpr(item: any, key: string, field?: Field, upsert?: boolean) { const escaped = escapeId(key) @@ -179,6 +221,7 @@ export class MySQLDriver extends Driver { public config: MySQLDriver.Config public sql: MySQLBuilder + private _compat: Compat = {} private _queryTasks: QueryTask[] = [] constructor(database: Database, config?: MySQLDriver.Config) { @@ -223,6 +266,18 @@ export class MySQLDriver extends Driver { async start() { this.pool = createPool(this.config) + + const version = Object.values((await this.query(`SELECT version()`))[0])[0] as string + // https://jira.mariadb.org/browse/MDEV-30623 + this._compat.maria = version.includes('MariaDB') + // https://jira.mariadb.org/browse/MDEV-26506 + this._compat.maria105 = !!version.match(/10.5.\d+-MariaDB/) + // For json_table + this._compat.mysql57 = !!version.match(/5.7.\d+/) + + if (this._compat.mysql57 || this._compat.maria) { + await this._setupCompatFunctions() + } } async stop() { @@ -357,6 +412,29 @@ export class MySQLDriver extends Driver { }).join(', ') } + async _setupCompatFunctions() { + try { + await this.query(`DROP FUNCTION IF EXISTS minato_cfunc_sum`) + await this.query(`CREATE FUNCTION minato_cfunc_sum (j JSON) RETURNS DOUBLE DETERMINISTIC BEGIN DECLARE n int; DECLARE i int; DECLARE r DOUBLE; +DROP TEMPORARY TABLE IF EXISTS mtt; CREATE TEMPORARY TABLE mtt (value JSON); SELECT json_length(j) into n; set i = 0; WHILE i(sql: string, debug = true): Promise { const error = new Error() return new Promise((resolve, reject) => { @@ -428,7 +506,7 @@ export class MySQLDriver extends Driver { async get(sel: Selection.Immutable) { const { model, tables } = sel - const builder = new MySQLBuilder(tables) + const builder = new MySQLBuilder(tables, this._compat) const sql = builder.get(sel) if (!sql) return [] return this.queue(sql).then((data) => { @@ -437,16 +515,17 @@ export class MySQLDriver extends Driver { } async eval(sel: Selection.Immutable, expr: Eval.Expr) { - const builder = new MySQLBuilder(sel.tables) - const output = builder.parseEval(expr) - const inner = builder.get(sel.table as Selection, true) - const [data] = await this.queue(`SELECT ${output} AS value FROM ${inner}`) - return data.value + const builder = new MySQLBuilder(sel.tables, this._compat) + const inner = builder.get(sel.table as Selection, true, true) + const output = builder.parseEval(expr, false) + const ref = inner.startsWith('(') && inner.endsWith(')') ? sel.ref : '' + const [data] = await this.queue(`SELECT ${output} AS value FROM ${inner} ${ref}`) + return builder.load(data.value) } async set(sel: Selection.Mutable, data: {}) { const { model, query, table, tables, ref } = sel - const builder = new MySQLBuilder(tables) + const builder = new MySQLBuilder(tables, this._compat) const filter = builder.parseQuery(query) const { fields } = model if (filter === '0') return @@ -463,7 +542,7 @@ export class MySQLDriver extends Driver { async remove(sel: Selection.Mutable) { const { query, table, tables } = sel - const builder = new MySQLBuilder(tables) + const builder = new MySQLBuilder(tables, this._compat) const filter = builder.parseQuery(query) if (filter === '0') return await this.query(`DELETE FROM ${escapeId(table)} WHERE ` + filter) @@ -485,7 +564,7 @@ export class MySQLDriver extends Driver { async upsert(sel: Selection.Mutable, data: any[], keys: string[]) { if (!data.length) return const { model, table, tables, ref } = sel - const builder = new MySQLBuilder(tables) + const builder = new MySQLBuilder(tables, this._compat) const merged = {} const insertion = data.map((item) => { diff --git a/packages/sql-utils/src/index.ts b/packages/sql-utils/src/index.ts index f93c03a7..e2cfbd00 100644 --- a/packages/sql-utils/src/index.ts +++ b/packages/sql-utils/src/index.ts @@ -1,5 +1,5 @@ import { Dict, isNullable } from 'cosmokit' -import { Eval, Field, isComparable, Model, Modifier, Query, Selection } from '@minatojs/core' +import { Eval, Field, isComparable, Model, Modifier, Query, randomId, Selection } from '@minatojs/core' export function escapeId(value: string) { return '`' + value + '`' @@ -21,6 +21,14 @@ export interface Transformer { load: (value: T, initial?: S) => S | null } +type SQLType = 'raw' | 'json' | 'list' + +interface State { + sqlType?: SQLType + sqlTypes?: Dict + group?: boolean +} + export class Builder { protected escapeMap = {} protected escapeRegExp?: RegExp @@ -28,6 +36,7 @@ export class Builder { protected createEqualQuery = this.comparator('=') protected queryOperators: QueryOperators protected evalOperators: EvalOperators + protected state: State = {} constructor(public tables?: Dict) { this.queryOperators = { @@ -73,7 +82,11 @@ export class Builder { }, $size: (key, value) => { if (!value) return this.logicalNot(key) - return `${key} AND LENGTH(${key}) - LENGTH(REPLACE(${key}, ${this.escape(',')}, ${this.escape('')})) = ${this.escape(value)} - 1` + if (this.state.sqlTypes?.[this.unescapeId(key)] === 'json') { + return `${this.jsonLength(key)} = ${this.escape(value)}` + } else { + return `${key} AND LENGTH(${key}) - LENGTH(REPLACE(${key}, ${this.escape(',')}, ${this.escape('')})) = ${this.escape(value)} - 1` + } }, } @@ -105,22 +118,48 @@ export class Builder { $lt: this.binary('<'), $lte: this.binary('<='), + // membership + $in: ([key, value]) => this.createMemberQuery(this.parseEval(key), value, ''), + $nin: ([key, value]) => this.createMemberQuery(this.parseEval(key), value, ' NOT'), + // aggregation - $sum: (expr) => `ifnull(sum(${this.parseAggr(expr)}), 0)`, - $avg: (expr) => `avg(${this.parseAggr(expr)})`, - $min: (expr) => `min(${this.parseAggr(expr)})`, - $max: (expr) => `max(${this.parseAggr(expr)})`, - $count: (expr) => `count(distinct ${this.parseAggr(expr)})`, + $sum: (expr) => this.createAggr(expr, value => `ifnull(sum(${value}), 0)`), + $avg: (expr) => this.createAggr(expr, value => `avg(${value})`), + $min: (expr) => this.createAggr(expr, value => `(0+min(${value}))`), + $max: (expr) => this.createAggr(expr, value => `(0+max(${value}))`), + $count: (expr) => this.createAggr(expr, value => `count(distinct ${value})`), + $length: (expr) => this.createAggr(expr, value => `count(${value})`, value => { + if (this.state.sqlType === 'json') { + this.state.sqlType = 'raw' + return `${this.jsonLength(value)}` + } else { + this.state.sqlType = 'raw' + return `if(${value}, LENGTH(${value}) - LENGTH(REPLACE(${value}, ${this.escape(',')}, ${this.escape('')})) + 1, 0)` + } + }), + + $object: (fields) => this.groupObject(fields), + $array: (expr) => this.groupArray(this.parseEval(expr, false)), } } + protected unescapeId(value: string) { + return value.slice(1, value.length - 1) + } + protected createNullQuery(key: string, value: boolean) { return `${key} is ${value ? 'not ' : ''}null` } - protected createMemberQuery(key: string, value: any[], notStr = '') { - if (!value.length) return notStr ? '1' : '0' - return `${key}${notStr} in (${value.map(val => this.escape(val)).join(', ')})` + protected createMemberQuery(key: string, value: any, notStr = '') { + if (Array.isArray(value)) { + if (!value.length) return notStr ? '1' : '0' + return `${key}${notStr} in (${value.map(val => this.escape(val)).join(', ')})` + } else { + const res = this.jsonContains(this.parseEval(value, false), this.jsonQuote(key, true)) + this.state.sqlType = 'raw' + return notStr ? this.logicalNot(res) : res + } } protected createRegExpQuery(key: string, value: string | RegExp) { @@ -128,7 +167,11 @@ export class Builder { } protected createElementQuery(key: string, value: any) { - return `find_in_set(${this.escape(value)}, ${key})` + if (this.state.sqlTypes?.[this.unescapeId(key)] === 'json') { + return this.jsonContains(key, this.quote(JSON.stringify(value))) + } else { + return `find_in_set(${this.escape(value)}, ${key})` + } } protected comparator(operator: string) { @@ -159,6 +202,59 @@ export class Builder { return `NOT(${condition})` } + protected jsonLength(value: string) { + return `json_length(${value})` + } + + protected jsonContains(obj: string, value: string) { + return `json_contains(${obj}, ${value})` + } + + protected jsonUnquote(value: string, pure: boolean = false) { + if (pure) return `json_unquote(${value})` + const res = this.state.sqlType === 'json' ? `json_unquote(${value})` : value + this.state.sqlType = 'raw' + return res + } + + protected jsonQuote(value: string, pure: boolean = false) { + if (pure) return `cast(${value} as json)` + const res = this.state.sqlType === 'raw' ? `cast(${value} as json)` : value + this.state.sqlType = 'json' + return res + } + + protected createAggr(expr: any, aggr: (value: string) => string, nonaggr?: (value: string) => string) { + if (this.state.group) { + this.state.group = false + const value = aggr(this.parseEval(expr, false)) + this.state.group = true + this.state.sqlType = 'raw' + return value + } else { + const value = this.parseEval(expr, false) + const res = nonaggr ? nonaggr(value) + : `(select ${aggr(`json_unquote(${escapeId('value')})`)} from json_table(${value}, '$[*]' columns (value json path '$')) ${randomId()})` + this.state.sqlType = 'raw' + return res + } + } + + protected groupObject(fields: any) { + const parse = (expr) => { + const value = this.parseEval(expr, false) + return this.state.sqlType === 'json' ? `json_extract(${value}, '$')` : `${value}` + } + const res = `json_object(` + Object.entries(fields).map(([key, expr]) => `'${key}', ${parse(expr)}`).join(',') + `)` + this.state.sqlType = 'json' + return res + } + + protected groupArray(value: string) { + this.state.sqlType = 'json' + return `ifnull(json_arrayagg(${value}), json_array())` + } + protected parseFieldQuery(key: string, query: Query.FieldExpr) { const conditions: string[] = [] @@ -204,6 +300,7 @@ export class Builder { } private parseEvalExpr(expr: any) { + this.state.sqlType = 'raw' for (const key in expr) { if (key in this.evalOperators) { return this.evalOperators[key](expr[key]) @@ -212,18 +309,21 @@ export class Builder { return this.escape(expr) } - private parseAggr(expr: any) { - if (typeof expr === 'string') { - return this.getRecursive(expr) - } - return this.parseEvalExpr(expr) + protected transformJsonField(obj: string, path: string) { + this.state.sqlType = 'json' + return `json_extract(${obj}, '$${path}')` } - private transformKey(key: string, fields: {}, prefix: string) { - if (key in fields || !key.includes('.')) return prefix + escapeId(key) + private transformKey(key: string, fields: {}, prefix: string, fullKey: string) { + if (key in fields || !key.includes('.')) { + if (this.state.sqlTypes?.[key] || this.state.sqlTypes?.[fullKey]) { + this.state.sqlType = this.state.sqlTypes[key] || this.state.sqlTypes[fullKey] + } + return prefix + escapeId(key) + } const field = Object.keys(fields).find(k => key.startsWith(k + '.')) || key.split('.')[0] const rest = key.slice(field.length + 1).split('.') - return `json_unquote(json_extract(${prefix} ${escapeId(field)}, '$${rest.map(key => `."${key}"`).join('')}'))` + return this.transformJsonField(`${prefix} ${escapeId(field)}`, rest.map(key => `."${key}"`).join('')) } private getRecursive(args: string | string[]) { @@ -232,26 +332,34 @@ export class Builder { } const [table, key] = args const fields = this.tables?.[table]?.fields || {} - if (fields[key]?.expr) { - return this.parseEvalExpr(fields[key]?.expr) + const fkey = Object.keys(fields).find(field => key === field || key.startsWith(field + '.')) + if (fkey && fields[fkey]?.expr) { + if (key === fkey) { + return this.parseEvalExpr(fields[fkey]?.expr) + } else { + const field = this.parseEvalExpr(fields[fkey]?.expr) + const rest = key.slice(fkey.length + 1).split('.') + return this.transformJsonField(`${field}`, rest.map(key => `."${key}"`).join('')) + } } const prefix = !this.tables || table === '_' || key in fields // the only table must be the main table || (Object.keys(this.tables).length === 1 && table in this.tables) ? '' : `${escapeId(table)}.` - return this.transformKey(key, fields, prefix) + return this.transformKey(key, fields, prefix, `${table}.${key}`) } - parseEval(expr: any): string { + parseEval(expr: any, unquote: boolean = true): string { + this.state.sqlType = 'raw' if (typeof expr === 'string' || typeof expr === 'number' || typeof expr === 'boolean' || expr instanceof Date) { return this.escape(expr) } - return this.parseEvalExpr(expr) + return unquote ? this.jsonUnquote(this.parseEvalExpr(expr)) : this.parseEvalExpr(expr) } suffix(modifier: Modifier) { const { limit, offset, sort, group, having } = modifier let sql = '' - if (group.length) { + if (group?.length) { sql += ` GROUP BY ${group.map(escapeId).join(', ')}` const filter = this.parseEval(having) if (filter !== '1') sql += ` HAVING ${filter}` @@ -266,41 +374,56 @@ export class Builder { return sql } - get(sel: Selection.Immutable, inline = false) { + get(sel: Selection.Immutable, inline = false, group = false) { const { args, table, query, ref, model } = sel - const filter = this.parseQuery(query) - if (filter === '0') return // get prefix - const fields = args[0].fields ?? Object.fromEntries(Object - .entries(model.fields) - .filter(([, field]) => !field!.deprecated) - .map(([key]) => [key, { $: [ref, key] }])) - const keys = Object.entries(fields).map(([key, value]) => { - key = escapeId(key) - value = this.parseEval(value) - return key === value ? key : `${value} AS ${key}` - }).join(', ') let prefix: string | undefined if (typeof table === 'string') { prefix = escapeId(table) + this.state.sqlTypes = Object.fromEntries(Object.entries(model.fields).map(([key, field]) => { + return [key, field!.type === 'json' ? 'json' : field!.type === 'list' ? 'list' : 'raw'] + })) } else if (table instanceof Selection) { prefix = this.get(table, true) if (!prefix) return } else { + const sqlTypes: Dict = {} prefix = Object.entries(table).map(([key, table]) => { if (typeof table !== 'string') { - return `${this.get(table, true)} AS ${escapeId(key)}` + const t = `${this.get(table, true)} AS ${escapeId(key)}` + for (const [fieldKey, fieldType] of Object.entries(this.state.sqlTypes!)) { + sqlTypes[`${key}.${fieldKey}`] = fieldType + } + return t } else { return key === table ? escapeId(table) : `${escapeId(table)} AS ${escapeId(key)}` } }).join(' JOIN ') + this.state.sqlTypes = sqlTypes const filter = this.parseEval(args[0].having) if (filter !== '1') prefix += ` ON ${filter}` } + const filter = this.parseQuery(query) + if (filter === '0') return + + this.state.group = group || !!args[0].group + const sqlTypes: Dict = {} + const fields = args[0].fields ?? Object.fromEntries(Object + .entries(model.fields) + .filter(([, field]) => !field!.deprecated) + .map(([key]) => [key, { $: [ref, key] }])) + const keys = Object.entries(fields).map(([key, value]) => { + value = this.parseEval(value, false) + sqlTypes[key] = this.state.sqlType! + return escapeId(key) === value ? escapeId(key) : `${value} AS ${escapeId(key)}` + }).join(', ') + // get suffix let suffix = this.suffix(args[0]) + this.state.sqlTypes = sqlTypes + if (filter !== '1') { suffix = ` WHERE ${filter}` + suffix } @@ -330,12 +453,19 @@ export class Builder { return result } - load(model: Model, obj: any): any { + load(obj: any): any + load(model: Model, obj: any): any + load(model: any, obj?: any) { + if (!obj) { + const converter = this.types[this.state.sqlType!] + return converter ? converter.load(model) : model + } + const result = {} for (const key in obj) { if (!(key in model.fields)) continue const { type, initial } = model.fields[key]! - const converter = this.types[type] + const converter = this.state.sqlTypes?.[key] === 'raw' ? this.types[type] : this.types[this.state.sqlTypes![key]] result[key] = converter ? converter.load(obj[key], initial) : obj[key] } return model.parse(result) diff --git a/packages/sqlite/src/index.ts b/packages/sqlite/src/index.ts index f0578c4b..a6c81fb0 100644 --- a/packages/sqlite/src/index.ts +++ b/packages/sqlite/src/index.ts @@ -1,5 +1,5 @@ import { deepEqual, Dict, difference, isNullable, makeArray } from 'cosmokit' -import { Database, Driver, Eval, executeUpdate, Field, Model, Selection } from '@minatojs/core' +import { Database, Driver, Eval, executeUpdate, Field, Model, randomId, Selection } from '@minatojs/core' import { Builder, escapeId } from '@minatojs/sql-utils' import { promises as fs } from 'fs' import init from '@minatojs/sql.js' @@ -51,6 +51,16 @@ class SQLiteBuilder extends Builder { super(tables) this.evalOperators.$if = (args) => `iif(${args.map(arg => this.parseEval(arg)).join(', ')})` + this.evalOperators.$concat = (args) => `(${args.map(arg => this.parseEval(arg)).join('||')})` + this.evalOperators.$length = (expr) => this.createAggr(expr, value => `count(${value})`, value => { + if (this.state.sqlType === 'json') { + this.state.sqlType = 'raw' + return `${this.jsonLength(value)}` + } else { + this.state.sqlType = 'raw' + return `iif(${value}, LENGTH(${value}) - LENGTH(REPLACE(${value}, ${this.escape(',')}, ${this.escape('')})) + 1, 0)` + } + }) this.define({ types: ['boolean'], @@ -83,7 +93,43 @@ class SQLiteBuilder extends Builder { } protected createElementQuery(key: string, value: any) { - return `(',' || ${key} || ',') LIKE ${this.escape('%,' + value + ',%')}` + if (this.state.sqlTypes?.[this.unescapeId(key)] === 'json') { + return this.jsonContains(key, this.quote(JSON.stringify(value))) + } else { + return `(',' || ${key} || ',') LIKE ${this.escape('%,' + value + ',%')}` + } + } + + protected jsonLength(value: string) { + return `json_array_length(${value})` + } + + protected jsonContains(obj: string, value: string) { + return `json_array_contains(${obj}, ${value})` + } + + protected jsonUnquote(value: string, pure: boolean = false) { + return value + } + + protected createAggr(expr: any, aggr: (value: string) => string, nonaggr?: (value: string) => string) { + if (!this.state.group && !nonaggr) { + const value = this.parseEval(expr, false) + return `(select ${aggr(escapeId('value'))} from json_each(${value}) ${randomId()})` + } else { + return super.createAggr(expr, aggr, nonaggr) + } + } + + protected groupArray(value: string) { + const res = this.state.sqlType === 'json' ? `('[' || group_concat(${value}) || ']')` : `('[' || group_concat(json_quote(${value})) || ']')` + this.state.sqlType = 'json' + return `ifnull(${res}, json_array())` + } + + protected transformJsonField(obj: string, path: string) { + this.state.sqlType = 'raw' + return `json_extract(${obj}, '$${path}')` } } @@ -202,6 +248,7 @@ export class SQLiteDriver extends Driver { init(buffer: ArrayLike | null) { this.db = new this.sqlite.Database(buffer) this.db.create_function('regexp', (pattern, str) => +new RegExp(pattern).test(str)) + this.db.create_function('json_array_contains', (array, value) => +(JSON.parse(array) as any[]).includes(JSON.parse(value))) } async load() { @@ -316,10 +363,10 @@ export class SQLiteDriver extends Driver { async eval(sel: Selection.Immutable, expr: Eval.Expr) { const builder = new SQLiteBuilder(sel.tables) - const output = builder.parseEval(expr) - const inner = builder.get(sel.table as Selection, true) + const inner = builder.get(sel.table as Selection, true, true) + const output = builder.parseEval(expr, false) const { value } = this.#get(`SELECT ${output} AS value FROM ${inner}`) - return value + return builder.load(value) } #update(sel: Selection.Mutable, indexFields: string[], updateFields: string[], update: {}, data: {}) { diff --git a/packages/tests/src/index.ts b/packages/tests/src/index.ts index b493d387..615e23dd 100644 --- a/packages/tests/src/index.ts +++ b/packages/tests/src/index.ts @@ -4,6 +4,7 @@ import UpdateOperators from './update' import ObjectOperations from './object' import Migration from './migration' import Selection from './selection' +import Json from './json' import './setup' const Keywords = ['name'] @@ -52,6 +53,7 @@ namespace Tests { export const object = ObjectOperations export const selection = Selection export const migration = Migration + export const json = Json } export default createUnit(Tests, true) diff --git a/packages/tests/src/json.ts b/packages/tests/src/json.ts new file mode 100644 index 00000000..01e848f9 --- /dev/null +++ b/packages/tests/src/json.ts @@ -0,0 +1,403 @@ +import { $, Database } from '@minatojs/core' +import { expect } from 'chai' +import { setup } from './utils' + +interface Foo { + id: number + value: number +} + +interface Bar { + id: number + uid: number + pid: number + value: number + s: string + obj: { + x: number + y: string + z: string + o: { + a: number + b: string + } + } + l: string[] +} + +interface Baz { + id: number + nums: number[] +} + +interface Tables { + foo: Foo + bar: Bar + baz: Baz +} + +function JsonTests(database: Database) { + database.extend('foo', { + id: 'unsigned', + value: 'integer', + }) + + database.extend('bar', { + id: 'unsigned', + uid: 'unsigned', + pid: 'unsigned', + value: 'integer', + obj: 'json', + s: 'string', + l: 'list', + }, { + autoInc: true, + }) + + database.extend('baz', { + id: 'unsigned', + nums: { type: 'json', initial: [] }, + }) + + before(async () => { + await setup(database, 'foo', [ + { id: 1, value: 0 }, + { id: 2, value: 2 }, + { id: 3, value: 2 }, + ]) + + await setup(database, 'bar', [ + { uid: 1, pid: 1, value: 0, obj: { x: 1, y: 'a', z: '1', o: { a: 1, b: '1' } }, s: '1', l: ['1', '2'] }, + { uid: 1, pid: 1, value: 1, obj: { x: 2, y: 'b', z: '2', o: { a: 2, b: '2' } }, s: '2', l: ['5', '3', '4'] }, + { uid: 1, pid: 2, value: 0, obj: { x: 3, y: 'c', z: '3', o: { a: 3, b: '3' } }, s: '3', l: ['2'] }, + ]) + + await setup(database, 'baz', [ + { id: 1, nums: [4, 5, 6] }, + { id: 2, nums: [5, 6, 7] }, + { id: 3, nums: [7, 8] }, + ]) + }) +} + +namespace JsonTests { + export function query(database: Database) { + it('$size', async () => { + await expect(database.get('baz', { + nums: { $size: 3 }, + })).to.eventually.deep.equal([ + { id: 1, nums: [4, 5, 6] }, + { id: 2, nums: [5, 6, 7] }, + ]) + + await expect(database.select('baz', { + nums: { $size: 3 }, + }).project({ + size: row => $.length(row.nums), + }).execute()).to.eventually.deep.equal([ + { size: 3 }, + { size: 3 }, + ]) + }) + + it('$el', async () => { + await expect(database.get('baz', { + nums: { $el: 5 }, + })).to.eventually.deep.equal([ + { id: 1, nums: [4, 5, 6] }, + { id: 2, nums: [5, 6, 7] }, + ]) + }) + + it('$in', async () => { + await expect(database.get('baz', row => $.in($.add(3, row.id), row.nums))) + .to.eventually.deep.equal([ + { id: 1, nums: [4, 5, 6] }, + { id: 2, nums: [5, 6, 7] }, + ]) + }) + + it('$nin', async () => { + await expect(database.get('baz', row => $.nin($.add(3, row.id), row.nums))) + .to.eventually.deep.equal([ + { id: 3, nums: [7, 8] }, + ]) + }) + } + + export function selection(database: Database) { + it('$.object', async () => { + const res = await database.select('foo') + .project({ + obj: row => $.object({ + id: row.id, + value: row.value, + }) + }) + .orderBy(row => row.obj.id) + .execute() + + expect(res).to.deep.equal([ + { obj: { id: 1, value: 0 } }, + { obj: { id: 2, value: 2 } }, + { obj: { id: 3, value: 2 } }, + ]) + }) + + it('$.object in json', async () => { + const res = await database.select('bar') + .project({ + obj: row => $.object({ + num: row.obj.x, + str: row.obj.y, + str2: row.obj.z, + obj: row.obj.o, + a: row.obj.o.a, + }), + }) + .execute() + + expect(res).to.deep.equal([ + { obj: { a: 1, num: 1, obj: { a: 1, b: '1' }, str: 'a', str2: '1' } }, + { obj: { a: 2, num: 2, obj: { a: 2, b: '2' }, str: 'b', str2: '2' } }, + { obj: { a: 3, num: 3, obj: { a: 3, b: '3' }, str: 'c', str2: '3' } }, + ]) + }) + + it('$.object on cell', async () => { + const res = await database.join(['foo', 'bar'] as const, (foo, bar) => $.eq(foo.id, bar.pid)) + .groupBy('bar', { + x: row => $.array($.object(row.foo)), + }) + .execute(['x']) + + expect(res).to.have.deep.members([ + { x: [{ id: 1, value: 0 }] }, + { x: [{ id: 1, value: 0 }] }, + { x: [{ id: 2, value: 2 }] }, + ]) + }) + + it('$.array groupBy', async () => { + await expect(database.join(['foo', 'bar'] as const, (foo, bar) => $.eq(foo.id, bar.pid)) + .groupBy(['foo'], { + x: row => $.array(row.bar.obj.x), + y: row => $.array(row.bar.obj.y), + }) + .orderBy(row => row.foo.id) + .execute() + ).to.eventually.deep.equal([ + { foo: { id: 1, value: 0 }, x: [1, 2], y: ['a', 'b'] }, + { foo: { id: 2, value: 2 }, x: [3], y: ['c'] }, + ]) + + await expect(database.join(['foo', 'bar'] as const, (foo, bar) => $.eq(foo.id, bar.pid)) + .groupBy(['foo'], { + x: row => $.array(row.bar.obj.x), + y: row => $.array(row.bar.obj.y), + }) + .orderBy(row => row.foo.id) + .execute(row => $.array(row.y)) + ).to.eventually.deep.equal([ + ['a', 'b'], + ['c'], + ]) + + await expect(database.join(['foo', 'bar'] as const, (foo, bar) => $.eq(foo.id, bar.pid)) + .groupBy(['foo'], { + x: row => $.array(row.bar.obj.x), + y: row => $.array(row.bar.obj.y), + }) + .orderBy(row => row.foo.id) + .execute(row => $.count(row.y)) + ).to.eventually.deep.equal(2) + }) + + it('$.array groupFull', async () => { + const res = await database.select('bar') + .groupBy({}, { + count2: row => $.array(row.s), + countnumber: row => $.array(row.value), + x: row => $.array(row.obj.x), + y: row => $.array(row.obj.y), + }) + .execute() + + expect(res).to.deep.equal([ + { + count2: ['1', '2', '3'], + countnumber: [0, 1, 0], + x: [1, 2, 3], + y: ['a', 'b', 'c'], + }, + ]) + }) + + it('$.array in json', async () => { + const res = await database.join(['foo', 'bar'] as const, (foo, bar) => $.eq(foo.id, bar.pid)) + .groupBy('foo', { + bars: row => $.array($.object({ + value: row.bar.value, + obj: row.bar.obj, + })), + x: row => $.array(row.bar.obj.x), + y: row => $.array(row.bar.obj.y), + z: row => $.array(row.bar.obj.z), + o: row => $.array(row.bar.obj.o), + }) + .orderBy(row => row.foo.id) + .execute() + + expect(res).to.deep.equal([ + { + foo: { id: 1, value: 0 }, + bars: [{ + obj: { o: { a: 1, b: '1' }, x: 1, y: 'a', z: '1' }, + value: 0, + }, { + obj: { o: { a: 2, b: '2' }, x: 2, y: 'b', z: '2' }, + value: 1, + }], + x: [1, 2], + y: ['a', 'b'], + z: ['1', '2'], + o: [{ a: 1, b: '1' }, { a: 2, b: '2' }], + }, + { + foo: { id: 2, value: 2 }, + bars: [{ + obj: { o: { a: 3, b: '3' }, x: 3, y: 'c', z: '3' }, + value: 0, + }], + x: [3], + y: ['c'], + z: ['3'], + o: [{ a: 3, b: '3' }], + }, + ]) + }) + + it('$.array with expressions', async () => { + const res = await database.join(['foo', 'bar'] as const, (foo, bar) => $.eq(foo.id, bar.pid)) + .groupBy('foo', { + bars: row => $.array($.object({ + value: row.bar.value, + value2: $.add(row.bar.value, row.foo.value), + })), + x: row => $.array($.add(1, row.bar.obj.x)), + y: row => $.array(row.bar.obj.y), + }) + .orderBy(row => row.foo.id) + .execute() + + expect(res).to.deep.equal([ + { + foo: { id: 1, value: 0 }, + bars: [{ value: 0, value2: 0 }, { value: 1, value2: 1 }], + x: [2, 3], + y: ['a', 'b'], + }, + { + foo: { id: 2, value: 2 }, + bars: [{ value: 0, value2: 2 }], + x: [4], + y: ['c'], + }, + ]) + }) + + + it('$.array nested', async () => { + const res = await database.join(['foo', 'bar'] as const, (foo, bar) => $.eq(foo.id, bar.pid)) + .groupBy('foo', { + y: row => $.array(row.bar.obj.x), + }) + .orderBy(row => row.foo.id) + .groupBy({}, { + z: row => $.array(row.y), + }) + .execute() + + expect(res).to.deep.equal([ + { + z: [[1, 2], [3]], + }, + ]) + }) + + it('non-aggr func', async () => { + const res = await database.join(['foo', 'bar'] as const, (foo, bar) => $.eq(foo.id, bar.pid)) + .groupBy('foo', { + y: row => $.array(row.bar.obj.x), + }) + .project({ + sum: row => $.sum(row.y), + avg: row => $.avg(row.y), + min: row => $.min(row.y), + max: row => $.max(row.y), + count: row => $.length(row.y), + }) + .orderBy(row => row.count) + .execute() + + expect(res).to.deep.equal([ + { sum: 3, avg: 3, min: 3, max: 3, count: 1 }, + { sum: 3, avg: 1.5, min: 1, max: 2, count: 2 }, + ]) + }) + + it('non-aggr func inside aggr', async () => { + const res = await database.join(['foo', 'bar'] as const, (foo, bar) => $.eq(foo.id, bar.pid)) + .groupBy('foo', { + y: row => $.array(row.bar.obj.x), + }) + .orderBy(row => row.foo.id) + .groupBy({}, { + sum: row => $.avg($.sum(row.y)), + avg: row => $.avg($.avg(row.y)), + min: row => $.min($.min(row.y)), + max: row => $.max($.max(row.y)), + }) + .execute() + + expect(res).to.deep.equal([ + { sum: 3, avg: 2.25, min: 1, max: 3 }, + ]) + }) + + it('pass sqlType', async () => { + const res = await database.select('bar') + .project({ + x: row => row.l, + y: row => row.obj, + }) + .execute() + + expect(res).to.deep.equal([ + { x: ['1', '2'], y: { x: 1, y: 'a', z: '1', o: { a: 1, b: '1' } } }, + { x: ['5', '3', '4'], y: { x: 2, y: 'b', z: '2', o: { a: 2, b: '2' } } }, + { x: ['2'], y: { x: 3, y: 'c', z: '3', o: { a: 3, b: '3' } } }, + ]) + }) + + it('pass sqlType in join', async () => { + const res = await database.join({ + foo: 'foo', + bar: 'bar', + }, ({ foo, bar }) => $.eq(foo.id, bar.pid)) + .project({ + x: row => row.bar.l, + y: row => row.bar.obj, + }) + .execute() + + expect(res).to.deep.equal([ + { x: ['1', '2'], y: { x: 1, y: 'a', z: '1', o: { a: 1, b: '1' } } }, + { x: ['5', '3', '4'], y: { x: 2, y: 'b', z: '2', o: { a: 2, b: '2' } } }, + { x: ['2'], y: { x: 3, y: 'c', z: '3', o: { a: 3, b: '3' } } }, + ]) + }) + } +} + +export default JsonTests diff --git a/packages/tests/src/query.ts b/packages/tests/src/query.ts index b695a918..b96e2b3f 100644 --- a/packages/tests/src/query.ts +++ b/packages/tests/src/query.ts @@ -271,6 +271,19 @@ namespace QueryOperators { })).eventually.to.have.length(2).with.shape([{ id: 2 }, { id: 3 }]) }) + size && it('$.length', async () => { + await expect(database.select('temp1') + .project({ x: row => $.length(row.list) }) + .orderBy(row => row.x) + .execute() + ).eventually.to.deep.equal([ + { x: 0 }, + { x: 1 }, + { x: 1 }, + { x: 2 }, + ]) + }) + element && it('$el shorthand', async () => { await expect(database.get('temp1', { list: { $el: 233 }, diff --git a/packages/tests/src/selection.ts b/packages/tests/src/selection.ts index dc34bf63..c4a33606 100644 --- a/packages/tests/src/selection.ts +++ b/packages/tests/src/selection.ts @@ -107,6 +107,10 @@ namespace SelectionTests { { id: 2 }, { id: 3 }, ]) + + await expect(database.select('foo', row => $.eq(row.id, 1)).orderBy('id').execute(['id'])).to.eventually.deep.equal([ + { id: 1 }, + ]) }) it('callback', async () => { @@ -141,29 +145,17 @@ namespace SelectionTests { }) it('aggregate', async () => { - await expect(database - .select('foo') - .project({ - count: row => $.count(row.id), - max: row => $.max(row.id), - min: row => $.min(row.id), - avg: row => $.avg(row.id), - }) - .execute() - ).to.eventually.deep.equal([ - { avg: 2, count: 3, max: 3, min: 1 }, - ]) - await expect(database.select('foo') .groupBy({}, { count: row => $.count(row.id), + size: row => $.length(row.id), max: row => $.max(row.id), min: row => $.min(row.id), avg: row => $.avg(row.id), }) .execute() ).to.eventually.deep.equal([ - { avg: 2, count: 3, max: 3, min: 1 }, + { avg: 2, count: 3, max: 3, min: 1, size: 3 }, ]) }) }