Skip to content
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('')}`
}
193 changes: 108 additions & 85 deletions packages/mongo/src/builder.ts
Original file line number Diff line number Diff line change
@@ -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<any>[] = []
const result: Filter<any> = {}
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<any>[], 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<any> = {}
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<any>[] = []
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> = T extends [infer U] ? U : T

export type EvalOperators = {
Expand Down Expand Up @@ -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<any>[] = []
const result: Filter<any> = {}
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<any>[], 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<any> = {}
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<any>[] = []
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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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: <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
13 changes: 11 additions & 2 deletions packages/mongo/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -75,6 +78,12 @@ export class MongoDriver extends Driver<MongoDriver.Config> {
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
15 changes: 14 additions & 1 deletion packages/mysql/src/builder.ts
Original file line number Diff line number Diff line change
@@ -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
}

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