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..8f1ab4e0 100644 --- a/packages/mongo/src/builder.ts +++ b/packages/mongo/src/builder.ts @@ -1,82 +1,11 @@ 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 { + Eval, Field, flatten, isAggrExpr, isComparable, isEvalExpr, isFlat, makeRegExp, + Model, Query, Selection, Type, unravel, +} from '@cordisjs/plugin-database' import { Filter, FilterOperators, ObjectId } from 'mongodb' import MongoDriver from '.' -function createFieldFilter(query: Query.Field, key: string, type?: Type) { - const filters: Filter[] = [] - const result: Filter = {} - const child = transformFieldQuery(query, key, filters, type) - if (child === false) return false - if (child !== true) result[key] = child - if (filters.length) result.$and = filters - if (Object.keys(result).length) return result - return true -} - -function transformFieldQuery(query: Query.Field, key: string, filters: Filter[], type?: Type) { - // shorthand syntax - if (isComparable(query) || query instanceof ObjectId) { - if (type?.type === 'primary' && typeof query === 'string') query = new ObjectId(query) - return { $eq: query } - } else if (Array.isArray(query)) { - if (!query.length) return false - return { $in: query } - } else if (query instanceof RegExp) { - return { $regex: query } - } else if (isNullable(query)) { - return null - } - - // query operators - const result: FilterOperators = {} - for (const prop in query) { - if (prop === '$and') { - for (const item of query[prop]!) { - const child = createFieldFilter(item, key, type) - if (child === false) return false - if (child !== true) filters.push(child) - } - } else if (prop === '$or') { - const $or: Filter[] = [] - if (!query[prop]!.length) return false - const always = query[prop]!.some((item) => { - const child = createFieldFilter(item, key, type) - if (typeof child === 'boolean') return child - $or.push(child) - }) - if (!always) filters.push({ $or }) - } else if (prop === '$not') { - const child = createFieldFilter(query[prop], key, type) - if (child === true) return false - if (child !== false) filters.push({ $nor: [child] }) - } else if (prop === '$el') { - const child = transformFieldQuery(query[prop]!, key, filters) - if (child === false) return false - if (child !== true) result.$elemMatch = child! - } else if (prop === '$regex') { - return { $regex: typeof query[prop] === 'string' ? query[prop] : makeRegExp(query[prop]) } - } else if (prop === '$regexFor') { - filters.push({ - $expr: { - $regexMatch: { - input: query[prop].input ?? query[prop], - regex: '$' + key, - ...(query[prop].flags ? { options: query[prop].flags } : {}), - }, - }, - }) - } else if (prop === '$exists') { - if (query[prop]) return { $ne: null } - else return null - } else { - result[prop] = query[prop] - } - } - if (!Object.keys(result).length) return true - return result -} - export type ExtractUnary = T extends [infer U] ? U : T export type EvalOperators = { @@ -315,6 +244,88 @@ export class Builder { this.evalOperators = Object.assign(Object.create(null), this.evalOperators) } + private dumpQuery(value: any, type?: Type): any { + const typeKey = type?.type + if (!typeKey) return value + const converter = this.driver.types[typeKey] + if (!converter?.dump) return value + if (Array.isArray(value)) return value.map(v => converter.dump!(v)) + return converter.dump(value) + } + + private createFieldFilter(query: Query.Field, key: string, type?: Type) { + const filters: Filter[] = [] + const result: Filter = {} + const child = this.transformFieldQuery(query, key, filters, type) + if (child === false) return false + if (child !== true) result[key] = child + if (filters.length) result.$and = filters + if (Object.keys(result).length) return result + return true + } + + private transformFieldQuery(query: Query.Field, key: string, filters: Filter[], type?: Type) { + // shorthand syntax + if (isComparable(query) || query instanceof ObjectId) { + return { $eq: this.dumpQuery(query, type) } + } else if (Array.isArray(query)) { + if (!query.length) return false + return { $in: this.dumpQuery(query, type) } + } else if (query instanceof RegExp) { + return { $regex: query } + } else if (isNullable(query)) { + return null + } + + // query operators + const result: FilterOperators = {} + for (const prop in query) { + if (prop === '$and') { + for (const item of query[prop]!) { + const child = this.createFieldFilter(item, key, type) + if (child === false) return false + if (child !== true) filters.push(child) + } + } else if (prop === '$or') { + const $or: Filter[] = [] + if (!query[prop]!.length) return false + const always = query[prop]!.some((item) => { + const child = this.createFieldFilter(item, key, type) + if (typeof child === 'boolean') return child + $or.push(child) + }) + if (!always) filters.push({ $or }) + } else if (prop === '$not') { + const child = this.createFieldFilter(query[prop], key, type) + if (child === true) return false + if (child !== false) filters.push({ $nor: [child] }) + } else if (prop === '$el') { + const child = this.transformFieldQuery(query[prop]!, key, filters) + if (child === false) return false + if (child !== true) result.$elemMatch = child! + } else if (prop === '$regex') { + return { $regex: typeof query[prop] === 'string' ? query[prop] : makeRegExp(query[prop]) } + } else if (prop === '$regexFor') { + filters.push({ + $expr: { + $regexMatch: { + input: query[prop].input ?? query[prop], + regex: '$' + key, + ...(query[prop].flags ? { options: query[prop].flags } : {}), + }, + }, + }) + } else if (prop === '$exists') { + if (query[prop]) return { $ne: null } + else return null + } else { + result[prop] = this.dumpQuery(query[prop], type) + } + } + if (!Object.keys(result).length) return true + return result + } + public createKey() { return '_temp_' + ++this.counter } @@ -430,7 +441,7 @@ export class Builder { const flattenQuery = ignore(value) ? { [key]: value } : flatten(value, `${key}.`, ignore) for (const key in flattenQuery) { const value = flattenQuery[key], actualKey = this.getActualKey(key) - const query = transformFieldQuery(value, actualKey, additional, sel.model.fields[key]?.type) + const query = this.transformFieldQuery(value, actualKey, additional, sel.model.fields[key]?.type) if (query === false) return if (query !== true) filter[actualKey] = query } @@ -603,15 +614,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 f6279c25..2351b678 100644 --- a/packages/mongo/src/index.ts +++ b/packages/mongo/src/index.ts @@ -1,6 +1,9 @@ -import { BSONType, ClientSession, Collection, Db, IndexDescription, Long, MongoClient, MongoClientOptions, MongoError, ObjectId } from 'mongodb' +import { + BSONType, ClientSession, Collection, Db, IndexDescription, Long, Binary as MongoBinary, + 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 { Builder } from './builder' import zhCN from './locales/zh-CN.yml' import enUS from './locales/en-US.yml' @@ -75,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..5fec35c1 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -1,11 +1,12 @@ 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 mysql57?: boolean + uuid?: boolean timezone?: string } @@ -96,6 +97,18 @@ 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 => isNullable(value) || typeof value === 'object' ? value : Buffer.from(uuidToBuffer(value)), + dump: value => isNullable(value) || typeof value === 'string' ? value : bufferToUuid(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 605f9f80..63dda6b7 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -1,7 +1,7 @@ import { Binary, Dict, difference, isNullable, makeArray, pick } from 'cosmokit' import { createPool, format } from '@vlasky/mysql' 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' @@ -49,7 +49,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 = {} @@ -80,9 +80,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() } @@ -118,6 +128,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, @@ -289,6 +317,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/builder.ts b/packages/postgres/src/builder.ts index ff23690a..ed897a05 100644 --- a/packages/postgres/src/builder.ts +++ b/packages/postgres/src/builder.ts @@ -223,9 +223,9 @@ export class PostgresBuilder extends 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 => isEvalExpr(val) ? this.parseEval(val, false) : 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') { diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts index 54f91fec..a6697267 100644 --- a/packages/postgres/src/index.ts +++ b/packages/postgres/src/index.ts @@ -473,6 +473,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..7b874fac 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 @@ -179,8 +182,8 @@ export class Builder { $lte: this.binary('<='), // membership - $in: ([key, value]) => this.asEncoded(this.createMemberQuery(this.parseEval(key, false), value, ''), false), - $nin: ([key, value]) => this.asEncoded(this.createMemberQuery(this.parseEval(key, false), value, ' NOT'), false), + $in: ([key, value]) => this.asEncoded(this.createMemberQuery(this.parseEval(key, false), value, '', key), false), + $nin: ([key, value]) => this.asEncoded(this.createMemberQuery(this.parseEval(key, false), value, ' NOT', key), false), // typecast $literal: ([value, type]) => this.escape(value, type as any), @@ -208,20 +211,20 @@ export class Builder { return `${key} is ${value ? 'not ' : ''}null` } - protected createMemberQuery(key: string, value: any, notStr = '') { + protected createMemberQuery(key: string, value: any, notStr = '', rawKey?: any) { 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 => isEvalExpr(val) ? this.parseEval(val, false) : 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') { const res = this.listContains(this.parseEval(value), key) return notStr ? this.logicalNot(res) : res } else { - const res = this.jsonContains(this.parseEval(value, false), this.encode(key, true, true)) + const res = this.jsonContains(this.parseEval(value, false), !rawKey || isEvalExpr(rawKey) ? this.encode(key, true, true) : this.escape(rawKey, 'json')) return notStr ? this.logicalNot(res) : res } } @@ -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..2b4f9d18 100644 --- a/packages/sqlite/src/builder.ts +++ b/packages/sqlite/src/builder.ts @@ -33,9 +33,6 @@ export class SQLiteBuilder extends Builder { return this.asEncoded(`ifnull(${res}, 0)`, false) } - this.evalOperators.$in = ([key, value]) => this.asEncoded(this.createMemberEval(key, value, ''), false) - this.evalOperators.$nin = ([key, value]) => this.asEncoded(this.createMemberEval(key, value, ' NOT'), false) - const binaryXor = (left: string, right: string) => `((${left} & ~${right}) | (~${left} & ${right}))` this.evalOperators.$xor = (args) => { const type = Type.fromTerm(this.state.expr, Type.Boolean) @@ -59,6 +56,13 @@ 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 => isNullable(value) || typeof value === 'object' ? value : !value ? null : Binary.fromHex(value), + dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toHex(value), + } } escapePrimitive(value: any, type?: Type) { diff --git a/packages/sqlite/src/index.ts b/packages/sqlite/src/index.ts index afb17757..3ec194a0 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 { escapeId } from '@cordisjs/sql-utils' import type { DatabaseSync, StatementSync } from 'node:sqlite' import enUS from './locales/en-US.yml' @@ -29,6 +29,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}`) } } @@ -205,6 +206,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 083275ed..85694d54 100644 --- a/packages/tests/src/index.ts +++ b/packages/tests/src/index.ts @@ -12,6 +12,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 'vitest' @@ -88,6 +89,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 80df18f2..a3940655 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -43,6 +43,7 @@ interface DType { bnum?: number bnum2?: number text2?: string + uuid?: string } interface DObject { @@ -253,6 +254,10 @@ function ModelOperations(database: Database) { initial: 0, }, text2: 'string2', + uuid: { + type: 'uuid', + initial: '00000000-0000-0000-0000-000000000000', + }, } const baseObject = { @@ -285,6 +290,7 @@ function ModelOperations(database: Database) { namespace ModelOperations { const magicBorn = new Date('1970/08/17') + const u1 = '550e8400-e29b-41d4-a716-446655440000' const dtypeTable: DType[] = [ { id: 1, bool: false }, @@ -300,12 +306,12 @@ namespace ModelOperations { { id: 11, bigint: BigInt(1e63) }, { id: 12, decimal: 2.432, int64: 9223372036854775806n }, { id: 13, bnum: 114514, bnum2: 12345 }, - { id: 14, object: { embed: { custom: { a: 'abc', b: 123 } } } }, + { id: 14, object: { embed: { custom: { a: 'abc', b: 123 } } }, uuid: u1 }, ] const dobjectTable: DObject[] = [ { id: 1 }, - { id: 2, foo: { nested: { id: 1, int64: 123n, list: ['1', '1', '4'], array: [1, 1, 4], object: { num: 10, text: 'ab', embed: { bool: false, bigint: BigInt(1e163), custom: { a: '?', b: 8 }, bstr: 'wo' } }, bigint: BigInt(1e63), bnum: 114514, bnum2: 12345 } } }, + { id: 2, foo: { nested: { id: 1, int64: 123n, list: ['1', '1', '4'], array: [1, 1, 4], object: { num: 10, text: 'ab', embed: { bool: false, bigint: BigInt(1e163), custom: { a: '?', b: 8 }, bstr: 'wo' } }, bigint: BigInt(1e63), bnum: 114514, bnum2: 12345, uuid: u1 } } }, { id: 3, bar: { nested: { id: 1, list: ['1', '1', '4'], array: [1, 1, 4], object: { num: 10, text: 'ab', embed: { bool: false, bigint: BigInt(1e163), custom: { a: '?', b: 8 }, bstr: 'wo' } }, bigint: BigInt(1e63), bnum: 114514, bnum2: 12345 } } }, { id: 4, baz: [{ nested: { id: 1, list: ['1', '1', '4'], array: [1, 1, 4], object: { num: 10, text: 'ab', embed: { bool: false, bigint: BigInt(1e163), custom: { a: '?', b: 8 }, bstr: 'wo' } }, bigint: BigInt(1e63), bnum: 114514, bnum2: 12345 } }, { nested: { id: 2 } }] }, { id: 5, foo: { nested: { id: 1, list: ['1', '1', '4'], array: [1, 1, 4], object2: { num: 10, text: 'ab', embed: { bool: false, bigint: BigInt(1e163) } }, bigint: BigInt(1e63), bnum: 114514, bnum2: 12345 } } }, @@ -667,6 +673,11 @@ namespace ModelOperations { await expect(database.get('dobjects', row => $.eq(row.baz[0].nested.id, 1))).to.eventually.have.length(2) await expect(database.get('dobjects', row => $.eq(row.baz[0].nested.array[0], 1))).to.eventually.have.length(2) }) + + nullableComparator && it('decode uuid', async () => { + await setup(database, 'dobjects', dobjectTable) + await expect(database.get('dobjects', row => $.eq(row.foo!.nested!.uuid!, $.literal(u1, 'uuid')))).to.eventually.have.length(1) + }) } } diff --git a/packages/tests/src/performance.ts b/packages/tests/src/performance.ts index f26b1907..a88ea210 100644 --- a/packages/tests/src/performance.ts +++ b/packages/tests/src/performance.ts @@ -72,7 +72,7 @@ function PerformanceTests(database: Database) { }, }) - await database.upsert('perf', new Array(2000).fill(0).map((_, i) => ({ text: 'hello', number: i }))) + await database.upsert('perf', new Array(1000).fill(0).map((_, i) => ({ text: 'hello', number: i }))) await setup(database, 'perfNested', new Array(500).fill(0).map((_, i) => ({ id: i + 1, @@ -89,8 +89,8 @@ function PerformanceTests(database: Database) { it('bulk get flat rows', async (ctx) => { const rows = await benchmark(ctx.task.name, 10, () => database.get('perf', {})) - expect(rows).to.have.length(2000) - expect(rows[1999]).to.include({ text: 'hello', number: 1999 }) + expect(rows).to.have.length(1000) + expect(rows[999]).to.include({ text: 'hello', number: 999 }) }) it('bulk get nested rows', async (ctx) => { diff --git a/packages/tests/src/uuid.ts b/packages/tests/src/uuid.ts new file mode 100644 index 00000000..eb6c630c --- /dev/null +++ b/packages/tests/src/uuid.ts @@ -0,0 +1,124 @@ +import { $, Database } from '@cordisjs/plugin-database' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +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) { + beforeAll(() => { + 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 byEval = await database.get('uuidParents', r => $.eq(r.id, $.literal(u1, 'uuid'))) + expect(byEval).to.have.length(1) + expect(byEval[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