diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3cf9622b..7af140e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,7 @@ jobs: - mysql:5.7 - mysql:8.0 - mariadb:10.5 + - mariadb:11.4 node-version: [22, 24] services: @@ -23,7 +24,7 @@ jobs: image: ${{ matrix.mysql-image }} ports: - 3306:3306 - options: --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 + options: --health-cmd "${{ startsWith(matrix.mysql-image, 'mariadb:') && 'mariadb-admin ping' || 'mysqladmin ping' }}" --health-interval 10s --health-timeout 5s --health-retries 5 env: MYSQL_USER: koishi MYSQL_DATABASE: test diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index 6c5aac97..ed12daa6 100644 --- a/packages/core/src/database.ts +++ b/packages/core/src/database.ts @@ -204,6 +204,9 @@ export class Database extends Service { model.unique = model.unique.map(keys => typeof keys === 'string' ? model.fields[keys]!.relation?.fields || keys : keys.map(key => model.fields[key]!.relation?.fields || key).flat()) + // refresh the type cache to pick up relation foreign key columns added above + defineProperty(model, 'type', Type.Object(mapValues(model.fields, field => Type.fromField(field!))) as any) + this.prepareTasks[name] = this.prepare(name) ;(this.ctx as Context).emit('database/model', name) } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index a1217681..3c453d12 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -130,7 +130,7 @@ export namespace Field { export type Type = | T extends Primary ? 'primary' : T extends number ? 'integer' | 'unsigned' | 'float' | 'double' | 'decimal' - : T extends string ? 'char' | 'string' | 'text' + : T extends string ? 'char' | 'string' | 'text' | 'uuid' : T extends boolean ? 'boolean' : T extends Date ? 'timestamp' | 'date' | 'time' : T extends ArrayBuffer ? 'binary' diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 6649c29a..c2eb3048 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -154,3 +154,28 @@ export function isEmpty(value: any) { } return true } + +const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export function uuidToBuffer(value: string): Uint8Array { + if (!uuidRegex.test(value)) throw new TypeError(`invalid uuid: ${value}`) + const hex = value.replace(/-/g, '') + const buffer = new Uint8Array(16) + for (let i = 0; i < 16; i++) { + buffer[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return buffer +} + +export function bufferToUuid(value: Uint8Array | ArrayBuffer | ArrayBufferView): string { + let bytes: Uint8Array + if (value instanceof Uint8Array) bytes = value + else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value) + else bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + if (bytes.byteLength !== 16) throw new TypeError(`invalid uuid buffer length: ${bytes.byteLength}`) + const hex: string[] = [] + for (let i = 0; i < 16; i++) { + hex.push(bytes[i].toString(16).padStart(2, '0')) + } + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}` +} diff --git a/packages/mongo/src/builder.ts b/packages/mongo/src/builder.ts index 914b37e0..ef5e1840 100644 --- a/packages/mongo/src/builder.ts +++ b/packages/mongo/src/builder.ts @@ -1,8 +1,12 @@ import { Dict, isNullable, mapValues } from 'cosmokit' -import { Eval, Field, flatten, isAggrExpr, isComparable, isEvalExpr, isFlat, makeRegExp, Model, Query, Selection, Type, unravel } from '@cordisjs/plugin-database' -import { Filter, FilterOperators, ObjectId } from 'mongodb' +import { Eval, Field, flatten, isAggrExpr, isComparable, isEvalExpr, isFlat, makeRegExp, Model, Query, Selection, Type, unravel, uuidToBuffer } from '@cordisjs/plugin-database' +import { Binary, Filter, FilterOperators, ObjectId } from 'mongodb' import MongoDriver from '.' +function toUuidBinary(value: string) { + return new Binary(Buffer.from(uuidToBuffer(value)), Binary.SUBTYPE_UUID) +} + function createFieldFilter(query: Query.Field, key: string, type?: Type) { const filters: Filter[] = [] const result: Filter = {} @@ -18,9 +22,13 @@ function transformFieldQuery(query: Query.Field, key: string, filters: Filter typeof x === 'string' ? toUuidBinary(x) : x) } + } return { $in: query } } else if (query instanceof RegExp) { return { $regex: query } @@ -70,7 +78,15 @@ function transformFieldQuery(query: Query.Field, key: string, filters: Filter typeof x === 'string' ? toUuidBinary(x) : x) + } else if (typeof value === 'string') { + value = toUuidBinary(value) + } + } + result[prop] = value } } if (!Object.keys(result).length) return true @@ -603,15 +619,27 @@ export class Builder { dump(value: any, type: Model | Type | Eval.Expr | undefined): any { if (!type) return value if (isEvalExpr(type)) type = Type.fromTerm(type) - if (!Type.isType(type)) type = type.getType() - - const converter = this.driver.types[type?.type] - let res = value - 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) - return res + if (Type.isType(type)) { + const converter = this.driver.types[type?.type] + let res = value + 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) + return res + } + + // Model: flatten, dump each leaf, then restore nested object layout + // (mongo rejects dotted field names, and nested relation columns like + // `parent.id` must be stored as `{ parent: { id: } }`). + const formatted = type.format(value) + const flat: Dict = {} + for (const key in formatted) { + const field = type.fields[key] + if (!field) continue + flat[key] = this.dump(formatted[key], field.type) + } + return unravel(flat) } load(rows: any[], model: Model): any[] diff --git a/packages/mongo/src/index.ts b/packages/mongo/src/index.ts index a38541f2..4f39d8bc 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, ObjectId } from 'mongodb' +import { BSONType, Binary as MongoBinary, ClientSession, Collection, Db, IndexDescription, Long, MongoClient, MongoClientOptions, MongoError, ObjectId } from 'mongodb' import { Binary, deepEqual, Dict, isNullable, makeArray, mapValues, noop, omit, pick, remove } from 'cosmokit' -import { Driver, Eval, executeUpdate, Field, hasSubquery, Query, RuntimeError, Selection } from '@cordisjs/plugin-database' +import { bufferToUuid, Driver, Eval, executeUpdate, Field, hasSubquery, Query, RuntimeError, Selection, uuidToBuffer } from '@cordisjs/plugin-database' import { Inject } from 'cordis' import type {} from '@cordisjs/plugin-logger' import { Builder } from './builder' @@ -78,6 +78,12 @@ export class MongoDriver extends Driver { load: value => isNullable(value) ? value : Binary.fromSource(value.buffer), }) + this.define({ + types: ['uuid'], + dump: value => isNullable(value) ? value as any : new MongoBinary(Buffer.from(uuidToBuffer(value)), MongoBinary.SUBTYPE_UUID), + load: value => isNullable(value) || typeof value === 'string' ? value as any : bufferToUuid(value.buffer), + }) + this.define({ types: ['bigint'], dump: value => isNullable(value) ? value : value as any, diff --git a/packages/mysql/src/builder.ts b/packages/mysql/src/builder.ts index b6e490ea..98155993 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -1,10 +1,11 @@ import { Builder, escapeId, isBracketed } from '@cordisjs/sql-utils' import { Binary, Dict, isNullable, Time } from 'cosmokit' -import { Driver, Field, isAggrExpr, isEvalExpr, Model, randomId, Selection, Type } from '@cordisjs/plugin-database' +import { bufferToUuid, Driver, Field, isAggrExpr, isEvalExpr, Model, randomId, Selection, Type, uuidToBuffer } from '@cordisjs/plugin-database' export interface Compat { maria?: boolean maria105?: boolean + uuid?: boolean mysql57?: boolean timezone?: string } @@ -96,6 +97,26 @@ export class MySQLBuilder extends Builder { dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toBase64(value), } + if (!compat.uuid) { + // MySQL 8.0 has bin_to_uuid / uuid_to_bin built-in; + // MySQL 5.7 & MariaDB <10.7 get polyfills via _setupCompatFunctions. + // MariaDB 10.7+ uses the native UUID type in JSON, no wrapping needed. + this.transformers['uuid'] = { + encode: value => `bin_to_uuid(${value})`, + decode: value => `uuid_to_bin(${value})`, + load: value => { + if (isNullable(value)) return value + if (typeof value === 'string') return value + return bufferToUuid(value) + }, + dump: value => { + if (isNullable(value)) return value + if (typeof value === 'string') return value + return Buffer.from(uuidToBuffer(value)) + }, + } + } + this.transformers['date'] = { decode: value => `cast(${value} as date)`, load: value => { @@ -143,6 +164,9 @@ export class MySQLBuilder extends Builder { } escapePrimitive(value: any, type?: Type) { + if (type?.type === 'uuid' && typeof value === 'string' && !this.compat.uuid) { + return `uuid_to_bin(${this.quote(value)})` + } if (value instanceof Date) { value = Time.template('yyyy-MM-dd hh:mm:ss.SSS', value) } else if (value instanceof RegExp) { diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts index 726d9157..278fb6c5 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -3,7 +3,7 @@ import { createPool, format } from '@vlasky/mysql' import { Inject } from 'cordis' import type {} from '@cordisjs/plugin-logger' import type { OkPacket, Pool, PoolConfig, PoolConnection } from 'mysql' -import { Driver, Eval, executeUpdate, Field, RuntimeError, Selection } from '@cordisjs/plugin-database' +import { bufferToUuid, Driver, Eval, executeUpdate, Field, RuntimeError, Selection, uuidToBuffer } from '@cordisjs/plugin-database' import { escapeId, isBracketed } from '@cordisjs/sql-utils' import { Compat, MySQLBuilder } from './builder' import zhCN from './locales/zh-CN.yml' @@ -52,7 +52,7 @@ export class MySQLDriver extends Driver { static name = 'mysql' public pool!: Pool - public sql: MySQLBuilder = new MySQLBuilder(this) + public sql!: MySQLBuilder private session?: PoolConnection private _compat: Compat = {} @@ -83,9 +83,19 @@ export class MySQLDriver extends Driver { this._compat.maria105 = !!version.match(/10.5.\d+-MariaDB/) // For json_table this._compat.mysql57 = !!version.match(/5.7.\d+/) + // MariaDB 10.7+ has the native UUID data type + // (MariaDB has no BIN_TO_UUID/UUID_TO_BIN; see MDEV-15854) + const mariaVer = version.match(/(\d+)\.(\d+)\.\d+-MariaDB/) + if (mariaVer) { + const major = +mariaVer[1] + const minor = +mariaVer[2] + this._compat.uuid = major > 10 || (major === 10 && minor >= 7) + } this._compat.timezone = timezone + this.sql = new MySQLBuilder(this, undefined, this._compat) + if (this._compat.mysql57 || this._compat.maria) { await this._setupCompatFunctions() } @@ -121,6 +131,24 @@ export class MySQLDriver extends Driver { load: value => isNullable(value) ? value : Binary.fromSource(value), }) + if (this._compat.uuid) { + // MariaDB native UUID — strings round-trip directly. + this.define({ + types: ['uuid'], + dump: value => value, + load: value => isNullable(value) || typeof value === 'string' ? value : bufferToUuid(value), + }) + } else { + // BINARY(16) storage — JS handles the string ↔ Buffer conversion. + // MySQL 8.0 has bin_to_uuid/uuid_to_bin as built-ins; legacy versions + // get polyfilled equivalents via _setupCompatFunctions. + this.define({ + types: ['uuid'], + dump: value => isNullable(value) ? value : Buffer.from(uuidToBuffer(value)), + load: value => isNullable(value) || typeof value === 'string' ? value : bufferToUuid(value), + }) + } + this.define({ types: Field.number as any, dump: value => value, @@ -292,6 +320,14 @@ INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WH await this.query(`CREATE FUNCTION minato_cfunc_max (j JSON) RETURNS DOUBLE DETERMINISTIC BEGIN DECLARE n int; DECLARE i int; DECLARE r DOUBLE; DROP TEMPORARY TABLE IF EXISTS mtt; CREATE TEMPORARY TABLE mtt (value JSON); SELECT json_length(j) into n; set i = 0; WHILE i 65536 ? 'longtext' : `varchar(${length || 255})` case 'text': return (length || 255) > 65536 ? 'longtext' : `text(${length || 65535})` case 'binary': return (length || 65537) > 65536 ? 'longblob' : `blob` + case 'uuid': + // MariaDB 10.7+ has a native UUID type; legacy versions (MySQL 5.7, + // MariaDB <10.7) get polyfilled bin_to_uuid / uuid_to_bin functions + // via _setupCompatFunctions, so BINARY(16) works universally. + return this._compat.uuid ? 'uuid' : 'binary(16)' case 'list': return `text(${length || 65535})` case 'json': return `text(${length || 65535})` default: throw new Error(`unsupported type: ${type}`) diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts index 9c526103..e388f6e4 100644 --- a/packages/postgres/src/index.ts +++ b/packages/postgres/src/index.ts @@ -104,6 +104,12 @@ export class PostgresDriver extends Driver { load: value => isNullable(value) ? value : Binary.fromSource(value), }) + this.define({ + types: ['uuid'], + dump: value => value, + load: value => value, + }) + this.define({ types: Field.number as any, dump: value => value, @@ -476,6 +482,7 @@ export class PostgresDriver extends Driver { case 'time': return 'time with time zone' case 'timestamp': return 'timestamp with time zone' case 'binary': return 'bytea' + case 'uuid': return 'uuid' default: throw new Error(`unsupported type: ${type}`) } } diff --git a/packages/sql-utils/src/index.ts b/packages/sql-utils/src/index.ts index d9f0e008..70aae268 100644 --- a/packages/sql-utils/src/index.ts +++ b/packages/sql-utils/src/index.ts @@ -44,6 +44,9 @@ interface State { // current eval expr expr?: Eval.Expr + // current field type (used by parseFieldQuery → comparator → escape) + fieldType?: Type + group?: boolean tables?: Dict @@ -212,9 +215,9 @@ export class Builder { 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: any[]) => `(${val.map(x => this.escape(x, this.state.fieldType)).join(', ')})`).join(', ')})` } - return `${key}${notStr} in (${value.map(val => this.escape(val)).join(', ')})` + return `${key}${notStr} in (${value.map(val => this.escape(val, this.state.fieldType)).join(', ')})` } else if (value.$exec) { return `(${key})${notStr} in ${this.parseSelection(value.$exec, true)}` } else if (Type.fromTerm(value)?.type === 'list') { @@ -252,7 +255,7 @@ export class Builder { protected comparator(operator: string) { return (key: string, value: any) => { - return `${key} ${operator} ${this.escape(value)}` + return `${key} ${operator} ${this.escape(value, this.state.fieldType)}` } } @@ -402,7 +405,10 @@ export class Builder { for (const key in flattenQuery) { const model = this.state.tables![this.state.table!] ?? Object.values(this.state.tables!)[0] const expr = Eval('', [this.state.table ?? Object.keys(this.state.tables!)[0], key], model.getType(key)!) + const prevType = this.state.fieldType + this.state.fieldType = model.getType(key) conditions.push(this.parseFieldQuery(this.parseEval(expr), flattenQuery[key])) + this.state.fieldType = prevType } } } diff --git a/packages/sqlite/src/builder.ts b/packages/sqlite/src/builder.ts index d394fee8..26cb5054 100644 --- a/packages/sqlite/src/builder.ts +++ b/packages/sqlite/src/builder.ts @@ -1,6 +1,6 @@ import { Builder, escapeId } from '@cordisjs/sql-utils' import { Binary, Dict, isNullable } from 'cosmokit' -import { Driver, Field, isEvalExpr, Model, randomId, RegExpLike, Type } from '@cordisjs/plugin-database' +import { bufferToUuid, Driver, Field, isEvalExpr, Model, randomId, RegExpLike, Type, uuidToBuffer } from '@cordisjs/plugin-database' export class SQLiteBuilder extends Builder { protected escapeMap = { @@ -59,9 +59,30 @@ export class SQLiteBuilder extends Builder { load: value => isNullable(value) || typeof value === 'object' ? value : Binary.fromHex(value), dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toHex(value), } + + this.transformers['uuid'] = { + encode: value => `hex(${value})`, + decode: value => `unhex(${value})`, + load: value => { + if (isNullable(value)) return value + if (typeof value === 'string') { + if (value.length === 36) return value + return bufferToUuid(Binary.fromHex(value)) + } + return bufferToUuid(value) + }, + dump: value => { + if (isNullable(value)) return value + if (typeof value === 'string') return value + return bufferToUuid(value) + }, + } } escapePrimitive(value: any, type?: Type) { + if (type?.type === 'uuid' && typeof value === 'string') { + return `X'${Binary.toHex(uuidToBuffer(value).buffer as ArrayBuffer)}'` + } if (value instanceof Date) value = +value else if (value instanceof RegExp) value = value.source else if (Binary.is(value)) return `X'${Binary.toHex(value)}'` diff --git a/packages/sqlite/src/index.ts b/packages/sqlite/src/index.ts index 66b16100..399bd21a 100644 --- a/packages/sqlite/src/index.ts +++ b/packages/sqlite/src/index.ts @@ -1,5 +1,5 @@ import { Binary, deepEqual, Dict, difference, isNullable, makeArray, mapValues } from 'cosmokit' -import { Driver, Eval, executeUpdate, Field, getCell, hasSubquery, isEvalExpr, Selection } from '@cordisjs/plugin-database' +import { bufferToUuid, Driver, Eval, executeUpdate, Field, getCell, hasSubquery, isEvalExpr, Selection, uuidToBuffer } from '@cordisjs/plugin-database' import { Inject } from 'cordis' import type {} from '@cordisjs/plugin-logger' import { escapeId } from '@cordisjs/sql-utils' @@ -31,6 +31,7 @@ function getTypeDef({ deftype: type }: Field) { case 'list': case 'json': return `TEXT` case 'binary': return `BLOB` + case 'uuid': return `BLOB` default: throw new Error(`unsupported type: ${type}`) } } @@ -208,6 +209,12 @@ export class SQLiteDriver extends Driver { load: value => isNullable(value) ? value : Binary.fromSource(value), }) + this.define({ + types: ['uuid'], + dump: value => isNullable(value) ? value : uuidToBuffer(value), + load: value => isNullable(value) || typeof value === 'string' ? value : bufferToUuid(value), + }) + this.define({ types: ['primary', ...Field.number as any], dump: value => value, diff --git a/packages/tests/src/index.ts b/packages/tests/src/index.ts index 3b3e7e51..cf9b297c 100644 --- a/packages/tests/src/index.ts +++ b/packages/tests/src/index.ts @@ -11,6 +11,7 @@ import Json from './json' import Transaction from './transaction' import Relation from './relation' import Performance from './performance' +import Uuid from './uuid' import './setup' export { expect } from 'chai' @@ -105,6 +106,7 @@ namespace Tests { export const transaction = Transaction export const relation = Relation export const performance = Performance + export const uuid = Uuid } export default createUnit(Tests, true) diff --git a/packages/tests/src/uuid.ts b/packages/tests/src/uuid.ts new file mode 100644 index 00000000..1086a91e --- /dev/null +++ b/packages/tests/src/uuid.ts @@ -0,0 +1,120 @@ +import { Database } from '@cordisjs/plugin-database' +import { expect } from 'chai' + +interface UuidRow { + id: number + value?: string +} + +interface UuidParent { + id: string + name?: string + children?: UuidChild[] +} + +interface UuidChild { + id: string + label?: string + parent?: UuidParent +} + +declare module '@cordisjs/plugin-database' { + interface Tables { + uuids: UuidRow + uuidParents: UuidParent + uuidChildren: UuidChild + } +} + +const u1 = '550e8400-e29b-41d4-a716-446655440000' +const u2 = '00112233-4455-6677-8899-aabbccddeeff' +const u3 = 'ffffffff-eeee-dddd-cccc-bbbbbbbbbbbb' + +function UuidOperations(database: Database) { + before(() => { + database.extend('uuids', { + id: 'unsigned', + value: { + type: 'uuid', + initial: '00000000-0000-0000-0000-000000000000', + }, + }, { autoInc: true }) + + database.extend('uuidParents', { + id: 'uuid', + name: 'string', + }, { primary: 'id' }) + + database.extend('uuidChildren', { + id: 'uuid', + label: 'string', + parent: { + type: 'manyToOne', + table: 'uuidParents', + target: 'children', + }, + }, { primary: 'id' }) + }) + + it('round-trip', async () => { + await database.remove('uuids', {}) + const a = await database.create('uuids', { id: 1, value: u1 }) + expect(a.value).to.equal(u1) + + const b = await database.create('uuids', { id: 2 }) + expect(b.value).to.equal('00000000-0000-0000-0000-000000000000') + + await database.set('uuids', a.id, { value: u2 }) + const fetched = await database.get('uuids', a.id) + expect(fetched[0].value).to.equal(u2) + + await database.upsert('uuids', [{ id: a.id, value: u1 }]) + const after = await database.get('uuids', a.id) + expect(after[0].value).to.equal(u1) + }) + + it('uuid primary key', async () => { + await database.remove('uuidParents', {}) + await database.create('uuidParents', { id: u1, name: 'parent-1' }) + await database.create('uuidParents', { id: u2, name: 'parent-2' }) + + const all = await database.get('uuidParents', {}) + expect(all.map(x => x.id)).to.have.members([u1, u2]) + + const byId = await database.get('uuidParents', { id: u1 }) + expect(byId).to.have.length(1) + expect(byId[0]).to.deep.include({ id: u1, name: 'parent-1' }) + + const byIn = await database.get('uuidParents', { id: { $in: [u1, u3] } }) + expect(byIn.map(x => x.id)).to.have.members([u1]) + }) + + it('uuid foreign key join (manyToOne)', async () => { + await database.remove('uuidChildren', {}) + await database.remove('uuidParents', {}) + await database.create('uuidParents', { id: u1, name: 'parent-1' }) + await database.create('uuidParents', { id: u2, name: 'parent-2' }) + await database.create('uuidChildren', { id: u3, label: 'c1', parent: { $literal: { id: u1 } } }) + await database.create('uuidChildren', { id: '11111111-2222-3333-4444-555555555555', label: 'c2', parent: { $literal: { id: u1 } } }) + await database.create('uuidChildren', { id: '22222222-3333-4444-5555-666666666666', label: 'c3', parent: { $literal: { id: u2 } } }) + + const joined = await database.get('uuidChildren', {}, { include: { parent: true } }) + expect(joined).to.have.length(3) + const c1 = joined.find(x => x.label === 'c1')! + expect(c1.parent?.id).to.equal(u1) + expect(c1.parent?.name).to.equal('parent-1') + + const parentsWithChildren = await database.get('uuidParents', {}, { include: { children: true } }) + const p1 = parentsWithChildren.find(x => x.id === u1)! + expect(p1.children).to.have.length(2) + expect(p1.children!.map(x => x.label)).to.have.members(['c1', 'c2']) + }) + + it('query by uuid foreign key', async () => { + const rows = await database.get('uuidChildren', { parent: { id: u1 } }) + expect(rows).to.have.length(2) + expect(rows.every(x => x.label === 'c1' || x.label === 'c2')).to.be.true + }) +} + +export default UuidOperations