diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index b06d72b9..5d1e7d79 100644 --- a/packages/core/src/database.ts +++ b/packages/core/src/database.ts @@ -1,10 +1,10 @@ -import { defineProperty, Dict, filterKeys, makeArray, mapValues, MaybeArray, noop, omit } from 'cosmokit' +import { deduplicate, defineProperty, Dict, filterKeys, makeArray, mapValues, MaybeArray, noop, omit, pick } from 'cosmokit' import { Context, Service, Spread } from 'cordis' -import { DeepPartial, FlatKeys, FlatPick, getCell, Indexable, Keys, randomId, Row, unravel, Values } from './utils.ts' +import { AtomicTypes, DeepPartial, FlatKeys, FlatPick, Flatten, getCell, Indexable, Keys, randomId, Row, unravel, Values } from './utils.ts' import { Selection } from './selection.ts' import { Field, Model, Relation } from './model.ts' import { Driver } from './driver.ts' -import { Eval, Update } from './eval.ts' +import { Eval, isUpdateExpr, Update } from './eval.ts' import { Query } from './query.ts' import { Type } from './type.ts' @@ -44,6 +44,26 @@ export namespace Join2 { export type Predicate> = (args: Parameters) => Eval.Expr } +type CreateMap = { [K in keyof T]?: Create } + +export type Create = + | T extends Values ? T + : T extends (infer I extends Values)[] ? CreateMap[] | + { + $create?: MaybeArray> + $upsert?: MaybeArray> + $connect?: Query.Expr> + } + : T extends Values ? CreateMap | + { + $create?: CreateMap + $upsert?: CreateMap + $connect?: Query.Expr> + } + : T extends (infer U)[] ? DeepPartial[] + : T extends object ? CreateMap + : T + export class Database extends Service { static [Service.provide] = 'model' static [Service.immediate] = true @@ -97,7 +117,7 @@ export class Database extends Servi await driver.prepare(name) } - extend, T extends Field.Extension>(name: K, fields: T, config: Partial>> = {}) { + extend>(name: K, fields: Field.Extension, config: Partial>> = {}) { let model = this.tables[name] if (!model) { model = this.tables[name] = new Model(name) @@ -113,7 +133,8 @@ export class Database extends Servi } 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]) + const subprimary = !def.fields && makeArray(model.primary).includes(key) + const [relation, inverse] = Relation.parse(def, key, model, this.tables[def.table ?? key], subprimary) 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) { @@ -131,27 +152,32 @@ export class Database extends Servi } 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 shared = Object.entries(relation.shared).map(([x, y]) => [Relation.buildSharedKey(x, y), model.fields[x]!.deftype] as const) + 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]), + ...Object.fromEntries([...shared, ...fields, ...references]), [name]: { type: 'manyToOne', table: name, - fields: fields.map(x => x[0]), - references: relation.references, + fields: [...shared, ...fields].map(x => x[0]), + references: [...Object.keys(relation.shared), ...relation.fields], }, [relation.table]: { type: 'manyToOne', table: relation.table, - fields: references.map(x => x[0]), - references: relation.fields, + fields: [...shared, ...references].map(x => x[0]), + references: [...Object.values(relation.shared), ...relation.references], }, } as any, { - primary: [...fields.map(x => x[0]), ...references.map(x => x[0])], + primary: [...shared, ...fields, ...references].map(x => x[0]) as any, }) } }) + // use relation field as primary + if (Array.isArray(model.primary) && model.primary.every(key => model.fields[key]?.relation)) { + model.primary = deduplicate(model.primary.map(key => model.fields[key]!.relation!.fields).flat()) + } this.prepareTasks[name] = this.prepare(name) ;(this.ctx as Context).emit('model', name) } @@ -338,7 +364,12 @@ export class Database extends Servi } else if (relation.type === 'manyToMany') { const assocTable: any = Relation.buildAssociationTable(relation.table, table) const references = relation.fields.map(x => Relation.buildAssociationKey(x, table)) + const shared = Object.entries(relation.shared).map(([x, y]) => [Relation.buildSharedKey(x, y), { + field: x, + reference: y, + }] as const) sel = whereOnly ? sel : sel.join(key, this.select(assocTable, {}, { [relation.table]: cursor[key] } as any), (self, other) => Eval.and( + ...shared.map(([k, v]) => Eval.eq(self[v.field], other[k])), ...relation.fields.map((k, i) => Eval.eq(self[k], other[references[i]])), ), true) sel = applyQuery(sel, key) @@ -398,12 +429,13 @@ export class Database extends Servi async get, P extends FlatKeys = any>( table: K, query: Query, - cursor?: Driver.Cursor

