Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ jobs:
- mysql:5.7
- mysql:8.0
- mariadb:10.5
- mariadb:11.4
node-version: [22, 24]

services:
mysql:
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
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export namespace Field {
export type Type<T = any> =
| 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'
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('')}`
}
52 changes: 40 additions & 12 deletions packages/mongo/src/builder.ts
Original file line number Diff line number Diff line change
@@ -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'

Check warning on line 2 in packages/mongo/src/builder.ts

View workflow job for this annotation

GitHub Actions / lint

This line has a length of 176. Maximum allowed is 160

Check warning on line 2 in packages/mongo/src/builder.ts

View workflow job for this annotation

GitHub Actions / lint

This line has a length of 176. Maximum allowed is 160
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<any>[] = []
const result: Filter<any> = {}
Expand All @@ -18,9 +22,13 @@
// shorthand syntax
if (isComparable(query) || query instanceof ObjectId) {
if (type?.type === 'primary' && typeof query === 'string') query = new ObjectId(query)
if (type?.type === 'uuid' && typeof query === 'string') query = toUuidBinary(query)
return { $eq: query }
} else if (Array.isArray(query)) {
if (!query.length) return false
if (type?.type === 'uuid') {
return { $in: query.map(x => typeof x === 'string' ? toUuidBinary(x) : x) }
}
return { $in: query }
} else if (query instanceof RegExp) {
return { $regex: query }
Expand Down Expand Up @@ -70,7 +78,15 @@
if (query[prop]) return { $ne: null }
else return null
} else {
result[prop] = query[prop]
let value = query[prop]
if (type?.type === 'uuid') {
if (Array.isArray(value)) {
value = value.map(x => typeof x === 'string' ? toUuidBinary(x) : x)
} else if (typeof value === 'string') {
value = toUuidBinary(value)
}
}
result[prop] = value
}
}
if (!Object.keys(result).length) return true
Expand Down Expand Up @@ -603,15 +619,27 @@
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: <dumped> } }`).
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[]
Expand Down
10 changes: 8 additions & 2 deletions packages/mongo/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'

Check warning on line 1 in packages/mongo/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Member 'ClientSession' of the import declaration should be sorted alphabetically

Check warning on line 1 in packages/mongo/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

This line has a length of 167. Maximum allowed is 160

Check warning on line 1 in packages/mongo/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

Member 'ClientSession' of the import declaration should be sorted alphabetically

Check warning on line 1 in packages/mongo/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

This line has a length of 167. Maximum allowed is 160
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'
Expand Down Expand Up @@ -78,6 +78,12 @@
load: value => isNullable(value) ? value : Binary.fromSource(value.buffer),
})

this.define<string, MongoBinary>({
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<bigint, number | Long>({
types: ['bigint'],
dump: value => isNullable(value) ? value : value as any,
Expand Down
26 changes: 25 additions & 1 deletion packages/mysql/src/builder.ts
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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) {
Expand Down
45 changes: 43 additions & 2 deletions packages/mysql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -52,7 +52,7 @@ export class MySQLDriver extends Driver<MySQLDriver.Config> {
static name = 'mysql'

public pool!: Pool
public sql: MySQLBuilder = new MySQLBuilder(this)
public sql!: MySQLBuilder

private session?: PoolConnection
private _compat: Compat = {}
Expand Down Expand Up @@ -83,9 +83,19 @@ export class MySQLDriver extends Driver<MySQLDriver.Config> {
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()
}
Expand Down Expand Up @@ -121,6 +131,24 @@ export class MySQLDriver extends Driver<MySQLDriver.Config> {
load: value => isNullable(value) ? value : Binary.fromSource(value),
})

if (this._compat.uuid) {
// MariaDB native UUID — strings round-trip directly.
this.define<string, any>({
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<string, any>({
types: ['uuid'],
dump: value => isNullable(value) ? value : Buffer.from(uuidToBuffer(value)),
load: value => isNullable(value) || typeof value === 'string' ? value : bufferToUuid(value),
})
}

this.define<number, number>({
types: Field.number as any,
dump: value => value,
Expand Down Expand Up @@ -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<n DO
INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WHILE; SELECT max(value) INTO r FROM mtt; RETURN r; END`)
// Polyfill MySQL 8.0's BIN_TO_UUID / UUID_TO_BIN (single-arg form).
// MariaDB never implemented these (MDEV-15854); MySQL 5.7 predates them.
await this.query(`DROP FUNCTION IF EXISTS bin_to_uuid`)
await this.query(`CREATE FUNCTION bin_to_uuid (b BINARY(16)) RETURNS CHAR(36) DETERMINISTIC
RETURN LOWER(CONCAT_WS('-', SUBSTR(HEX(b), 1, 8), SUBSTR(HEX(b), 9, 4), SUBSTR(HEX(b), 13, 4), SUBSTR(HEX(b), 17, 4), SUBSTR(HEX(b), 21, 12)))`)
await this.query(`DROP FUNCTION IF EXISTS uuid_to_bin`)
await this.query(`CREATE FUNCTION uuid_to_bin (u CHAR(36)) RETURNS BINARY(16) DETERMINISTIC
RETURN UNHEX(REPLACE(u, '-', ''))`)
} catch (e) {
this.ctx.logger?.warn(`Failed to setup compact functions: ${e}`)
}
Expand Down Expand Up @@ -566,6 +602,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':
// 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}`)
Expand Down
7 changes: 7 additions & 0 deletions packages/postgres/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export class PostgresDriver extends Driver<PostgresDriver.Config> {
load: value => isNullable(value) ? value : Binary.fromSource(value),
})

this.define<string, string>({
types: ['uuid'],
dump: value => value,
load: value => value,
})

this.define<number, number>({
types: Field.number as any,
dump: value => value,
Expand Down Expand Up @@ -476,6 +482,7 @@ export class PostgresDriver extends Driver<PostgresDriver.Config> {
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}`)
}
}
Expand Down
12 changes: 9 additions & 3 deletions packages/sql-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Model>

Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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)}`
}
}

Expand Down Expand Up @@ -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
}
}
}
Expand Down
Loading