diff --git a/package.json b/package.json index bbaf988a..541cd791 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "eslint-plugin-mocha": "^10.4.1", "mocha": "^9.2.2", "shx": "^0.3.4", - "typescript": "^5.4.3", + "typescript": "^5.5.0-beta", "yakumo": "^1.0.0-beta.14", "yakumo-esbuild": "^1.0.0-beta.5", "yakumo-mocha": "^1.0.0-beta.2", diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index 7882bc5d..bb2d3127 100644 --- a/packages/core/src/database.ts +++ b/packages/core/src/database.ts @@ -1,8 +1,8 @@ -import { defineProperty, Dict, makeArray, mapValues, MaybeArray, omit } from 'cosmokit' +import { defineProperty, Dict, filterKeys, makeArray, mapValues, MaybeArray, noop, omit } from 'cosmokit' import { Context, Service, Spread } from 'cordis' -import { FlatKeys, FlatPick, Indexable, Keys, randomId, Row, unravel } from './utils.ts' +import { DeepPartial, FlatKeys, FlatPick, Indexable, Keys, randomId, Row, unravel, Values } from './utils.ts' import { Selection } from './selection.ts' -import { Field, Model } from './model.ts' +import { Field, Model, Relation } from './model.ts' import { Driver } from './driver.ts' import { Eval, Update } from './eval.ts' import { Query } from './query.ts' @@ -44,6 +44,10 @@ export namespace Join2 { export type Predicate> = (args: Parameters) => Eval.Expr } +function getCell(row: any, key: any): any { + return key.split('.').reduce((r, k) => r[k], row) +} + export class Database extends Service { static [Service.provide] = 'model' static [Service.immediate] = true @@ -111,6 +115,47 @@ export class Database extends Servi if (makeArray(model.primary).every(key => key in fields)) { defineProperty(model, 'ctx', this[Context.origin]) } + Object.entries(fields).forEach(([key, def]: [string, Relation.Definition]) => { + if (!Relation.Type.includes(def.type)) return + const [relation, inverse] = Relation.parse(def, key, model, this.tables[def.table ?? key]) + if (!this.tables[relation.table]) throw new Error(`relation table ${relation.table} does not exist`) + ;(model.fields[key] = Field.parse('expr')).relation = relation + if (def.target) { + (this.tables[relation.table].fields[def.target] ??= Field.parse('expr')).relation = inverse + } + + if (relation.type === 'oneToOne' || relation.type === 'manyToOne') { + relation.fields.forEach((x, i) => { + model.fields[x] ??= { ...this.tables[relation.table].fields[relation.references[i]] } as any + if (!relation.required) { + model.fields[x]!.nullable = true + model.fields[x]!.initial = null + } + }) + } else if (relation.type === 'manyToMany') { + const assocTable = Relation.buildAssociationTable(relation.table, name) + if (this.tables[assocTable]) return + const fields = relation.fields.map(x => [Relation.buildAssociationKey(x, name), model.fields[x]?.deftype] as const) + const references = relation.references.map((x, i) => [Relation.buildAssociationKey(x, relation.table), fields[i][1]] as const) + this.extend(assocTable as any, { + ...Object.fromEntries([...fields, ...references]), + [name]: { + type: 'manyToOne', + table: name, + fields: fields.map(x => x[0]), + references: relation.references, + }, + [relation.table]: { + type: 'manyToOne', + table: relation.table, + fields: references.map(x => x[0]), + references: relation.fields, + }, + } as any, { + primary: [...fields.map(x => x[0]), ...references.map(x => x[0])], + }) + } + }) this.prepareTasks[name] = this.prepare(name) ;(this.ctx as Context).emit('model', name) } @@ -222,9 +267,96 @@ export class Database extends Servi } select(table: Selection, query?: Query): Selection - select>(table: K, query?: Query): Selection - select(table: any, query?: any) { - return new Selection(this.getDriver(table), table, query) + select>( + table: K, + query?: Query, + cursor?: Relation.Include> | null, + ): Selection + + select(table: any, query?: any, cursor?: any) { + let sel = new Selection(this.getDriver(table), table, query) + if (typeof table !== 'string') return sel + const whereOnly = cursor === null + const rawquery = typeof query === 'function' ? query : () => query + const modelFields = this.tables[table].fields + if (cursor) cursor = filterKeys(cursor, (key) => !!modelFields[key]?.relation) + for (const key in { ...sel.query, ...sel.query.$not }) { + if (modelFields[key]?.relation) { + if (sel.query[key] === null && !modelFields[key].relation.required) { + sel.query[key] = Object.fromEntries(modelFields[key]!.relation!.references.map(k => [k, null])) + } + if (sel.query[key] && typeof sel.query[key] !== 'function' && typeof sel.query[key] === 'object' + && Object.keys(sel.query[key]).every(x => modelFields[key]!.relation!.fields.includes(`${key}.${x}`))) { + Object.entries(sel.query[key]).forEach(([k, v]) => sel.query[`${key}.${k}`] = v) + delete sel.query[key] + } + if (sel.query.$not?.[key] === null && !modelFields[key].relation.required) { + sel.query.$not[key] = Object.fromEntries(modelFields[key]!.relation!.references.map(k => [k, null])) + } + if (sel.query.$not?.[key] && typeof sel.query.$not[key] !== 'function' && typeof sel.query.$not[key] === 'object' + && Object.keys(sel.query.$not[key]).every(x => modelFields[key]!.relation!.fields.includes(`${key}.${x}`))) { + Object.entries(sel.query.$not[key]).forEach(([k, v]) => sel.query.$not![`${key}.${k}`] = v) + delete sel.query.$not[key] + } + if (!cursor || !Object.getOwnPropertyNames(cursor).includes(key)) { + (cursor ??= {})[key] = true + } + } + } + + sel.query = omit(sel.query, Object.keys(cursor ?? {})) + if (Object.keys(sel.query.$not ?? {}).length) { + sel.query.$not = omit(sel.query.$not!, Object.keys(cursor ?? {})) + if (Object.keys(sel.query.$not).length === 0) Reflect.deleteProperty(sel.query, '$not') + } + + if (cursor && typeof cursor === 'object') { + if (typeof table !== 'string') throw new Error('cannot include relations on derived selection') + const extraFields: string[] = [] + const applyQuery = (sel: Selection, key: string) => { + const query2 = rawquery(sel.row) + const relquery = query2[key] !== undefined ? query2[key] + : query2.$not?.[key] !== undefined ? { $not: query2.$not?.[key] } + : undefined + return relquery === undefined ? sel : sel.where(this.transformRelationQuery(table, sel.row, key, relquery)) + } + for (const key in cursor) { + if (!cursor[key] || !modelFields[key]?.relation) continue + const relation: Relation.Config = modelFields[key]!.relation as any + if (relation.type === 'oneToOne' || relation.type === 'manyToOne') { + sel = whereOnly ? sel : sel.join(key, this.select(relation.table, {}, cursor[key]), (self, other) => Eval.and( + ...relation.fields.map((k, i) => Eval.eq(self[k], other[relation.references[i]])), + ), true) + sel = applyQuery(sel, key) + } else if (relation.type === 'oneToMany') { + sel = whereOnly ? sel : sel.join(key, this.select(relation.table, {}, cursor[key]), (self, other) => Eval.and( + ...relation.fields.map((k, i) => Eval.eq(self[k], other[relation.references[i]])), + ), true) + sel = applyQuery(sel, key) + sel = whereOnly ? sel : sel.groupBy([ + ...Object.entries(modelFields).filter(([, field]) => Field.available(field)).map(([k]) => k), + ...extraFields, + ], { + [key]: row => Eval.ignoreNull(Eval.array(row[key])), + }) + } else if (relation.type === 'manyToMany') { + const assocTable: any = Relation.buildAssociationTable(relation.table, table) + const references = relation.fields.map(x => Relation.buildAssociationKey(x, table)) + sel = whereOnly ? sel : sel.join(key, this.select(assocTable, {}, { [relation.table]: cursor[key] } as any), (self, other) => Eval.and( + ...relation.fields.map((k, i) => Eval.eq(self[k], other[references[i]])), + ), true) + sel = applyQuery(sel, key) + sel = whereOnly ? sel : sel.groupBy([ + ...Object.entries(modelFields).filter(([, field]) => Field.available(field)).map(([k]) => k), + ...extraFields, + ], { + [key]: row => Eval.ignoreNull(Eval.array(row[key][relation.table as any])), + }) + } + extraFields.push(key) + } + } + return sel } join>( @@ -248,7 +380,7 @@ export class Database extends Servi return typeof t === 'string' ? this.select(t) : t }) if (Object.keys(sels).length === 0) throw new Error('no tables to join') - const drivers = new Set(Object.values(sels).map(sel => sel.driver)) + const drivers = new Set(Object.values(sels).map(sel => sel.driver[Database.transact] ?? sel.driver)) if (drivers.size !== 1) throw new Error('cannot join tables from different drivers') if (Object.keys(sels).length === 2 && (optional?.[0] || optional?.[Object.keys(sels)[0]])) { if (optional[1] || optional[Object.keys(sels)[1]]) throw new Error('full join is not supported') @@ -274,7 +406,8 @@ export class Database extends Servi ): Promise[]> async get>(table: K, query: Query, cursor?: any) { - return this.select(table, query).execute(cursor) as any + const fields = Array.isArray(cursor) ? cursor : cursor?.fields + return this.select(table, query, fields && Object.fromEntries(fields.map(x => [x, true])) as any).execute(cursor) as any } async eval, T>(table: K, expr: Selection.Callback, query?: Query): Promise { @@ -286,31 +419,113 @@ export class Database extends Servi query: Query, update: Row.Computed>, ): Promise { - const sel = this.select(table, query) + const rawupdate = typeof update === 'function' ? update : () => update + const sel = this.select(table, query, null) if (typeof update === 'function') update = update(sel.row) const primary = makeArray(sel.model.primary) if (primary.some(key => key in update)) { throw new TypeError(`cannot modify primary key`) } + + const relations: [string, Relation.Config][] = Object.entries(sel.model.fields) + .filter(([key, field]) => key in update && field!.relation) + .map(([key, field]) => [key, field!.relation!] as const) as any + if (relations.length) { + return await this.ensureTransaction(async (database) => { + const rows = await database.get(table, query) + let baseUpdate = omit(update, relations.map(([key]) => key) as any) + baseUpdate = sel.model.format(baseUpdate) + for (const [key, relation] of relations) { + if (relation.type === 'oneToOne') { + if (update[key] === null) { + await Promise.all(rows.map(row => database.remove(relation.table, + Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])) as any, + ))) + } else { + await database.upsert(relation.table, rows.map(row => ({ + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...rawupdate(row as any)[key], + })), relation.references as any) + } + } else if (relation.type === 'manyToOne') { + await database.upsert(relation.table, rows.map(row => ({ + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...rawupdate(row as any)[key], + })), relation.references as any) + } else if (relation.type === 'oneToMany' || relation.type === 'manyToMany') { + await Promise.all(rows.map(row => this.processRelationUpdate(table, row, key, rawupdate(row as any)[key]))) + } + } + return Object.keys(baseUpdate).length === 0 ? {} : await sel._action('set', baseUpdate).execute() + }) + } + update = sel.model.format(update) if (Object.keys(update).length === 0) return {} - return await sel._action('set', update).execute() + return sel._action('set', update).execute() } async remove>(table: K, query: Query): Promise { - const sel = this.select(table, query) - return await sel._action('remove').execute() + const sel = this.select(table, query, null) + return sel._action('remove').execute() } - async create>(table: K, data: Partial): Promise { + async create>(table: K, data: DeepPartial): Promise + async create>(table: K, data: any): Promise { const sel = this.select(table) - const { primary, autoInc } = sel.model + const { primary, autoInc, fields } = sel.model if (!autoInc) { const keys = makeArray(primary) if (keys.some(key => !(key in data))) { throw new Error('missing primary key') } } + + const tasks: any[] = [] + for (const key in data) { + if (data[key] && this.tables[table].fields[key]?.relation) { + const relation = this.tables[table].fields[key].relation + if (relation.type === 'oneToOne' && relation.required) { + const mergedData = { ...data[key] } + for (const k in relation.fields) { + mergedData[relation.references[k]] = getCell(data, relation.fields[k]) + } + tasks.push([relation.table, [mergedData], relation.references]) + } else if (relation.type === 'oneToMany' && Array.isArray(data[key])) { + const mergedData = data[key].map(row => { + const mergedData = { ...row } + for (const k in relation.fields) { + mergedData[relation.references[k]] = getCell(data, relation.fields[k]) + } + return mergedData + }) + + tasks.push([relation.table, mergedData]) + } else { + // handle shadowed fields + data = { + ...omit(data, [key]), + ...Object.fromEntries(Object.entries(data[key]).map(([k, v]) => { + if (!fields[`${key}.${k}`]) { + throw new Error(`field ${key}.${k} does not exist`) + } + return [`${key}.${k}`, v] + })), + } + continue + } + data = omit(data, [key]) as any + } + } + + if (tasks.length) { + return this.ensureTransaction(async (database) => { + for (const [table, data, keys] of tasks) { + await database.upsert(table, data, keys) + } + return database.create(table, data) + }) + } return sel._action('create', sel.model.create(data)).execute() } @@ -321,9 +536,67 @@ export class Database extends Servi ): Promise { const sel = this.select(table) if (typeof upsert === 'function') upsert = upsert(sel.row) + else { + const buildKey = (relation: Relation.Config) => [relation.table, ...relation.references].join('__') + const tasks: Dict<{ + table: string + upsert: any[] + keys?: string[] + }> = {} + + const upsert2 = (upsert as any[]).map(data => { + for (const key in data) { + if (data[key] && this.tables[table].fields[key]?.relation) { + const relation = this.tables[table].fields[key].relation + if (relation.type === 'oneToOne' && relation.required) { + const mergedData = { ...data[key] } + for (const k in relation.fields) { + mergedData[relation.references[k]] = data[relation.fields[k]] + } + ;(tasks[buildKey(relation)] ??= { + table: relation.table, + upsert: [], + keys: relation.references, + }).upsert.push(mergedData) + } else if (relation.type === 'oneToMany' && Array.isArray(data[key])) { + const mergedData = data[key].map(row => { + const mergedData = { ...row } + for (const k in relation.fields) { + mergedData[relation.references[k]] = data[relation.fields[k]] + } + return mergedData + }) + + ;(tasks[relation.table] ??= { table: relation.table, upsert: [] }).upsert.push(...mergedData) + } else { + // handle shadowed fields + data = { + ...omit(data, [key]), + ...Object.fromEntries(Object.entries(data[key]).map(([k, v]) => { + if (!sel.model.fields[`${key}.${k}`]) throw new Error(`field ${key}.${k} does not exist`) + return [`${key}.${k}`, v] + })), + } + continue + } + data = omit(data, [key]) as any + } + } + return data + }) + + if (Object.keys(tasks).length) { + return this.ensureTransaction(async (database) => { + for (const { table, upsert, keys } of Object.values(tasks)) { + await database.upsert(table as any, upsert, keys as any) + } + return database.upsert(table, upsert2) + }) + } + } upsert = upsert.map(item => sel.model.format(item)) keys = makeArray(keys || sel.model.primary) as any - return await sel._action('upsert', upsert, keys).execute() + return sel._action('upsert', upsert, keys).execute() } makeProxy(marker: any, getDriver?: (driver: Driver, database: this) => Driver) { @@ -355,15 +628,16 @@ export class Database extends Servi return this.transact(callback) } - async transact(callback: (database: this) => Promise) { + async transact(callback: (database: this) => Promise) { if (this[Database.transact]) throw new Error('nested transactions are not supported') const finalTasks: Promise[] = [] const database = this.makeProxy(Database.transact, (driver) => { - let session: any + let initialized = false, session: any let _resolve: (value: any) => void const sessionTask = new Promise((resolve) => _resolve = resolve) driver = new Proxy(driver, { get: (target, p, receiver) => { + if (p === Database.transact) return target if (p === 'database') return database if (p === 'session') return session if (p === '_ensureSession') return () => sessionTask @@ -371,16 +645,16 @@ export class Database extends Servi }, }) finalTasks.push(driver.withTransaction((_session) => { + if (initialized) initialTask = initialTaskFactory() + initialized = true _resolve(session = _session) - return initialTask + return initialTask as any })) return driver }) - const initialTask = (async () => { - await Promise.resolve() - await callback(database) - })() - await initialTask.finally(() => Promise.all(finalTasks)) + const initialTaskFactory = () => Promise.resolve().then(() => callback(database)) + let initialTask = initialTaskFactory() + return initialTask.catch(noop).finally(() => Promise.all(finalTasks)) } async stopAll() { @@ -407,4 +681,160 @@ export class Database extends Servi })) return stats } + + private ensureTransaction(callback: (database: this) => Promise) { + if (this[Database.transact]) { + return callback(this) + } else { + return this.transact(callback) + } + } + + private transformRelationQuery(table: any, row: any, key: any, query: Query.FieldExpr) { + const relation: Relation.Config = this.tables[table].fields[key]!.relation! as any + const results: Eval.Expr[] = [] + if (relation.type === 'oneToOne' || relation.type === 'manyToOne') { + if (query === null) { + results.push(Eval.nin( + relation.fields.map(x => row[x]), + this.select(relation.table).evaluate(relation.references), + )) + } else { + results.push(Eval.in( + relation.fields.map(x => row[x]), + this.select(relation.table, query as any).evaluate(relation.references), + )) + } + } else if (relation.type === 'oneToMany') { + if (query.$some) { + results.push(Eval.in( + relation.fields.map(x => row[x]), + this.select(relation.table, query.$some).evaluate(relation.references), + )) + } + if (query.$none) { + results.push(Eval.nin( + relation.fields.map(x => row[x]), + this.select(relation.table, query.$none).evaluate(relation.references), + )) + } + if (query.$every) { + results.push(Eval.nin( + relation.fields.map(x => row[x]), + this.select(relation.table, Eval.not(query.$every as any) as any).evaluate(relation.references), + )) + } + } else if (relation.type === 'manyToMany') { + const assocTable: any = Relation.buildAssociationTable(table, relation.table) + const fields: any[] = relation.fields.map(x => Relation.buildAssociationKey(x, table)) + const references = relation.references.map(x => Relation.buildAssociationKey(x, relation.table)) + if (query.$some) { + const innerTable = this.select(relation.table, query.$some).evaluate(relation.references) + const relTable = this.select(assocTable, r => Eval.in(references.map(x => r[x]), innerTable)).evaluate(fields) + results.push(Eval.in(relation.fields.map(x => row[x]), relTable)) + } + if (query.$none) { + const innerTable = this.select(relation.table, query.$none).evaluate(relation.references) + const relTable = this.select(assocTable, r => Eval.in(references.map(x => r[x]), innerTable)).evaluate(fields) + results.push(Eval.nin(relation.fields.map(x => row[x]), relTable)) + } + if (query.$every) { + const innerTable = this.select(relation.table, Eval.not(query.$every as any) as any).evaluate(relation.references) + const relTable = this.select(assocTable, r => Eval.in(references.map(x => r[x]), innerTable)).evaluate(fields) + results.push(Eval.nin(relation.fields.map(x => row[x]), relTable)) + } + } + return { $expr: Eval.and(...results) } as any + } + + private async processRelationUpdate(table: any, row: any, key: any, modifier: Relation.Modifier) { + const relation: Relation.Config = this.tables[table].fields[key]!.relation! as any + if (Array.isArray(modifier)) { + if (relation.type === 'oneToMany') { + modifier = { $remove: {}, $create: modifier } + } else if (relation.type === 'manyToMany') { + throw new Error('override for manyToMany relation is not supported') + } + } + if (modifier.$remove) { + if (relation.type === 'oneToMany') { + await this.remove(relation.table, (r: any) => Eval.query(r, { + ...Object.fromEntries(relation.references.map((k, i) => [k, row[relation.fields[i]]])), + ...(typeof modifier.$remove === 'function' ? { $expr: modifier.$remove(r) } : modifier.$remove), + }) as any) + } else if (relation.type === 'manyToMany') { + throw new Error('remove for manyToMany relation is not supported') + } + } + if (modifier.$set) { + if (relation.type === 'oneToMany') { + for (const setexpr of makeArray(modifier.$set) as any[]) { + const [query, update] = setexpr.update ? [setexpr.where, setexpr.update] : [{}, setexpr] + await this.set(relation.table, + (r: any) => Eval.query(r, { + ...Object.fromEntries(relation.references.map((k, i) => [k, row[relation.fields[i]]])), + ...(typeof query === 'function' ? { $expr: query } : query), + }) as any, + update, + ) + } + } else if (relation.type === 'manyToMany') { + throw new Error('set for manyToMany relation is not supported') + } + } + if (modifier.$create) { + if (relation.type === 'oneToMany') { + const upsert = makeArray(modifier.$create).map((r: any) => { + const data = { ...r } + for (const k in relation.fields) { + data[relation.references[k]] = row[relation.fields[k]] + } + return data + }) + await this.upsert(relation.table, upsert) + } else if (relation.type === 'manyToMany') { + throw new Error('create for manyToMany relation is not supported') + } + } + if (modifier.$disconnect) { + if (relation.type === 'oneToMany') { + await this.set(relation.table, + (r: any) => Eval.query(r, { + ...Object.fromEntries(relation.references.map((k, i) => [k, row[relation.fields[i]]])), + ...(typeof modifier.$disconnect === 'function' ? { $expr: modifier.$disconnect } : modifier.$disconnect), + } as any), + Object.fromEntries(relation.references.map((k, i) => [k, null])) as any, + ) + } else if (relation.type === 'manyToMany') { + const assocTable = Relation.buildAssociationTable(table, relation.table) as Keys + const fields = relation.fields.map(x => Relation.buildAssociationKey(x, table)) + const references = relation.references.map(x => Relation.buildAssociationKey(x, relation.table)) + const rows = await this.select(assocTable, { + ...Object.fromEntries(fields.map((k, i) => [k, row[relation.fields[i]]])) as any, + [relation.table]: modifier.$disconnect, + }, null).execute() + await this.remove(assocTable, r => Eval.in( + [...fields.map(x => r[x]), ...references.map(x => r[x])], + rows.map(r => [...fields.map(x => r[x]), ...references.map(x => r[x])]), + )) + } + } + if (modifier.$connect) { + if (relation.type === 'oneToMany') { + await this.set(relation.table, + modifier.$connect, + Object.fromEntries(relation.references.map((k, i) => [k, row[relation.fields[i]]])) as any, + ) + } else if (relation.type === 'manyToMany') { + const assocTable: any = Relation.buildAssociationTable(table, relation.table) + const fields = relation.fields.map(x => Relation.buildAssociationKey(x, table)) + const references = relation.references.map(x => Relation.buildAssociationKey(x, relation.table)) + const rows = await this.get(relation.table, modifier.$connect) + await this.upsert(assocTable, rows.map(r => ({ + ...Object.fromEntries(fields.map((k, i) => [k, row[relation.fields[i]]])), + ...Object.fromEntries(references.map((k, i) => [k, r[relation.references[i] as any]])), + })) as any) + } + } + } } diff --git a/packages/core/src/driver.ts b/packages/core/src/driver.ts index f9c64815..b98163e2 100644 --- a/packages/core/src/driver.ts +++ b/packages/core/src/driver.ts @@ -113,7 +113,7 @@ export abstract class Driver { for (const key in table) { const submodel = this.model(table[key]) for (const field in submodel.fields) { - if (submodel.fields[field]!.deprecated) continue + if (!Field.available(submodel.fields[field])) continue model.fields[`${key}.${field}`] = { expr: Eval('', [table[key].ref, field], Type.fromField(submodel.fields[field]!)), type: Type.fromField(submodel.fields[field]!), diff --git a/packages/core/src/eval.ts b/packages/core/src/eval.ts index ebe7267c..78c5dd5b 100644 --- a/packages/core/src/eval.ts +++ b/packages/core/src/eval.ts @@ -1,12 +1,17 @@ import { defineProperty, isNullable, mapValues } from 'cosmokit' -import { Comparable, Flatten, isComparable, makeRegExp, Row } from './utils.ts' +import { AtomicTypes, Comparable, Flatten, isComparable, isEmpty, makeRegExp, Row, Values } from './utils.ts' import { Type } from './type.ts' -import { Field } from './model.ts' +import { Field, Relation } from './model.ts' +import { Query } from './query.ts' export function isEvalExpr(value: any): value is Eval.Expr { return value && Object.keys(value).some(key => key.startsWith('$')) } +export function isAggrExpr(expr: Eval.Expr): boolean { + return expr['$'] || expr['$select'] +} + export function hasSubquery(value: any): boolean { if (!isEvalExpr(value)) return false return Object.entries(value).filter(([k]) => k.startsWith('$')).some(([k, v]) => { @@ -19,16 +24,18 @@ export function hasSubquery(value: any): boolean { }) } +type UnevalObject = { + [K in keyof S]?: (undefined extends S[K] ? null : never) | Uneval, boolean> +} + export type Uneval = - | U extends number ? Eval.Term - : U extends string ? Eval.Term - : U extends boolean ? Eval.Term - : U extends Date ? Eval.Term - : U extends RegExp ? Eval.Term + | U extends Values ? Eval.Term + : U extends (infer T extends object)[] ? Relation.Modifier | Eval.Array + : U extends object ? Eval.Expr | UnevalObject> : any export type Eval = - | U extends Comparable ? U + | U extends Values ? U : U extends Eval.Expr ? T : never @@ -66,6 +73,10 @@ export namespace Eval { export interface Static { (key: string, value: any, type: Type): Eval.Expr + ignoreNull(value: Eval.Expr): Eval.Expr + select(...args: Any[]): Expr + query(row: Row, query: Query.Expr): Expr + // univeral if(cond: Any, vThen: Term, vElse: Term): Expr ifNull(...args: Term[]): Expr @@ -105,7 +116,9 @@ export namespace Eval { // element in(x: Term, array: Array): Expr + in(x: Term[], array: Array): Expr nin(x: Term, array: Array): Expr + nin(x: Term[], array: Array): Expr // string concat: Multi @@ -128,7 +141,6 @@ export namespace Eval { min: Aggr count(value: Any): Expr length(value: Any): Expr - size(value: (Any | Expr)[] | Expr): Expr length(value: any[] | Expr): Expr object(row: Row.Cell): Expr @@ -177,6 +189,10 @@ operators.$switch = (args, data) => { return executeEval(data, args.default) } +Eval.ignoreNull = (expr) => (expr[Type.kType]!.ignoreNull = true, expr) +Eval.select = multary('select', (args, table) => args.map(arg => executeEval(table, arg)), Type.Array()) +Eval.query = (row, query) => ({ $expr: true, ...query }) as any + // univeral Eval.if = multary('if', ([cond, vThen, vElse], data) => executeEval(data, cond) ? executeEval(data, vThen) : executeEval(data, vElse), (cond, vThen, vElse) => Type.fromTerm(vThen)) @@ -209,8 +225,18 @@ Eval.lt = comparator('lt', (left, right) => left < right) Eval.le = Eval.lte = comparator('lte', (left, right) => left <= right) // element -Eval.in = multary('in', ([value, array], data) => executeEval(data, array).includes(executeEval(data, value)), Type.Boolean) -Eval.nin = multary('nin', ([value, array], data) => !executeEval(data, array).includes(executeEval(data, value)), Type.Boolean) +Eval.in = (value, array) => Eval('in', [Array.isArray(value) ? Eval.select(...value) : value, array], Type.Boolean) +operators.$in = ([value, array], data) => { + const val = executeEval(data, value), arr = executeEval(data, array) + if (typeof val === 'object') return arr.includes(val) || arr.map(JSON.stringify).includes(JSON.stringify(val)) + return arr.includes(val) +} +Eval.nin = (value, array) => Eval('nin', [Array.isArray(value) ? Eval.select(...value) : value, array], Type.Boolean) +operators.$nin = ([value, array], data) => { + const val = executeEval(data, value), arr = executeEval(data, array) + if (typeof val === 'object') return !arr.includes(val) && !arr.map(JSON.stringify).includes(JSON.stringify(val)) + return !arr.includes(val) +} // string Eval.concat = multary('concat', (args, data) => args.map(arg => executeEval(data, arg)).join(''), Type.String) @@ -286,7 +312,7 @@ Eval.object = (fields: any) => { const modelFields: [string, Field][] = Object.entries(fields.$model.fields) const prefix: string = fields.$prefix fields = Object.fromEntries(modelFields - .filter(([, field]) => !field.deprecated) + .filter(([, field]) => Field.available(field)) .filter(([path]) => path.startsWith(prefix)) .map(([k]) => [k.slice(prefix.length), fields[k.slice(prefix.length)]])) return Eval('object', fields, Type.Object(mapValues(fields, (value) => Type.fromTerm(value)))) @@ -295,18 +321,14 @@ Eval.object = (fields: any) => { } Eval.array = unary('array', (expr, table) => Array.isArray(table) - ? table.map(data => executeAggr(expr, data)) - : Array.from(executeEval(table, expr)), (expr) => Type.Array(Type.fromTerm(expr))) + ? table.map(data => executeAggr(expr, data)).filter(x => !expr[Type.kType]?.ignoreNull || !isEmpty(x)) + : Array.from(executeEval(table, expr)).filter(x => !expr[Type.kType]?.ignoreNull || !isEmpty(x)), (expr) => Type.Array(Type.fromTerm(expr))) Eval.exec = unary('exec', (expr, data) => (expr.driver as any).executeSelection(expr, data), (expr) => Type.fromTerm(expr.args[0])) export { Eval as $ } -type MapUneval = { - [K in keyof S]?: Uneval -} - -export type Update = MapUneval> +export type Update = UnevalObject> function getRecursive(args: string | string[], data: any): any { if (typeof args === 'string') { diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index e5bd1bc6..3644f34b 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,13 +1,89 @@ -import { Binary, clone, isNullable, makeArray, mapValues, MaybeArray } from 'cosmokit' +import { Binary, clone, filterKeys, isNullable, makeArray, mapValues, MaybeArray } from 'cosmokit' import { Context } from 'cordis' -import { Eval, isEvalExpr } from './eval.ts' -import { Flatten, Keys, unravel } from './utils.ts' +import { Eval, isEvalExpr, Update } from './eval.ts' +import { DeepPartial, FlatKeys, Flatten, Keys, Row, unravel } from './utils.ts' import { Type } from './type.ts' import { Driver } from './driver.ts' +import { Query } from './query.ts' +import { Selection } from './selection.ts' -export const Primary = Symbol('Primary') +const Primary = Symbol('minato.primary') export type Primary = (string | number) & { [Primary]: true } +export namespace Relation { + const Marker = Symbol('minato.relation') + export type Marker = { [Marker]: true } + + export const Type = ['oneToOne', 'oneToMany', 'manyToOne', 'manyToMany'] as const + export type Type = typeof Type[number] + + export interface Config = Keys, K extends string = string> { + type: Type + table: T + references: Keys[] + fields: K[] + required: boolean + } + + export interface Definition { + type: 'oneToOne' | 'manyToOne' | 'manyToMany' + table?: string + target?: string + references?: MaybeArray + fields?: MaybeArray + } + + export type Include = boolean | { + [P in keyof T]?: T[P] extends MaybeArray | undefined ? Include : never + } + + export type SetExpr = Row.Computed> | { + where: Query.Expr> | Selection.Callback + update: Row.Computed> + } + + export interface Modifier { + $create?: MaybeArray> + $set?: MaybeArray> + $remove?: Query.Expr> | Selection.Callback + $connect?: Query.Expr> | Selection.Callback + $disconnect?: Query.Expr> | Selection.Callback + } + + export function buildAssociationTable(...tables: [string, string]) { + return '_' + tables.sort().join('To') + } + + export function buildAssociationKey(key: string, table: string) { + return `${table}_${key}` + } + + export function parse(def: Definition, key: string, model: Model, relmodel: Model): [Config, Config] { + const fields = def.fields ?? ((model.name === relmodel.name || def.type === 'manyToOne' + || (def.type === 'oneToOne' && !makeArray(relmodel.primary).every(key => !relmodel.fields[key]?.nullable))) + ? makeArray(relmodel.primary).map(x => `${key}.${x}`) : model.primary) + const relation: Config = { + type: def.type, + table: def.table ?? relmodel.name, + fields: makeArray(fields), + references: makeArray(def.references ?? relmodel.primary), + required: def.type !== 'manyToOne' && model.name !== relmodel.name + && makeArray(fields).every(key => !model.fields[key]?.nullable || makeArray(model.primary).includes(key)), + } + const inverse: Config = { + type: relation.type === 'oneToMany' ? 'manyToOne' + : relation.type === 'manyToOne' ? 'oneToMany' + : relation.type, + table: model.name, + fields: relation.references, + references: relation.fields, + required: relation.type !== 'oneToMany' && !relation.required + && relation.references.every(key => !relmodel.fields[key]?.nullable || makeArray(relmodel.primary).includes(key)), + } + return [relation, inverse] + } +} + export interface Field { type: Type deftype?: Field.Type @@ -19,6 +95,7 @@ export interface Field { expr?: Eval.Expr legacy?: string[] deprecated?: boolean + relation?: Relation.Config transformers?: Driver.Transformer[] } @@ -37,8 +114,8 @@ export namespace Field { : T extends Date ? 'timestamp' | 'date' | 'time' : T extends ArrayBuffer ? 'binary' : T extends bigint ? 'bigint' - : T extends unknown[] ? 'list' | 'json' - : T extends object ? 'json' + : T extends unknown[] ? 'list' | 'json' | 'oneToMany' | 'manyToMany' + : T extends object ? 'json' | 'oneToOne' | 'manyToOne' : 'expr' type Shorthand = S | `${S}(${any})` @@ -77,12 +154,16 @@ export namespace Field { } & Omit, 'type'> type MapField = { - [K in keyof O]?: Literal | Definition | Transform + [K in keyof O]?: + | Literal + | Definition + | Transform + | (O[K] extends object ? Relation.Definition> : never) } export type Extension = MapField, N> - const NewType = Symbol('newtype') + const NewType = Symbol('minato.newtype') export type NewType = string & { [NewType]: T } export type Config = { @@ -132,6 +213,10 @@ export namespace Field { } return initial } + + export function available(field?: Field) { + return !!field && !field.deprecated && !field.relation + } } export namespace Model { @@ -239,7 +324,7 @@ export class Model { } format(source: object, strict = true, prefix = '', result = {} as S) { - const fields = Object.keys(this.fields) + const fields = Object.keys(this.fields).filter(key => !this.fields[key].relation) Object.entries(source).map(([key, value]) => { key = prefix + key if (value === undefined) return @@ -251,18 +336,18 @@ export class Model { if (field) { result[key] = value } else if (!value || typeof value !== 'object' || isEvalExpr(value) || Object.keys(value).length === 0) { - if (strict) { + if (strict && (typeof value !== 'object' || Object.keys(value).length)) { throw new TypeError(`unknown field "${key}" in model ${this.name}`) } } else { this.format(value, strict, key + '.', result) } }) - return prefix === '' ? this.resolveModel(result) : result + return (strict && prefix === '') ? this.resolveModel(result) : result } parse(source: object, strict = true, prefix = '', result = {} as S) { - const fields = Object.keys(this.fields) + const fields = Object.keys(this.fields).filter(key => !this.fields[key].relation) if (strict && prefix === '') { // initialize object layout Object.assign(result as any, unravel(Object.fromEntries(fields @@ -293,15 +378,15 @@ export class Model { } } } - return prefix === '' ? this.resolveModel(result) : result + return (strict && prefix === '') ? this.resolveModel(result) : result } create(data?: {}) { const result = {} as S const keys = makeArray(this.primary) for (const key in this.fields) { - const { initial, deprecated } = this.fields[key]! - if (deprecated) continue + if (!Field.available(this.fields[key])) continue + const { initial } = this.fields[key]! if (!keys.includes(key) && !isNullable(initial)) { result[key] = clone(initial) } @@ -309,6 +394,10 @@ export class Model { return this.parse({ ...result, ...data }) } + avaiableFields() { + return filterKeys(this.fields, (_, field) => Field.available(field)) + } + getType(): Type getType(key: string): Type | undefined getType(key?: string): Type | undefined { diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 0c5ecdb0..5aef44bf 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -1,6 +1,6 @@ import { Extract, isNullable } from 'cosmokit' import { Eval, executeEval } from './eval.ts' -import { Comparable, Flatten, Indexable, isComparable, makeRegExp } from './utils.ts' +import { AtomicTypes, Comparable, Flatten, Indexable, isComparable, makeRegExp, Values } from './utils.ts' import { Selection } from './selection.ts' export type Query = Query.Expr> | Query.Shorthand | Selection.Callback @@ -40,6 +40,11 @@ export namespace Query { $bitsAllSet?: Extract $bitsAnyClear?: Extract $bitsAnySet?: Extract + + // relation + $some?: T extends (infer U)[] ? Query : never + $none?: T extends (infer U)[] ? Query : never + $every?: T extends (infer U)[] ? Query : never } export interface LogicalExpr { @@ -47,7 +52,7 @@ export namespace Query { $and?: Expr[] $not?: Expr /** @deprecated use query callback instead */ - $expr?: Eval.Expr + $expr?: Eval.Term } export type Shorthand = @@ -57,8 +62,12 @@ export namespace Query { export type Field = FieldExpr | Shorthand + type NonNullExpr = T extends Values | any[] ? Field : T extends object + ? Expr> | Selection.Callback + : Field + export type Expr = LogicalExpr & { - [K in keyof T]?: null | Field + [K in keyof T]?: (undefined extends T[K] ? null : never) | NonNullExpr> } } @@ -139,9 +148,13 @@ export function executeQuery(data: any, query: Query.Expr, ref: string, env: any // execute field query try { - return executeFieldQuery(value, data[key]) + return executeFieldQuery(value, getCell(data, key)) } catch { return false } }) } + +function getCell(row: any, key: any): any { + return key.split('.').reduce((r, k) => r[k], row) +} diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index cd69d66f..443c9fb5 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -1,7 +1,7 @@ import { defineProperty, Dict, filterKeys } from 'cosmokit' import { Driver } from './driver.ts' -import { Eval, executeEval } from './eval.ts' -import { Model } from './model.ts' +import { Eval, executeEval, isAggrExpr, isEvalExpr } from './eval.ts' +import { Field, Model } from './model.ts' import { Query } from './query.ts' import { FlatKeys, FlatPick, Flatten, Keys, randomId, Row } from './utils.ts' import { Type } from './type.ts' @@ -57,7 +57,7 @@ const createRow = (ref: string, expr = {}, prefix = '', model?: Model) => new Pr .map(([k, field]) => [k.slice(prefix.length + key.length + 1), Type.fromField(field!)]))) } else { // unknown field inside json - type = Type.fromField('expr') + type = model?.getType(`${prefix}${key}`) ?? Type.fromField('expr') } const row = createRow(ref, Eval('', [ref, `${prefix}${key}`], type), `${prefix}${key}.`, model) @@ -85,8 +85,11 @@ class Executable { protected resolveQuery(query?: Query): Query.Expr protected resolveQuery(query: Query = {}): any { - if (typeof query === 'function') return { $expr: query(this.row) } - if (Array.isArray(query) || query instanceof RegExp || ['string', 'number'].includes(typeof query)) { + if (typeof query === 'function') { + const expr = query(this.row) + return expr['$expr'] ? expr : isEvalExpr(expr) ? { $expr: expr } : expr + } + if (Array.isArray(query) || query instanceof RegExp || ['string', 'number', 'bigint'].includes(typeof query)) { const { primary } = this.model if (Array.isArray(primary)) { throw new TypeError('invalid shorthand for composite primary key') @@ -121,7 +124,7 @@ class Executable { } else { const entries = Object.entries(fields).flatMap(([key, field]) => { const expr = this.resolveField(field) - if (expr['$object']) { + if (expr['$object'] && !Type.fromTerm(expr).ignoreNull) { return Object.entries(expr['$object']).map(([key2, expr2]) => [`${key}.${key2}`, expr2]) } return [[key, expr]] @@ -242,18 +245,39 @@ export class Selection extends Executable { return new Selection(this.driver, this) } + join( + name: K, + selection: Selection, + callback: (self: Row, other: Row) => Eval.Expr = () => Eval.and(), + optional: boolean = false, + ): Selection { + const fields = Object.fromEntries(Object.entries(this.model.fields) + .filter(([key, field]) => Field.available(field) && !key.startsWith(name + '.')) + .map(([key]) => [key, (row) => key.split('.').reduce((r, k) => r[k], row[this.ref])])) + if (optional) { + return this.driver.database + .join({ [this.ref]: this as Selection, [name]: selection }, (t: any) => callback(t[this.ref], t[name]), { [this.ref]: false, [name]: true }) + .project({ ...fields, [name]: (row) => Eval.ignoreNull(row[name]) }) as any + } else { + return this.driver.database + .join({ [this.ref]: this as Selection, [name]: selection }, (t: any) => callback(t[this.ref], t[name])) + .project({ ...fields, [name]: (row) => Eval.ignoreNull(row[name]) }) as any + } + } + _action(type: Executable.Action, ...args: any[]) { return new Executable(this.driver, { ...this, type, args }) } evaluate(callback: Selection.Callback): Eval.Expr - evaluate>(field: K): Eval.Expr + evaluate>(field: K): Eval.Expr + evaluate>(field: K[]): Eval.Expr evaluate(): Eval.Expr evaluate(callback?: any): any { const selection = new Selection(this.driver, this) if (!callback) callback = (row: any) => Eval.array(Eval.object(row)) - const expr = this.resolveField(callback) - if (expr['$']) defineProperty(expr, Type.kType, Type.Array(Type.fromTerm(expr))) + const expr = Array.isArray(callback) ? Eval.select(...callback.map(x => this.resolveField(x))) : this.resolveField(callback) + if (isAggrExpr(expr)) defineProperty(expr, Type.kType, Type.Array(Type.fromTerm(expr))) return Eval.exec(selection._action('eval', expr)) } diff --git a/packages/core/src/type.ts b/packages/core/src/type.ts index 704125e2..a0256e89 100644 --- a/packages/core/src/type.ts +++ b/packages/core/src/type.ts @@ -1,6 +1,7 @@ import { Binary, defineProperty, isNullable, mapValues } from 'cosmokit' import { Field } from './model.ts' import { Eval, isEvalExpr } from './eval.ts' +import { isEmpty } from './utils.ts' // import { Keys } from './utils.ts' export interface Type { @@ -9,6 +10,8 @@ export interface Type { type: Field.Type // | Keys | Field.NewType inner?: T extends (infer I)[] ? Type : Field.Type extends 'json' ? { [key in keyof T]: Type } : never array?: boolean + // For left joined unmatched result only + ignoreNull?: boolean } export namespace Type { @@ -60,13 +63,13 @@ export namespace Type { throw new TypeError(`invalid field: ${field}`) } - export function fromTerm(value: Eval.Term): Type { - if (isEvalExpr(value)) return value[kType] ?? fromField('expr' as any) + export function fromTerm(value: Eval.Term, initial?: Type): Type { + if (isEvalExpr(value)) return value[kType] ?? initial ?? fromField('expr' as any) else return fromPrimitive(value as T) } export function fromTerms(values: Eval.Term[], initial?: Type): Type { - return values.map(fromTerm).find((type) => type.type !== 'expr') ?? initial ?? fromField('expr') + return values.map((x) => fromTerm(x)).find((type) => type.type !== 'expr') ?? initial ?? fromField('expr') } export function isType(value: any): value is Type { @@ -88,4 +91,16 @@ export namespace Type { .map(([k, v]) => [k.slice(key.length + 1), v]), )) } + + export function transform(value: any, type: Type, callback: (value: any, type?: Type) => any) { + if (!isNullable(value) && type?.inner) { + if (Type.isArray(type)) { + return (value as any[]).map(x => callback(x, Type.getInner(type))).filter(x => !type.ignoreNull || !isEmpty(x)) + } else { + if (type.ignoreNull && isEmpty(value)) return null + return mapValues(value, (x, k) => callback(x, Type.getInner(type, k))) + } + } + return value + } } diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 4276aba7..a30987a2 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,4 +1,4 @@ -import { Intersect } from 'cosmokit' +import { Intersect, isNullable } from 'cosmokit' import { Eval } from './eval.ts' export type Values = S[keyof S] @@ -16,6 +16,12 @@ export type FlatPick> = { : FlatPick>> } +export type DeepPartial = + | T extends Values ? T + : T extends (infer U)[] ? DeepPartial[] + : T extends object ? { [K in keyof T]?: DeepPartial } + : T + export interface AtomicTypes { Number: number String: string @@ -32,7 +38,7 @@ export interface AtomicTypes { export type Indexable = string | number | bigint export type Comparable = string | number | boolean | bigint | Date -type FlatWrap = { [K in P]?: S } +type FlatWrap = { [K in P]: S } // rule out atomic types | (S extends Values ? never // rule out array types @@ -91,3 +97,12 @@ export function unravel(source: object, init?: (value) => any) { } return result } + +export function isEmpty(value: any) { + if (isNullable(value)) return true + if (typeof value !== 'object') return false + for (const key in value) { + if (!isEmpty(value[key])) return false + } + return true +} diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 454f4e86..c3237fd7 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -1,5 +1,5 @@ -import { clone, Dict, makeArray, mapValues, noop, omit, pick } from 'cosmokit' -import { Driver, Eval, executeEval, executeQuery, executeSort, executeUpdate, RuntimeError, Selection, z } from 'minato' +import { clone, deepEqual, Dict, makeArray, mapValues, noop, omit, pick } from 'cosmokit' +import { Driver, Eval, executeEval, executeQuery, executeSort, executeUpdate, Field, isAggrExpr, RuntimeError, Selection, z } from 'minato' export class MemoryDriver extends Driver { static name = 'memory' @@ -62,7 +62,7 @@ export class MemoryDriver extends Driver { for (let row of executeSort(data, args[0], ref)) { row = model.format(row, false) for (const key in model.fields) { - if (model.fields[key]!.deprecated) continue + if (!Field.available(model.fields[key])) continue row[key] ??= null } let index = row @@ -72,7 +72,7 @@ export class MemoryDriver extends Driver { let branch = branches.find((branch) => { if (!group || !groupFields) return false for (const key in groupFields) { - if (branch.index[key] !== index[key]) return false + if (!deepEqual(branch.index[key], index[key])) return false } return true }) @@ -156,10 +156,9 @@ export class MemoryDriver extends Driver { throw new RuntimeError('duplicate-entry') } } - const copy = model.create(data) - store.push(copy) + store.push(clone(data)) this.$save(table) - return clone(copy) + return clone(clone(data)) } async upsert(sel: Selection.Mutable, data: any, keys: string[]) { @@ -174,7 +173,7 @@ export class MemoryDriver extends Driver { result.matched++ } else { const data = executeUpdate(model.create(), update, ref) - await this.database.create(table, data).catch(noop) + await this.create(sel, data).catch(noop) result.inserted++ } } @@ -186,7 +185,7 @@ export class MemoryDriver extends Driver { const expr = sel.args[0], table = sel.table as Selection if (Array.isArray(env)) env = { [sel.ref]: env } const data = this.table(sel.table, env) - const res = expr.$ ? data.map(row => executeEval({ ...env, [table.ref]: row, _: row }, expr)) + const res = isAggrExpr(expr) ? data.map(row => executeEval({ ...env, [table.ref]: row, _: row }, expr)) : executeEval(Object.assign(data.map(row => ({ [table.ref]: row, _: row })), env), expr) return res } diff --git a/packages/memory/tests/index.spec.ts b/packages/memory/tests/index.spec.ts index 9b277cd6..79a8ec50 100644 --- a/packages/memory/tests/index.spec.ts +++ b/packages/memory/tests/index.spec.ts @@ -30,5 +30,13 @@ describe('@minatojs/driver-memory', () => { nullableComparator: false, }, }, + relation: { + select: { + ignoreNullObject: false, + }, + modify: { + ignoreNullObject: false, + }, + }, }) }) diff --git a/packages/mongo/src/builder.ts b/packages/mongo/src/builder.ts index 72f3749a..d4009ee1 100644 --- a/packages/mongo/src/builder.ts +++ b/packages/mongo/src/builder.ts @@ -1,5 +1,5 @@ import { Dict, isNullable, mapValues } from 'cosmokit' -import { Eval, Field, isComparable, isEvalExpr, Model, Query, Selection, Type, unravel } from 'minato' +import { Eval, Field, isAggrExpr, isComparable, isEvalExpr, Model, Query, Selection, Type, unravel } from 'minato' import { Filter, FilterOperators, ObjectId } from 'mongodb' import MongoDriver from '.' @@ -88,7 +88,7 @@ export class Builder { public pipeline: any[] = [] protected lookups: any[] = [] public evalKey?: string - private evalType?: Type + private evalExpr?: Eval.Expr private refTables: string[] = [] private refVirtualKeys: Dict = {} private joinTables: Dict = {} @@ -119,10 +119,11 @@ export class Builder { throw new Error(`$ not transformed: ${JSON.stringify(arg)}`) } }, + $select: (args, group) => args.map(val => this.eval(val, group)), $if: (arg, group) => ({ $cond: arg.map(val => this.eval(val, group)) }), $and: (args, group) => { - const type = this.evalType! + const type = Type.fromTerm(this.evalExpr, Type.Boolean) if (Field.boolean.includes(type.type)) return { $and: args.map(arg => this.eval(arg, group)) } else if (this.driver.version >= 7) return { $bitAnd: args.map(arg => this.eval(arg, group)) } else if (Field.number.includes(type.type)) { @@ -146,7 +147,7 @@ export class Builder { } }, $or: (args, group) => { - const type = this.evalType! + const type = Type.fromTerm(this.evalExpr, Type.Boolean) if (Field.boolean.includes(type.type)) return { $or: args.map(arg => this.eval(arg, group)) } else if (this.driver.version >= 7) return { $bitOr: args.map(arg => this.eval(arg, group)) } else if (Field.number.includes(type.type)) { @@ -170,7 +171,7 @@ export class Builder { } }, $not: (arg, group) => { - const type = this.evalType! + const type = Type.fromTerm(this.evalExpr, Type.Boolean) if (Field.boolean.includes(type.type)) return { $not: this.eval(arg, group) } else if (this.driver.version >= 7) return { $bitNot: this.eval(arg, group) } else if (Field.number.includes(type.type)) { @@ -194,7 +195,7 @@ export class Builder { } }, $xor: (args, group) => { - const type = this.evalType! + const type = Type.fromTerm(this.evalExpr, Type.Boolean) if (Field.boolean.includes(type.type)) return args.map(arg => this.eval(arg, group)).reduce((prev, curr) => ({ $ne: [prev, curr] })) else if (this.driver.version >= 7) return { $bitXor: args.map(arg => this.eval(arg, group)) } else if (Field.number.includes(type.type)) { @@ -269,7 +270,7 @@ export class Builder { }, }, { $set: { - [name]: !(sel.args[0] as any).$ ? { + [name]: !isAggrExpr(sel.args[0] as any) ? { $getField: { input: { $ifNull: [ @@ -311,7 +312,7 @@ export class Builder { for (const key in expr) { if (this.evalOperators[key]) { - this.evalType = Type.fromTerm(expr) + this.evalExpr = expr return this.evalOperators[key](expr[key], group) } else if (key?.startsWith('$') && Eval[key.slice(1)]) { return mapValues(expr, (value) => { @@ -473,6 +474,7 @@ export class Builder { } else { const $project: Dict = { _id: 0 } for (const key in model.fields) { + if (!Field.available(model.fields[key])) continue $project[key] = key === this.virtualKey ? '$_id' : 1 } stages.push({ $project }) @@ -579,15 +581,7 @@ export class Builder { const converter = this.driver.types[type?.type] let res = value - - if (!isNullable(res) && type.inner) { - if (Type.isArray(type)) { - res = res.map(x => this.dump(x, Type.getInner(type)!)) - } else { - res = mapValues(res, (x, k) => this.dump(x, Type.getInner(type, k))) - } - } - + res = Type.transform(res, type, (value, type) => this.dump(value, type)) res = converter?.dump ? converter.dump(res) : res const ancestor = this.driver.database.types[type.type]?.type res = this.dump(res, ancestor ? Type.fromField(ancestor) : undefined) @@ -603,14 +597,7 @@ export class Builder { const ancestor = this.driver.database.types[type.type]?.type let res = this.load(value, ancestor ? Type.fromField(ancestor) : undefined) res = converter?.load ? converter.load(res) : res - - if (!isNullable(res) && type.inner) { - if (Type.isArray(type)) { - res = res.map(x => this.load(x, Type.getInner(type as Type))) - } else { - res = mapValues(res, (x, k) => this.load(x, Type.getInner(type as Type, k))) - } - } + res = Type.transform(res, type, (value, type) => this.load(value, type)) return res } diff --git a/packages/mongo/src/index.ts b/packages/mongo/src/index.ts index 0276a948..19efa6a2 100644 --- a/packages/mongo/src/index.ts +++ b/packages/mongo/src/index.ts @@ -1,6 +1,6 @@ import { BSONType, ClientSession, Collection, Db, IndexDescription, Long, MongoClient, MongoClientOptions, MongoError } from 'mongodb' import { Binary, Dict, isNullable, makeArray, mapValues, noop, omit, pick } from 'cosmokit' -import { Driver, Eval, executeUpdate, hasSubquery, Query, RuntimeError, Selection, z } from 'minato' +import { Driver, Eval, executeUpdate, Field, hasSubquery, Query, RuntimeError, Selection, z } from 'minato' import { URLSearchParams } from 'url' import { Builder } from './builder' import zhCN from './locales/zh-CN.yml' @@ -53,7 +53,12 @@ export class MongoDriver extends Driver { this.db.admin().serverInfo().then((doc) => this.version = +doc.version.split('.')[0]).catch(noop) await this.client.withSession((session) => session.withTransaction( () => this.db.collection('_fields').findOne({}, { session }), - )).catch(() => this._replSet = false) + )).catch(() => { + this._replSet = false + this.logger.warn(`MongoDB is currently running as standalone server, transaction is disabled. + Convert to replicaSet to enable the feature. + See https://www.mongodb.com/docs/manual/tutorial/convert-standalone-to-replica-set/`) + }) this.define({ types: ['binary'], @@ -115,8 +120,8 @@ export class MongoDriver extends Driver { const virtualKey = this.getVirtualKey(table) for (const key in fields) { if (virtualKey === key) continue - const { initial, legacy = [], deprecated } = fields[key]! - if (deprecated) continue + const { initial, legacy = [] } = fields[key]! + if (!Field.available(fields[key])) continue const filter = { [key]: { $exists: false } } for (const oldKey of legacy) { bulk @@ -310,10 +315,7 @@ export class MongoDriver extends Driver { return this.db .collection(transformer.table) .aggregate(transformer.pipeline, { allowDiskUse: true, session: this.session }) - .toArray().then(rows => { - // console.dir(rows, { depth: 8 }) - return rows.map(row => this.builder.load(row, sel.model)) - }) + .toArray().then(rows => rows.map(row => this.builder.load(row, sel.model))) } async eval(sel: Selection.Immutable, expr: Eval.Expr) { @@ -485,9 +487,6 @@ export class MongoDriver extends Driver { if (this._replSet) { await this.client.withSession((session) => session.withTransaction(() => callback(session))) } else { - this.logger.warn(`MongoDB is currently running as standalone server, transaction is disabled. -Convert to replicaSet to enable the feature. -See https://www.mongodb.com/docs/manual/tutorial/convert-standalone-to-replica-set/`) await callback(undefined) } } diff --git a/packages/mysql/src/builder.ts b/packages/mysql/src/builder.ts index 156e5eca..d5443518 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -1,6 +1,6 @@ import { Builder, escapeId, isBracketed } from '@minatojs/sql-utils' import { Binary, Dict, isNullable, Time } from 'cosmokit' -import { Driver, Field, isEvalExpr, Model, randomId, Selection, Type } from 'minato' +import { Driver, Field, isAggrExpr, isEvalExpr, Model, randomId, Selection, Type } from 'minato' export interface Compat { maria?: boolean @@ -33,6 +33,14 @@ export class MySQLBuilder extends Builder { super(driver, tables) this._dbTimezone = compat.timezone ?? 'SYSTEM' + this.evalOperators.$select = (args) => { + if (compat.maria || compat.mysql57) { + return this.asEncoded(`json_object(${args.map(arg => this.parseEval(arg, false)).flatMap((x, i) => [`${i}`, x]).join(', ')})`, true) + } else { + return `${args.map(arg => this.parseEval(arg, false)).join(', ')}` + } + } + 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})`) @@ -47,22 +55,22 @@ export class MySQLBuilder extends Builder { } this.evalOperators.$or = (args) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return this.logicalOr(args.map(arg => this.parseEval(arg))) else return `cast(${args.map(arg => this.parseEval(arg)).join(' | ')} as signed)` } this.evalOperators.$and = (args) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return this.logicalAnd(args.map(arg => this.parseEval(arg))) else return `cast(${args.map(arg => this.parseEval(arg)).join(' & ')} as signed)` } this.evalOperators.$not = (arg) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return this.logicalNot(this.parseEval(arg)) else return `cast(~(${this.parseEval(arg)}) as signed)` } this.evalOperators.$xor = (args) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return args.map(arg => this.parseEval(arg)).reduce((prev, curr) => `(${prev} != ${curr})`) else return `cast(${args.map(arg => this.parseEval(arg)).join(' ^ ')} as signed)` } @@ -122,6 +130,18 @@ export class MySQLBuilder extends Builder { } } + protected createMemberQuery(key: string, value: any, notStr = '') { + if (Array.isArray(value) && Array.isArray(value[0]) && (this.compat.maria || this.compat.mysql57)) { + const vals = `json_array(${value.map((val: any[]) => `(${this.evalOperators.$select!(val)})`).join(', ')})` + return this.jsonContains(vals, key) + } + if (value.$exec && (this.compat.maria || this.compat.mysql57)) { + const res = this.jsonContains(this.parseEval(value, false), this.encode(key, true, true)) + return notStr ? this.logicalNot(res) : res + } + return super.createMemberQuery(key, value, notStr) + } + escapePrimitive(value: any, type?: Type) { if (value instanceof Date) { value = Time.template('yyyy-MM-dd hh:mm:ss.SSS', value) @@ -158,20 +178,24 @@ export class MySQLBuilder extends Builder { return this.asEncoded(`ifnull(${res}, json_array())`, true) } - protected parseSelection(sel: Selection) { - if (!this.compat.maria && !this.compat.mysql57) return super.parseSelection(sel) + protected parseSelection(sel: Selection, inline: boolean = false) { + if (!this.compat.maria && !this.compat.mysql57) return super.parseSelection(sel, inline) 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 fields = expr['$select']?.map(x => this.getRecursive(x['$'])) + const where = fields && this.logicalAnd(fields.map(x => `(${x} is not null)`)) 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 : ''})` + if (inline || !isAggrExpr(expr as any)) { + query = `(SELECT ${output} FROM ${inner} ${isBracketed(inner) ? ref : ''}${where ? ` WHERE ${where}` : ''})` } else { - query = `(ifnull((SELECT ${this.groupArray(this.transform(output, Type.getInner(Type.fromTerm(expr)), 'encode'))} - AS value FROM ${inner} ${isBracketed(inner) ? ref : ''}), json_array()))` + query = [ + `(ifnull((SELECT ${this.groupArray(this.transform(output, Type.getInner(Type.fromTerm(expr)), 'encode'))}`, + `FROM ${inner} ${isBracketed(inner) ? ref : ''}), json_array()))`, + ].join(' ') } if (Object.keys(refFields ?? {}).length) { const funcname = `minato_tfunc_${randomId()}` diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts index 7abef518..61c5a1e6 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -153,7 +153,7 @@ export class MySQLDriver extends Driver { const table = this.model(name) const { primary, foreign, autoInc } = table - const fields = { ...table.fields } + const fields = table.avaiableFields() const unique = [...table.unique] const create: string[] = [] const update: string[] = [] @@ -161,8 +161,7 @@ export class MySQLDriver extends Driver { // field definitions for (const key in fields) { - const { deprecated, initial, nullable = true } = fields[key]! - if (deprecated) continue + const { initial, nullable = true } = fields[key]! const legacy = [key, ...fields[key]!.legacy || []] const column = columns.find(info => legacy.includes(info.COLUMN_NAME)) let shouldUpdate = column?.COLUMN_NAME !== key @@ -397,7 +396,7 @@ INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WH const { model, query, table, tables, ref } = sel const builder = new MySQLBuilder(this, tables, this._compat) const filter = builder.parseQuery(query) - const { fields } = model + const fields = model.avaiableFields() if (filter === '0') return {} const updateFields = [...new Set(Object.keys(data).map((key) => { return Object.keys(fields).find(field => field === key || key.startsWith(field + '.'))! @@ -444,12 +443,12 @@ INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WH Object.assign(merged, item) return model.format(executeUpdate(model.create(), item, ref)) }) - const initFields = Object.keys(model.fields).filter(key => !model.fields[key]?.deprecated) + const initFields = Object.keys(model.avaiableFields()) const dataFields = [...new Set(Object.keys(merged).map((key) => { return initFields.find(field => field === key || key.startsWith(field + '.'))! }))] let updateFields = difference(dataFields, keys) - if (!updateFields.length) updateFields = [dataFields[0]] + if (!updateFields.length) updateFields = dataFields.length ? [dataFields[0]] : [] const createFilter = (item: any) => builder.parseQuery(pick(item, keys)) const createMultiFilter = (items: any[]) => { @@ -485,7 +484,7 @@ INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WH const result = await this.query([ `INSERT INTO ${escapeId(table)} (${initFields.map(escapeId).join(', ')})`, `VALUES (${insertion.map(item => this._formatValues(table, item, initFields)).join('), (')})`, - `ON DUPLICATE KEY UPDATE ${update}`, + update ? `ON DUPLICATE KEY UPDATE ${update}` : '', ].join(' ')) const records = +(/^&Records:\s*(\d+)/.exec(result.message)?.[1] ?? result.affectedRows) if (!result.message && !result.insertId) return { inserted: 0, matched: result.affectedRows, modified: 0 } diff --git a/packages/postgres/src/builder.ts b/packages/postgres/src/builder.ts index 1bf1fe19..9c52f2e0 100644 --- a/packages/postgres/src/builder.ts +++ b/packages/postgres/src/builder.ts @@ -1,6 +1,6 @@ import { Builder, isBracketed } from '@minatojs/sql-utils' import { Binary, Dict, isNullable, Time } from 'cosmokit' -import { Driver, Field, isEvalExpr, Model, randomId, Selection, Type, unravel } from 'minato' +import { Driver, Field, isAggrExpr, isEvalExpr, Model, randomId, Selection, Type, unravel } from 'minato' export function escapeId(value: string) { return '"' + value.replace(/"/g, '""') + '"' @@ -46,6 +46,7 @@ export class PostgresBuilder extends Builder { this.evalOperators = { ...this.evalOperators, + $select: (args) => `${args.map(arg => this.parseEval(arg, this.getLiteralType(arg))).join(', ')}`, $if: (args) => { const type = this.getLiteralType(args[1]) ?? this.getLiteralType(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)` @@ -70,22 +71,22 @@ export class PostgresBuilder extends Builder { $random: () => `random()`, $or: (args) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return this.logicalOr(args.map(arg => this.parseEval(arg, 'boolean'))) else return `(${args.map(arg => this.parseEval(arg, 'bigint')).join(' | ')})` }, $and: (args) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return this.logicalAnd(args.map(arg => this.parseEval(arg, 'boolean'))) else return `(${args.map(arg => this.parseEval(arg, 'bigint')).join(' & ')})` }, $not: (arg) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return this.logicalNot(this.parseEval(arg, 'boolean')) else return `(~(${this.parseEval(arg, 'bigint')}))` }, $xor: (args) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return args.map(arg => this.parseEval(arg, 'boolean')).reduce((prev, curr) => `(${prev} != ${curr})`) else return `(${args.map(arg => this.parseEval(arg, 'bigint')).join(' # ')})` }, @@ -186,7 +187,7 @@ export class PostgresBuilder extends Builder { if (typeof expr === 'string' || typeof expr === 'number' || typeof expr === 'boolean' || expr instanceof Date || expr instanceof RegExp) { return this.escape(expr) } - return outtype ? `(${this.encode(this.parseEvalExpr(expr), false, false, Type.fromTerm(expr), typeof outtype === 'string' ? outtype : undefined)})` + return outtype ? this.encode(this.parseEvalExpr(expr), false, false, Type.fromTerm(expr), typeof outtype === 'string' ? outtype : undefined) : this.parseEvalExpr(expr) } @@ -241,24 +242,28 @@ export class PostgresBuilder extends Builder { } return `jsonb_build_object(` + Object.entries(fields).map(([key, expr]) => `'${key}', ${parse(expr, key)}`).join(',') + `)` } - return this.asEncoded(_groupObject(unravel(_fields), this.state.type, ''), true) + return this.asEncoded(_groupObject(unravel(_fields), Type.fromTerm(this.state.expr), ''), true) } protected groupArray(value: string) { return this.asEncoded(`coalesce(jsonb_agg(${value}), '[]'::jsonb)`, true) } - protected parseSelection(sel: Selection) { + protected parseSelection(sel: Selection, inline: boolean = false) { const { args: [expr], ref, table, tables } = sel const restore = this.saveState({ tables }) const inner = this.get(table as Selection, true, true) as string const output = this.parseEval(expr, false) + const fields = expr['$select']?.map(x => this.getRecursive(x['$'])) + const where = fields && this.logicalAnd(fields.map(x => `(${x} is not null)`)) restore() - if (!(sel.args[0] as any).$) { - return `(SELECT ${output} AS value FROM ${inner} ${isBracketed(inner) ? ref : ''})` + if (inline || !isAggrExpr(expr as any)) { + return `(SELECT ${output} FROM ${inner} ${isBracketed(inner) ? ref : ''}${where ? ` WHERE ${where}` : ''})` } else { - return `(coalesce((SELECT ${this.groupArray(this.transform(output, Type.getInner(Type.fromTerm(expr)), 'encode'))} - AS value FROM ${inner} ${isBracketed(inner) ? ref : ''}), '[]'::jsonb))` + return [ + `(coalesce((SELECT ${this.groupArray(this.transform(output, Type.getInner(Type.fromTerm(expr)), 'encode'))}`, + `FROM ${inner} ${isBracketed(inner) ? ref : ''}), '[]'::jsonb))`, + ].join(' ') } } diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts index 343ad94e..76f8c221 100644 --- a/packages/postgres/src/index.ts +++ b/packages/postgres/src/index.ts @@ -168,7 +168,7 @@ export class PostgresDriver extends Driver { const table = this.model(name) const { primary, foreign } = table - const fields = { ...table.fields } + const fields = { ...table.avaiableFields() } const unique = [...table.unique] const create: string[] = [] const update: string[] = [] @@ -176,8 +176,7 @@ export class PostgresDriver extends Driver { // field definitions for (const key in fields) { - const { deprecated, initial, nullable = true } = fields[key]! - if (deprecated) continue + const { initial, nullable = true } = fields[key]! const legacy = [key, ...fields[key]!.legacy || []] const column = columns.find(info => legacy.includes(info.column_name)) let shouldUpdate = column?.column_name !== key @@ -308,7 +307,7 @@ export class PostgresDriver extends Driver { const { model, query, table, tables, ref } = sel const builder = new PostgresBuilder(this, tables) const filter = builder.parseQuery(query) - const { fields } = model + const fields = model.avaiableFields() if (filter === '0') return {} const updateFields = [...new Set(Object.keys(data).map((key) => { return Object.keys(fields).find(field => field === key || key.startsWith(field + '.'))! @@ -356,12 +355,12 @@ export class PostgresDriver extends Driver { Object.assign(merged, item) return model.format(executeUpdate(model.create(), item, ref)) }) - const initFields = Object.keys(model.fields).filter(key => !model.fields[key]?.deprecated) + const initFields = Object.keys(model.avaiableFields()) const dataFields = [...new Set(Object.keys(merged).map((key) => { return initFields.find(field => field === key || key.startsWith(field + '.'))! }))] let updateFields = difference(dataFields, keys) - if (!updateFields.length) updateFields = [dataFields[0]] + if (!updateFields.length) updateFields = dataFields.length ? [dataFields[0]] : [] const createFilter = (item: any) => builder.parseQuery(pick(item, keys)) const createMultiFilter = (items: any[]) => { @@ -405,8 +404,8 @@ export class PostgresDriver extends Driver { const result = await this.query([ `INSERT INTO ${builder.escapeId(table)} (${initFields.map(builder.escapeId).join(', ')})`, `VALUES (${insertion.map(item => formatValues(table, item, initFields)).join('), (')})`, - `ON CONFLICT (${keys.map(builder.escapeId).join(', ')})`, - `DO UPDATE SET ${update}, _pg_mtime = ${mtime}`, + update ? `ON CONFLICT (${keys.map(builder.escapeId).join(', ')})` : '', + update ? `DO UPDATE SET ${update}, _pg_mtime = ${mtime}` : '', `RETURNING _pg_mtime as rtime`, ].join(' ')) return { inserted: result.filter(({ rtime }) => +rtime !== mtime).length, matched: result.filter(({ rtime }) => +rtime === mtime).length } diff --git a/packages/sql-utils/src/index.ts b/packages/sql-utils/src/index.ts index 315d04c5..6d5c341c 100644 --- a/packages/sql-utils/src/index.ts +++ b/packages/sql-utils/src/index.ts @@ -1,5 +1,5 @@ -import { Dict, isNullable, mapValues } from 'cosmokit' -import { Driver, Eval, Field, isComparable, isEvalExpr, Model, Modifier, Query, randomId, Selection, Type, unravel } from 'minato' +import { Dict, isNullable } from 'cosmokit' +import { Driver, Eval, Field, isAggrExpr, isComparable, isEvalExpr, Model, Modifier, Query, randomId, Selection, Type, unravel } from 'minato' export function escapeId(value: string) { return '`' + value + '`' @@ -38,8 +38,8 @@ interface State { encoded?: boolean encodedMap?: Dict - // current eval expr type - type?: Type + // current eval expr + expr?: Eval.Expr group?: boolean tables?: Dict @@ -122,6 +122,7 @@ export class Builder { this.evalOperators = { // universal $: (key) => this.getRecursive(key), + $select: (args) => `${args.map(arg => this.parseEval(arg, false)).join(', ')}`, $if: (args) => `if(${args.map(arg => this.parseEval(arg)).join(', ')})`, $ifNull: (args) => `ifnull(${args.map(arg => this.parseEval(arg)).join(', ')})`, @@ -148,17 +149,17 @@ export class Builder { // logical / bitwise $or: (args) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return this.logicalOr(args.map(arg => this.parseEval(arg))) else return `(${args.map(arg => this.parseEval(arg)).join(' | ')})` }, $and: (args) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return this.logicalAnd(args.map(arg => this.parseEval(arg))) else return `(${args.map(arg => this.parseEval(arg)).join(' & ')})` }, $not: (arg) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return this.logicalNot(this.parseEval(arg)) else return `(~(${this.parseEval(arg)}))` }, @@ -172,8 +173,8 @@ export class Builder { $lte: this.binary('<='), // membership - $in: ([key, value]) => this.asEncoded(this.createMemberQuery(this.parseEval(key), value, ''), false), - $nin: ([key, value]) => this.asEncoded(this.createMemberQuery(this.parseEval(key), value, ' NOT'), false), + $in: ([key, value]) => this.asEncoded(this.createMemberQuery(this.parseEval(key, false), value, ''), false), + $nin: ([key, value]) => this.asEncoded(this.createMemberQuery(this.parseEval(key, false), value, ' NOT'), false), // typecast $literal: ([value, type]) => this.escape(value, type as any), @@ -205,7 +206,12 @@ export class Builder { protected createMemberQuery(key: string, value: any, notStr = '') { if (Array.isArray(value)) { if (!value.length) return notStr ? this.$true : this.$false + if (Array.isArray(value[0])) { + return `(${key})${notStr} in (${value.map((val: any[]) => `(${val.map(x => this.escape(x)).join(', ')})`).join(', ')})` + } return `${key}${notStr} in (${value.map(val => this.escape(val)).join(', ')})` + } else if (value.$exec) { + return `(${key})${notStr} in ${this.parseSelection(value.$exec, true)}` } else { const res = this.jsonContains(this.parseEval(value, false), this.encode(key, true, true)) return notStr ? this.logicalNot(res) : res @@ -256,17 +262,21 @@ export class Builder { return `NOT(${condition})` } - protected parseSelection(sel: Selection) { + protected parseSelection(sel: Selection, inline = false) { const { args: [expr], ref, table, tables } = sel const restore = this.saveState({ tables }) const inner = this.get(table as Selection, true, true) as string const output = this.parseEval(expr, false) + const fields = expr['$select']?.map(x => this.getRecursive(x['$'])) + const where = fields && this.logicalAnd(fields.map(x => `(${x} is not null)`)) restore() - if (!(sel.args[0] as any).$) { - return `(SELECT ${output} AS value FROM ${inner} ${isBracketed(inner) ? ref : ''})` + if (inline || !isAggrExpr(expr as any)) { + return `(SELECT ${output} FROM ${inner} ${isBracketed(inner) ? ref : ''}${where ? ` WHERE ${where}` : ''})` } else { - return `(ifnull((SELECT ${this.groupArray(this.transform(output, Type.getInner(Type.fromTerm(expr)), 'encode'))} - AS value FROM ${inner} ${isBracketed(inner) ? ref : ''}), json_array()))` + return [ + `(ifnull((SELECT ${this.groupArray(this.transform(output, Type.getInner(Type.fromTerm(expr)), 'encode'))}`, + `FROM ${inner} ${isBracketed(inner) ? ref : ''}), json_array()))`, + ].join(' ') } } @@ -328,7 +338,7 @@ export class Builder { } return `json_object(` + Object.entries(fields).map(([key, expr]) => `'${key}', ${parse(expr, key)}`).join(',') + `)` } - return this.asEncoded(_groupObject(unravel(_fields), this.state.type, ''), true) + return this.asEncoded(_groupObject(unravel(_fields), Type.fromTerm(this.state.expr), ''), true) } protected groupArray(value: string) { @@ -384,7 +394,7 @@ export class Builder { this.state.encoded = false for (const key in expr) { if (key in this.evalOperators) { - this.state.type = Type.fromTerm(expr) + this.state.expr = expr return this.evalOperators[key](expr[key]) } } @@ -519,7 +529,7 @@ export class Builder { const encodedMap: Dict = {} const fields = args[0].fields ?? Object.fromEntries(Object .entries(model.fields) - .filter(([, field]) => !field!.deprecated) + .filter(([, field]) => Field.available(field)) .map(([key, field]) => [key, field!.expr ? field!.expr : Eval('', [ref, key], Type.fromField(field!))])) const keys = Object.entries(fields).map(([key, value]) => { value = this.parseEval(value, false) @@ -559,14 +569,7 @@ export class Builder { const converter = (type.inner || type.type === 'json') ? (root ? this.driver.types['json'] : undefined) : this.driver.types[type.type] if (type.inner || type.type === 'json') root = false let res = value - - if (!isNullable(res) && type.inner) { - if (Type.isArray(type)) { - res = res.map(x => this.dump(x, Type.getInner(type as Type), root)) - } else { - res = mapValues(res, (x, k) => this.dump(x, Type.getInner(type as Type, k), root)) - } - } + res = Type.transform(res, type, (value, type) => this.dump(value, type, root)) res = converter?.dump ? converter.dump(res) : res const ancestor = this.driver.database.types[type.type]?.type if (!root && !ancestor) res = this.transform(res, type, 'dump') @@ -596,14 +599,7 @@ export class Builder { let res = this.load(value, ancestor ? Type.fromField(ancestor) : undefined, root) res = this.transform(res, type, 'load') res = converter?.load ? converter.load(res) : res - - if (!isNullable(res) && type.inner) { - if (Type.isArray(type)) { - res = res.map(x => this.load(x, Type.getInner(type as Type), false)) - } else { - res = mapValues(res, (x, k) => this.load(x, Type.getInner(type as Type, k), false)) - } - } + res = Type.transform(res, type, (value, type) => this.load(value, type, false)) return (!isNullable(res) && type.inner && !Type.isArray(type)) ? unravel(res) : res } diff --git a/packages/sqlite/src/builder.ts b/packages/sqlite/src/builder.ts index 9657df5e..0cb5e3de 100644 --- a/packages/sqlite/src/builder.ts +++ b/packages/sqlite/src/builder.ts @@ -27,7 +27,7 @@ export class SQLiteBuilder extends Builder { const binaryXor = (left: string, right: string) => `((${left} & ~${right}) | (~${left} & ${right}))` this.evalOperators.$xor = (args) => { - const type = this.state.type! + const type = Type.fromTerm(this.state.expr, Type.Boolean) if (Field.boolean.includes(type.type)) return args.map(arg => this.parseEval(arg)).reduce((prev, curr) => `(${prev} != ${curr})`) else return args.map(arg => this.parseEval(arg)).reduce((prev, curr) => binaryXor(prev, curr)) } diff --git a/packages/sqlite/src/index.ts b/packages/sqlite/src/index.ts index f4b88321..61bb70ad 100644 --- a/packages/sqlite/src/index.ts +++ b/packages/sqlite/src/index.ts @@ -73,7 +73,7 @@ export class SQLiteDriver extends Driver { // field definitions for (const key in model.fields) { - if (model.fields[key]!.deprecated) { + if (!Field.available(model.fields[key])) { if (dropKeys?.includes(key)) shouldMigrate = true continue } @@ -342,7 +342,7 @@ export class SQLiteDriver extends Driver { async set(sel: Selection.Mutable, update: {}) { const { model, table, query } = sel - const { primary, fields } = model + const { primary } = model, fields = model.avaiableFields() const updateFields = [...new Set(Object.keys(update).map((key) => { return Object.keys(fields).find(field => field === key || key.startsWith(field + '.'))! }))] @@ -387,9 +387,10 @@ export class SQLiteDriver extends Driver { async upsert(sel: Selection.Mutable, data: any[], keys: string[]) { if (!data.length) return {} const { model, table, ref } = sel + const fields = model.avaiableFields() const result = { inserted: 0, matched: 0, modified: 0 } const dataFields = [...new Set(Object.keys(Object.assign({}, ...data)).map((key) => { - return Object.keys(model.fields).find(field => field === key || key.startsWith(field + '.'))! + return Object.keys(fields).find(field => field === key || key.startsWith(field + '.'))! }))] let updateFields = difference(dataFields, keys) if (!updateFields.length) updateFields = [dataFields[0]] diff --git a/packages/tests/src/index.ts b/packages/tests/src/index.ts index af745f15..c5425317 100644 --- a/packages/tests/src/index.ts +++ b/packages/tests/src/index.ts @@ -7,6 +7,7 @@ import Migration from './migration' import Selection from './selection' import Json from './json' import Transaction from './transaction' +import Relation from './relation' import './setup' const Keywords = ['name'] @@ -70,6 +71,7 @@ namespace Tests { export const migration = Migration export const json = Json export const transaction = Transaction + export const relation = Relation } export default createUnit(Tests, true) diff --git a/packages/tests/src/migration.ts b/packages/tests/src/migration.ts index 8a5d2049..3a1ac834 100644 --- a/packages/tests/src/migration.ts +++ b/packages/tests/src/migration.ts @@ -1,4 +1,4 @@ -import { Database, Flatten, Indexable, Keys } from 'minato' +import { Database } from 'minato' import { expect } from 'chai' interface Qux { diff --git a/packages/tests/src/object.ts b/packages/tests/src/object.ts index 417627f6..c50a2e09 100644 --- a/packages/tests/src/object.ts +++ b/packages/tests/src/object.ts @@ -72,9 +72,9 @@ namespace ObjectOperations { table[1].meta = { a: '1', embed: { b: 514, c: 'world' } } table.push({ id: '2', meta: { a: '666', embed: { b: 1919 } } }) table.push({ id: '3', meta: { a: 'foo', embed: { b: 810, c: 'world' } } }) - await expect(database.upsert('object', [ + await expect(database.upsert('object', (row) => [ { id: '0', meta: { embed: { b: 114 } } }, - { id: '1', meta: { a: { $: 'id' }, 'embed.b': { $add: [500, 14] } } }, + { id: '1', meta: { a: row.id, 'embed.b': $.add(500, 14) } }, { id: '2', meta: { embed: { b: 1919 } } }, { id: '3', meta: { a: 'foo', 'embed.b': 810 } }, ])).eventually.fulfilled @@ -127,12 +127,12 @@ namespace ObjectOperations { const table = await setup(database) table[0].meta = { a: '0', embed: { b: 114 } } table[1].meta = { a: '1', embed: { b: 514, c: 'world' } } - await expect(database.set('object', '0', { - meta: { a: { $: 'id' }, embed: { b: 114 } }, - })).eventually.fulfilled - await expect(database.set('object', '1', { - meta: { a: { $: 'id' }, 'embed.b': 514 }, - })).eventually.fulfilled + await expect(database.set('object', '0', (row) => ({ + meta: { a: row.id, embed: { b: 114 } }, + }))).eventually.fulfilled + await expect(database.set('object', '1', (row) => ({ + meta: { a: row.id, 'embed.b': 514 }, + }))).eventually.fulfilled await expect(database.get('object', {})).to.eventually.deep.equal(table) }) @@ -140,12 +140,18 @@ namespace ObjectOperations { const table = await setup(database) table[0].meta = { a: '0', embed: { b: 114 } } table[1].meta = { a: '1', embed: { b: 514, c: 'world' } } - await expect(database.set('object', row => $.eq(row.id, database.select('object', '0').evaluate(r => $.max(r.id))), { - meta: { a: { $: 'id' }, embed: { b: 114 } }, - })).eventually.fulfilled - await expect(database.set('object', row => $.eq(row.id, database.select('object', '1').evaluate(r => $.max(r.id))), { - meta: { a: { $: 'id' }, 'embed.b': 514 }, - })).eventually.fulfilled + await expect(database.set('object', + row => $.eq(row.id, database.select('object', '0').evaluate(r => $.max(r.id))), + row => ({ + meta: { a: row.id, embed: { b: 114 } }, + })), + ).eventually.fulfilled + await expect(database.set('object', + row => $.eq(row.id, database.select('object', '1').evaluate(r => $.max(r.id))), + row => ({ + meta: { a: row.id, 'embed.b': 514 }, + }), + )).eventually.fulfilled await expect(database.get('object', {})).to.eventually.deep.equal(table) }) diff --git a/packages/tests/src/relation.ts b/packages/tests/src/relation.ts new file mode 100644 index 00000000..fc9d949b --- /dev/null +++ b/packages/tests/src/relation.ts @@ -0,0 +1,846 @@ +import { $, Database, Query, Relation } from 'minato' +import { expect } from 'chai' +import { setup } from './utils' + +interface User { + id: number + value?: number + profile?: Profile + posts?: Post[] + successor?: { id: number } + predecessor?: { id: number } +} + +interface Profile { + id: number + name?: string + user?: User +} + +interface Post { + id: number + score?: number + author?: User + content?: string + + tags?: Tag[] + _tags?: Post2Tag[] +} + +interface Tag { + id: number + name: string + posts?: Post[] + _posts?: Post2Tag[] +} + +interface Post2Tag { + postId: number + tagId: number + post?: Post + tag?: Tag +} + +interface Tables { + user: User + profile: Profile + post: Post + tag: Tag + post2tag: Post2Tag +} + +function RelationTests(database: Database) { + database.extend('profile', { + id: 'unsigned', + name: 'string', + }) + + database.extend('user', { + id: 'unsigned', + value: 'integer', + successor: { + type: 'oneToOne', + table: 'user', + target: 'predecessor', + }, + profile: { + type: 'oneToOne', + table: 'profile', + target: 'user', + }, + }, { + autoInc: true, + }) + + database.extend('post', { + id: 'unsigned', + score: 'unsigned', + content: 'string', + author: { + type: 'manyToOne', + table: 'user', + target: 'posts', + }, + }, { + autoInc: true, + }) + + database.extend('tag', { + id: 'unsigned', + name: 'string', + posts: { + type: 'manyToMany', + table: 'post', + target: 'tags', + }, + }, { + autoInc: true, + }) + + database.extend('post2tag', { + id: 'unsigned', + postId: 'unsigned', + tagId: 'unsigned', + post: { + type: 'manyToOne', + table: 'post', + target: '_tags', + fields: 'postId', + }, + tag: { + type: 'manyToOne', + table: 'tag', + target: '_posts', + fields: 'tagId', + }, + }, { + primary: ['postId', 'tagId'], + }) + + async function setupAutoInc(database: Database, name: K, length: number) { + await database.upsert(name, Array(length).fill({})) + await database.remove(name, {}) + } + + before(async () => { + await setupAutoInc(database, 'user', 3) + await setupAutoInc(database, 'post', 3) + await setupAutoInc(database, 'tag', 3) + }) +} + +namespace RelationTests { + const userTable: User[] = [ + { id: 1, value: 0 }, + { id: 2, value: 1, successor: { id: 1 } }, + { id: 3, value: 2 }, + ] + + const profileTable: Profile[] = [ + { id: 1, name: 'Apple' }, + { id: 2, name: 'Banana' }, + { id: 3, name: 'Cat' }, + ] + + const postTable: Post[] = [ + { id: 1, content: 'A1', author: { id: 1 } }, + { id: 2, content: 'B2', author: { id: 1 } }, + { id: 3, content: 'C3', author: { id: 2 } }, + ] + + const tagTable: Tag[] = [ + { id: 1, name: 'X' }, + { id: 2, name: 'Y' }, + { id: 3, name: 'Z' }, + ] + + const post2TagTable: Post2Tag[] = [ + { postId: 1, tagId: 1 }, + { postId: 1, tagId: 2 }, + { postId: 2, tagId: 1 }, + { postId: 2, tagId: 3 }, + { postId: 3, tagId: 3 }, + ] + + export interface RelationOptions { + ignoreNullObject?: boolean + } + + export function select(database: Database, options: RelationOptions = {}) { + const { ignoreNullObject = true } = options + + it('basic support', async () => { + const users = await setup(database, 'user', userTable) + const profiles = await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + + await expect(database.get('profile', {}, ['user'])).to.eventually.have.shape( + profiles.map(profile => ({ + user: users.find(user => user.id === profile.id), + })), + ) + + await expect(database.select('user', {}, { profile: true, posts: true }).execute()).to.eventually.have.shape( + users.map(user => ({ + ...user, + profile: profiles.find(profile => profile.id === user.id), + posts: posts.filter(post => post.author?.id === user.id), + })), + ) + + await expect(database.select('post', {}, { author: true }).execute()).to.eventually.have.shape( + posts.map(post => ({ + ...post, + author: users.find(user => user.id === post.author?.id), + })), + ) + }) + + ignoreNullObject && it('self relation', async () => { + const users = await setup(database, 'user', userTable) + + await expect(database.select('user', {}, { successor: true }).execute()).to.eventually.have.shape( + users.map(user => ({ + ...user, + successor: users.find(successor => successor.id === user.successor?.id), + })), + ) + }) + + it('nested reads', async () => { + const users = await setup(database, 'user', userTable) + const profiles = await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + + await expect(database.select('user', {}, { posts: { author: true } }).execute()).to.eventually.have.shape( + users.map(user => ({ + ...user, + posts: posts.filter(post => post.author?.id === user.id).map(post => ({ + ...post, + author: users.find(user => user.id === post.author?.id), + })), + })), + ) + + await expect(database.select('profile', {}, { user: { posts: { author: true } } }).execute()).to.eventually.have.shape( + profiles.map(profile => ({ + ...profile, + user: { + ...(users.find(user => user.id === profile.id)), + posts: posts.filter(post => post.author?.id === profile.id).map(post => ({ + ...post, + author: users.find(user => user.id === profile.id), + })), + }, + })), + ) + + await expect(database.select('post', {}, { author: { profile: true } }).execute()).to.eventually.have.shape( + posts.map(post => ({ + ...post, + author: { + ...users.find(user => user.id === post.author?.id), + profile: profiles.find(profile => profile.id === post.author?.id), + }, + })), + ) + }) + + it('manyToMany', async () => { + const users = await setup(database, 'user', userTable) + const profiles = await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + const tags = await setup(database, 'tag', tagTable) + const post2tags = await setup(database, 'post2tag', post2TagTable) + const re = await setup(database, Relation.buildAssociationTable('post', 'tag') as any, post2TagTable.map(x => ({ + post_id: x.postId, + tag_id: x.tagId, + }))) + + // explicit manyToMany + await expect(database.select('post', {}, { _tags: { tag: { _posts: { post: true } } } }).execute()).to.eventually.be.fulfilled + + await expect(database.select('post', {}, { tags: { posts: true } }).execute()).to.eventually.have.shape( + posts.map(post => ({ + ...post, + tags: post2tags.filter(p2t => p2t.postId === post.id) + .map(p2t => tags.find(tag => tag.id === p2t.tagId)) + .filter(tag => tag) + .map(tag => ({ + ...tag, + posts: post2tags.filter(p2t => p2t.tagId === tag!.id).map(p2t => posts.find(post => post.id === p2t.postId)), + })), + })), + ) + }) + } + + export function query(database: Database) { + it('oneToOne / manyToOne', async () => { + const users = await setup(database, 'user', userTable) + const profiles = await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + + await expect(database.get('user', { + profile: { + user: { + id: 1, + }, + }, + })).to.eventually.have.shape(users.slice(0, 1).map(user => ({ + ...user, + profile: profiles.find(profile => profile.id === user.id), + }))) + + await expect(database.get('user', row => $.query(row, { + profile: r => $.eq(r.id, row.id), + }))).to.eventually.have.shape(users.map(user => ({ + ...user, + profile: profiles.find(profile => profile.id === user.id), + }))) + + await expect(database.get('user', { + profile: { + user: { + value: 1, + }, + }, + })).to.eventually.have.shape(users.slice(1, 2).map(user => ({ + ...user, + profile: profiles.find(profile => profile.id === user.id), + }))) + + await expect(database.get('post', { + author: { + id: 1, + }, + })).to.eventually.have.shape(posts.map(post => ({ + ...post, + author: users.find(user => post.author?.id === user.id), + })).filter(post => post.author?.id === 1)) + + await expect(database.get('post', { + author: { + id: 1, + value: 1, + }, + })).to.eventually.have.length(0) + }) + + it('oneToMany', async () => { + const users = await setup(database, 'user', userTable) + const profiles = await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + + await expect(database.get('user', { + posts: { + $some: { + author: { + id: 1, + }, + }, + }, + })).to.eventually.have.shape(users.slice(0, 1).map(user => ({ + ...user, + posts: posts.filter(post => post.author?.id === user.id), + }))) + + await expect(database.get('user', { + posts: { + $some: row => $.eq(row.id, 1), + }, + })).to.eventually.have.shape(users.slice(0, 1).map(user => ({ + ...user, + posts: posts.filter(post => post.author?.id === user.id), + }))) + + await expect(database.get('user', { + posts: { + $none: { + author: { + id: 1, + }, + }, + }, + })).to.eventually.have.shape(users.slice(1).map(user => ({ + ...user, + posts: posts.filter(post => post.author?.id === user.id), + }))) + + await expect(database.get('user', { + posts: { + $every: { + author: { + id: 1, + }, + }, + }, + })).to.eventually.have.shape([users[0], users[2]].map(user => ({ + ...user, + posts: posts.filter(post => post.author?.id === user.id), + }))) + }) + + it('manyToMany', async () => { + const users = await setup(database, 'user', userTable) + const profiles = await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + const tags = await setup(database, 'tag', tagTable) + const post2tags = await setup(database, 'post2tag', post2TagTable) + const re = await setup(database, Relation.buildAssociationTable('post', 'tag') as any, post2TagTable.map(x => ({ + post_id: x.postId, + tag_id: x.tagId, + }))) + + await expect(database.get('post', { + tags: { + $some: { + id: 1, + }, + }, + })).to.eventually.have.shape(posts.slice(0, 2).map(post => ({ + ...post, + tags: post2tags.filter(p2t => p2t.postId === post.id) + .map(p2t => tags.find(tag => tag.id === p2t.tagId)) + .filter(tag => tag), + }))) + + await expect(database.get('post', { + tags: { + $none: { + id: 1, + }, + }, + })).to.eventually.have.shape(posts.slice(2).map(post => ({ + ...post, + tags: post2tags.filter(p2t => p2t.postId === post.id) + .map(p2t => tags.find(tag => tag.id === p2t.tagId)) + .filter(tag => tag), + }))) + + await expect(database.get('post', { + tags: { + $every: { + id: 3, + }, + }, + })).to.eventually.have.shape(posts.slice(2, 3).map(post => ({ + ...post, + tags: post2tags.filter(p2t => p2t.postId === post.id) + .map(p2t => tags.find(tag => tag.id === p2t.tagId)) + .filter(tag => tag), + }))) + + await expect(database.get('post', { + tags: { + $some: 1, + $none: [3], + $every: {}, + }, + })).to.eventually.have.shape(posts.slice(0, 1).map(post => ({ + ...post, + tags: post2tags.filter(p2t => p2t.postId === post.id) + .map(p2t => tags.find(tag => tag.id === p2t.tagId)) + .filter(tag => tag), + }))) + }) + + it('nested query', async () => { + const users = await setup(database, 'user', userTable) + const profiles = await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + const tags = await setup(database, 'tag', tagTable) + const post2tags = await setup(database, 'post2tag', post2TagTable) + const re = await setup(database, Relation.buildAssociationTable('post', 'tag') as any, post2TagTable.map(x => ({ + post_id: x.postId, + tag_id: x.tagId, + }))) + + await expect(database.get('user', { + posts: { + $some: { + tags: { + $some: { + id: 1, + }, + }, + }, + }, + })).to.eventually.have.shape([users[0]].map(user => ({ + ...user, + posts: posts.filter(post => post.author?.id === user.id), + }))) + + await expect(database.get('tag', { + posts: { + $some: { + author: { + id: 2, + }, + }, + }, + })).to.eventually.have.shape([tags[2]].map(tag => ({ + ...tag, + posts: post2tags.filter(p2t => p2t.tagId === tag.id) + .map(p2t => posts.find(post => post.id === p2t.postId)) + .filter(post => post), + }))) + }) + + it('omit query', async () => { + const users = await setup(database, 'user', userTable) + const profiles = await setup(database, 'profile', profileTable) + + await expect(database.get('user', { id: 2 }, ['id', 'profile'])).to.eventually.have.shape( + [users[1]].map(user => ({ + id: user.id, + profile: profiles.find(profile => user.id === profile.id), + })), + ) + }) + + it('existence', async () => { + await setup(database, 'user', userTable) + await setup(database, 'profile', profileTable) + await setup(database, 'post', postTable) + + await expect(database.select('user', { successor: null }, null).execute()).to.eventually.have.shape([ + { id: 1 }, + { id: 3 }, + ]) + + await expect(database.select('user', { predecessor: null }, null).execute()).to.eventually.have.shape([ + { id: 2 }, + { id: 3 }, + ]) + + await database.set('user', 1, { profile: null }) + await expect(database.select('user', { profile: null }, null).execute()).to.eventually.have.shape([ + { id: 1 }, + ]) + + await database.set('user', 2, { + posts: { + $disconnect: { + id: 3, + }, + }, + }) + await expect(database.select('post', { author: null }, null).execute()).to.eventually.have.shape([ + { id: 3 }, + ]) + await expect(database.select('user', { + posts: { + $every: { + author: null, + }, + }, + }, null).execute()).to.eventually.have.shape([ + { id: 2 }, + { id: 3 }, + ]) + }) + } + + export function create(database: Database) { + it('basic support', async () => { + await setup(database, 'user', []) + await setup(database, 'profile', []) + await setup(database, 'post', []) + + for (const user of userTable) { + await expect(database.create('user', { + ...user, + profile: { + ...profileTable.find(profile => profile.id === user.id)!, + }, + posts: postTable.filter(post => post.author?.id === user.id), + })).to.eventually.have.shape(user) + } + + await expect(database.select('profile', {}, { user: true }).execute()).to.eventually.have.shape( + profileTable.map(profile => ({ + ...profile, + user: userTable.find(user => user.id === profile.id), + })), + ) + + await expect(database.select('user', {}, { profile: true, posts: true }).execute()).to.eventually.have.shape( + userTable.map(user => ({ + ...user, + profile: profileTable.find(profile => profile.id === user.id), + posts: postTable.filter(post => post.author?.id === user.id), + })), + ) + }) + + it('oneToMany', async () => { + await setup(database, 'user', []) + await setup(database, 'profile', []) + await setup(database, 'post', []) + + for (const user of userTable) { + await database.create('user', { + ...userTable.find(u => u.id === user.id)!, + posts: postTable.filter(post => post.author?.id === user.id), + }) + } + + await expect(database.select('user', {}, { posts: true }).execute()).to.eventually.have.shape( + userTable.map(user => ({ + ...user, + posts: postTable.filter(post => post.author?.id === user.id), + })), + ) + }) + } + + export function modify(database: Database, options: RelationOptions = {}) { + const { ignoreNullObject = true } = options + + it('oneToOne / manyToOne', async () => { + const users = await setup(database, 'user', userTable) + const profiles = await setup(database, 'profile', profileTable) + await setup(database, 'post', postTable) + + profiles.splice(2, 1) + await database.set('user', 3, { + profile: null, + }) + await expect(database.get('profile', {})).to.eventually.have.deep.members(profiles) + + profiles.push(database.tables['profile'].create({ id: 3, name: 'Reborn' })) + await database.set('user', 3, { + profile: { + name: 'Reborn', + }, + }) + await expect(database.get('profile', {})).to.eventually.have.deep.members(profiles) + + users[0].value = 99 + await database.set('post', 1, { + author: { + value: 99, + }, + }) + await expect(database.get('user', {})).to.eventually.have.deep.members(users) + + profiles.splice(2, 1) + await database.set('user', 3, { + profile: null, + }) + await expect(database.get('profile', {})).to.eventually.have.deep.members(profiles) + }) + + it('upsert', async () => { + await setup(database, 'user', []) + await setup(database, 'profile', []) + await setup(database, 'post', []) + + for (const user of userTable) { + await database.upsert('user', [{ + ...userTable.find(u => u.id === user.id)!, + profile: profileTable.find(profile => profile.id === user.id), + }] as any) + } + + await expect(database.select('user', {}, { profile: true }).execute()).to.eventually.have.shape( + userTable.map(user => ({ + ...user, + profile: profileTable.find(profile => profile.id === user.id), + })), + ) + }) + + it('create oneToMany', async () => { + await setup(database, 'user', userTable) + await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + + posts.push(database.tables['post'].create({ id: posts.length + 1, author: { id: 2 }, content: 'post1' })) + posts.push(database.tables['post'].create({ id: posts.length + 1, author: { id: 2 }, content: 'post2' })) + + await database.set('user', 2, { + posts: { + $create: [ + { content: 'post1' }, + { content: 'post2' }, + ], + }, + }) + await expect(database.get('post', {})).to.eventually.have.deep.members(posts) + + posts.push(database.tables['post'].create({ id: 101, author: { id: 1 }, content: 'post101' })) + await database.set('user', 1, row => ({ + value: $.add(row.id, 98), + posts: { + $create: { id: 101, content: 'post101' }, + }, + })) + await expect(database.get('post', {})).to.eventually.have.deep.members(posts) + }) + + it('set oneToMany', async () => { + await setup(database, 'user', userTable) + await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + + posts[0].score = 2 + posts[1].score = 3 + await database.set('user', 1, row => ({ + posts: { + $set: r => ({ + score: $.add(row.id, r.id), + }), + }, + })) + await expect(database.get('post', {})).to.eventually.have.deep.members(posts) + + posts[1].score = 13 + await database.set('user', 1, row => ({ + posts: { + $set: { + where: { score: { $gt: 2 } }, + update: r => ({ score: $.add(r.score, 10) }), + }, + }, + })) + await expect(database.get('post', {})).to.eventually.have.deep.members(posts) + }) + + it('delete oneToMany', async () => { + await setup(database, 'user', userTable) + await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + + posts.splice(0, 1) + await database.set('user', {}, row => ({ + posts: { + $remove: r => $.eq(r.id, row.id), + }, + })) + await expect(database.get('post', {})).to.eventually.have.deep.members(posts) + }) + + it('override oneToMany', async () => { + await setup(database, 'user', userTable) + await setup(database, 'profile', profileTable) + const posts = await setup(database, 'post', postTable) + + posts[0].score = 2 + posts[1].score = 3 + await database.set('user', 1, row => ({ + posts: posts.slice(0, 2), + })) + await expect(database.get('post', {})).to.eventually.have.deep.members(posts) + + posts[0].score = 4 + posts[1].score = 5 + await database.upsert('user', [{ + id: 1, + posts: posts.slice(0, 2), + }]) + await expect(database.get('post', {})).to.eventually.have.deep.members(posts) + }) + + ignoreNullObject && it('connect / disconnect oneToMany', async () => { + await setup(database, 'user', userTable) + await setup(database, 'profile', profileTable) + await setup(database, 'post', postTable) + + await database.set('user', 1, { + posts: { + $disconnect: {}, + $connect: { id: 3 }, + }, + }) + await expect(database.get('user', 1, ['posts'])).to.eventually.have.shape([{ + posts: [ + { id: 3 }, + ], + }]) + }) + + it('connect / disconnect manyToMany', async () => { + await setup(database, 'user', userTable) + await setup(database, 'profile', profileTable) + await setup(database, 'post', postTable) + await setup(database, 'tag', tagTable) + + await setup(database, Relation.buildAssociationTable('post', 'tag') as any, post2TagTable.map(x => ({ + post_id: x.postId, + tag_id: x.tagId, + }))) + + await database.set('post', 2, { + tags: { + $disconnect: {}, + }, + }) + await expect(database.get('post', 2, ['tags'])).to.eventually.have.nested.property('[0].tags').deep.equal([]) + + await database.set('post', 2, row => ({ + tags: { + $connect: r => $.eq(r.id, row.id), + }, + })) + await expect(database.get('post', 2, ['tags'])).to.eventually.have.nested.property('[0].tags').with.shape([{ + id: 2, + }]) + }) + + it('query relation', async () => { + await setup(database, 'user', userTable) + const posts = await setup(database, 'post', postTable) + await setup(database, 'tag', tagTable) + await setup(database, Relation.buildAssociationTable('post', 'tag') as any, post2TagTable.map(x => ({ + post_id: x.postId, + tag_id: x.tagId, + }))) + + posts.filter(post => post2TagTable.some(p2t => p2t.postId === post.id && p2t.tagId === 1)).forEach(post => post.score! += 10) + await database.set('post', { + tags: { + $some: { + id: 1, + }, + }, + }, row => ({ + score: $.add(row.score, 10), + })) + await expect(database.get('post', {})).to.eventually.have.deep.members(posts) + }) + } + + export function misc(database: Database) { + it('unsupported', async () => { + await setup(database, 'user', userTable) + await setup(database, 'post', postTable) + await setup(database, 'tag', tagTable) + + await expect(database.set('post', 1, { + tags: [], + })).to.eventually.be.rejected + + await expect(database.set('post', 1, { + tags: { + $remove: {}, + }, + })).to.eventually.be.rejected + + await expect(database.set('post', 1, { + tags: { + $set: {}, + }, + })).to.eventually.be.rejected + + await expect(database.set('post', 1, { + tags: { + $create: {}, + }, + })).to.eventually.be.rejected + }) + } +} + +export default RelationTests diff --git a/packages/tests/src/selection.ts b/packages/tests/src/selection.ts index ea418a10..8f058281 100644 --- a/packages/tests/src/selection.ts +++ b/packages/tests/src/selection.ts @@ -285,6 +285,89 @@ namespace SelectionTests { .join(['foo', 'bar'], (foo, bar) => $.eq(foo.value, bar.value)) .execute() ).to.eventually.have.length(2) + + await expect(database.select('foo') + .join('bar', database.select('bar'), (foo, bar) => $.eq(foo.value, bar.value)) + .execute() + ).to.eventually.have.length(2) + }) + + it('left join', async () => { + await expect(database + .join(['foo', 'bar'], (foo, bar) => $.eq(foo.value, bar.value), [false, true]) + .execute() + ).to.eventually.have.shape([ + { + foo: { value: 0, id: 1 }, + bar: { uid: 1, pid: 1, value: 0, id: 1 }, + }, + { + foo: { value: 0, id: 1 }, + bar: { uid: 1, pid: 2, value: 0, id: 3 }, + }, + { foo: { value: 2, id: 2 }, bar: {} }, + { foo: { value: 2, id: 3 }, bar: {} }, + ]) + + await expect(database + .join(['foo', 'bar'], (foo, bar) => $.eq(foo.value, bar.value), [true, false]) + .execute() + ).to.eventually.have.shape([ + { + bar: { uid: 1, pid: 1, value: 0, id: 1 }, + foo: { value: 0, id: 1 }, + }, + { bar: { uid: 1, pid: 1, value: 1, id: 2 }, foo: {} }, + { + bar: { uid: 1, pid: 2, value: 0, id: 3 }, + foo: { value: 0, id: 1 }, + }, + { bar: { uid: 1, pid: 3, value: 1, id: 4 }, foo: {} }, + { bar: { uid: 2, pid: 1, value: 1, id: 5 }, foo: {} }, + { bar: { uid: 2, pid: 1, value: 1, id: 6 }, foo: {} }, + ]) + + await expect(database.select('foo') + .join('bar', database.select('bar'), (foo, bar) => $.eq(foo.value, bar.value), true) + .execute() + ).to.eventually.have.shape([ + { + value: 0, id: 1, + bar: { uid: 1, pid: 1, value: 0, id: 1 }, + }, + { + value: 0, id: 1, + bar: { uid: 1, pid: 2, value: 0, id: 3 }, + }, + { value: 2, id: 2 }, + { value: 2, id: 3 }, + ]) + + await expect(database.select('bar') + .join('foo', database.select('foo'), (bar, foo) => $.eq(foo.value, bar.value), true) + .execute() + ).to.eventually.have.shape([ + { + uid: 1, pid: 1, value: 0, id: 1, + foo: { value: 0, id: 1 }, + }, + { uid: 1, pid: 1, value: 1, id: 2 }, + { + uid: 1, pid: 2, value: 0, id: 3, + foo: { value: 0, id: 1 }, + }, + { uid: 1, pid: 3, value: 1, id: 4 }, + { uid: 2, pid: 1, value: 1, id: 5 }, + { uid: 2, pid: 1, value: 1, id: 6 }, + ]) + }) + + it('duplicate', async () => { + await expect(database.select('foo') + .project(['value']) + .join('bar', database.select('bar'), (foo, bar) => $.eq(foo.value, bar.uid)) + .execute() + ).to.eventually.have.length(4) }) it('left join', async () => { @@ -362,6 +445,12 @@ namespace SelectionTests { }, ({ t1, t2, t3 }) => $.gt($.add(t1.id, t2.id, t3.id), 14)) .execute() ).to.eventually.have.length(4) + + await expect(database.select('bar').where(row => $.gt(row.pid, 1)) + .join('t2', database.select('bar').where(row => $.gt(row.uid, 1))) + .join('t3', database.select('bar').where(row => $.gt(row.id, 4)), (self, t3) => $.gt($.add(self.id, self.t2.id, t3.id), 14)) + .execute() + ).to.eventually.have.length(4) }) it('aggregate', async () => { @@ -408,6 +497,20 @@ namespace SelectionTests { { id: 2, value: 2 }, { id: 3, value: 2 }, ]) + + await expect(database.get('foo', row => $.in( + [row.id, row.id], database.select('foo').project({ x: row => $.add(row.id, 1) }).evaluate(['x', 'x']) + ))).to.eventually.deep.equal([ + { id: 2, value: 2 }, + { id: 3, value: 2 }, + ]) + + await expect(database.get('foo', row => $.in( + [row.id, row.id], [[2, 2], [3, 3]] + ))).to.eventually.deep.equal([ + { id: 2, value: 2 }, + { id: 3, value: 2 }, + ]) }) it('from', async () => { diff --git a/packages/tests/src/shape.ts b/packages/tests/src/shape.ts index 1a0651b9..20e27458 100644 --- a/packages/tests/src/shape.ts +++ b/packages/tests/src/shape.ts @@ -82,7 +82,7 @@ export default (({ Assertion }) => { } for (const prop in expect) { - if (typeof actual[prop] === 'undefined' && typeof expect[prop] !== 'undefined') { + if (isNullable(actual[prop]) && !isNullable(expect[prop])) { return `expected "${prop}" field to be defined at path ${path}` } const message = checkShape(expect[prop], actual[prop], `${path}${prop}/`, ordered)