, + cursor?: Driver.Cursor, P>, ): Promise[]> async get>(table: K, query: Query, cursor?: 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 + let fields = Array.isArray(cursor) ? cursor : cursor?.fields + fields = fields ? Object.fromEntries(fields.map(x => [x, true])) : cursor?.include + return this.select(table, query, fields).execute(cursor) as any } async eval, T>(table: K, expr: Selection.Callback, query?: Query): Promise { @@ -416,7 +448,7 @@ export class Database extends Servi update: Row.Computed>, ): Promise { const rawupdate = typeof update === 'function' ? update : () => update - const sel = this.select(table, query, null) + let 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)) { @@ -429,28 +461,11 @@ export class Database extends Servi 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) + sel = database.select(table, query, null) + let baseUpdate = omit(rawupdate(sel.row), 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]))) - } + for (const [key] of relations) { + await Promise.all(rows.map(row => database.processRelationUpdate(table, row, key, rawupdate(row as any)[key]))) } return Object.keys(baseUpdate).length === 0 ? {} : await sel._action('set', baseUpdate).execute() }) @@ -466,63 +481,32 @@ export class Database extends Servi return sel._action('remove').execute() } - async create>(table: K, data: DeepPartial): Promise + async create>(table: K, data: Create): Promise async create>(table: K, data: any): Promise { const sel = this.select(table) - const { primary, autoInc, fields } = sel.model - if (!autoInc) { - const keys = makeArray(primary) - if (keys.some(key => getCell(data, key) === undefined)) { - throw new Error('missing primary key') - } - } - - const tasks: any[] = [] + let hasRelation = false for (const key in data) { - if (data[key] && this.tables[table].fields[key]?.relation) { + if (data[key] !== undefined && 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 (relation.type === 'oneToOne' && !relation.required && !isUpdateExpr(data[key])) continue + if (relation.type === 'manyToOne' && !isUpdateExpr(data[key])) continue + hasRelation = true + break } } - if (tasks.length) { - return this.ensureTransaction(async (database) => { - for (const [table, data, keys] of tasks) { - await database.upsert(table, data, keys) + if (!hasRelation) { + const { primary, autoInc } = sel.model + if (!autoInc) { + const keys = makeArray(primary) + if (keys.some(key => getCell(data, key) === undefined)) { + throw new Error('missing primary key') } - return database.create(table, data) - }) + } + return sel._action('create', sel.model.create(data)).execute() + } else { + return this.ensureTransaction(database => database.createOrUpdate(table, data, false)) } - return sel._action('create', sel.model.create(data)).execute() } async upsert>( @@ -532,64 +516,6 @@ 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 sel._action('upsert', upsert, keys).execute() @@ -743,28 +669,280 @@ export class Database extends Servi 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 } + private async createOrUpdate>(table: K, data: any, upsert: boolean = true): Promise { + const sel = this.select(table) + data = { ...data } + const tasks = [''] + for (const key in data) { + if (data[key] !== undefined && this.tables[table].fields[key]?.relation) { + const relation = this.tables[table].fields[key].relation + if (relation.type === 'oneToOne' && relation.required) tasks.push(key) + else if (relation.type === 'oneToOne' && isUpdateExpr(data[key])) tasks.unshift(key) + else if (relation.type === 'oneToMany') tasks.push(key) + else if (relation.type === 'manyToOne' && isUpdateExpr(data[key])) tasks.unshift(key) + else if (relation.type === 'manyToMany') tasks.push(key) + } + } + + for (const key of tasks) { + if (!key) { + // create the plain data, with or without upsert + const { primary, autoInc } = sel.model + const keys = makeArray(primary) + if (keys.some(key => getCell(data, key) === undefined)) { + if (!autoInc) { + throw new Error('missing primary key') + } else { + upsert = false + } + } + if (upsert) { + await sel._action('upsert', [sel.model.format(omit(data, tasks))], keys).execute() + } else { + Object.assign(data, await sel._action('create', sel.model.create(omit(data, tasks))).execute()) + } + continue + } + const value: Relation.Modifier = data[key] + const relation: Relation.Config = this.tables[table].fields[key]!.relation! as any + if (relation.type === 'oneToOne') { + if (value.$create || value.$upsert || !isUpdateExpr(value)) { + const result = await this.createOrUpdate(relation.table, { + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(data, relation.fields[i])])), + ...value.$create ?? value.$upsert ?? value, + } as any) + if (!relation.required) { + relation.references.forEach((k, i) => data[relation.fields[i]] = getCell(result, k)) + } + } else if (value.$connect) { + if (relation.required) { + await this.set(relation.table, + value.$connect, + Object.fromEntries(relation.references.map((k, i) => [k, getCell(data, relation.fields[i])])) as any, + ) + } else { + const result = relation.references.every(k => value.$connect![k as any] !== undefined) ? [value.$connect] + : await this.get(relation.table, value.$connect as any) + if (result.length !== 1) throw new Error('related row not found or not unique') + relation.references.forEach((k, i) => data[relation.fields[i]] = getCell(result[0], k)) + } + } + } else if (relation.type === 'manyToOne') { + if (value.$create || !isUpdateExpr(value)) { + const result = await this.createOrUpdate(relation.table, value.$create ?? value) + relation.references.forEach((k, i) => data[relation.fields[i]] = getCell(result, k)) + } else if (value.$upsert) { + await this.upsert(relation.table, [value.$upsert]) + relation.references.forEach((k, i) => data[relation.fields[i]] = getCell(value.$upsert, k)) + } else if (value.$connect) { + const result = relation.references.every(k => value.$connect![k as any] !== undefined) ? [value.$connect] + : await this.get(relation.table, value.$connect as any) + if (result.length !== 1) throw new Error('related row not found or not unique') + relation.references.forEach((k, i) => data[relation.fields[i]] = getCell(result[0], k)) + } + } else if (relation.type === 'oneToMany') { + if (value.$create || Array.isArray(value)) { + for (const item of makeArray(value.$create ?? value)) { + await this.createOrUpdate(relation.table, { + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(data, relation.fields[i])])), + ...item, + }) + } + } + if (value.$upsert) { + await this.upsert(relation.table, makeArray(value.$upsert).map(r => ({ + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(data, relation.fields[i])])), + ...r, + }))) + } + if (value.$connect) { + await this.set(relation.table, + value.$connect, + Object.fromEntries(relation.references.map((k, i) => [k, getCell(data, relation.fields[i])])) as any, + ) + } } else if (relation.type === 'manyToMany') { - throw new Error('override for manyToMany relation is not supported') + const assocTable = Relation.buildAssociationTable(relation.table, table) + const fields = relation.fields.map(x => Relation.buildAssociationKey(x, table)) + const references = relation.references.map(x => Relation.buildAssociationKey(x, relation.table)) + const shared = Object.entries(relation.shared).map(([x, y]) => [Relation.buildSharedKey(x, y), { + field: x, + reference: y, + }] as const) + const result: any[] = [] + if (value.$create || Array.isArray(value)) { + for (const item of makeArray(value.$create ?? value)) { + result.push(await this.createOrUpdate(relation.table, { + ...Object.fromEntries(shared.map(([, v]) => [v.reference, getCell(item, v.reference) ?? getCell(data, v.field)])), + ...item, + })) + } + } + if (value.$upsert) { + const upsert = makeArray(value.$upsert).map(r => ({ + ...Object.fromEntries(shared.map(([, v]) => [v.reference, getCell(r, v.reference) ?? getCell(data, v.field)])), + ...r, + })) + await this.upsert(relation.table, upsert) + result.push(...upsert) + } + if (value.$connect) { + for (const item of makeArray(value.$connect)) { + if (references.every(k => item[k] !== undefined)) result.push(item) + else result.push(...await this.get(relation.table, item)) + } + } + await this.upsert(assocTable as any, result.map(r => ({ + ...Object.fromEntries(shared.map(([k, v]) => [k, getCell(r, v.reference) ?? getCell(data, v.field)])), + ...Object.fromEntries(fields.map((k, i) => [k, getCell(data, relation.fields[i])])), + ...Object.fromEntries(references.map((k, i) => [k, getCell(r, relation.references[i])])), + } as any))) } } - if (modifier.$remove) { - if (relation.type === 'oneToMany') { + return data + } + + private async processRelationUpdate(table: any, row: any, key: any, value: Relation.Modifier) { + const model = this.tables[table] + const relation: Relation.Config = this.tables[table].fields[key]!.relation! as any + if (relation.type === 'oneToOne') { + if (value === null) { + value = relation.required ? { $remove: {} } : { $disconnect: {} } + } + if (typeof value === 'object' && !isUpdateExpr(value)) { + value = { $upsert: value } + } + if (value.$remove) { + await this.remove(relation.table, Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])) as any) + } + if (value.$disconnect) { + if (relation.required) { + await this.set(relation.table, + (r: any) => Eval.query(r, { + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...(typeof value.$disconnect === 'function' ? { $expr: value.$disconnect } : value.$disconnect), + } as any), + Object.fromEntries(relation.references.map((k, i) => [k, null])) as any, + ) + } else { + await this.set( + table, + pick(model.format(row), makeArray(model.primary)), + Object.fromEntries(relation.fields.map((k, i) => [k, null])) as any, + ) + } + } + if (value.$set || typeof value === 'function') { + await this.set( + relation.table, + Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])) as any, + value.$set ?? value as any, + ) + } + if (value.$create) { + const result = await this.createOrUpdate(relation.table, { + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...value.$create, + }) + if (!relation.required) { + await this.set( + table, + pick(model.format(row), makeArray(model.primary)), + Object.fromEntries(relation.fields.map((k, i) => [k, getCell(result, relation.references[i])])) as any, + ) + } + } + if (value.$upsert) { + await this.upsert(relation.table, makeArray(value.$upsert).map(r => ({ + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...r, + }))) + } + if (value.$connect) { + if (relation.required) { + await this.set(relation.table, + value.$connect, + Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])) as any, + ) + } else { + const result = await this.get(relation.table, value.$connect as any) + if (result.length !== 1) throw new Error('related row not found or not unique') + await this.set( + table, + pick(model.format(row), makeArray(model.primary)), + Object.fromEntries(relation.fields.map((k, i) => [k, getCell(result[0], relation.references[i])])) as any, + ) + } + } + } else if (relation.type === 'manyToOne') { + if (value === null) { + value = { $disconnect: {} } + } + if (typeof value === 'object' && !isUpdateExpr(value)) { + value = { $upsert: value } as any + } + if (value.$remove) { + await this.remove(relation.table, Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])) as any) + } + if (value.$disconnect) { + await this.set( + table, + pick(model.format(row), makeArray(model.primary)), + Object.fromEntries(relation.fields.map((k, i) => [k, null])) as any, + ) + } + if (value.$set || typeof value === 'function') { + await this.set( + relation.table, + Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])) as any, + value.$set ?? value as any, + ) + } + if (value.$create) { + const result = await this.createOrUpdate(relation.table, value.$create) + await this.set( + table, + pick(model.format(row), makeArray(model.primary)), + Object.fromEntries(relation.fields.map((k, i) => [k, getCell(result, relation.references[i])])) as any, + ) + } + if (value.$upsert) { + await this.upsert(relation.table, makeArray(value.$upsert).map(r => ({ + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...r, + }))) + } + if (value.$connect) { + const result = await this.get(relation.table, value.$connect) + if (result.length !== 1) throw new Error('related row not found or not unique') + await this.set( + table, + pick(model.format(row), makeArray(model.primary)), + Object.fromEntries(relation.fields.map((k, i) => [k, getCell(result[0], relation.references[i])])) as any, + ) + } + } else if (relation.type === 'oneToMany') { + if (Array.isArray(value)) { + // default to upsert, this will block nested relation update + value = { $remove: {}, $upsert: value } + } + if (value.$remove) { 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), + ...(typeof value.$remove === 'function' ? { $expr: value.$remove(r) } : value.$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[]) { + if (value.$disconnect) { + await this.set(relation.table, + (r: any) => Eval.query(r, { + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...(typeof value.$disconnect === 'function' ? { $expr: value.$disconnect } : value.$disconnect), + } as any), + Object.fromEntries(relation.references.map((k, i) => [k, null])) as any, + ) + } + if (value.$set || typeof value === 'function') { + for (const setexpr of makeArray(value.$set ?? value) as any[]) { const [query, update] = setexpr.update ? [setexpr.where, setexpr.update] : [{}, setexpr] await this.set(relation.table, (r: any) => Eval.query(r, { @@ -774,59 +952,114 @@ export class Database extends Servi 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 (value.$create) { + for (const item of makeArray(value.$create)) { + await this.createOrUpdate(relation.table, { + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...item, + }) + } } - } - if (modifier.$disconnect) { - if (relation.type === 'oneToMany') { + if (value.$upsert) { + await this.upsert(relation.table, makeArray(value.$upsert).map(r => ({ + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...r, + }))) + } + if (value.$connect) { 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, + value.$connect, + Object.fromEntries(relation.references.map((k, i) => [k, row[relation.fields[i]]])) 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)) + } + } 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 shared = Object.entries(relation.shared).map(([x, y]) => [Relation.buildSharedKey(x, y), { + field: x, + reference: y, + }] as const) + if (Array.isArray(value)) { + // default to upsert, this will block nested relation update + value = { $disconnect: {}, $upsert: value } + } + if (value.$remove) { const rows = await this.select(assocTable, { - ...Object.fromEntries(fields.map((k, i) => [k, row[relation.fields[i]]])) as any, - [relation.table]: modifier.$disconnect, + ...Object.fromEntries(shared.map(([k, v]) => [k, getCell(row, v.field)])), + ...Object.fromEntries(fields.map((k, i) => [k, getCell(row, relation.fields[i])])) as any, + [relation.table]: value.$remove, }, 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])]), + [...shared.map(([k, v]) => r[k]), ...fields.map(x => r[x]), ...references.map(x => r[x])], + rows.map(r => [...shared.map(([k, v]) => getCell(r, k)), ...fields.map(x => getCell(r, x)), ...references.map(x => getCell(r, x))]), + )) + await this.remove(relation.table, (r) => Eval.in( + [...shared.map(([k, v]) => r[v.reference]), ...relation.references.map(x => r[x])], + rows.map(r => [...shared.map(([k, v]) => getCell(r, k)), ...references.map(x => getCell(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) + if (value.$disconnect) { + const rows = await this.select(assocTable, { + ...Object.fromEntries(shared.map(([k, v]) => [k, getCell(row, v.field)])), + ...Object.fromEntries(fields.map((k, i) => [k, getCell(row, relation.fields[i])])) as any, + [relation.table]: value.$disconnect, + }, null).execute() + await this.remove(assocTable, r => Eval.in( + [...shared.map(([k, v]) => r[k]), ...fields.map(x => r[x]), ...references.map(x => r[x])], + rows.map(r => [...shared.map(([k, v]) => getCell(r, k)), ...fields.map(x => getCell(r, x)), ...references.map(x => getCell(r, x))]), + )) + } + if (value.$set) { + for (const setexpr of makeArray(value.$set) as any[]) { + const [query, update] = setexpr.update ? [setexpr.where, setexpr.update] : [{}, setexpr] + const rows = await this.select(assocTable, { + ...Object.fromEntries(shared.map(([k, v]) => [k, getCell(row, v.field)])), + ...Object.fromEntries(fields.map((k, i) => [k, getCell(row, relation.fields[i])])) as any, + [relation.table]: typeof query === 'function' ? { $expr: query } : query, + }, null).execute() + await this.set(relation.table, + (r) => Eval.in( + [...shared.map(([k, v]) => r[v.reference]), ...relation.references.map(x => r[x])], + rows.map(r => [...shared.map(([k, v]) => getCell(r, k)), ...references.map(x => getCell(r, x))]), + ), + update, + ) + } + } + if (value.$create) { + const result: any[] = [] + for (const item of makeArray(value.$create)) { + result.push(await this.createOrUpdate(relation.table, { + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...item, + })) + } + await this.upsert(assocTable, result.map(r => ({ + ...Object.fromEntries(shared.map(([k, v]) => [k, getCell(row, v.field)])), + ...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) + } + if (value.$upsert) { + await this.upsert(relation.table, makeArray(value.$upsert).map(r => ({ + ...Object.fromEntries(relation.references.map((k, i) => [k, getCell(row, relation.fields[i])])), + ...r, + }))) + await this.upsert(assocTable, makeArray(value.$upsert).map(r => ({ + ...Object.fromEntries(shared.map(([k, v]) => [k, getCell(row, v.field)])), + ...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) + } + if (value.$connect) { + const rows = await this.get(relation.table, (r: any) => Eval.query(r, { + ...Object.fromEntries(shared.map(([k, v]) => [v.reference, getCell(row, v.field)])), + ...(typeof value.$connect === 'function' ? { $expr: value.$connect(r) } : value.$connect), + }) as any) await this.upsert(assocTable, rows.map(r => ({ + ...Object.fromEntries(shared.map(([k, v]) => [k, getCell(row, v.field)])), ...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 b98163e2..0b56746c 100644 --- a/packages/core/src/driver.ts +++ b/packages/core/src/driver.ts @@ -2,9 +2,10 @@ import { Awaitable, Dict, mapValues, remove } from 'cosmokit' import { Context, Logger } from 'cordis' import { Eval, Update } from './eval.ts' import { Direction, Modifier, Selection } from './selection.ts' -import { Field, Model } from './model.ts' +import { Field, Model, Relation } from './model.ts' import { Database } from './database.ts' import { Type } from './type.ts' +import { FlatKeys } from './utils.ts' export namespace Driver { export interface Stats { @@ -17,13 +18,14 @@ export namespace Driver { size: number } - export type Cursor = K[] | CursorOptions + export type Cursor = any> = K[] | CursorOptions - export interface CursorOptions { + export interface CursorOptions = any> { limit?: number offset?: number fields?: K[] sort?: Dict + include?: Relation.Include } export interface WriteResult { diff --git a/packages/core/src/eval.ts b/packages/core/src/eval.ts index a2c8abed..c753fe5d 100644 --- a/packages/core/src/eval.ts +++ b/packages/core/src/eval.ts @@ -8,6 +8,8 @@ export function isEvalExpr(value: any): value is Eval.Expr { return value && Object.keys(value).some(key => key.startsWith('$')) } +export const isUpdateExpr: (value: any) => boolean = isEvalExpr + export function isAggrExpr(expr: Eval.Expr): boolean { return expr['$'] || expr['$select'] } @@ -31,7 +33,7 @@ type UnevalObject = { export type Uneval = | U extends Values ? Eval.Term : U extends (infer T extends object)[] ? Relation.Modifier | Eval.Array - : U extends object ? Eval.Expr | UnevalObject> + : U extends object ? Eval.Expr | UnevalObject> | Relation.Modifier : any export type Eval = diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 7185a55b..583f2893 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -6,6 +6,7 @@ import { Type } from './type.ts' import { Driver } from './driver.ts' import { Query } from './query.ts' import { Selection } from './selection.ts' +import { Create } from './database.ts' const Primary = Symbol('minato.primary') export type Primary = (string | number) & { [Primary]: true } @@ -22,6 +23,7 @@ export namespace Relation { table: T references: Keys[] fields: K[] + shared: Record> required: boolean } @@ -31,45 +33,61 @@ export namespace Relation { target?: string references?: MaybeArray fields?: MaybeArray + shared?: MaybeArray | Partial> } export type Include = boolean | { - [P in keyof T]?: T[P] extends MaybeArray | undefined ? Include : never + [P in keyof T]?: T[P] extends MaybeArray | undefined ? U extends S ? Include : never : never } - export type SetExpr = Row.Computed> | { + export type SetExpr = ((row: Row) => Update) | { 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 interface Modifier { + $create?: MaybeArray> + $upsert?: 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') + return '_' + tables.sort().join('_') } export function buildAssociationKey(key: string, table: string) { - return `${table}_${key}` + 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' + export function buildSharedKey(field: string, reference: string) { + return [field, reference].sort().join('_') + } + + export function parse(def: Definition, key: string, model: Model, relmodel: Model, subprimary?: boolean): [Config, Config] { + const shared = !def.shared ? {} + : typeof def.shared === 'string' ? { [def.shared]: def.shared } + : Array.isArray(def.shared) ? Object.fromEntries(def.shared.map(x => [x, x])) + : def.shared + const fields = def.fields ?? ((subprimary || 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), + shared: shared as any, 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)), } + // remove shared keys from fields and references + Object.entries(shared).forEach(([k, v]) => { + relation.fields = relation.fields.filter(x => x !== k) + relation.references = relation.references.filter(x => x !== v) + }) const inverse: Config = { type: relation.type === 'oneToMany' ? 'manyToOne' : relation.type === 'manyToOne' ? 'oneToMany' @@ -77,9 +95,11 @@ export namespace Relation { table: model.name, fields: relation.references, references: relation.fields, - required: relation.type !== 'oneToMany' && !relation.required + shared: Object.fromEntries(Object.entries(shared).map(([k, v]) => [v, k])), + required: relation.type !== 'oneToMany' && relation.references.every(key => !relmodel.fields[key]?.nullable || makeArray(relmodel.primary).includes(key)), } + if (inverse.required) relation.required = false return [relation, inverse] } } @@ -158,7 +178,7 @@ export namespace Field { | Literal | Definition | Transform - | (O[K] extends object ? Relation.Definition> : never) + | (O[K] extends object | undefined ? Relation.Definition> : never) } export type Extension = MapField, N> @@ -215,7 +235,7 @@ export namespace Field { } export function available(field?: Field) { - return !!field && !field.deprecated && !field.relation + return !!field && !field.deprecated && !field.relation && field.deftype !== 'expr' } } diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index 443c9fb5..41bfd6cb 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -1,9 +1,9 @@ -import { defineProperty, Dict, filterKeys } from 'cosmokit' +import { defineProperty, Dict, filterKeys, mapValues } from 'cosmokit' import { Driver } from './driver.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 { FlatKeys, FlatPick, Flatten, getCell, Keys, randomId, Row } from './utils.ts' import { Type } from './type.ts' declare module './eval.ts' { @@ -61,7 +61,7 @@ const createRow = (ref: string, expr = {}, prefix = '', model?: Model) => new Pr } const row = createRow(ref, Eval('', [ref, `${prefix}${key}`], type), `${prefix}${key}.`, model) - if (Object.keys(model?.fields!).some(k => k.startsWith(`${prefix}${key}.`))) { + if (!field && Object.keys(model?.fields!).some(k => k.startsWith(`${prefix}${key}.`))) { return createRow(ref, Eval.object(row), `${prefix}${key}.`, model) } else { return row @@ -253,15 +253,20 @@ export class Selection extends Executable { ): 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])])) + .map(([key]) => [key, (row) => getCell(row[this.ref], key)])) + const joinFields = Object.fromEntries(Object.entries(selection.model.fields) + .filter(([key, field]) => Field.available(field) || Field.available(this.model.fields[`${name}.${key}`])) + .map(([key]) => [key, + (row) => Field.available(this.model.fields[`${name}.${key}`]) ? getCell(row[this.ref], `${name}.${key}`) : getCell(row[name], key), + ])) 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 + .project({ ...fields, [name]: (row) => Eval.ignoreNull(Eval.object(mapValues(joinFields, x => x(row)))) }) 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 + .project({ ...fields, [name]: (row) => Eval.ignoreNull(Eval.object(mapValues(joinFields, x => x(row)))) }) as any } } @@ -282,7 +287,7 @@ export class Selection extends Executable { } execute(): Promise - execute = any>(cursor?: Driver.Cursor): Promise[]> + execute = any>(cursor?: Driver.Cursor): Promise[]> execute(callback: Selection.Callback): Promise async execute(cursor?: any) { if (typeof cursor === 'function') { diff --git a/packages/memory/tests/index.spec.ts b/packages/memory/tests/index.spec.ts index 79a8ec50..0049ea6f 100644 --- a/packages/memory/tests/index.spec.ts +++ b/packages/memory/tests/index.spec.ts @@ -32,10 +32,13 @@ describe('@minatojs/driver-memory', () => { }, relation: { select: { - ignoreNullObject: false, + nullableComparator: false, + }, + create: { + nullableComparator: false, }, modify: { - ignoreNullObject: false, + nullableComparator: false, }, }, }) diff --git a/packages/tests/src/model.ts b/packages/tests/src/model.ts index 512b747e..4f0c5bf5 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -182,7 +182,7 @@ function ModelOperations(database: Database) { inner: { num: 'unsigned', text: 'string', - json: 'json', + json: 'object', embed: { type: 'object', inner: { diff --git a/packages/tests/src/relation.ts b/packages/tests/src/relation.ts index fc9d949b..d8f1afa6 100644 --- a/packages/tests/src/relation.ts +++ b/packages/tests/src/relation.ts @@ -1,4 +1,4 @@ -import { $, Database, Query, Relation } from 'minato' +import { $, Database, Relation } from 'minato' import { expect } from 'chai' import { setup } from './utils' @@ -7,8 +7,8 @@ interface User { value?: number profile?: Profile posts?: Post[] - successor?: { id: number } - predecessor?: { id: number } + successor?: Record & { id: number } + predecessor?: Record & { id: number } } interface Profile { @@ -18,7 +18,7 @@ interface Profile { } interface Post { - id: number + id2: number score?: number author?: User content?: string @@ -35,10 +35,31 @@ interface Tag { } interface Post2Tag { - postId: number - tagId: number - post?: Post - tag?: Tag + post?: Post & { id: number } + tag?: Tag & { id: number } +} + +interface Login { + id: string + platform: string + name?: string + guilds?: Guild[] + syncs?: GuildSync[] +} + +interface Guild { + id: string + platform2: string + name?: string + logins?: Login[] + syncs?: GuildSync[] +} + +interface GuildSync { + platform: string + syncAt?: number + guild?: Guild + login?: Login } interface Tables { @@ -47,14 +68,12 @@ interface Tables { post: Post tag: Tag post2tag: Post2Tag + guildSync: GuildSync + login: Login + guild: Guild } function RelationTests(database: Database) { - database.extend('profile', { - id: 'unsigned', - name: 'string', - }) - database.extend('user', { id: 'unsigned', value: 'integer', @@ -63,17 +82,22 @@ function RelationTests(database: Database) { table: 'user', target: 'predecessor', }, - profile: { - type: 'oneToOne', - table: 'profile', - target: 'user', - }, }, { autoInc: true, }) - database.extend('post', { + database.extend('profile', { id: 'unsigned', + name: 'string', + user: { + type: 'oneToOne', + table: 'user', + target: 'profile', + }, + }) + + database.extend('post', { + id2: 'unsigned', score: 'unsigned', content: 'string', author: { @@ -83,6 +107,7 @@ function RelationTests(database: Database) { }, }, { autoInc: true, + primary: 'id2', }) database.extend('tag', { @@ -98,23 +123,61 @@ function RelationTests(database: Database) { }) database.extend('post2tag', { - id: 'unsigned', - postId: 'unsigned', - tagId: 'unsigned', + 'post.id': 'unsigned', + 'tag.id': 'unsigned', post: { type: 'manyToOne', table: 'post', target: '_tags', - fields: 'postId', }, tag: { type: 'manyToOne', table: 'tag', target: '_posts', - fields: 'tagId', }, }, { - primary: ['postId', 'tagId'], + primary: ['post.id', 'tag.id'], + }) + + database.extend('login', { + id: 'string', + platform: 'string', + name: 'string', + }, { + primary: ['id', 'platform'], + }) + + database.extend('guild', { + id: 'string', + platform2: 'string', + name: 'string', + logins: { + type: 'manyToMany', + table: 'login', + target: 'guilds', + shared: { platform2: 'platform' }, + }, + }, { + primary: ['id', 'platform2'], + }) + + database.extend('guildSync', { + syncAt: 'unsigned', + platform: 'string', + guild: { + type: 'manyToOne', + table: 'guild', + target: 'syncs', + fields: ['guild.id', 'platform'], + }, + login: { + type: 'manyToOne', + table: 'login', + target: 'syncs', + fields: ['login.id', 'platform'], + }, + }, { + primary: ['guild', 'login'], }) async function setupAutoInc(database: Database, name: K, length: number) { @@ -143,9 +206,9 @@ namespace RelationTests { ] const postTable: Post[] = [ - { id: 1, content: 'A1', author: { id: 1 } }, - { id: 2, content: 'B2', author: { id: 1 } }, - { id: 3, content: 'C3', author: { id: 2 } }, + { id2: 1, content: 'A1', author: { id: 1 } }, + { id2: 2, content: 'B2', author: { id: 1 } }, + { id2: 3, content: 'C3', author: { id: 2 } }, ] const tagTable: Tag[] = [ @@ -155,19 +218,27 @@ namespace RelationTests { ] const post2TagTable: Post2Tag[] = [ - { postId: 1, tagId: 1 }, - { postId: 1, tagId: 2 }, - { postId: 2, tagId: 1 }, - { postId: 2, tagId: 3 }, - { postId: 3, tagId: 3 }, - ] + { post: { id: 1 }, tag: { id: 1 } }, + { post: { id: 1 }, tag: { id: 2 } }, + { post: { id: 2 }, tag: { id: 1 } }, + { post: { id: 2 }, tag: { id: 3 } }, + { post: { id: 3 }, tag: { id: 3 } }, + ] as any + + const post2TagTable2: Post2Tag[] = [ + { post: { id2: 1 }, tag: { id: 1 } }, + { post: { id2: 1 }, tag: { id: 2 } }, + { post: { id2: 2 }, tag: { id: 1 } }, + { post: { id2: 2 }, tag: { id: 3 } }, + { post: { id2: 3 }, tag: { id: 3 } }, + ] as any export interface RelationOptions { - ignoreNullObject?: boolean + nullableComparator?: boolean } export function select(database: Database, options: RelationOptions = {}) { - const { ignoreNullObject = true } = options + const { nullableComparator = true } = options it('basic support', async () => { const users = await setup(database, 'user', userTable) @@ -180,7 +251,7 @@ namespace RelationTests { })), ) - await expect(database.select('user', {}, { profile: true, posts: true }).execute()).to.eventually.have.shape( + await expect(database.get('user', {}, { include: { profile: true, posts: true } })).to.eventually.have.shape( users.map(user => ({ ...user, profile: profiles.find(profile => profile.id === user.id), @@ -196,13 +267,13 @@ namespace RelationTests { ) }) - ignoreNullObject && it('self relation', async () => { + nullableComparator && 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), + successor: users.find(successor => successor.id === user.successor?.id) ?? null, })), ) }) @@ -212,7 +283,7 @@ namespace RelationTests { 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( + await expect(database.select('user', {}, { posts: { author: { successor: false } } }).execute()).to.eventually.have.shape( users.map(user => ({ ...user, posts: posts.filter(post => post.author?.id === user.id).map(post => ({ @@ -252,10 +323,7 @@ namespace RelationTests { 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, - }))) + const re = await setup(database, Relation.buildAssociationTable('post', 'tag') as any, post2TagTable2) // explicit manyToMany await expect(database.select('post', {}, { _tags: { tag: { _posts: { post: true } } } }).execute()).to.eventually.be.fulfilled @@ -263,12 +331,12 @@ namespace RelationTests { 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)) + tags: post2tags.filter(p2t => p2t.post?.id === post.id2) + .map(p2t => tags.find(tag => tag.id === p2t.tag?.id)) .filter(tag => tag) .map(tag => ({ ...tag, - posts: post2tags.filter(p2t => p2t.tagId === tag!.id).map(p2t => posts.find(post => post.id === p2t.postId)), + posts: post2tags.filter(p2t => p2t.tag?.id === tag!.id).map(p2t => posts.find(post => post.id2 === p2t.post?.id)), })), })), ) @@ -347,7 +415,7 @@ namespace RelationTests { await expect(database.get('user', { posts: { - $some: row => $.eq(row.id, 1), + $some: row => $.eq(row.id2, 1), }, })).to.eventually.have.shape(users.slice(0, 1).map(user => ({ ...user, @@ -387,10 +455,7 @@ namespace RelationTests { 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, - }))) + const re = await setup(database, Relation.buildAssociationTable('post', 'tag') as any, post2TagTable2) await expect(database.get('post', { tags: { @@ -400,8 +465,8 @@ namespace RelationTests { }, })).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)) + tags: post2tags.filter(p2t => p2t.post?.id === post.id2) + .map(p2t => tags.find(tag => tag.id === p2t.tag?.id)) .filter(tag => tag), }))) @@ -413,8 +478,8 @@ namespace RelationTests { }, })).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)) + tags: post2tags.filter(p2t => p2t.post?.id === post.id2) + .map(p2t => tags.find(tag => tag.id === p2t.tag?.id)) .filter(tag => tag), }))) @@ -426,8 +491,8 @@ namespace RelationTests { }, })).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)) + tags: post2tags.filter(p2t => p2t.post?.id === post.id2) + .map(p2t => tags.find(tag => tag.id === p2t.tag?.id)) .filter(tag => tag), }))) @@ -439,8 +504,8 @@ namespace RelationTests { }, })).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)) + tags: post2tags.filter(p2t => p2t.post?.id === post.id2) + .map(p2t => tags.find(tag => tag.id === p2t.tag?.id)) .filter(tag => tag), }))) }) @@ -451,10 +516,7 @@ namespace RelationTests { 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, - }))) + const re = await setup(database, Relation.buildAssociationTable('post', 'tag') as any, post2TagTable2) await expect(database.get('user', { posts: { @@ -481,8 +543,8 @@ namespace RelationTests { }, })).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)) + posts: post2tags.filter(p2t => p2t.tag?.id === tag.id) + .map(p2t => posts.find(post => post.id2 === p2t.post?.id)) .filter(post => post), }))) }) @@ -522,12 +584,12 @@ namespace RelationTests { await database.set('user', 2, { posts: { $disconnect: { - id: 3, + id2: 3, }, }, }) await expect(database.select('post', { author: null }, null).execute()).to.eventually.have.shape([ - { id: 3 }, + { id2: 3 }, ]) await expect(database.select('user', { posts: { @@ -540,9 +602,43 @@ namespace RelationTests { { id: 3 }, ]) }) + + it('manyToOne fallback', async () => { + await setup(database, 'user', []) + await setup(database, 'profile', []) + await setup(database, 'post', []) + + await database.create('post', { + id2: 1, + content: 'new post', + author: { + id: 2, + }, + }) + + await expect(database.get('post', 1, ['author'])).to.eventually.have.shape([{ + author: { + id: 2, + }, + }]) + + await database.create('user', { + id: 2, + value: 123, + }) + + await expect(database.get('post', 1, ['author'])).to.eventually.have.shape([{ + author: { + id: 2, + value: 123, + }, + }]) + }) } - export function create(database: Database) { + export function create(database: Database, options: RelationOptions = {}) { + const { nullableComparator = true } = options + it('basic support', async () => { await setup(database, 'user', []) await setup(database, 'profile', []) @@ -574,6 +670,87 @@ namespace RelationTests { ) }) + nullableComparator && it('nullable oneToOne', async () => { + await setup(database, 'user', []) + + await database.create('user', { + id: 1, + value: 1, + successor: { + $create: { + id: 2, + value: 2, + }, + }, + predecessor: { + $create: { + id: 6, + value: 6, + }, + }, + }) + await expect(database.select('user', {}, { successor: true }).orderBy('id').execute()).to.eventually.have.shape([ + { id: 1, value: 1, successor: { id: 2, value: 2 } }, + { id: 2, value: 2, successor: null }, + { id: 6, value: 6, successor: { id: 1, value: 1 } }, + ]) + + await database.create('user', { + id: 3, + value: 3, + predecessor: { + $upsert: { + id: 4, + value: 4, + }, + }, + }) + await expect(database.select('user', {}, { successor: true }).orderBy('id').execute()).to.eventually.have.shape([ + { id: 1, value: 1, successor: { id: 2, value: 2 } }, + { id: 2, value: 2, successor: null }, + { id: 3, value: 3, successor: null }, + { id: 4, value: 4, successor: { id: 3, value: 3 } }, + { id: 6, value: 6 }, + ]) + + await database.remove('user', [2, 4]) + await database.create('user', { + id: 2, + value: 2, + successor: { + $connect: { + id: 1, + }, + }, + }) + await database.create('user', { + id: 4, + value: 4, + predecessor: { + $connect: { + id: 3, + }, + }, + }) + await database.create('user', { + id: 5, + value: 5, + successor: { + $connect: { + value: 3, + }, + }, + }) + await expect(database.select('user', {}, { successor: true }).orderBy('id').execute()).to.eventually.have.shape([ + { id: 1, value: 1, successor: { id: 2, value: 2 } }, + { id: 2, value: 2, successor: { id: 1, value: 1 } }, + { id: 3, value: 3, successor: { id: 4, value: 4 } }, + { id: 4, value: 4, successor: null }, + { id: 5, value: 5, successor: { id: 3, value: 3 } }, + { id: 6, value: 6, successor: { id: 1, value: 1 } }, + ]) + }) + it('oneToMany', async () => { await setup(database, 'user', []) await setup(database, 'profile', []) @@ -593,10 +770,308 @@ namespace RelationTests { })), ) }) + + it('upsert / connect oneToMany / manyToOne', async () => { + await setup(database, 'user', []) + await setup(database, 'profile', []) + await setup(database, 'post', []) + + await database.create('user', { + id: 1, + value: 1, + posts: { + $upsert: [ + { + id2: 1, + content: 'post1', + }, + { + id2: 2, + content: 'post2', + }, + ], + }, + }) + + await expect(database.select('user', 1, { posts: true }).execute()).to.eventually.have.shape([ + { + id: 1, + value: 1, + posts: [ + { id2: 1, content: 'post1' }, + { id2: 2, content: 'post2' }, + ], + }, + ]) + + await database.create('user', { + id: 2, + value: 2, + posts: { + $connect: { + id2: 1, + }, + $create: [ + { + id2: 3, + content: 'post3', + author: { + $upsert: { + id: 2, + value: 3, + }, + }, + }, + { + id2: 4, + content: 'post4', + author: { + $connect: { + id: 1, + }, + }, + }, + ], + }, + }) + + await expect(database.select('user', {}, { posts: true }).execute()).to.eventually.have.shape([ + { + id: 1, + value: 1, + posts: [ + { id2: 2, content: 'post2' }, + { id2: 4, content: 'post4' }, + ], + }, + { + id: 2, + value: 3, + posts: [ + { id2: 1, content: 'post1' }, + { id2: 3, content: 'post3' }, + ], + }, + ]) + }) + + it('manyToOne', async () => { + const users = await setup(database, 'user', []) + await setup(database, 'post', []) + + users.push({ id: 1, value: 2 }) + + await database.create('post', { + id2: 1, + content: 'post2', + author: { + $create: { + id: 1, + value: 2, + }, + }, + }) + await expect(database.get('user', {})).to.eventually.have.shape(users) + + users[0].value = 3 + await database.create('post', { + id2: 2, + content: 'post3', + author: { + $create: { + id: 1, + value: 3, + }, + }, + }) + await expect(database.get('user', {})).to.eventually.have.shape(users) + + await database.create('post', { + id2: 3, + content: 'post4', + author: { + id: 1, + }, + }) + await expect(database.get('user', {})).to.eventually.have.shape(users) + await expect(database.get('post', {}, { include: { author: true } })).to.eventually.have.shape([ + { id2: 1, content: 'post2', author: { id: 1, value: 3 } }, + { id2: 2, content: 'post3', author: { id: 1, value: 3 } }, + { id2: 3, content: 'post4', author: { id: 1, value: 3 } }, + ]) + }) + + it('manyToMany', async () => { + await setup(database, 'user', []) + await setup(database, 'post', []) + await setup(database, 'tag', []) + await setup(database, Relation.buildAssociationTable('post', 'tag') as any, []) + + 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).map(post => ({ + ...post, + tags: { + $upsert: post2TagTable.filter(p2t => p2t.post?.id === post.id2).map(p2t => tagTable.find(tag => tag.id === p2t.tag?.id)).filter(x => !!x), + }, + })), + }) + } + + await expect(database.select('user', {}, { posts: { tags: true } }).execute()).to.eventually.have.shape( + userTable.map(user => ({ + ...user, + posts: postTable.filter(post => post.author?.id === user.id).map(post => ({ + ...post, + tags: post2TagTable.filter(p2t => p2t.post?.id === post.id2).map(p2t => tagTable.find(tag => tag.id === p2t.tag?.id)), + })), + })), + ) + }) + + it('manyToMany expr', async () => { + await setup(database, 'user', []) + await setup(database, 'post', []) + await setup(database, 'tag', []) + await setup(database, Relation.buildAssociationTable('post', 'tag') as any, []) + + await database.create('post', { + id2: 1, + content: 'post1', + author: { + $create: { + id: 1, + value: 1, + }, + }, + tags: { + $create: [ + { + name: 'tag1', + }, + { + name: 'tag2', + }, + ], + }, + }) + + await database.create('post', { + id2: 2, + content: 'post2', + author: { + $connect: { + id: 1, + }, + }, + tags: { + $connect: { + name: 'tag1', + }, + }, + }) + + await expect(database.select('user', {}, { posts: { tags: true } }).execute()).to.eventually.have.shape([ + { + id: 1, + value: 1, + posts: [ + { + id2: 1, + content: 'post1', + tags: [ + { name: 'tag1' }, + { name: 'tag2' }, + ], + }, + { + id2: 2, + content: 'post2', + tags: [ + { name: 'tag1' }, + ], + }, + ], + }, + ]) + }) + + it('explicit manyToMany', async () => { + await setup(database, 'login', []) + await setup(database, 'guild', []) + await setup(database, 'guildSync', []) + + await database.create('login', { + id: '1', + platform: 'sandbox', + name: 'Bot1', + syncs: { + $create: [ + { + syncAt: 123, + guild: { + $upsert: { id: '1', platform2: 'sandbox', name: 'Guild1' }, + }, + }, + ], + }, + }) + + await database.upsert('guild', [ + { id: '2', platform2: 'sandbox', name: 'Guild2' }, + { id: '3', platform2: 'sandbox', name: 'Guild3' }, + ]) + + await database.create('login', { + id: '2', + platform: 'sandbox', + name: 'Bot2', + syncs: { + $create: [ + { + syncAt: 123, + guild: { + $connect: { id: '2' }, + }, + }, + ], + }, + }) + + await expect(database.get('login', { + platform: 'sandbox', + }, { + include: { syncs: { guild: true } }, + })).to.eventually.have.shape([ + { + id: '1', + platform: 'sandbox', + name: 'Bot1', + syncs: [ + { + syncAt: 123, + guild: { id: '1', platform2: 'sandbox', name: 'Guild1' }, + }, + ], + }, + { + id: '2', + platform: 'sandbox', + name: 'Bot2', + syncs: [ + { + syncAt: 123, + guild: { id: '2', platform2: 'sandbox', name: 'Guild2' }, + }, + ], + }, + ]) + }) } export function modify(database: Database, options: RelationOptions = {}) { - const { ignoreNullObject = true } = options + const { nullableComparator = true } = options it('oneToOne / manyToOne', async () => { const users = await setup(database, 'user', userTable) @@ -630,25 +1105,198 @@ namespace RelationTests { profile: null, }) await expect(database.get('profile', {})).to.eventually.have.deep.members(profiles) + + users.push({ id: 100, value: 200, successor: { id: undefined } } as any) + await database.set('post', 1, { + author: { + id: 100, + value: 200, + }, + }) + await expect(database.get('user', {})).to.eventually.have.deep.members(users) }) - it('upsert', async () => { + it('oneToOne expr', 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 database.create('user', { + id: 1, + value: 0, + }) + await database.set('user', 1, { + profile: { + $create: { + name: 'Apple', + }, + }, + }) await expect(database.select('user', {}, { profile: true }).execute()).to.eventually.have.shape( - userTable.map(user => ({ - ...user, - profile: profileTable.find(profile => profile.id === user.id), - })), + [{ id: 1, value: 0, profile: { name: 'Apple' } }], + ) + + await database.set('user', 1, { + profile: { + $upsert: [{ + name: 'Apple2', + }], + }, + }) + await expect(database.select('user', {}, { profile: true }).execute()).to.eventually.have.shape( + [{ id: 1, value: 0, profile: { name: 'Apple2' } }], + ) + + await database.set('user', 1, { + profile: { + $set: r => ({ + name: $.concat(r.name, '3'), + }), + }, + }) + await expect(database.select('user', {}, { profile: true }).execute()).to.eventually.have.shape( + [{ id: 1, value: 0, profile: { name: 'Apple23' } }], + ) + }) + + nullableComparator && it('nullable oneToOne', async () => { + await setup(database, 'user', []) + + await database.upsert('user', [ + { id: 1, value: 1, successor: { id: 2 } }, + { id: 2, value: 2, successor: { id: 1 } }, + { id: 3, value: 3 }, + ]) + await database.set('user', 3, { + successor: { + $create: { + id: 4, + value: 4, + }, + }, + predecessor: { + $connect: { + id: 4, + }, + }, + }) + await expect(database.select('user', {}, { successor: true }).execute()).to.eventually.have.shape([ + { id: 1, value: 1, successor: { id: 2, value: 2 } }, + { id: 2, value: 2, successor: { id: 1, value: 1 } }, + { id: 3, value: 3, successor: { id: 4, value: 4 } }, + { id: 4, value: 4, successor: { id: 3, value: 3 } }, + ]) + + await database.set('user', [1, 2], { + successor: null, + }) + await expect(database.select('user', {}, { successor: true }).execute()).to.eventually.have.shape([ + { id: 1, value: 1, successor: null }, + { id: 2, value: 2, successor: null }, + { id: 3, value: 3, successor: { id: 4, value: 4 } }, + { id: 4, value: 4, successor: { id: 3, value: 3 } }, + ]) + + await database.set('user', 3, { + predecessor: { + $disconnect: {}, + }, + successor: { + $disconnect: {}, + }, + }) + await expect(database.select('user', {}, { successor: true }).execute()).to.eventually.have.shape([ + { id: 1, value: 1, successor: null }, + { id: 2, value: 2, successor: null }, + { id: 3, value: 3, successor: null }, + { id: 4, value: 4, successor: null }, + ]) + + await database.set('user', 2, { + predecessor: { + $connect: { + id: 3, + }, + }, + successor: { + $connect: { + id: 1 + }, + }, + }) + await expect(database.select('user', {}, { successor: true }).execute()).to.eventually.have.shape([ + { id: 1, value: 1, successor: null }, + { id: 2, value: 2, successor: { id: 1, value: 1 } }, + { id: 3, value: 3, successor: { id: 2, value: 2 } }, + { id: 4, value: 4, successor: null }, + ]) + }) + + nullableComparator && it('set null on oneToOne', async () => { + await setup(database, 'user', [ + { id: 1, value: 1, profile: { name: 'A' } }, + { id: 2, value: 2, profile: { name: 'B' } }, + { id: 3, value: 3, profile: { name: 'B' } }, + ] as any) + + await database.set('user', 1, { + profile: null, + }) + await expect(database.select('user', {}, { profile: true }).execute()).to.eventually.have.shape([ + { id: 1, value: 1, profile: null }, + { id: 2, value: 2, profile: { name: 'B' } }, + { id: 3, value: 3, profile: { name: 'B' } }, + ]) + + await expect(database.set('profile', 3, { + user: null, + })).to.be.eventually.rejected + }) + + nullableComparator && it('manyToOne expr', async () => { + await setup(database, 'user', []) + await setup(database, 'profile', []) + await setup(database, 'post', []) + + await database.create('post', { + id2: 1, + content: 'Post1', + }) + + await database.set('post', 1, { + author: { + $create: { + id: 1, + value: 0, + profile: { + $create: { + name: 'Apple', + }, + }, + }, + }, + }) + await expect(database.select('user', {}, { posts: true, profile: true }).execute()).to.eventually.have.shape( + [{ id: 1, value: 0, profile: { name: 'Apple' }, posts: [{ id2: 1, content: 'Post1' }] }], + ) + + await database.set('post', 1, { + author: { + $set: r => ({ + value: 123, + }), + }, + }) + await expect(database.select('user', {}, { posts: true, profile: true }).execute()).to.eventually.have.shape( + [{ id: 1, value: 123, profile: { name: 'Apple' }, posts: [{ id2: 1, content: 'Post1' }] }], + ) + + await database.set('post', 1, { + author: null, + }) + await expect(database.select('user', {}, { posts: true, profile: true }).execute()).to.eventually.have.shape( + [{ id: 1, value: 123, profile: { name: 'Apple' }, posts: [] }], ) }) @@ -657,24 +1305,30 @@ namespace RelationTests { 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' })) + posts.push(database.tables['post'].create({ id2: 4, author: { id: 2 }, content: 'post1' })) + posts.push(database.tables['post'].create({ id2: 5, author: { id: 2 }, content: 'post2' })) + posts.push(database.tables['post'].create({ id2: 6, author: { id: 2 }, content: 'post1' })) + posts.push(database.tables['post'].create({ id2: 7, author: { id: 2 }, content: 'post2' })) await database.set('user', 2, { posts: { $create: [ - { content: 'post1' }, - { content: 'post2' }, + { id2: 4, content: 'post1' }, + { id2: 5, content: 'post2' }, + ], + $upsert: [ + { id2: 6, content: 'post1' }, + { id2: 7, 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' })) + posts.push(database.tables['post'].create({ id2: 101, author: { id: 1 }, content: 'post101' })) await database.set('user', 1, row => ({ value: $.add(row.id, 98), posts: { - $create: { id: 101, content: 'post101' }, + $create: { id2: 101, content: 'post101' }, }, })) await expect(database.get('post', {})).to.eventually.have.deep.members(posts) @@ -690,7 +1344,7 @@ namespace RelationTests { await database.set('user', 1, row => ({ posts: { $set: r => ({ - score: $.add(row.id, r.id), + score: $.add(row.id, r.id2), }), }, })) @@ -708,7 +1362,7 @@ namespace RelationTests { await expect(database.get('post', {})).to.eventually.have.deep.members(posts) }) - it('delete oneToMany', async () => { + nullableComparator && it('delete oneToMany', async () => { await setup(database, 'user', userTable) await setup(database, 'profile', profileTable) const posts = await setup(database, 'post', postTable) @@ -716,10 +1370,34 @@ namespace RelationTests { posts.splice(0, 1) await database.set('user', {}, row => ({ posts: { - $remove: r => $.eq(r.id, row.id), + $remove: r => $.eq(r.id2, row.id), }, })) await expect(database.get('post', {})).to.eventually.have.deep.members(posts) + + await database.set('post', 2, { + author: { + $remove: {}, + $connect: { id: 2 }, + }, + }) + await database.set('post', 3, { + author: { + $disconnect: {}, + }, + }) + await expect(database.get('user', {}, { include: { posts: true } })).to.eventually.have.shape([ + { + id: 2, + posts: [ + { id2: 2 }, + ], + }, + { + id: 3, + posts: [], + }, + ]) }) it('override oneToMany', async () => { @@ -736,14 +1414,13 @@ namespace RelationTests { posts[0].score = 4 posts[1].score = 5 - await database.upsert('user', [{ - id: 1, + await database.set('user', 1, { posts: posts.slice(0, 2), - }]) + }) await expect(database.get('post', {})).to.eventually.have.deep.members(posts) }) - ignoreNullObject && it('connect / disconnect oneToMany', async () => { + nullableComparator && it('connect / disconnect oneToMany', async () => { await setup(database, 'user', userTable) await setup(database, 'profile', profileTable) await setup(database, 'post', postTable) @@ -751,26 +1428,84 @@ namespace RelationTests { await database.set('user', 1, { posts: { $disconnect: {}, - $connect: { id: 3 }, + $connect: { id2: 3 }, }, }) await expect(database.get('user', 1, ['posts'])).to.eventually.have.shape([{ posts: [ - { id: 3 }, + { id2: 3 }, ], }]) }) + it('modify manyToMany', async () => { + await setup(database, 'user', userTable) + await setup(database, 'profile', profileTable) + await setup(database, 'post', postTable) + await setup(database, 'tag', []) + + await setup(database, Relation.buildAssociationTable('post', 'tag') as any, []) + + await database.set('post', 2, { + tags: { + $create: { + id: 1, + name: 'Tag1', + }, + $upsert: [ + { + id: 2, + name: 'Tag2', + }, + { + id: 3, + name: 'Tag3', + }, + ], + }, + }) + await expect(database.get('post', 2, ['tags'])).to.eventually.have.nested.property('[0].tags').with.shape([ + { id: 1, name: 'Tag1' }, + { id: 2, name: 'Tag2' }, + { id: 3, name: 'Tag3' }, + ]) + + await database.set('post', 2, row => ({ + tags: { + $set: r => ({ + name: $.concat(r.name, row.content, '2'), + }), + $remove: { + id: 3, + }, + }, + })) + await expect(database.get('post', 2, ['tags'])).to.eventually.have.nested.property('[0].tags').with.shape([ + { id: 1, name: 'Tag1B22' }, + { id: 2, name: 'Tag2B22' }, + ]) + + await database.set('post', 2, { + tags: [ + { id: 1, name: 'Tag1' }, + { id: 2, name: 'Tag2' }, + { id: 3, name: 'Tag3' }, + ], + }) + await expect(database.get('post', 2, ['tags'])).to.eventually.have.nested.property('[0].tags').with.shape([ + { id: 1, name: 'Tag1' }, + { id: 2, name: 'Tag2' }, + { id: 3, name: 'Tag3' }, + ]) + }) + 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 setup(database, Relation.buildAssociationTable('post', 'tag') as any, post2TagTable2) await database.set('post', 2, { tags: { @@ -781,7 +1516,7 @@ namespace RelationTests { await database.set('post', 2, row => ({ tags: { - $connect: r => $.eq(r.id, row.id), + $connect: r => $.eq(r.id, row.id2), }, })) await expect(database.get('post', 2, ['tags'])).to.eventually.have.nested.property('[0].tags').with.shape([{ @@ -793,12 +1528,9 @@ namespace RelationTests { 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, - }))) + await setup(database, Relation.buildAssociationTable('post', 'tag') as any, post2TagTable2) - posts.filter(post => post2TagTable.some(p2t => p2t.postId === post.id && p2t.tagId === 1)).forEach(post => post.score! += 10) + posts.filter(post => post2TagTable.some(p2t => p2t.post?.id === post.id2 && p2t.tag?.id === 1)).forEach(post => post.score! += 10) await database.set('post', { tags: { $some: { @@ -810,35 +1542,146 @@ namespace RelationTests { })) await expect(database.get('post', {})).to.eventually.have.deep.members(posts) }) - } - export function misc(database: Database) { - it('unsupported', async () => { + it('nested modify', async () => { await setup(database, 'user', userTable) await setup(database, 'post', postTable) - await setup(database, 'tag', tagTable) + const profiles = await setup(database, 'profile', profileTable) - await expect(database.set('post', 1, { - tags: [], - })).to.eventually.be.rejected + profiles[0].name = 'Evil' + await database.set('user', 1, { + posts: { + $set: { + where: { id2: { $gt: 1 } }, + update: { + author: { + $set: _ => ({ + profile: { + $set: _ => ({ + name: 'Evil', + }), + }, + }), + }, + }, + }, + }, + }) + await expect(database.get('profile', {})).to.eventually.have.deep.members(profiles) + }) - await expect(database.set('post', 1, { - tags: { - $remove: {}, + it('shared manyToMany', async () => { + await setup(database, 'login', [ + { id: '1', platform: 'sandbox', name: 'Bot1' }, + { id: '2', platform: 'sandbox', name: 'Bot2' }, + { id: '3', platform: 'sandbox', name: 'Bot3' }, + { id: '1', platform: 'whitebox', name: 'Bot1' }, + ]) + await setup(database, 'guild', [ + { id: '1', platform2: 'sandbox', name: 'Guild1' }, + { id: '2', platform2: 'sandbox', name: 'Guild2' }, + { id: '3', platform2: 'sandbox', name: 'Guild3' }, + { id: '1', platform2: 'whitebox', name: 'Guild1' }, + ]) + await setup(database, Relation.buildAssociationTable('login', 'guild') as any, []) + + await database.set('login', { + id: '1', + platform: 'sandbox', + }, { + guilds: { + $connect: { + id: { + $or: ['1', '2'], + }, + }, + }, + }) + await expect(database.get('login', { + id: '1', + platform: 'sandbox', + }, ['guilds'])).to.eventually.have.nested.property('[0].guilds').with.length(2) + + await database.set('login', { + id: '1', + platform: 'sandbox', + }, { + guilds: { + $disconnect: { + id: '2', + }, }, - })).to.eventually.be.rejected + }) - await expect(database.set('post', 1, { - tags: { - $set: {}, + await expect(database.get('login', { + id: '1', + platform: 'sandbox', + }, ['guilds'])).to.eventually.have.nested.property('[0].guilds').with.length(1) + + await database.create('guild', { + id: '4', + platform2: 'sandbox', + name: 'Guild4', + logins: { + $upsert: [ + { id: '1' }, + { id: '2' }, + ], }, - })).to.eventually.be.rejected + }) - await expect(database.set('post', 1, { - tags: { - $create: {}, + await expect(database.get('login', { platform: 'sandbox' }, ['id', 'guilds'])).to.eventually.have.shape([ + { id: '1', guilds: [{ id: '1' }, { id: '4' }] }, + { id: '2', guilds: [{ id: '4' }] }, + { id: '3', guilds: [] }, + ]) + + await expect(database.get('guild', { platform2: 'sandbox' }, ['id', 'logins'])).to.eventually.have.shape([ + { id: '1', logins: [{ id: '1' }] }, + { id: '2', logins: [] }, + { id: '3', logins: [] }, + { id: '4', logins: [{ id: '1' }, { id: '2' }] }, + ]) + }) + + it('explicit manyToMany', async () => { + await setup(database, 'login', [ + { id: '1', platform: 'sandbox', name: 'Guild1' }, + { id: '2', platform: 'sandbox', name: 'Guild2' }, + { id: '3', platform: 'sandbox', name: 'Guild3' }, + ]) + await setup(database, 'guild', []) + await setup(database, 'guildSync', []) + + await database.set('login', { + id: '1', + platform: 'sandbox', + }, { + syncs: { + $create: [ + { + syncAt: 123, + guild: { + $create: { id: '1', platform2: 'sandbox' }, + }, + }, + ], + }, + }) + + await expect(database.get('login', { + id: '1', + platform: 'sandbox', + }, { + include: { syncs: { guild: true } }, + })).to.eventually.have.shape([ + { + id: '1', + syncs: [ + { syncAt: 123, guild: { id: '1', platform2: 'sandbox' } }, + ], }, - })).to.eventually.be.rejected + ]) }) } }