From 9332ab183f4af0ee315713f2965ac57c9a343f0b Mon Sep 17 00:00:00 2001 From: Shigma Date: Mon, 4 May 2026 13:44:46 +0800 Subject: [PATCH 1/5] feat: support uuid --- packages/core/src/model.ts | 2 +- packages/core/src/utils.ts | 25 +++++++++++++++++++++++++ packages/mongo/src/index.ts | 10 ++++++++-- packages/mysql/src/builder.ts | 17 ++++++++++++++++- packages/mysql/src/index.ts | 9 ++++++++- packages/postgres/src/index.ts | 7 +++++++ packages/sqlite/src/builder.ts | 20 +++++++++++++++++++- packages/sqlite/src/index.ts | 9 ++++++++- packages/tests/src/model.ts | 22 ++++++++++++++++++++++ 9 files changed, 114 insertions(+), 7 deletions(-) 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/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..0ec3ba23 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -1,6 +1,6 @@ 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 @@ -96,6 +96,21 @@ export class MySQLBuilder extends Builder { dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toBase64(value), } + 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 => { diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts index 726d9157..80021645 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' @@ -121,6 +121,12 @@ export class MySQLDriver extends Driver { load: value => isNullable(value) ? value : Binary.fromSource(value), }) + 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, @@ -566,6 +572,7 @@ INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WH case 'string': return (length || 255) > 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': return '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/sqlite/src/builder.ts b/packages/sqlite/src/builder.ts index d394fee8..ef85b557 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 { Driver, Field, isEvalExpr, Model, randomId, RegExpLike, Type, uuidToBuffer, bufferToUuid } from '@cordisjs/plugin-database' export class SQLiteBuilder extends Builder { protected escapeMap = { @@ -59,6 +59,24 @@ 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) { 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/model.ts b/packages/tests/src/model.ts index 948b6fb3..87f62888 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -39,6 +39,7 @@ interface DType { date?: Date time?: Date binary?: ArrayBuffer | Buffer + uuid?: string bigint?: bigint bnum?: number bnum2?: number @@ -244,6 +245,10 @@ function ModelOperations(database: Database) { type: 'binary', initial: toBinary('initial buffer') }, + uuid: { + type: 'uuid', + initial: '00000000-0000-0000-0000-000000000000', + }, bigint: 'bigint2', bnum, bnum2: { @@ -301,6 +306,7 @@ namespace ModelOperations { { id: 12, decimal: 2.432, int64: 9223372036854775806n }, { id: 13, bnum: 114514, bnum2: 12345 }, { id: 14, object: { embed: { custom: { a: 'abc', b: 123 } } } }, + { id: 15, uuid: '550e8400-e29b-41d4-a716-446655440000' }, ] const dobjectTable: DObject[] = [ @@ -349,6 +355,22 @@ namespace ModelOperations { await expect(database.get('dtypes', {})).to.eventually.have.deep.members(table) }) + it('uuid round-trip', async () => { + const table = await setup(database, 'dtypes', dtypeTable) + const row = table.find(x => x.id === 15)! + expect(row.uuid).to.equal('550e8400-e29b-41d4-a716-446655440000') + table.filter(x => x.id !== 15).forEach(x => expect(x.uuid).to.equal('00000000-0000-0000-0000-000000000000')) + + const updated = '00112233-4455-6677-8899-aabbccddeeff' + await database.set('dtypes', row.id, { uuid: updated }) + const after = await database.get('dtypes', row.id) + expect(after[0].uuid).to.equal(updated) + + await database.upsert('dtypes', [{ id: row.id, uuid: '550e8400-e29b-41d4-a716-446655440000' }]) + const after2 = await database.get('dtypes', row.id) + expect(after2[0].uuid).to.equal('550e8400-e29b-41d4-a716-446655440000') + }) + it('modifier', async () => { const table = await setup(database, 'dtypes', dtypeTable) await database.remove('dtypes', {}) From e3821903c5a518e64c4cc7f8ff91a498bf20ab6f Mon Sep 17 00:00:00 2001 From: Shigma Date: Mon, 4 May 2026 14:09:35 +0800 Subject: [PATCH 2/5] test: separate uuid tests --- packages/mysql/src/index.ts | 6 ++++- packages/tests/src/index.ts | 2 ++ packages/tests/src/model.ts | 22 ------------------ packages/tests/src/uuid.ts | 45 +++++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 23 deletions(-) create mode 100644 packages/tests/src/uuid.ts diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts index 80021645..5730e9f7 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -572,7 +572,11 @@ INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WH case 'string': return (length || 255) > 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': return 'binary(16)' + case 'uuid': + if (this._compat.mysql57 || this._compat.maria) { + throw new Error(`uuid type requires MySQL 8.0+ or MariaDB 10.7+`) + } + return '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/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/model.ts b/packages/tests/src/model.ts index 87f62888..948b6fb3 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -39,7 +39,6 @@ interface DType { date?: Date time?: Date binary?: ArrayBuffer | Buffer - uuid?: string bigint?: bigint bnum?: number bnum2?: number @@ -245,10 +244,6 @@ function ModelOperations(database: Database) { type: 'binary', initial: toBinary('initial buffer') }, - uuid: { - type: 'uuid', - initial: '00000000-0000-0000-0000-000000000000', - }, bigint: 'bigint2', bnum, bnum2: { @@ -306,7 +301,6 @@ namespace ModelOperations { { id: 12, decimal: 2.432, int64: 9223372036854775806n }, { id: 13, bnum: 114514, bnum2: 12345 }, { id: 14, object: { embed: { custom: { a: 'abc', b: 123 } } } }, - { id: 15, uuid: '550e8400-e29b-41d4-a716-446655440000' }, ] const dobjectTable: DObject[] = [ @@ -355,22 +349,6 @@ namespace ModelOperations { await expect(database.get('dtypes', {})).to.eventually.have.deep.members(table) }) - it('uuid round-trip', async () => { - const table = await setup(database, 'dtypes', dtypeTable) - const row = table.find(x => x.id === 15)! - expect(row.uuid).to.equal('550e8400-e29b-41d4-a716-446655440000') - table.filter(x => x.id !== 15).forEach(x => expect(x.uuid).to.equal('00000000-0000-0000-0000-000000000000')) - - const updated = '00112233-4455-6677-8899-aabbccddeeff' - await database.set('dtypes', row.id, { uuid: updated }) - const after = await database.get('dtypes', row.id) - expect(after[0].uuid).to.equal(updated) - - await database.upsert('dtypes', [{ id: row.id, uuid: '550e8400-e29b-41d4-a716-446655440000' }]) - const after2 = await database.get('dtypes', row.id) - expect(after2[0].uuid).to.equal('550e8400-e29b-41d4-a716-446655440000') - }) - it('modifier', async () => { const table = await setup(database, 'dtypes', dtypeTable) await database.remove('dtypes', {}) diff --git a/packages/tests/src/uuid.ts b/packages/tests/src/uuid.ts new file mode 100644 index 00000000..cc550c1b --- /dev/null +++ b/packages/tests/src/uuid.ts @@ -0,0 +1,45 @@ +import { Database } from '@cordisjs/plugin-database' +import { expect } from 'chai' + +interface UuidRow { + id: number + value?: string +} + +declare module '@cordisjs/plugin-database' { + interface Tables { + uuids: UuidRow + } +} + +function UuidOperations(database: Database) { + before(() => { + database.extend('uuids', { + id: 'unsigned', + value: { + type: 'uuid', + initial: '00000000-0000-0000-0000-000000000000', + }, + }, { autoInc: true }) + }) + + it('round-trip', async () => { + await database.remove('uuids', {}) + const a = await database.create('uuids', { id: 1, value: '550e8400-e29b-41d4-a716-446655440000' }) + expect(a.value).to.equal('550e8400-e29b-41d4-a716-446655440000') + + const b = await database.create('uuids', { id: 2 }) + expect(b.value).to.equal('00000000-0000-0000-0000-000000000000') + + const updated = '00112233-4455-6677-8899-aabbccddeeff' + await database.set('uuids', a.id, { value: updated }) + const fetched = await database.get('uuids', a.id) + expect(fetched[0].value).to.equal(updated) + + await database.upsert('uuids', [{ id: a.id, value: '550e8400-e29b-41d4-a716-446655440000' }]) + const after = await database.get('uuids', a.id) + expect(after[0].value).to.equal('550e8400-e29b-41d4-a716-446655440000') + }) +} + +export default UuidOperations From b9fe138caa6b8ec4f4ac1523246b936df51d8afa Mon Sep 17 00:00:00 2001 From: Shigma Date: Tue, 5 May 2026 13:54:34 +0800 Subject: [PATCH 3/5] fix(sql): fix foreign key encode / decode --- .github/workflows/test.yml | 3 +- packages/core/src/database.ts | 3 + packages/mysql/src/builder.ts | 4 ++ packages/mysql/src/index.ts | 30 ++++++++-- packages/mysql/tests/index.spec.ts | 3 + packages/sql-utils/src/index.ts | 12 +++- packages/sqlite/src/builder.ts | 3 + packages/tests/src/uuid.ts | 89 +++++++++++++++++++++++++++--- 8 files changed, 131 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3cf9622b..581c7e27 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: @@ -42,7 +43,7 @@ jobs: - name: Install run: yarn --no-immutable - name: Unit Test - run: yarn test:json mysql + run: yarn test:json mysql ${{ (matrix.mysql-image == 'mysql:8.0' || matrix.mysql-image == 'mariadb:11.4') && '--+uuid' || '' }} - name: Report Coverage uses: codecov/codecov-action@v6 with: 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/mysql/src/builder.ts b/packages/mysql/src/builder.ts index 0ec3ba23..341863dd 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -5,6 +5,7 @@ import { bufferToUuid, Driver, Field, isAggrExpr, isEvalExpr, Model, randomId, S export interface Compat { maria?: boolean maria105?: boolean + mariaUuid?: boolean mysql57?: boolean timezone?: string } @@ -158,6 +159,9 @@ export class MySQLBuilder extends Builder { } escapePrimitive(value: any, type?: Type) { + if (type?.type === 'uuid' && typeof value === 'string' && !this.compat.mariaUuid) { + return `X'${Binary.toHex(uuidToBuffer(value).buffer as ArrayBuffer)}'` + } 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 5730e9f7..7ee70b3b 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -83,6 +83,14 @@ 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.mariaUuid = major > 10 || (major === 10 && minor >= 7) + } this._compat.timezone = timezone @@ -121,11 +129,22 @@ export class MySQLDriver extends Driver { load: value => isNullable(value) ? value : Binary.fromSource(value), }) - this.define({ - types: ['uuid'], - dump: value => isNullable(value) ? value : Buffer.from(uuidToBuffer(value)), - load: value => isNullable(value) || typeof value === 'string' ? value : bufferToUuid(value), - }) + if (this._compat.mariaUuid) { + // 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 if (!this._compat.mysql57 && !this._compat.maria) { + // MySQL 8.0+ — BINARY(16); JS handles the string ↔ Buffer conversion. + this.define({ + types: ['uuid'], + dump: value => isNullable(value) ? value : Buffer.from(uuidToBuffer(value)), + load: value => isNullable(value) || typeof value === 'string' ? value : bufferToUuid(value), + }) + } + // otherwise: uuid fields cause getTypeDef to throw — no transformer needed. this.define({ types: Field.number as any, @@ -573,6 +592,7 @@ INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WH case 'text': return (length || 255) > 65536 ? 'longtext' : `text(${length || 65535})` case 'binary': return (length || 65537) > 65536 ? 'longblob' : `blob` case 'uuid': + if (this._compat.mariaUuid) return 'uuid' if (this._compat.mysql57 || this._compat.maria) { throw new Error(`uuid type requires MySQL 8.0+ or MariaDB 10.7+`) } diff --git a/packages/mysql/tests/index.spec.ts b/packages/mysql/tests/index.spec.ts index 6dd042bb..1dbccf8f 100644 --- a/packages/mysql/tests/index.spec.ts +++ b/packages/mysql/tests/index.spec.ts @@ -23,6 +23,9 @@ describe('@cordisjs/plugin-database-mysql', () => { }) test(ctx, { + // requires MySQL 8.0+ / MariaDB 10.7+ (uuid_to_bin / bin_to_uuid). + // enable locally with `--+uuid` when running against a supported server. + uuid: false, query: { list: { elementQuery: false, 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 ef85b557..3b2e5677 100644 --- a/packages/sqlite/src/builder.ts +++ b/packages/sqlite/src/builder.ts @@ -80,6 +80,9 @@ export class SQLiteBuilder extends Builder { } 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/tests/src/uuid.ts b/packages/tests/src/uuid.ts index cc550c1b..1086a91e 100644 --- a/packages/tests/src/uuid.ts +++ b/packages/tests/src/uuid.ts @@ -6,12 +6,30 @@ interface UuidRow { 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', { @@ -21,24 +39,81 @@ function UuidOperations(database: Database) { 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: '550e8400-e29b-41d4-a716-446655440000' }) - expect(a.value).to.equal('550e8400-e29b-41d4-a716-446655440000') + 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') - const updated = '00112233-4455-6677-8899-aabbccddeeff' - await database.set('uuids', a.id, { value: updated }) + await database.set('uuids', a.id, { value: u2 }) const fetched = await database.get('uuids', a.id) - expect(fetched[0].value).to.equal(updated) + expect(fetched[0].value).to.equal(u2) - await database.upsert('uuids', [{ id: a.id, value: '550e8400-e29b-41d4-a716-446655440000' }]) + await database.upsert('uuids', [{ id: a.id, value: u1 }]) const after = await database.get('uuids', a.id) - expect(after[0].value).to.equal('550e8400-e29b-41d4-a716-446655440000') + 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 }) } From 6f40a1432b0a6685759c8fd63df32b26f140e1b3 Mon Sep 17 00:00:00 2001 From: Shigma Date: Tue, 5 May 2026 15:32:43 +0800 Subject: [PATCH 4/5] feat: support uuid for mongodb --- .github/workflows/test.yml | 2 +- packages/mongo/src/builder.ts | 52 +++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 581c7e27..22f01f72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,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/mongo/src/builder.ts b/packages/mongo/src/builder.ts index 914b37e0..02756913 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 { bufferToUuid, 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[] From f1028a4a71cb2a89e237c1f9664058b50e53dd6d Mon Sep 17 00:00:00 2001 From: Shigma Date: Tue, 5 May 2026 15:54:29 +0800 Subject: [PATCH 5/5] feat: compat uuid support --- .github/workflows/test.yml | 2 +- packages/mongo/src/builder.ts | 2 +- packages/mysql/src/builder.ts | 37 +++++++++++++++++------------- packages/mysql/src/index.ts | 32 +++++++++++++++++--------- packages/mysql/tests/index.spec.ts | 3 --- packages/sqlite/src/builder.ts | 2 +- 6 files changed, 45 insertions(+), 33 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22f01f72..7af140e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,7 @@ jobs: - name: Install run: yarn --no-immutable - name: Unit Test - run: yarn test:json mysql ${{ (matrix.mysql-image == 'mysql:8.0' || matrix.mysql-image == 'mariadb:11.4') && '--+uuid' || '' }} + run: yarn test:json mysql - name: Report Coverage uses: codecov/codecov-action@v6 with: diff --git a/packages/mongo/src/builder.ts b/packages/mongo/src/builder.ts index 02756913..ef5e1840 100644 --- a/packages/mongo/src/builder.ts +++ b/packages/mongo/src/builder.ts @@ -1,5 +1,5 @@ import { Dict, isNullable, mapValues } from 'cosmokit' -import { bufferToUuid, Eval, Field, flatten, isAggrExpr, isComparable, isEvalExpr, isFlat, makeRegExp, Model, Query, Selection, Type, unravel, uuidToBuffer } from '@cordisjs/plugin-database' +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 '.' diff --git a/packages/mysql/src/builder.ts b/packages/mysql/src/builder.ts index 341863dd..98155993 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -5,7 +5,7 @@ import { bufferToUuid, Driver, Field, isAggrExpr, isEvalExpr, Model, randomId, S export interface Compat { maria?: boolean maria105?: boolean - mariaUuid?: boolean + uuid?: boolean mysql57?: boolean timezone?: string } @@ -97,19 +97,24 @@ export class MySQLBuilder extends Builder { dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toBase64(value), } - 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)) - }, + 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'] = { @@ -159,8 +164,8 @@ export class MySQLBuilder extends Builder { } escapePrimitive(value: any, type?: Type) { - if (type?.type === 'uuid' && typeof value === 'string' && !this.compat.mariaUuid) { - return `X'${Binary.toHex(uuidToBuffer(value).buffer as ArrayBuffer)}'` + 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) diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts index 7ee70b3b..278fb6c5 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -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 = {} @@ -89,11 +89,13 @@ export class MySQLDriver extends Driver { if (mariaVer) { const major = +mariaVer[1] const minor = +mariaVer[2] - this._compat.mariaUuid = major > 10 || (major === 10 && minor >= 7) + 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() } @@ -129,22 +131,23 @@ export class MySQLDriver extends Driver { load: value => isNullable(value) ? value : Binary.fromSource(value), }) - if (this._compat.mariaUuid) { + 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 if (!this._compat.mysql57 && !this._compat.maria) { - // MySQL 8.0+ — BINARY(16); JS handles the string ↔ Buffer conversion. + } 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), }) } - // otherwise: uuid fields cause getTypeDef to throw — no transformer needed. this.define({ types: Field.number as any, @@ -317,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' : `text(${length || 65535})` case 'binary': return (length || 65537) > 65536 ? 'longblob' : `blob` case 'uuid': - if (this._compat.mariaUuid) return 'uuid' - if (this._compat.mysql57 || this._compat.maria) { - throw new Error(`uuid type requires MySQL 8.0+ or MariaDB 10.7+`) - } - return 'binary(16)' + // 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/mysql/tests/index.spec.ts b/packages/mysql/tests/index.spec.ts index 1dbccf8f..6dd042bb 100644 --- a/packages/mysql/tests/index.spec.ts +++ b/packages/mysql/tests/index.spec.ts @@ -23,9 +23,6 @@ describe('@cordisjs/plugin-database-mysql', () => { }) test(ctx, { - // requires MySQL 8.0+ / MariaDB 10.7+ (uuid_to_bin / bin_to_uuid). - // enable locally with `--+uuid` when running against a supported server. - uuid: false, query: { list: { elementQuery: false, diff --git a/packages/sqlite/src/builder.ts b/packages/sqlite/src/builder.ts index 3b2e5677..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, uuidToBuffer, bufferToUuid } 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 = {