From 3b2971a38865cd3c1a5b462f63be9e27aa707a85 Mon Sep 17 00:00:00 2001 From: Hieuzest Date: Fri, 5 Apr 2024 02:30:34 +0800 Subject: [PATCH 1/9] feat: allow defined type based on defined type, refactor to arrayBuffer --- packages/core/src/database.ts | 10 +++--- packages/core/src/driver.ts | 4 +-- packages/core/src/model.ts | 21 ++++++------ packages/core/src/type.ts | 17 +++++----- packages/core/src/utils.ts | 47 ++++++++------------------ packages/mongo/src/builder.ts | 9 +++-- packages/mongo/src/index.ts | 8 ++--- packages/mysql/src/builder.ts | 12 +++---- packages/mysql/src/index.ts | 10 ++++-- packages/postgres/src/builder.ts | 17 ++++------ packages/postgres/src/index.ts | 8 ++++- packages/sql-utils/src/index.ts | 10 ++++-- packages/sqlite/src/builder.ts | 12 +++---- packages/sqlite/src/index.ts | 19 +++++------ packages/tests/src/model.ts | 57 ++++++++++++++++++++------------ packages/tests/src/update.ts | 23 +++++++------ 16 files changed, 146 insertions(+), 138 deletions(-) diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index 0a12eefe..f7fb3a5c 100644 --- a/packages/core/src/database.ts +++ b/packages/core/src/database.ts @@ -129,12 +129,12 @@ export class Database extends Ser return Type.Object() } else if (typeof field === 'string' && this.types[field]) { transformers.push({ - types: [this.types[field].type], + types: [field as any], load: this.types[field].load, dump: this.types[field].dump, }) setInitial?.(this.types[field].initial) - setField?.(this.types[field]) + setField?.({ ...this.types[field], type: field }) return Type.fromField(field as any) } else if (typeof field === 'object' && field.load && field.dump) { const name = this.define(field) @@ -171,8 +171,8 @@ export class Database extends Ser } } - define, Field.Type>>(name: K, field: Field.Transform): K - define(field: Field.Transform): Field.NewType + define, Field.Type>, T = any>(name: K, field: Field.Transform): K + define(field: Field.Transform): Field.NewType define(name: any, field?: any) { if (typeof name === 'object') { field = name @@ -182,7 +182,7 @@ export class Database extends Ser if (name && this.types[name]) throw new Error(`type "${name}" already defined`) if (!name) while (this.types[name = '_define_' + randomId()]); this[Context.current].effect(() => { - this.types[name] = { deftype: field.type, ...field, type: name } + this.types[name] = { deftype: this.types[field.type]?.deftype ?? field.type, ...field } return () => delete this.types[name] }) return name as any diff --git a/packages/core/src/driver.ts b/packages/core/src/driver.ts index b089cb1f..b9f0d4db 100644 --- a/packages/core/src/driver.ts +++ b/packages/core/src/driver.ts @@ -35,8 +35,8 @@ export namespace Driver { export interface Transformer { types: Field.Type[] - dump: (value: S) => T | null - load: (value: T) => S | null + dump: (value: S | null) => T | null | void + load: (value: T | null) => S | null | void } } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 03bd6f2f..0d51058a 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,7 +1,7 @@ -import { isNullable, makeArray, MaybeArray, valueMap } from 'cosmokit' +import { is, isNullable, makeArray, MaybeArray, valueMap } from 'cosmokit' import { Database } from './database.ts' import { Eval, isEvalExpr } from './eval.ts' -import { clone, Flatten, isUint8Array, Keys } from './utils.ts' +import { clone, Flatten, Keys } from './utils.ts' import { Type } from './type.ts' import { Driver } from './driver.ts' @@ -35,7 +35,7 @@ export namespace Field { : T extends string ? 'char' | 'string' | 'text' : T extends boolean ? 'boolean' : T extends Date ? 'timestamp' | 'date' | 'time' - : T extends Uint8Array ? 'binary' + : T extends ArrayBuffer ? 'binary' : T extends unknown[] ? 'list' | 'json' | 'array' : T extends object ? 'json' | 'object' : 'expr' @@ -52,10 +52,10 @@ export namespace Field { inner?: Definition } & Omit, 'type'> - export type Transform = { - type: Type - dump: (value: S) => T | null - load: (value: T) => S | null + export type Transform = { + type: Type | Keys | NewType + dump: (value: S | null) => T | null | void + load: (value: T | null) => S | null | void initial?: S } & Omit, 'type' | 'initial'> @@ -68,7 +68,7 @@ export namespace Field { | Object | (T extends (infer I)[] ? Array : never) | Shorthand> - | Transform + | Transform | Keys | NewType @@ -79,7 +79,7 @@ export namespace Field { export type Extension = MapField, N> const NewType = Symbol('newtype') - export type NewType = { [NewType]?: T } + export type NewType = string & { [NewType]: T } export type Config = { [K in keyof O]?: Field @@ -271,7 +271,8 @@ export class Model { const field = fields.find(field => fullKey === field || fullKey.startsWith(field + '.')) if (field) { node[segments[0]] = value - } else if (!value || typeof value !== 'object' || isEvalExpr(value) || Array.isArray(value) || isUint8Array(value) || Object.keys(value).length === 0) { + } else if (!value || typeof value !== 'object' || isEvalExpr(value) || Array.isArray(value) || is('ArrayBuffer', value) + || Object.keys(value).length === 0) { if (strict) { throw new TypeError(`unknown field "${fullKey}" in model ${this.name}`) } else { diff --git a/packages/core/src/type.ts b/packages/core/src/type.ts index fabd9f1d..d911647f 100644 --- a/packages/core/src/type.ts +++ b/packages/core/src/type.ts @@ -1,11 +1,12 @@ -import { defineProperty, isNullable, mapValues } from 'cosmokit' +import { defineProperty, is, isNullable, mapValues } from 'cosmokit' import { Field } from './model.ts' import { Eval, isEvalExpr } from './eval.ts' +import { Keys } from './utils.ts' -export interface Type { +export interface Type { [Type.kType]?: true - type: Field.Type - inner?: T extends (infer I)[] ? Type : Field.Type extends 'json' ? { [key in keyof T]: Type } : never + type: Field.Type | Keys | Field.NewType + inner?: T extends (infer I)[] ? Type : Field.Type extends 'json' ? { [key in keyof T]: Type } : never array?: boolean } @@ -42,13 +43,13 @@ export namespace Type { else if (typeof value === 'string') return String as any else if (typeof value === 'boolean') return Boolean as any else if (value instanceof Date) return fromField('timestamp' as any) - else if (ArrayBuffer.isView(value)) return fromField('binary' as any) + else if (is('ArrayBuffer', value)) return fromField('binary' as any) else if (globalThis.Array.isArray(value)) return Array(value.length ? fromPrimitive(value[0]) : undefined) as any else if (typeof value === 'object') return fromField('json' as any) throw new TypeError(`invalid primitive: ${value}`) } - export function fromField(field: Field | Field.Type): Type { + export function fromField(field: Field | Field.Type | Keys | Field.NewType): Type { if (isType(field)) throw new TypeError(`invalid field: ${JSON.stringify(field)}`) if (typeof field === 'string') return defineProperty({ type: field }, kType, true) else if (field.type) return field.type @@ -58,7 +59,7 @@ export namespace Type { export function fromTerm(value: Eval.Term): Type { if (isEvalExpr(value)) return value[kType] ?? fromField('expr' as any) - else return fromPrimitive(value) + else return fromPrimitive(value as T) } export function isType(value: any): value is Type { @@ -69,7 +70,7 @@ export namespace Type { return (type.type === 'json') && type.array } - export function getInner(type?: Type, key?: string): Type | undefined { + export function getInner(type?: Type, key?: string): Type | undefined { if (!type?.inner) return if (isArray(type) && isNullable(key)) return type.inner if (isNullable(key)) return diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index a36d27d5..b8576162 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -86,54 +86,33 @@ export function unravel(source: object, init?: (value) => any) { export function clone(source: T): T export function clone(source: any) { if (!source || typeof source !== 'object') return source - if (isUint8Array(source)) return (hasGlobalBuffer && Buffer.isBuffer(source)) ? Buffer.copyBytesFrom(source) : source.slice() + if (is('ArrayBuffer', source)) return source.slice(0) if (Array.isArray(source)) return source.map(clone) if (is('Date', source)) return new Date(source.valueOf()) if (is('RegExp', source)) return new RegExp(source.source, source.flags) return mapValues(source, clone) } -const hasGlobalBuffer = typeof Buffer === 'function' && Buffer.prototype?._isBuffer !== true - -export function isUint8Array(value: any): value is Uint8Array { - const stringTag = value?.[Symbol.toStringTag] ?? Object.prototype.toString.call(value) - return (hasGlobalBuffer && Buffer.isBuffer(value)) - || ArrayBuffer.isView(value) - || ['ArrayBuffer', 'SharedArrayBuffer', '[object ArrayBuffer]', '[object SharedArrayBuffer]'].includes(stringTag) +export function toArrayBuffer(source: ArrayBuffer | ArrayBufferView): ArrayBuffer { + return ArrayBuffer.isView(source) ? source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength) : source } -export function Uint8ArrayFromHex(source: string) { - if (hasGlobalBuffer) return Buffer.from(source, 'hex') - const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1) +export function hexToArrayBuffer(source: string): ArrayBuffer { const buffer: number[] = [] - for (let i = 0; i < hex.length; i += 2) { - buffer.push(Number.parseInt(`${hex[i]}${hex[i + 1]}`, 16)) + for (let i = 0; i < source.length; i += 2) { + buffer.push(Number.parseInt(source.substring(i, i + 2), 16)) } - return Uint8Array.from(buffer) -} - -export function Uint8ArrayToHex(source: Uint8Array) { - return (hasGlobalBuffer) ? toLocalUint8Array(source).toString('hex') - : Array.from(toLocalUint8Array(source), byte => byte.toString(16).padStart(2, '0')).join('') + return Uint8Array.from(buffer).buffer } -export function Uint8ArrayFromBase64(source: string) { - return (hasGlobalBuffer) ? Buffer.from(source, 'base64') : Uint8Array.from(atob(source), c => c.charCodeAt(0)) +export function arrayBufferToHex(source: ArrayBuffer): string { + return Array.from(new Uint8Array(source), byte => byte.toString(16).padStart(2, '0')).join('') } -export function Uint8ArrayToBase64(source: Uint8Array) { - return (hasGlobalBuffer) ? (source as Buffer).toString('base64') : btoa(Array.from(Uint16Array.from(source), b => String.fromCharCode(b)).join('')) +export function base64ToArrayBuffer(source: string): ArrayBuffer { + return Uint8Array.from(atob(source), c => c.charCodeAt(0)).buffer } -export function toLocalUint8Array(source: Uint8Array) { - if (hasGlobalBuffer) { - return Buffer.isBuffer(source) ? Buffer.from(source) - : ArrayBuffer.isView(source) ? Buffer.from(source.buffer, source.byteOffset, source.byteLength) - : Buffer.from(source) - } else { - const stringTag = source?.[Symbol.toStringTag] ?? Object.prototype.toString.call(source) - return stringTag === 'Uint8Array' ? source - : ArrayBuffer.isView(source) ? new Uint8Array(source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength)) - : new Uint8Array(source) - } +export function arrayBufferToBase64(source: ArrayBuffer): string { + return btoa(Array.from(new Uint8Array(source), b => String.fromCharCode(b)).join('')) } diff --git a/packages/mongo/src/builder.ts b/packages/mongo/src/builder.ts index 9701a51f..9e93ea88 100644 --- a/packages/mongo/src/builder.ts +++ b/packages/mongo/src/builder.ts @@ -444,7 +444,6 @@ export class Builder { if (isEvalExpr(type)) type = Type.fromTerm(type) if (!Type.isType(type)) type = type.getType() - type = Type.isType(type) ? type : Type.fromTerm(type) const converter = this.driver.types[type?.type] let res = value @@ -457,6 +456,8 @@ export class Builder { } res = converter ? converter.dump(res) : res + const ancestor = this.driver.database.types[type.type]?.type + res = this.dump(res, ancestor ? Type.fromField(ancestor) : undefined) return res } @@ -465,8 +466,10 @@ export class Builder { if (Type.isType(type) || isEvalExpr(type)) { type = Type.isType(type) ? type : Type.fromTerm(type) - const converter = this.driver.types[type?.inner ? 'json' : type?.type!] - let res = converter ? converter.load(value) : value + const converter = this.driver.types[type.type] + const ancestor = this.driver.database.types[type.type]?.type + let res = this.load(value, ancestor ? Type.fromField(ancestor) : undefined) + res = converter ? converter.load(res) : res if (!isNullable(res) && type.inner) { if (Type.isArray(type)) { diff --git a/packages/mongo/src/index.ts b/packages/mongo/src/index.ts index 10aef2d1..8b372175 100644 --- a/packages/mongo/src/index.ts +++ b/packages/mongo/src/index.ts @@ -1,6 +1,6 @@ import { BSONType, ClientSession, Collection, Db, IndexDescription, MongoClient, MongoClientOptions, MongoError } from 'mongodb' import { Dict, isNullable, makeArray, mapValues, noop, omit, pick } from 'cosmokit' -import { Driver, Eval, executeUpdate, Query, RuntimeError, Selection, z } from 'minato' +import { Driver, Eval, executeUpdate, Query, RuntimeError, Selection, toArrayBuffer, z } from 'minato' import { URLSearchParams } from 'url' import { Builder } from './builder' @@ -46,10 +46,10 @@ export class MongoDriver extends Driver { ])) this.db = this.client.db(this.config.database) - this.define({ + this.define({ types: ['binary'], - dump: value => value, - load: (value: any) => value ? value.buffer : value, + dump: value => isNullable(value) ? value : Buffer.from(value), + load: (value: any) => isNullable(value) ? value : toArrayBuffer(value.buffer), }) } diff --git a/packages/mysql/src/builder.ts b/packages/mysql/src/builder.ts index a54548d3..b18960bc 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -1,6 +1,6 @@ import { Builder, escapeId, isBracketed } from '@minatojs/sql-utils' -import { Dict, isNullable, Time } from 'cosmokit' -import { Driver, Field, isEvalExpr, isUint8Array, Model, randomId, Selection, Type, Uint8ArrayFromBase64, Uint8ArrayToBase64, Uint8ArrayToHex } from 'minato' +import { Dict, is, isNullable, Time } from 'cosmokit' +import { arrayBufferToBase64, arrayBufferToHex, base64ToArrayBuffer, Driver, Field, isEvalExpr, Model, randomId, Selection, Type } from 'minato' export interface Compat { maria?: boolean @@ -43,8 +43,8 @@ export class MySQLBuilder extends Builder { this.transformers['binary'] = { encode: value => `to_base64(${value})`, decode: value => `from_base64(${value})`, - load: value => isNullable(value) ? value : Uint8ArrayFromBase64(value), - dump: value => isNullable(value) ? value : Uint8ArrayToBase64(value), + load: value => isNullable(value) || typeof value === 'object' ? value : base64ToArrayBuffer(value), + dump: value => isNullable(value) || typeof value === 'string' ? value : arrayBufferToBase64(value), } this.transformers['date'] = { @@ -89,8 +89,8 @@ export class MySQLBuilder extends Builder { value = Time.template('yyyy-MM-dd hh:mm:ss.SSS', value) } else if (value instanceof RegExp) { value = value.source - } else if (isUint8Array(value)) { - return `X'${Uint8ArrayToHex(value)}'` + } else if (is('ArrayBuffer', value)) { + return `X'${arrayBufferToHex(value)}'` } else if (!!value && typeof value === 'object') { return `json_extract(${this.quote(JSON.stringify(value))}, '$')` } diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts index 13510869..a83ff3d7 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -1,7 +1,7 @@ +import { Dict, difference, isNullable, makeArray, pick } from 'cosmokit' import { createPool, format } from '@vlasky/mysql' import type { OkPacket, Pool, PoolConfig, PoolConnection } from 'mysql' -import { Dict, difference, makeArray, pick } from 'cosmokit' -import { Driver, Eval, executeUpdate, Field, RuntimeError, Selection, z } from 'minato' +import { Driver, Eval, executeUpdate, Field, RuntimeError, Selection, toArrayBuffer, z } from 'minato' import { escapeId, isBracketed } from '@minatojs/sql-utils' import { Compat, MySQLBuilder } from './builder' @@ -154,6 +154,12 @@ export class MySQLDriver extends Driver { return date }, }) + + this.define({ + types: ['binary'], + dump: value => value, + load: value => isNullable(value) ? value : toArrayBuffer(value), + }) } async stop() { diff --git a/packages/postgres/src/builder.ts b/packages/postgres/src/builder.ts index dbf5f455..57631b71 100644 --- a/packages/postgres/src/builder.ts +++ b/packages/postgres/src/builder.ts @@ -1,9 +1,6 @@ import { Builder, isBracketed } from '@minatojs/sql-utils' -import { Dict, isNullable, Time } from 'cosmokit' -import { - Driver, Field, isEvalExpr, isUint8Array, Model, randomId, Selection, Type, - Uint8ArrayFromBase64, Uint8ArrayToBase64, Uint8ArrayToHex, unravel, -} from 'minato' +import { Dict, is, isNullable, Time } from 'cosmokit' +import { arrayBufferToBase64, arrayBufferToHex, base64ToArrayBuffer, Driver, Field, isEvalExpr, Model, randomId, Selection, Type, unravel } from 'minato' export function escapeId(value: string) { return '"' + value.replace(/"/g, '""') + '"' @@ -77,7 +74,7 @@ export class PostgresBuilder extends Builder { $number: (arg) => { const value = this.parseEval(arg) const type = Type.fromTerm(arg) - const res = Field.date.includes(type.type!) ? `extract(epoch from ${value})::bigint` : `${value}::double precision` + const res = Field.date.includes(type.type as any) ? `extract(epoch from ${value})::bigint` : `${value}::double precision` return this.asEncoded(`coalesce(${res}, 0)`, false) }, @@ -110,8 +107,8 @@ export class PostgresBuilder extends Builder { this.transformers['binary'] = { encode: value => `encode(${value}, 'base64')`, decode: value => `decode(${value}, 'base64')`, - load: value => isNullable(value) ? value : Uint8ArrayFromBase64(value), - dump: value => isNullable(value) ? value : Uint8ArrayToBase64(value), + load: value => isNullable(value) || typeof value === 'object' ? value : base64ToArrayBuffer(value), + dump: value => isNullable(value) || typeof value === 'string' ? value : arrayBufferToBase64(value), } this.transformers['date'] = { @@ -254,8 +251,8 @@ export class PostgresBuilder extends Builder { value = formatTime(value) } else if (value instanceof RegExp) { value = value.source - } else if (isUint8Array(value)) { - return `'\\x${Uint8ArrayToHex(value)}'::bytea` + } else if (is('ArrayBuffer', value)) { + return `'\\x${arrayBufferToHex(value)}'::bytea` } else if (type?.type === 'list' && Array.isArray(value)) { return `ARRAY[${value.map(x => this.escape(x)).join(', ')}]::TEXT[]` } else if (!!value && typeof value === 'object') { diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts index 578554ca..0597d1b3 100644 --- a/packages/postgres/src/index.ts +++ b/packages/postgres/src/index.ts @@ -1,6 +1,6 @@ import postgres from 'postgres' import { Dict, difference, isNullable, makeArray, pick } from 'cosmokit' -import { Driver, Eval, executeUpdate, Field, Selection, z } from 'minato' +import { Driver, Eval, executeUpdate, Field, Selection, toArrayBuffer, z } from 'minato' import { isBracketed } from '@minatojs/sql-utils' import { escapeId, formatTime, PostgresBuilder } from './builder' @@ -165,6 +165,12 @@ export class PostgresDriver extends Driver { return date }, }) + + this.define({ + types: ['binary'], + dump: value => value, + load: value => isNullable(value) ? value : toArrayBuffer(value), + }) } async stop() { diff --git a/packages/sql-utils/src/index.ts b/packages/sql-utils/src/index.ts index 9db36551..da77fac3 100644 --- a/packages/sql-utils/src/index.ts +++ b/packages/sql-utils/src/index.ts @@ -311,7 +311,7 @@ export class Builder { */ protected transform(value: string, type: Type | Eval.Expr | undefined, method: 'encode' | 'decode' | 'load' | 'dump', miss?: any) { type = Type.isType(type) ? type : Type.fromTerm(type) - const transformer = this.transformers[type.type] ?? this.transformers[this.driver.database.types[type.type]?.deftype!] + const transformer = this.transformers[type.type] ?? this.transformers[this.driver.database.types[type.type]?.type!] return transformer ? transformer[method](value) : (miss ?? value) } @@ -561,7 +561,9 @@ export class Builder { } } res = converter ? converter.dump(res) : res - if (!root) res = this.transform(res, type, 'dump') + const ancestor = this.driver.database.types[type.type]?.type + if (!root && !ancestor) res = this.transform(res, type, 'dump') + res = this.dump(res, ancestor ? Type.fromField(ancestor) : undefined, root) return res } @@ -583,7 +585,9 @@ export class Builder { if (Type.isType(type) || isEvalExpr(type)) { type = Type.isType(type) ? type : Type.fromTerm(type) const converter = this.driver.types[(root && value && type.type === 'json') ? 'json' : type.type] - let res = this.transform(value, type, 'load') + const ancestor = this.driver.database.types[type.type]?.type + let res = this.load(value, ancestor ? Type.fromField(ancestor) : undefined, root) + res = this.transform(res, type, 'load') res = converter ? converter.load(res) : res if (!isNullable(res) && type.inner) { diff --git a/packages/sqlite/src/builder.ts b/packages/sqlite/src/builder.ts index 0acd1962..a799315f 100644 --- a/packages/sqlite/src/builder.ts +++ b/packages/sqlite/src/builder.ts @@ -1,6 +1,6 @@ import { Builder, escapeId } from '@minatojs/sql-utils' -import { Dict, isNullable } from 'cosmokit' -import { Driver, Field, isUint8Array, Model, randomId, Type, Uint8ArrayFromHex, Uint8ArrayToHex } from 'minato' +import { Dict, is, isNullable } from 'cosmokit' +import { arrayBufferToHex, Driver, Field, hexToArrayBuffer, Model, randomId, Type } from 'minato' export class SQLiteBuilder extends Builder { protected escapeMap = { @@ -21,22 +21,22 @@ export class SQLiteBuilder extends Builder { this.evalOperators.$number = (arg) => { const type = Type.fromTerm(arg) const value = this.parseEval(arg) - const res = Field.date.includes(type.type!) ? `cast(${value} / 1000 as integer)` : `cast(${this.parseEval(arg)} as double)` + const res = Field.date.includes(type.type as any) ? `cast(${value} / 1000 as integer)` : `cast(${this.parseEval(arg)} as double)` return this.asEncoded(`ifnull(${res}, 0)`, false) } this.transformers['binary'] = { encode: value => `hex(${value})`, decode: value => `unhex(${value})`, - load: value => isNullable(value) ? value : Uint8ArrayFromHex(value), - dump: value => isNullable(value) ? value : Uint8ArrayToHex(value), + load: value => isNullable(value) || typeof value === 'object' ? value : hexToArrayBuffer(value), + dump: value => isNullable(value) || typeof value === 'string' ? value : arrayBufferToHex(value), } } escapePrimitive(value: any, type?: Type) { if (value instanceof Date) value = +value else if (value instanceof RegExp) value = value.source - else if (isUint8Array(value)) return `X'${Uint8ArrayToHex(value)}'` + else if (is('ArrayBuffer', value)) return `X'${arrayBufferToHex(value)}'` return super.escapePrimitive(value, type) } diff --git a/packages/sqlite/src/index.ts b/packages/sqlite/src/index.ts index 7ecfee07..583cc07f 100644 --- a/packages/sqlite/src/index.ts +++ b/packages/sqlite/src/index.ts @@ -1,5 +1,5 @@ import { deepEqual, Dict, difference, isNullable, makeArray } from 'cosmokit' -import { clone, Driver, Eval, executeUpdate, Field, Selection, toLocalUint8Array, z } from 'minato' +import { Driver, Eval, executeUpdate, Field, Selection, toArrayBuffer, z } from 'minato' import { escapeId } from '@minatojs/sql-utils' import { resolve } from 'node:path' import { readFile, writeFile } from 'node:fs/promises' @@ -202,10 +202,10 @@ export class SQLiteDriver extends Driver { load: value => isNullable(value) ? value : new Date(value), }) - this.define({ + this.define({ types: ['binary'], - dump: value => value, - load: value => value ? toLocalUint8Array(value) : value, + dump: value => isNullable(value) ? value : new Uint8Array(value), + load: value => isNullable(value) ? value : toArrayBuffer(value), }) } @@ -311,14 +311,12 @@ export class SQLiteDriver extends Driver { #update(sel: Selection.Mutable, indexFields: string[], updateFields: string[], update: {}, data: {}) { const { ref, table } = sel const model = this.model(table) - const modified = !deepEqual(clone(data), executeUpdate(data, update, ref)) - if (!modified) return 0 + executeUpdate(data, update, ref) const row = this.sql.dump(data, model) const assignment = updateFields.map((key) => `${escapeId(key)} = ?`).join(',') const query = Object.fromEntries(indexFields.map(key => [key, row[key]])) const filter = this.sql.parseQuery(query) this.#run(`UPDATE ${escapeId(table)} SET ${assignment} WHERE ${filter}`, updateFields.map((key) => row[key] ?? null)) - return 1 } async set(sel: Selection.Mutable, update: {}) { @@ -329,11 +327,10 @@ export class SQLiteDriver extends Driver { }))] const primaryFields = makeArray(primary) const data = await this.database.get(table, query) - let modified = 0 for (const row of data) { - modified += this.#update(sel, primaryFields, updateFields, update, row) + this.#update(sel, primaryFields, updateFields, update, row) } - return { matched: data.length, modified } + return { matched: data.length } } #create(table: string, data: {}) { @@ -371,7 +368,7 @@ export class SQLiteDriver extends Driver { for (const item of chunk) { const row = results.find(row => keys.every(key => deepEqual(row[key], item[key], true))) if (row) { - result.modified += this.#update(sel, keys, updateFields, item, row) + this.#update(sel, keys, updateFields, item, row) result.matched++ } else { this.#create(table, executeUpdate(model.create(), item, ref)) diff --git a/packages/tests/src/model.ts b/packages/tests/src/model.ts index 4532faca..a55a52d4 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -1,4 +1,4 @@ -import { valueMap } from 'cosmokit' +import { valueMap, isNullable } from 'cosmokit' import { $, Database, Field, Type } from 'minato' import { expect } from 'chai' @@ -22,6 +22,7 @@ interface DType { bool?: boolean bigint?: bigint custom?: Custom + bstr?: string } } object2?: { @@ -35,7 +36,7 @@ interface DType { timestamp?: Date date?: Date time?: Date - binary?: Buffer + binary?: ArrayBuffer bigint?: bigint bnum?: number bnum2?: number @@ -69,6 +70,10 @@ interface Types { custom: Custom } +function toBinary(source: string): ArrayBuffer { + return new TextEncoder().encode(source).buffer +} + function flatten(type: any, prefix) { if (typeof type === 'object' && type?.type === 'object') { const result = {} @@ -84,24 +89,31 @@ function flatten(type: any, prefix) { function ModelOperations(database: Database) { database.define('bigint', { type: 'string', - dump: value => value ? value.toString() : value, - load: value => value ? BigInt(value) : value, + dump: value => isNullable(value) ? value : value.toString(), + load: value => isNullable(value) ? value : BigInt(value), initial: 123n }) database.define('custom', { type: 'string', - dump: value => value ? `${value.a}|${value.b}` : value, - load: value => value ? { a: value.split('|')[0], b: +value.split('|')[1] } : value + dump: value => isNullable(value) ? value : `${value.a}|${value.b}`, + load: value => isNullable(value) ? value : { a: value.split('|')[0], b: +value.split('|')[1] }, }) const bnum = database.define({ type: 'binary', - dump: value => value === undefined ? value : Buffer.from(String(value)), - load: value => value ? +value : value, + dump: value => isNullable(value) ? value : toBinary(String(value)), + load: value => isNullable(value) ? value : +Buffer.from(value), initial: 0, }) + const bstr = database.define({ + type: 'custom', + dump: value => isNullable(value) ? value : { a: value, b: 1 }, + load: value => isNullable(value) ? value : value.a, + initial: 'pooo', + }) + const baseFields: Field.Extension = { id: 'unsigned', text: { @@ -148,6 +160,7 @@ function ModelOperations(database: Database) { }, bigint: 'bigint', custom: 'custom', + bstr: bstr, }, }, }, @@ -192,14 +205,14 @@ function ModelOperations(database: Database) { }, binary: { type: 'binary', - initial: Buffer.from('initial buffer') + initial: toBinary('initial buffer') }, bigint: 'bigint', bnum, bnum2: { type: 'binary', - dump: value => value === undefined ? value : Buffer.from(String(value)), - load: value => value ? +value : value, + dump: value => isNullable(value) ? value : toBinary(String(value)), + load: value => isNullable(value) ? value : +Buffer.from(value), initial: 0, }, } @@ -216,7 +229,7 @@ function ModelOperations(database: Database) { database.extend('dobjects', { id: 'unsigned', - foo: baseObject, + foo: baseObject, ...flatten(baseObject, 'bar'), baz: { type: 'array', @@ -243,12 +256,12 @@ namespace ModelOperations { { id: 2, text: 'pku' }, { id: 3, num: 1989 }, { id: 4, list: ['1', '1', '4'], array: [1, 1, 4] }, - { id: 5, object: { num: 10, text: 'ab', embed: { bool: false, bigint: 90n } } }, + { id: 5, object: { num: 10, text: 'ab', embed: { bool: false, bigint: 90n, bstr: 'world' } } }, { id: 6, object2: { num: 10, text: 'ab', embed: { bool: false, bigint: 90n } } }, { id: 7, timestamp: magicBorn }, { id: 8, date: magicBorn }, { id: 9, time: new Date('1999-10-01 15:40:00') }, - { id: 10, binary: Buffer.from('hello') }, + { id: 10, binary: toBinary('hello') }, { id: 11, bigint: BigInt(1e63) }, { id: 12, decimal: 2.432 }, { id: 13, bnum: 114514, bnum2: 12345 }, @@ -257,9 +270,9 @@ namespace ModelOperations { const dobjectTable: DObject[] = [ { id: 1 }, - { id: 2, foo: { nested: { id: 1, list: ['1', '1', '4'], array: [1, 1, 4], object: { num: 10, text: 'ab', embed: { bool: false, bigint: BigInt(1e163) } }, bigint: BigInt(1e63), bnum: 114514, bnum2: 12345 } } }, - { 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) } }, 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) } }, bigint: BigInt(1e63), bnum: 114514, bnum2: 12345 } }, { nested: { id: 2 } }] }, + { id: 2, foo: { 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: 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 } } }, { id: 6, bar: { 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 } } }, { id: 7, baz: [{ 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 } }, { nested: { id: 2 } }] }, @@ -367,7 +380,7 @@ namespace ModelOperations { expect(Type.fromTerm($.literal('abc')).type).to.equal(Type.String.type) expect(Type.fromTerm($.literal(true)).type).to.equal(Type.Boolean.type) expect(Type.fromTerm($.literal(new Date('1970-01-01'))).type).to.equal('timestamp') - expect(Type.fromTerm($.literal(Buffer.from('hello'))).type).to.equal('binary') + expect(Type.fromTerm($.literal(toBinary('hello'))).type).to.equal('binary') expect(Type.fromTerm($.literal([1, 2, 3])).type).to.equal('json') expect(Type.fromTerm($.literal({ a: 1 })).type).to.equal('json') }) @@ -454,24 +467,24 @@ namespace ModelOperations { id: 1, timestamp: new Date('2009/10/01 15:40:00'), date: new Date('1999/10/01'), - binary: Buffer.from('boom'), + binary: toBinary('boom'), } table[0].bar!.nested = { ...table[0].bar?.nested, id: 9, timestamp: new Date('2009/10/01 15:40:00'), date: new Date('1999/10/01'), - binary: Buffer.from('boom'), + binary: toBinary('boom'), } await database.set('dobjects', table[0].id, { 'foo.nested.timestamp': new Date('2009/10/01 15:40:00'), 'foo.nested.date': new Date('1999/10/01'), - 'foo.nested.binary': Buffer.from('boom'), + 'foo.nested.binary': toBinary('boom'), 'bar.nested.id': 9, 'bar.nested.timestamp': new Date('2009/10/01 15:40:00'), 'bar.nested.date': new Date('1999/10/01'), - 'bar.nested.binary': Buffer.from('boom'), + 'bar.nested.binary': toBinary('boom'), }) await expect(database.get('dobjects', table[0].id)).to.eventually.deep.eq([table[0]]) diff --git a/packages/tests/src/update.ts b/packages/tests/src/update.ts index f77345f3..a7cca709 100644 --- a/packages/tests/src/update.ts +++ b/packages/tests/src/update.ts @@ -13,7 +13,7 @@ interface Bar { date?: Date time?: Date bigtext?: string - binary?: Buffer + binary?: ArrayBuffer bigint?: bigint } @@ -64,6 +64,7 @@ namespace OrmOperations { const merge = (a: T, b: Partial): T => ({ ...a, ...b }) const magicBorn = new Date('1970/08/17') + const toBinary = (source: string) => new TextEncoder().encode(source).buffer const barTable: Bar[] = [ { id: 1, bool: true }, @@ -73,7 +74,7 @@ namespace OrmOperations { { id: 5, timestamp: magicBorn }, { id: 6, date: magicBorn }, { id: 7, time: new Date('1970-01-01 12:00:00') }, - { id: 8, binary: Buffer.from('hello') }, + { id: 8, binary: toBinary('hello') }, { id: 9, bigint: BigInt(1e63) }, { id: 10, text: 'a\b\t\f\n\r\x1a\'\"\\\`b', list: ['a\b\t\f\n\r\x1a\'\"\\\`b'] }, ] @@ -147,10 +148,10 @@ namespace OrmOperations { it('advanced type', async () => { await setup(database, 'temp2', barTable) - await expect(database.create('temp2', { binary: Buffer.from('world') })).to.eventually.have.shape({ binary: Buffer.from('world') }) + await expect(database.create('temp2', { binary: toBinary('world') })).to.eventually.have.shape({ binary: toBinary('world') }) await expect(database.get('temp2', { binary: { $exists: true } })).to.eventually.have.shape([ - { binary: Buffer.from('hello') }, - { binary: Buffer.from('world') }, + { binary: toBinary('hello') }, + { binary: toBinary('world') }, ]) await expect(database.create('temp2', { bigint: 1234567891011121314151617181920n })).to.eventually.have.shape({ bigint: 1234567891011121314151617181920n }) @@ -217,9 +218,9 @@ namespace OrmOperations { it('advanced type', async () => { const table = await setup(database, 'temp2', barTable) const data1 = table.find(item => item.id === 1)! - data1.binary = Buffer.from('world') + data1.binary = toBinary('world') data1.bigint = 1234567891011121314151617181920n - await database.set('temp2', { id: 1 }, { binary: Buffer.from('world'), bigint: 1234567891011121314151617181920n }) + await database.set('temp2', { id: 1 }, { binary: toBinary('world'), bigint: 1234567891011121314151617181920n }) await expect(database.get('temp2', {})).to.eventually.have.shape(table) }) } @@ -317,12 +318,12 @@ namespace OrmOperations { it('advanced type', async () => { const table = await setup(database, 'temp2', barTable) const data1 = table.find(item => item.id === 1)! - data1.binary = Buffer.from('world') + data1.binary = toBinary('world') data1.bigint = 1234567891011121314151617181920n - table.push({ binary: Buffer.from('foobar'), bigint: 1234567891011121314151617181920212223n } as any) + table.push({ binary: toBinary('foobar'), bigint: 1234567891011121314151617181920212223n } as any) await database.upsert('temp2', [ - { id: 1, binary: Buffer.from('world'), bigint: 1234567891011121314151617181920n }, - { binary: Buffer.from('foobar'), bigint: 1234567891011121314151617181920212223n }, + { id: 1, binary: toBinary('world'), bigint: 1234567891011121314151617181920n }, + { binary: toBinary('foobar'), bigint: 1234567891011121314151617181920212223n }, ]) await expect(database.get('temp2', {})).to.eventually.have.shape(table) }) From 0721dad24d41814cf33e8a339bf3fff0bed56d3b Mon Sep 17 00:00:00 2001 From: Hieuzest Date: Fri, 5 Apr 2024 15:43:50 +0800 Subject: [PATCH 2/9] feat: support recursive type --- packages/core/src/database.ts | 60 +++++++++++++++++++++++---------- packages/core/src/model.ts | 31 ++++++++--------- packages/core/src/type.ts | 4 +-- packages/mongo/src/builder.ts | 4 +-- packages/sql-utils/src/index.ts | 6 ++-- packages/tests/src/model.ts | 55 ++++++++++++++++++++++-------- 6 files changed, 106 insertions(+), 54 deletions(-) diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index f7fb3a5c..8bba21ae 100644 --- a/packages/core/src/database.ts +++ b/packages/core/src/database.ts @@ -1,4 +1,4 @@ -import { Dict, Intersect, makeArray, mapValues, MaybeArray, valueMap } from 'cosmokit' +import { Dict, Intersect, makeArray, mapValues, MaybeArray, omit, valueMap } from 'cosmokit' import { Context, Service, Spread } from 'cordis' import { Flatten, Indexable, Keys, randomId, Row, unravel } from './utils.ts' import { Selection } from './selection.ts' @@ -118,7 +118,7 @@ export class Database extends Ser ;(this.ctx as Context).emit('model', name) } - private parseField(field: any, transformers: Driver.Transformer[] = [], setInitial?: (value) => void, setField?: (value) => void): Type { + private _parseField(field: any, transformers: Driver.Transformer[] = [], setInitial?: (value) => void, setField?: (value) => void): Type { if (field === 'array') { setInitial?.([]) setField?.({ type: 'json', initial: [] }) @@ -132,22 +132,19 @@ export class Database extends Ser types: [field as any], load: this.types[field].load, dump: this.types[field].dump, - }) + }, ...(this.types[field].transformers ?? [])) setInitial?.(this.types[field].initial) setField?.({ ...this.types[field], type: field }) - return Type.fromField(field as any) - } else if (typeof field === 'object' && field.load && field.dump) { - const name = this.define(field) + return Type.fromField(field) + } else if (typeof field === 'object' && this.types[field.type]) { transformers.push({ - types: [name as any], - load: field.load, - dump: field.dump, - }) - // for transform type, intentionally assign a null initial on default - // setInitial?.(Field.getInitial(field.type, field.initial)) - setInitial?.(field.initial) - setField?.({ ...field, deftype: field.type, type: name }) - return Type.fromField(name as any) + types: [field.type as any], + load: this.types[field.type].load, + dump: this.types[field.type].dump, + }, ...(this.types[field.type].transformers ?? [])) + setInitial?.(field.initial === undefined ? this.types[field.type].initial : field.initial) + setField?.({ initial: this.types[field.type].initial, ...field }) + return Type.fromField(field.type) } else if (typeof field === 'object' && field.type === 'object') { const inner = unravel(field.inner, value => (value.type = 'object', value.inner ??= {})) const initial = Object.create(null) @@ -171,8 +168,29 @@ export class Database extends Ser } } - define, Field.Type>, T = any>(name: K, field: Field.Transform): K - define(field: Field.Transform): Field.NewType + private parseField(field: any, transformers: Driver.Transformer[] = [], setInitial?: (value) => void, setField?: (value: Field.Parsable) => void): Type { + let midfield + let type = this._parseField(field, transformers, setInitial, (value) => (midfield = value, setField?.(value))) + if (typeof field === 'object' && field.load && field.dump) { + if (type.inner) type = Type.fromField(this.define({ ...omit(midfield, ['load', 'dump']), type } as any)) + + const name = this.define({ ...field, deftype: midfield.deftype, type: type.type }) + transformers.push({ + types: [name as any], + load: field.load, + dump: field.dump, + }) + // for transform type, intentionally assign a null initial on default + setInitial?.(field.initial) + setField?.({ ...field, deftype: midfield.deftype ?? this.types[type.type]?.deftype ?? type.type, initial: midfield.initial, type: name }) + return Type.fromField(name as any) + } + if (typeof midfield === 'object') setField?.({ ...midfield, deftype: midfield.deftype ?? this.types[type.type]?.deftype ?? type?.type }) + return type + } + + define, Field.Type | 'object' | 'array'>>(name: K, field: Field.Definition | Field.Transform): K + define(field: Field.Definition | Field.Transform): Field.NewType define(name: any, field?: any) { if (typeof name === 'object') { field = name @@ -181,8 +199,14 @@ export class Database extends Ser if (name && this.types[name]) throw new Error(`type "${name}" already defined`) if (!name) while (this.types[name = '_define_' + randomId()]); + + const transformers = [] + const type = this._parseField(field, transformers, undefined, value => field = value) + field.transformers = transformers + this[Context.current].effect(() => { - this.types[name] = { deftype: this.types[field.type]?.deftype ?? field.type, ...field } + this.types[name] = { ...field } + this.types[name].deftype ??= this.types[field.type]?.deftype ?? type.type as any return () => delete this.types[name] }) return name as any diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 0d51058a..03d47a67 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -36,8 +36,8 @@ export namespace Field { : T extends boolean ? 'boolean' : T extends Date ? 'timestamp' | 'date' | 'time' : T extends ArrayBuffer ? 'binary' - : T extends unknown[] ? 'list' | 'json' | 'array' - : T extends object ? 'json' | 'object' + : T extends unknown[] ? 'list' | 'json' + : T extends object ? 'json' : 'expr' type Shorthand = S | `${S}(${any})` @@ -49,31 +49,32 @@ export namespace Field { export type Array = { type: 'array' - inner?: Definition + inner?: Literal | Definition | Transform } & Omit, 'type'> - export type Transform = { - type: Type | Keys | NewType + export type Transform = { + type: Type | Keys | NewType | 'object' | 'array' dump: (value: S | null) => T | null | void load: (value: T | null) => S | null | void initial?: S - } & Omit, 'type' | 'initial'> - - type Parsed = { - type: Type | Field['type'] - } & Omit, 'type'> + } & Omit, 'type' | 'initial'> export type Definition = | (Omit, 'type'> & { type: Type }) | Object | (T extends (infer I)[] ? Array : never) + + export type Literal = | Shorthand> - | Transform | Keys | NewType + export type Parsable = { + type: Type | Field['type'] + } & Omit, 'type'> + type MapField = { - [K in keyof O]?: Definition + [K in keyof O]?: Literal | Definition | Transform } export type Extension = MapField, N> @@ -87,14 +88,14 @@ export namespace Field { const regexp = /^(\w+)(?:\((.+)\))?$/ - export function parse(source: string | Parsed): Field { + export function parse(source: string | Parsable): Field { if (typeof source === 'function') throw new TypeError('view field is not supported') if (typeof source !== 'string') { return { initial: null, deftype: source.type as any, ...source, - type: Type.isType(source.type) ? source.type : Type.fromField(source.type), + type: Type.fromField(source.type), } } @@ -196,7 +197,7 @@ export class Model { resolveValue(field: string | Field | Type, value: any) { if (isNullable(value)) return value if (typeof field === 'string') field = this.fields[field] as Field - if (field && !Type.isType(field)) field = Type.fromField(field) + if (field) field = Type.fromField(field) if (field?.type === 'time') { const date = new Date(0) date.setHours(value.getHours(), value.getMinutes(), value.getSeconds(), value.getMilliseconds()) diff --git a/packages/core/src/type.ts b/packages/core/src/type.ts index d911647f..b351587e 100644 --- a/packages/core/src/type.ts +++ b/packages/core/src/type.ts @@ -49,8 +49,8 @@ export namespace Type { throw new TypeError(`invalid primitive: ${value}`) } - export function fromField(field: Field | Field.Type | Keys | Field.NewType): Type { - if (isType(field)) throw new TypeError(`invalid field: ${JSON.stringify(field)}`) + export function fromField(field: Type | Field | Field.Type | Keys | Field.NewType): Type { + if (isType(field)) return field if (typeof field === 'string') return defineProperty({ type: field }, kType, true) else if (field.type) return field.type else if (field.expr?.[kType]) return field.expr[kType] diff --git a/packages/mongo/src/builder.ts b/packages/mongo/src/builder.ts index 9e93ea88..5077d0c3 100644 --- a/packages/mongo/src/builder.ts +++ b/packages/mongo/src/builder.ts @@ -455,7 +455,7 @@ export class Builder { } } - res = converter ? converter.dump(res) : res + 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 @@ -469,7 +469,7 @@ export class Builder { const converter = this.driver.types[type.type] const ancestor = this.driver.database.types[type.type]?.type let res = this.load(value, ancestor ? Type.fromField(ancestor) : undefined) - res = converter ? converter.load(res) : res + res = converter?.load ? converter.load(res) : res if (!isNullable(res) && type.inner) { if (Type.isArray(type)) { diff --git a/packages/sql-utils/src/index.ts b/packages/sql-utils/src/index.ts index da77fac3..46596a78 100644 --- a/packages/sql-utils/src/index.ts +++ b/packages/sql-utils/src/index.ts @@ -560,7 +560,7 @@ export class Builder { res = mapValues(res, (x, k) => this.dump(x, Type.getInner(type as Type, k), root)) } } - res = converter ? converter.dump(res) : res + res = converter?.dump ? converter.dump(res) : res const ancestor = this.driver.database.types[type.type]?.type if (!root && !ancestor) res = this.transform(res, type, 'dump') res = this.dump(res, ancestor ? Type.fromField(ancestor) : undefined, root) @@ -588,7 +588,7 @@ export class Builder { const ancestor = this.driver.database.types[type.type]?.type let res = this.load(value, ancestor ? Type.fromField(ancestor) : undefined, root) res = this.transform(res, type, 'load') - res = converter ? converter.load(res) : res + res = converter?.load ? converter.load(res) : res if (!isNullable(res) && type.inner) { if (Type.isArray(type)) { @@ -618,7 +618,7 @@ export class Builder { * Convert value from Type to SQL. */ escape(value: any, type?: Field | Field.Type | Type) { - type &&= (Type.isType(type) ? type : Type.fromField(type)) + type &&= Type.fromField(type) return this.escapePrimitive(type ? this.dump(value, type) : value, type) } diff --git a/packages/tests/src/model.ts b/packages/tests/src/model.ts index a55a52d4..167673f1 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -60,14 +60,27 @@ interface Custom { b: number } +interface RecursiveX { + id: number + y?: RecursiveY +} + +interface RecursiveY { + id: number + x?: RecursiveX +} + interface Tables { dtypes: DType dobjects: DObject + recurxs: RecursiveX } interface Types { bigint: bigint custom: Custom + recurx: RecursiveX + recury: RecursiveY } function toBinary(source: string): ArrayBuffer { @@ -91,7 +104,7 @@ function ModelOperations(database: Database) { type: 'string', dump: value => isNullable(value) ? value : value.toString(), load: value => isNullable(value) ? value : BigInt(value), - initial: 123n + initial: 123n, }) database.define('custom', { @@ -114,6 +127,22 @@ function ModelOperations(database: Database) { initial: 'pooo', }) + database.define('recurx', { + type: 'object', + inner: { + id: 'unsigned', + y: 'recury', + }, + }) + + database.define('recury', { + type: 'object', + inner: { + id: 'unsigned', + x: 'recurx', + }, + }) + const baseFields: Field.Extension = { id: 'unsigned', text: { @@ -159,23 +188,11 @@ function ModelOperations(database: Database) { initial: false, }, bigint: 'bigint', - custom: 'custom', + custom: { type: 'custom' }, bstr: bstr, }, }, }, - initial: { - num: 1, - text: '2', - json: { - text: '3', - num: 4, - }, - embed: { - bool: true, - bigint: 123n, - }, - }, }, // dot defined object 'object2.num': { @@ -237,6 +254,11 @@ function ModelOperations(database: Database) { initial: [] }, }, { autoInc: true }) + + database.extend('recurxs', { + id: 'unsigned', + y: 'recury', + }, { autoInc: true }) } function getValue(obj: any, path: string) { @@ -431,6 +453,11 @@ namespace ModelOperations { ).to.eventually.have.shape([{ x: table.map(x => getValue(x, key)) }]) )) }) + + it('recursive type', async () => { + const table = await setup(database, 'recurxs', [{ id: 1, y: { id: 2, x: { id: 3, y: { id: 4, x: { id: 5 } } } } }]) + await expect(database.get('recurxs', {})).to.eventually.have.deep.members(table) + }) } export const object = function ObjectFields(database: Database, options: ModelOptions = {}) { From 9f867ea3bb241716bf6f7f89f4454f04fdea2a17 Mon Sep 17 00:00:00 2001 From: Hieuzest Date: Fri, 5 Apr 2024 17:15:43 +0800 Subject: [PATCH 3/9] feat: refa resolveFields to accept all kinds of cell/fields --- packages/core/src/model.ts | 9 ++++++++- packages/core/src/selection.ts | 18 +++++++++++++----- packages/mongo/src/builder.ts | 4 ++-- packages/sql-utils/src/index.ts | 2 +- packages/tests/src/model.ts | 10 +++++++++- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 03d47a67..1508b2a1 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,7 +1,7 @@ import { is, isNullable, makeArray, MaybeArray, valueMap } from 'cosmokit' import { Database } from './database.ts' import { Eval, isEvalExpr } from './eval.ts' -import { clone, Flatten, Keys } from './utils.ts' +import { clone, Flatten, Keys, unravel } from './utils.ts' import { Type } from './type.ts' import { Driver } from './driver.ts' @@ -260,6 +260,13 @@ export class Model { parse(source: object, strict = true, prefix = '', result = {} as S) { const fields = Object.keys(this.fields) + if (strict && prefix === '') { + // initialize object layout + Object.assign(result as any, unravel(Object.fromEntries(fields + .filter(key => key.includes('.')) + .map(key => [key.slice(0, key.lastIndexOf('.')), {}])), + )) + } for (const key in source) { let node = result const segments = key.split('.').reverse() diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index 382b3941..f941d7d2 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -1,4 +1,4 @@ -import { defineProperty, Dict, valueMap } from 'cosmokit' +import { defineProperty, Dict, filterKeys, valueMap } from 'cosmokit' import { Driver } from './driver.ts' import { Eval, executeEval } from './eval.ts' import { Model } from './model.ts' @@ -104,11 +104,14 @@ class Executable { if (typeof fields === 'string') fields = [fields] if (Array.isArray(fields)) { const modelFields = Object.keys(this.model.fields) - const keys = fields.flatMap((key) => { - if (this.model.fields[key]) return key - return modelFields.filter(path => path.startsWith(key + '.')) + const entries = fields.flatMap((key) => { + if (this.model.fields[key]) return [[key, this.row[key]]] + else if (modelFields.some(path => path.startsWith(key + '.'))) { + return modelFields.filter(path => path.startsWith(key + '.')).map(path => [path, this.row[path]]) + } + return [[key, key.split('.').reduce((row, k) => row[k], this.row)]] }) - return Object.fromEntries(keys.map(key => [key, this.row[key]])) + return Object.fromEntries(entries) } else { return valueMap(fields, field => this.resolveField(field)) } @@ -260,6 +263,11 @@ export class Selection extends Executable { this.orderBy(field as any, cursor.sort[field]) } } + if (cursor.fields) { + return super.execute().then( + rows => rows.map(row => filterKeys(row as any, key => (cursor.fields as string[]).some(k => k === key || k.startsWith(`${key}.`)))), + ) + } return super.execute() } } diff --git a/packages/mongo/src/builder.ts b/packages/mongo/src/builder.ts index 5077d0c3..293b4401 100644 --- a/packages/mongo/src/builder.ts +++ b/packages/mongo/src/builder.ts @@ -357,7 +357,7 @@ export class Builder { groupStages.push(...this.flushLookups(), { $match: { $expr } }) } stages.push(...this.flushLookups(), ...groupStages, { $project }) - $group['_id'] = model.parse($group['_id'], false) + $group['_id'] = unravel($group['_id']) } else if (fields) { const $project = valueMap(fields, (expr) => this.eval(expr)) $project._id = 0 @@ -481,7 +481,7 @@ export class Builder { return res } - value = type.format(value) + value = type.format(value, false) const result = {} for (const key in value) { if (!(key in type.fields)) continue diff --git a/packages/sql-utils/src/index.ts b/packages/sql-utils/src/index.ts index 46596a78..5ec7ae67 100644 --- a/packages/sql-utils/src/index.ts +++ b/packages/sql-utils/src/index.ts @@ -597,7 +597,7 @@ export class Builder { res = mapValues(res, (x, k) => this.load(x, Type.getInner(type as Type, k), false)) } } - return (type.inner && !Type.isArray(type)) ? unravel(res) : res + return (!isNullable(res) && type.inner && !Type.isArray(type)) ? unravel(res) : res } const result = {} diff --git a/packages/tests/src/model.ts b/packages/tests/src/model.ts index 167673f1..3a92373e 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -1,5 +1,5 @@ import { valueMap, isNullable } from 'cosmokit' -import { $, Database, Field, Type } from 'minato' +import { $, Database, Field, Type, unravel } from 'minato' import { expect } from 'chai' interface DType { @@ -563,6 +563,14 @@ namespace ModelOperations { ).to.eventually.have.shape([{ x: table.map(x => getValue(x, key)) }]) )) }) + + it('project with dot notation', async () => { + const table = await setup(database, 'dobjects', dobjectTable) + const keys = Object.keys(database.tables['dobjects'].fields).flatMap(k => k.split('.').reduce((arr, c) => arr.length ? [`${arr[0]}.${c}`, ...arr] : [c], [])) + await Promise.all(keys.map(key => + expect(database.select('dobjects').project([key as any]).execute()).to.eventually.have.deep.members(table.map(row => unravel({ [key]: getValue(row, key) }))) + )) + }) } } From 74fd647e78e5a784cb904bce192a951eac693798 Mon Sep 17 00:00:00 2001 From: Hieuzest Date: Fri, 5 Apr 2024 18:20:03 +0800 Subject: [PATCH 4/9] feat: support ignoring $.object on Row.Cell --- packages/core/src/selection.ts | 8 +++++++- packages/core/src/type.ts | 2 +- packages/tests/src/model.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/core/src/selection.ts b/packages/core/src/selection.ts index f941d7d2..cab05eeb 100644 --- a/packages/core/src/selection.ts +++ b/packages/core/src/selection.ts @@ -59,7 +59,13 @@ const createRow = (ref: string, expr = {}, prefix = '', model?: Model) => new Pr // unknown field inside json type = Type.fromField('expr') } - return createRow(ref, Eval('', [ref, `${prefix}${key}`], type), `${prefix}${key}.`, model) + + const row = createRow(ref, Eval('', [ref, `${prefix}${key}`], type), `${prefix}${key}.`, model) + if (Object.keys(model?.fields!).some(k => k.startsWith(`${prefix}${key}.`))) { + return createRow(ref, Eval.object(row), `${prefix}${key}.`, model) + } else { + return row + } }, }) diff --git a/packages/core/src/type.ts b/packages/core/src/type.ts index b351587e..e2c018dc 100644 --- a/packages/core/src/type.ts +++ b/packages/core/src/type.ts @@ -6,7 +6,7 @@ import { Keys } from './utils.ts' export interface Type { [Type.kType]?: true type: Field.Type | Keys | Field.NewType - inner?: T extends (infer I)[] ? Type : Field.Type extends 'json' ? { [key in keyof T]: Type } : never + inner?: T extends (infer I)[] ? Type : Field.Type extends 'json' ? { [key in keyof T]: Type } : never array?: boolean } diff --git a/packages/tests/src/model.ts b/packages/tests/src/model.ts index 3a92373e..790eeb9d 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -432,7 +432,7 @@ namespace ModelOperations { typeModel && it('$.array encoding on cell', async () => { const table = await setup(database, 'dtypes', dtypeTable) await expect(database.eval('dtypes', row => $.array(row.object))).to.eventually.have.deep.members(table.map(x => x.object)) - await expect(database.eval('dtypes', row => $.array($.object(row.object2)))).to.eventually.have.deep.members(table.map(x => x.object2)) + await expect(database.eval('dtypes', row => $.array(row.object2))).to.eventually.have.deep.members(table.map(x => x.object2)) }) it('$.array encoding', async () => { From 6eac3c0a651dc9a262d78c1cca201704be3f2ebe Mon Sep 17 00:00:00 2001 From: Hieuzest Date: Sat, 6 Apr 2024 18:23:53 +0800 Subject: [PATCH 5/9] fix(mongo): existence check -> equality check --- packages/mongo/src/builder.ts | 7 +++++-- packages/mysql/src/builder.ts | 3 --- packages/postgres/src/builder.ts | 10 +--------- packages/sql-utils/src/index.ts | 10 +++++----- packages/tests/src/model.ts | 10 +++++++--- packages/tests/src/update.ts | 1 + 6 files changed, 19 insertions(+), 22 deletions(-) diff --git a/packages/mongo/src/builder.ts b/packages/mongo/src/builder.ts index 293b4401..b73d5e02 100644 --- a/packages/mongo/src/builder.ts +++ b/packages/mongo/src/builder.ts @@ -24,7 +24,7 @@ function transformFieldQuery(query: Query.FieldQuery, key: string, filters: Filt } else if (query instanceof RegExp) { return { $regex: query } } else if (isNullable(query)) { - return { $exists: false } + return null } // query operators @@ -52,7 +52,7 @@ function transformFieldQuery(query: Query.FieldQuery, key: string, filters: Filt } else if (prop === '$el') { const child = transformFieldQuery(query[prop], key, filters) if (child === false) return false - if (child !== true) result.$elemMatch = child + if (child !== true) result.$elemMatch = child! } else if (prop === '$regexFor') { filters.push({ $expr: { @@ -62,6 +62,9 @@ function transformFieldQuery(query: Query.FieldQuery, key: string, filters: Filt }, }, }) + } else if (prop === '$exists') { + if (query[prop]) return { $ne: null } + else return null } else { result[prop] = query[prop] } diff --git a/packages/mysql/src/builder.ts b/packages/mysql/src/builder.ts index b18960bc..89c0a190 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -48,7 +48,6 @@ export class MySQLBuilder extends Builder { } this.transformers['date'] = { - encode: value => value, decode: value => `cast(${value} as date)`, load: value => { if (isNullable(value) || typeof value === 'object') return value @@ -67,14 +66,12 @@ export class MySQLBuilder extends Builder { } this.transformers['time'] = { - encode: value => value, decode: value => `cast(${value} as time)`, load: value => this.driver.types['time'].load(value), dump: value => isNullable(value) ? value : Time.template('yyyy-MM-dd hh:mm:ss.SSS', value), } this.transformers['timestamp'] = { - encode: value => value, decode: value => `cast(${value} as datetime)`, load: value => { if (isNullable(value) || typeof value === 'object') return value diff --git a/packages/postgres/src/builder.ts b/packages/postgres/src/builder.ts index 57631b71..f669b64d 100644 --- a/packages/postgres/src/builder.ts +++ b/packages/postgres/src/builder.ts @@ -91,17 +91,12 @@ export class PostgresBuilder extends Builder { } this.transformers['boolean'] = { - encode: value => value, decode: value => `(${value})::boolean`, - load: value => value, - dump: value => value, } this.transformers['decimal'] = { - encode: value => value, decode: value => `(${value})::double precision`, load: value => isNullable(value) ? value : +value, - dump: value => value, } this.transformers['binary'] = { @@ -112,7 +107,6 @@ export class PostgresBuilder extends Builder { } this.transformers['date'] = { - encode: value => value, decode: value => `cast(${value} as date)`, load: value => { if (isNullable(value) || typeof value === 'object') return value @@ -125,15 +119,13 @@ export class PostgresBuilder extends Builder { } this.transformers['time'] = { - encode: value => value, decode: value => `cast(${value} as time)`, load: value => this.driver.types['time'].load(value), dump: value => this.driver.types['time'].dump(value), } this.transformers['timestamp'] = { - encode: value => value, - decode: value => `cast(${value} as datetime)`, + decode: value => `cast(${value} as timestamp)`, load: value => { if (isNullable(value) || typeof value === 'object') return value return new Date(value) diff --git a/packages/sql-utils/src/index.ts b/packages/sql-utils/src/index.ts index 5ec7ae67..c705253a 100644 --- a/packages/sql-utils/src/index.ts +++ b/packages/sql-utils/src/index.ts @@ -24,10 +24,10 @@ export type EvalOperators = { } & { $: (expr: any) => string } interface Transformer { - encode(value: string): string - decode(value: string): string - load(value: S): T - dump(value: T): S + encode?(value: string): string + decode?(value: string): string + load?(value: S | null): T | null + dump?(value: T | null): S | null } interface State { @@ -312,7 +312,7 @@ export class Builder { protected transform(value: string, type: Type | Eval.Expr | undefined, method: 'encode' | 'decode' | 'load' | 'dump', miss?: any) { type = Type.isType(type) ? type : Type.fromTerm(type) const transformer = this.transformers[type.type] ?? this.transformers[this.driver.database.types[type.type]?.type!] - return transformer ? transformer[method](value) : (miss ?? value) + return transformer?.[method] ? transformer[method]!(value) : (miss ?? value) } protected groupObject(_fields: any) { diff --git a/packages/tests/src/model.ts b/packages/tests/src/model.ts index 790eeb9d..059f68ae 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -1,4 +1,4 @@ -import { valueMap, isNullable } from 'cosmokit' +import { valueMap, isNullable, deduplicate } from 'cosmokit' import { $, Database, Field, Type, unravel } from 'minato' import { expect } from 'chai' @@ -264,7 +264,7 @@ function ModelOperations(database: Database) { function getValue(obj: any, path: string) { if (path.includes('.')) { const index = path.indexOf('.') - return getValue(obj[path.slice(0, index)] ??= {}, path.slice(index + 1)) + return getValue(obj[path.slice(0, index)] ?? {}, path.slice(index + 1)) } else { return obj[path] } @@ -566,7 +566,11 @@ namespace ModelOperations { it('project with dot notation', async () => { const table = await setup(database, 'dobjects', dobjectTable) - const keys = Object.keys(database.tables['dobjects'].fields).flatMap(k => k.split('.').reduce((arr, c) => arr.length ? [`${arr[0]}.${c}`, ...arr] : [c], [])) + const keys = deduplicate([ + 'foo.nested.object', + 'foo.nested.object.embed', + ...Object.keys(database.tables['dobjects'].fields).flatMap(k => k.split('.').reduce((arr, c) => arr.length ? [`${arr[0]}.${c}`, ...arr] : [c], [])), + ]) await Promise.all(keys.map(key => expect(database.select('dobjects').project([key as any]).execute()).to.eventually.have.deep.members(table.map(row => unravel({ [key]: getValue(row, key) }))) )) diff --git a/packages/tests/src/update.ts b/packages/tests/src/update.ts index a7cca709..1fdd2960 100644 --- a/packages/tests/src/update.ts +++ b/packages/tests/src/update.ts @@ -188,6 +188,7 @@ namespace OrmOperations { data.text = null as never await database.set('temp2', { timestamp: { $exists: true } }, { text: null }) await expect(database.get('temp2', {})).to.eventually.have.shape(table) + await expect(database.get('temp2', { text: { $exists: false } })).to.eventually.have.length(1) }) it('noop', async () => { From 1f4a58a77768feaf77aca8f9faae70acfb7f94dd Mon Sep 17 00:00:00 2001 From: Hieuzest Date: Sun, 7 Apr 2024 12:16:15 +0800 Subject: [PATCH 6/9] chore: refine --- packages/core/src/database.ts | 42 +++++++++++++++++------------------ packages/core/src/model.ts | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index 8bba21ae..97b16123 100644 --- a/packages/core/src/database.ts +++ b/packages/core/src/database.ts @@ -119,14 +119,14 @@ export class Database extends Ser } private _parseField(field: any, transformers: Driver.Transformer[] = [], setInitial?: (value) => void, setField?: (value) => void): Type { - if (field === 'array') { - setInitial?.([]) - setField?.({ type: 'json', initial: [] }) - return Type.Array() - } else if (field === 'object') { + if (field === 'object') { setInitial?.({}) setField?.({ type: 'json', initial: {} }) return Type.Object() + } else if (field === 'array') { + setInitial?.([]) + setField?.({ type: 'json', initial: [] }) + return Type.Array() } else if (typeof field === 'string' && this.types[field]) { transformers.push({ types: [field as any], @@ -136,17 +136,12 @@ export class Database extends Ser setInitial?.(this.types[field].initial) setField?.({ ...this.types[field], type: field }) return Type.fromField(field) - } else if (typeof field === 'object' && this.types[field.type]) { - transformers.push({ - types: [field.type as any], - load: this.types[field.type].load, - dump: this.types[field.type].dump, - }, ...(this.types[field.type].transformers ?? [])) - setInitial?.(field.initial === undefined ? this.types[field.type].initial : field.initial) - setField?.({ initial: this.types[field.type].initial, ...field }) - return Type.fromField(field.type) + } else if (typeof field === 'string') { + setInitial?.(Field.getInitial((field as any).split('(')[0])) + setField?.(field) + return Type.fromField(field.split('(')[0]) } else if (typeof field === 'object' && field.type === 'object') { - const inner = unravel(field.inner, value => (value.type = 'object', value.inner ??= {})) + const inner = field.inner ? unravel(field.inner, value => (value.type = 'object', value.inner ??= {})) : Object.create(null) const initial = Object.create(null) const res = Type.Object(mapValues(inner, (x, k) => this.parseField(x, transformers, value => initial[k] = value))) setInitial?.(Field.getInitial('json', initial)) @@ -157,14 +152,19 @@ export class Database extends Ser setInitial?.([]) setField?.({ initial: [], ...field, deftype: 'json', type: res }) return res - } else if (typeof field === 'object') { - setInitial?.(Field.getInitial(field.type.split('(')[0], field.initial)) - setField?.(field) - return Type.fromField(field.type.split('(')[0]) + } else if (typeof field === 'object' && this.types[field.type]) { + transformers.push({ + types: [field.type as any], + load: this.types[field.type].load, + dump: this.types[field.type].dump, + }, ...(this.types[field.type].transformers ?? [])) + setInitial?.(field.initial === undefined ? this.types[field.type].initial : field.initial) + setField?.({ initial: this.types[field.type].initial, ...field }) + return Type.fromField(field.type) } else { - setInitial?.(Field.getInitial(field.split('(')[0])) + setInitial?.(Field.getInitial(field.type, field.initial)) setField?.(field) - return Type.fromField(field.split('(')[0]) + return Type.fromField(field.type) } } diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 1508b2a1..3f1e8e4c 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -44,7 +44,7 @@ export namespace Field { export type Object = { type: 'object' - inner: Extension + inner?: Extension } & Omit, 'type'> export type Array = { From 7015265fb6e2801664dfc10ffe8d1658e65378c3 Mon Sep 17 00:00:00 2001 From: Hieuzest Date: Sun, 7 Apr 2024 21:50:25 +0800 Subject: [PATCH 7/9] refa: move binary utils to cosmokit --- packages/core/src/model.ts | 4 ++-- packages/core/src/utils.ts | 36 +------------------------------- packages/memory/src/index.ts | 4 ++-- packages/mongo/src/index.ts | 6 +++--- packages/mysql/src/builder.ts | 10 ++++----- packages/mysql/src/index.ts | 6 +++--- packages/postgres/src/builder.ts | 10 ++++----- packages/postgres/src/index.ts | 6 +++--- packages/sqlite/src/builder.ts | 10 ++++----- packages/sqlite/src/index.ts | 6 +++--- packages/tests/src/shape.ts | 8 +++---- 11 files changed, 36 insertions(+), 70 deletions(-) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 3f1e8e4c..175119a8 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,7 +1,7 @@ -import { is, isNullable, makeArray, MaybeArray, valueMap } from 'cosmokit' +import { clone, is, isNullable, makeArray, MaybeArray, valueMap } from 'cosmokit' import { Database } from './database.ts' import { Eval, isEvalExpr } from './eval.ts' -import { clone, Flatten, Keys, unravel } from './utils.ts' +import { Flatten, Keys, unravel } from './utils.ts' import { Type } from './type.ts' import { Driver } from './driver.ts' diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index b8576162..15f19504 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,4 +1,4 @@ -import { Intersect, is, mapValues } from 'cosmokit' +import { Intersect } from 'cosmokit' import { Eval } from './eval.ts' export type Values = S[keyof S] @@ -82,37 +82,3 @@ export function unravel(source: object, init?: (value) => any) { } return result } - -export function clone(source: T): T -export function clone(source: any) { - if (!source || typeof source !== 'object') return source - if (is('ArrayBuffer', source)) return source.slice(0) - if (Array.isArray(source)) return source.map(clone) - if (is('Date', source)) return new Date(source.valueOf()) - if (is('RegExp', source)) return new RegExp(source.source, source.flags) - return mapValues(source, clone) -} - -export function toArrayBuffer(source: ArrayBuffer | ArrayBufferView): ArrayBuffer { - return ArrayBuffer.isView(source) ? source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength) : source -} - -export function hexToArrayBuffer(source: string): ArrayBuffer { - const buffer: number[] = [] - for (let i = 0; i < source.length; i += 2) { - buffer.push(Number.parseInt(source.substring(i, i + 2), 16)) - } - return Uint8Array.from(buffer).buffer -} - -export function arrayBufferToHex(source: ArrayBuffer): string { - return Array.from(new Uint8Array(source), byte => byte.toString(16).padStart(2, '0')).join('') -} - -export function base64ToArrayBuffer(source: string): ArrayBuffer { - return Uint8Array.from(atob(source), c => c.charCodeAt(0)).buffer -} - -export function arrayBufferToBase64(source: ArrayBuffer): string { - return btoa(Array.from(new Uint8Array(source), b => String.fromCharCode(b)).join('')) -} diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index c1cc28d6..ab165a32 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -1,5 +1,5 @@ -import { Dict, makeArray, noop, omit, pick, valueMap } from 'cosmokit' -import { clone, Driver, Eval, executeEval, executeQuery, executeSort, executeUpdate, RuntimeError, Selection, z } from 'minato' +import { clone, Dict, makeArray, noop, omit, pick, valueMap } from 'cosmokit' +import { Driver, Eval, executeEval, executeQuery, executeSort, executeUpdate, RuntimeError, Selection, z } from 'minato' export class MemoryDriver extends Driver { static name = 'memory' diff --git a/packages/mongo/src/index.ts b/packages/mongo/src/index.ts index 8b372175..9dfdfc4c 100644 --- a/packages/mongo/src/index.ts +++ b/packages/mongo/src/index.ts @@ -1,6 +1,6 @@ import { BSONType, ClientSession, Collection, Db, IndexDescription, MongoClient, MongoClientOptions, MongoError } from 'mongodb' -import { Dict, isNullable, makeArray, mapValues, noop, omit, pick } from 'cosmokit' -import { Driver, Eval, executeUpdate, Query, RuntimeError, Selection, toArrayBuffer, z } from 'minato' +import { Binary, Dict, isNullable, makeArray, mapValues, noop, omit, pick } from 'cosmokit' +import { Driver, Eval, executeUpdate, Query, RuntimeError, Selection, z } from 'minato' import { URLSearchParams } from 'url' import { Builder } from './builder' @@ -49,7 +49,7 @@ export class MongoDriver extends Driver { this.define({ types: ['binary'], dump: value => isNullable(value) ? value : Buffer.from(value), - load: (value: any) => isNullable(value) ? value : toArrayBuffer(value.buffer), + load: (value: any) => isNullable(value) ? value : Binary.fromSource(value.buffer), }) } diff --git a/packages/mysql/src/builder.ts b/packages/mysql/src/builder.ts index 89c0a190..2fca07a3 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -1,6 +1,6 @@ import { Builder, escapeId, isBracketed } from '@minatojs/sql-utils' -import { Dict, is, isNullable, Time } from 'cosmokit' -import { arrayBufferToBase64, arrayBufferToHex, base64ToArrayBuffer, Driver, Field, isEvalExpr, Model, randomId, Selection, Type } from 'minato' +import { Binary, Dict, is, isNullable, Time } from 'cosmokit' +import { Driver, Field, isEvalExpr, Model, randomId, Selection, Type } from 'minato' export interface Compat { maria?: boolean @@ -43,8 +43,8 @@ export class MySQLBuilder extends Builder { this.transformers['binary'] = { encode: value => `to_base64(${value})`, decode: value => `from_base64(${value})`, - load: value => isNullable(value) || typeof value === 'object' ? value : base64ToArrayBuffer(value), - dump: value => isNullable(value) || typeof value === 'string' ? value : arrayBufferToBase64(value), + load: value => isNullable(value) || typeof value === 'object' ? value : Binary.fromBase64(value), + dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toBase64(value), } this.transformers['date'] = { @@ -87,7 +87,7 @@ export class MySQLBuilder extends Builder { } else if (value instanceof RegExp) { value = value.source } else if (is('ArrayBuffer', value)) { - return `X'${arrayBufferToHex(value)}'` + return `X'${Binary.toHex(value)}'` } else if (!!value && typeof value === 'object') { return `json_extract(${this.quote(JSON.stringify(value))}, '$')` } diff --git a/packages/mysql/src/index.ts b/packages/mysql/src/index.ts index a83ff3d7..75060dd2 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -1,7 +1,7 @@ -import { Dict, difference, isNullable, makeArray, pick } from 'cosmokit' +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, toArrayBuffer, z } from 'minato' +import { Driver, Eval, executeUpdate, Field, RuntimeError, Selection, z } from 'minato' import { escapeId, isBracketed } from '@minatojs/sql-utils' import { Compat, MySQLBuilder } from './builder' @@ -158,7 +158,7 @@ export class MySQLDriver extends Driver { this.define({ types: ['binary'], dump: value => value, - load: value => isNullable(value) ? value : toArrayBuffer(value), + load: value => isNullable(value) ? value : Binary.fromSource(value), }) } diff --git a/packages/postgres/src/builder.ts b/packages/postgres/src/builder.ts index f669b64d..f2ac2ea5 100644 --- a/packages/postgres/src/builder.ts +++ b/packages/postgres/src/builder.ts @@ -1,6 +1,6 @@ import { Builder, isBracketed } from '@minatojs/sql-utils' -import { Dict, is, isNullable, Time } from 'cosmokit' -import { arrayBufferToBase64, arrayBufferToHex, base64ToArrayBuffer, Driver, Field, isEvalExpr, Model, randomId, Selection, Type, unravel } from 'minato' +import { Binary, Dict, is, isNullable, Time } from 'cosmokit' +import { Driver, Field, isEvalExpr, Model, randomId, Selection, Type, unravel } from 'minato' export function escapeId(value: string) { return '"' + value.replace(/"/g, '""') + '"' @@ -102,8 +102,8 @@ export class PostgresBuilder extends Builder { this.transformers['binary'] = { encode: value => `encode(${value}, 'base64')`, decode: value => `decode(${value}, 'base64')`, - load: value => isNullable(value) || typeof value === 'object' ? value : base64ToArrayBuffer(value), - dump: value => isNullable(value) || typeof value === 'string' ? value : arrayBufferToBase64(value), + load: value => isNullable(value) || typeof value === 'object' ? value : Binary.fromBase64(value), + dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toBase64(value), } this.transformers['date'] = { @@ -244,7 +244,7 @@ export class PostgresBuilder extends Builder { } else if (value instanceof RegExp) { value = value.source } else if (is('ArrayBuffer', value)) { - return `'\\x${arrayBufferToHex(value)}'::bytea` + return `'\\x${Binary.toHex(value)}'::bytea` } else if (type?.type === 'list' && Array.isArray(value)) { return `ARRAY[${value.map(x => this.escape(x)).join(', ')}]::TEXT[]` } else if (!!value && typeof value === 'object') { diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts index 0597d1b3..f189c505 100644 --- a/packages/postgres/src/index.ts +++ b/packages/postgres/src/index.ts @@ -1,6 +1,6 @@ import postgres from 'postgres' -import { Dict, difference, isNullable, makeArray, pick } from 'cosmokit' -import { Driver, Eval, executeUpdate, Field, Selection, toArrayBuffer, z } from 'minato' +import { Binary, Dict, difference, isNullable, makeArray, pick } from 'cosmokit' +import { Driver, Eval, executeUpdate, Field, Selection, z } from 'minato' import { isBracketed } from '@minatojs/sql-utils' import { escapeId, formatTime, PostgresBuilder } from './builder' @@ -169,7 +169,7 @@ export class PostgresDriver extends Driver { this.define({ types: ['binary'], dump: value => value, - load: value => isNullable(value) ? value : toArrayBuffer(value), + load: value => isNullable(value) ? value : Binary.fromSource(value), }) } diff --git a/packages/sqlite/src/builder.ts b/packages/sqlite/src/builder.ts index a799315f..0df2b0b3 100644 --- a/packages/sqlite/src/builder.ts +++ b/packages/sqlite/src/builder.ts @@ -1,6 +1,6 @@ import { Builder, escapeId } from '@minatojs/sql-utils' -import { Dict, is, isNullable } from 'cosmokit' -import { arrayBufferToHex, Driver, Field, hexToArrayBuffer, Model, randomId, Type } from 'minato' +import { Binary, Dict, is, isNullable } from 'cosmokit' +import { Driver, Field, Model, randomId, Type } from 'minato' export class SQLiteBuilder extends Builder { protected escapeMap = { @@ -28,15 +28,15 @@ export class SQLiteBuilder extends Builder { this.transformers['binary'] = { encode: value => `hex(${value})`, decode: value => `unhex(${value})`, - load: value => isNullable(value) || typeof value === 'object' ? value : hexToArrayBuffer(value), - dump: value => isNullable(value) || typeof value === 'string' ? value : arrayBufferToHex(value), + load: value => isNullable(value) || typeof value === 'object' ? value : Binary.fromHex(value), + dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toHex(value), } } escapePrimitive(value: any, type?: Type) { if (value instanceof Date) value = +value else if (value instanceof RegExp) value = value.source - else if (is('ArrayBuffer', value)) return `X'${arrayBufferToHex(value)}'` + else if (is('ArrayBuffer', value)) return `X'${Binary.toHex(value)}'` return super.escapePrimitive(value, type) } diff --git a/packages/sqlite/src/index.ts b/packages/sqlite/src/index.ts index 583cc07f..f6b67469 100644 --- a/packages/sqlite/src/index.ts +++ b/packages/sqlite/src/index.ts @@ -1,5 +1,5 @@ -import { deepEqual, Dict, difference, isNullable, makeArray } from 'cosmokit' -import { Driver, Eval, executeUpdate, Field, Selection, toArrayBuffer, z } from 'minato' +import { Binary, deepEqual, Dict, difference, isNullable, makeArray } from 'cosmokit' +import { Driver, Eval, executeUpdate, Field, Selection, z } from 'minato' import { escapeId } from '@minatojs/sql-utils' import { resolve } from 'node:path' import { readFile, writeFile } from 'node:fs/promises' @@ -205,7 +205,7 @@ export class SQLiteDriver extends Driver { this.define({ types: ['binary'], dump: value => isNullable(value) ? value : new Uint8Array(value), - load: value => isNullable(value) ? value : toArrayBuffer(value), + load: value => isNullable(value) ? value : Binary.fromSource(value), }) } diff --git a/packages/tests/src/shape.ts b/packages/tests/src/shape.ts index b6c7a6e6..e5ba9352 100644 --- a/packages/tests/src/shape.ts +++ b/packages/tests/src/shape.ts @@ -1,4 +1,4 @@ -import { isNullable } from 'cosmokit' +import { Binary, deepEqual, isNullable } from 'cosmokit' import { inspect } from 'util' function flag(obj, key, value?) { @@ -60,9 +60,9 @@ export = (({ Assertion }) => { return } - // buffer - if (Buffer.isBuffer(expect)) { - if (!Buffer.isBuffer(actual) || !expect.equals(actual)) { + // binary + if (Binary.is(expect)) { + if (!Binary.is(actual) || !deepEqual(actual, expect)) { return formatError(inspect(expect), inspect(actual)) } return From 194cf53fed1bd6e8066f4cd4c9847eb1577752f4 Mon Sep 17 00:00:00 2001 From: Hieuzest Date: Sun, 7 Apr 2024 22:11:41 +0800 Subject: [PATCH 8/9] chore: fix types, cov --- packages/core/src/model.ts | 11 ++++++----- packages/core/src/type.ts | 4 ++-- packages/mysql/src/builder.ts | 4 ++-- packages/postgres/src/builder.ts | 4 ++-- packages/sqlite/src/builder.ts | 4 ++-- packages/tests/src/model.ts | 5 +---- 6 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 175119a8..b3aab7c3 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,4 +1,4 @@ -import { clone, is, isNullable, makeArray, MaybeArray, valueMap } from 'cosmokit' +import { Binary, clone, isNullable, makeArray, MaybeArray, valueMap } from 'cosmokit' import { Database } from './database.ts' import { Eval, isEvalExpr } from './eval.ts' import { Flatten, Keys, unravel } from './utils.ts' @@ -60,14 +60,16 @@ export namespace Field { } & Omit, 'type' | 'initial'> export type Definition = - | (Omit, 'type'> & { type: Type }) - | Object + | (Omit, 'type'> & { type: Type | Keys | NewType }) + | (T extends object ? Object : never) | (T extends (infer I)[] ? Array : never) export type Literal = | Shorthand> | Keys | NewType + | (T extends object ? 'object' : never) + | (T extends unknown[] ? 'array' : never) export type Parsable = { type: Type | Field['type'] @@ -279,8 +281,7 @@ export class Model { const field = fields.find(field => fullKey === field || fullKey.startsWith(field + '.')) if (field) { node[segments[0]] = value - } else if (!value || typeof value !== 'object' || isEvalExpr(value) || Array.isArray(value) || is('ArrayBuffer', value) - || Object.keys(value).length === 0) { + } else if (!value || typeof value !== 'object' || isEvalExpr(value) || Array.isArray(value) || Binary.is(value) || Object.keys(value).length === 0) { if (strict) { throw new TypeError(`unknown field "${fullKey}" in model ${this.name}`) } else { diff --git a/packages/core/src/type.ts b/packages/core/src/type.ts index e2c018dc..9c71208e 100644 --- a/packages/core/src/type.ts +++ b/packages/core/src/type.ts @@ -1,4 +1,4 @@ -import { defineProperty, is, isNullable, mapValues } from 'cosmokit' +import { Binary, defineProperty, isNullable, mapValues } from 'cosmokit' import { Field } from './model.ts' import { Eval, isEvalExpr } from './eval.ts' import { Keys } from './utils.ts' @@ -43,7 +43,7 @@ export namespace Type { else if (typeof value === 'string') return String as any else if (typeof value === 'boolean') return Boolean as any else if (value instanceof Date) return fromField('timestamp' as any) - else if (is('ArrayBuffer', value)) return fromField('binary' as any) + else if (Binary.is(value)) return fromField('binary' as any) else if (globalThis.Array.isArray(value)) return Array(value.length ? fromPrimitive(value[0]) : undefined) as any else if (typeof value === 'object') return fromField('json' as any) throw new TypeError(`invalid primitive: ${value}`) diff --git a/packages/mysql/src/builder.ts b/packages/mysql/src/builder.ts index 2fca07a3..029a65cd 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -1,5 +1,5 @@ import { Builder, escapeId, isBracketed } from '@minatojs/sql-utils' -import { Binary, Dict, is, isNullable, Time } from 'cosmokit' +import { Binary, Dict, isNullable, Time } from 'cosmokit' import { Driver, Field, isEvalExpr, Model, randomId, Selection, Type } from 'minato' export interface Compat { @@ -86,7 +86,7 @@ export class MySQLBuilder extends Builder { value = Time.template('yyyy-MM-dd hh:mm:ss.SSS', value) } else if (value instanceof RegExp) { value = value.source - } else if (is('ArrayBuffer', value)) { + } else if (Binary.is(value)) { return `X'${Binary.toHex(value)}'` } else if (!!value && typeof value === 'object') { return `json_extract(${this.quote(JSON.stringify(value))}, '$')` diff --git a/packages/postgres/src/builder.ts b/packages/postgres/src/builder.ts index f2ac2ea5..412db384 100644 --- a/packages/postgres/src/builder.ts +++ b/packages/postgres/src/builder.ts @@ -1,5 +1,5 @@ import { Builder, isBracketed } from '@minatojs/sql-utils' -import { Binary, Dict, is, isNullable, Time } from 'cosmokit' +import { Binary, Dict, isNullable, Time } from 'cosmokit' import { Driver, Field, isEvalExpr, Model, randomId, Selection, Type, unravel } from 'minato' export function escapeId(value: string) { @@ -243,7 +243,7 @@ export class PostgresBuilder extends Builder { value = formatTime(value) } else if (value instanceof RegExp) { value = value.source - } else if (is('ArrayBuffer', value)) { + } else if (Binary.is(value)) { return `'\\x${Binary.toHex(value)}'::bytea` } else if (type?.type === 'list' && Array.isArray(value)) { return `ARRAY[${value.map(x => this.escape(x)).join(', ')}]::TEXT[]` diff --git a/packages/sqlite/src/builder.ts b/packages/sqlite/src/builder.ts index 0df2b0b3..6df09099 100644 --- a/packages/sqlite/src/builder.ts +++ b/packages/sqlite/src/builder.ts @@ -1,5 +1,5 @@ import { Builder, escapeId } from '@minatojs/sql-utils' -import { Binary, Dict, is, isNullable } from 'cosmokit' +import { Binary, Dict, isNullable } from 'cosmokit' import { Driver, Field, Model, randomId, Type } from 'minato' export class SQLiteBuilder extends Builder { @@ -36,7 +36,7 @@ export class SQLiteBuilder extends Builder { escapePrimitive(value: any, type?: Type) { if (value instanceof Date) value = +value else if (value instanceof RegExp) value = value.source - else if (is('ArrayBuffer', value)) return `X'${Binary.toHex(value)}'` + else if (Binary.is(value)) return `X'${Binary.toHex(value)}'` return super.escapePrimitive(value, type) } diff --git a/packages/tests/src/model.ts b/packages/tests/src/model.ts index 059f68ae..5322d466 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -170,10 +170,7 @@ function ModelOperations(database: Database) { type: 'list', initial: ['a`a', 'b"b', 'c\'c', 'd\\d'], }, - array: { - type: 'json', - initial: [1, 2, 3], - }, + array: 'array', object: { type: 'object', inner: { From 04461561d1a46a2d5a69a438f61c7a31417ac18c Mon Sep 17 00:00:00 2001 From: Hieuzest Date: Sun, 7 Apr 2024 22:36:06 +0800 Subject: [PATCH 9/9] feat: allow passing Buffer to binary in modifier --- packages/mysql/src/builder.ts | 2 ++ packages/postgres/src/builder.ts | 2 ++ packages/sqlite/src/builder.ts | 1 + packages/tests/src/model.ts | 7 +++++++ 4 files changed, 12 insertions(+) diff --git a/packages/mysql/src/builder.ts b/packages/mysql/src/builder.ts index 029a65cd..89c1d014 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -88,6 +88,8 @@ export class MySQLBuilder extends Builder { value = value.source } else if (Binary.is(value)) { return `X'${Binary.toHex(value)}'` + } else if (Binary.isSource(value)) { + return `X'${Binary.toHex(Binary.fromSource(value))}'` } else if (!!value && typeof value === 'object') { return `json_extract(${this.quote(JSON.stringify(value))}, '$')` } diff --git a/packages/postgres/src/builder.ts b/packages/postgres/src/builder.ts index 412db384..52d445fe 100644 --- a/packages/postgres/src/builder.ts +++ b/packages/postgres/src/builder.ts @@ -245,6 +245,8 @@ export class PostgresBuilder extends Builder { value = value.source } else if (Binary.is(value)) { return `'\\x${Binary.toHex(value)}'::bytea` + } else if (Binary.isSource(value)) { + return `'\\x${Binary.toHex(Binary.fromSource(value))}'::bytea` } else if (type?.type === 'list' && Array.isArray(value)) { return `ARRAY[${value.map(x => this.escape(x)).join(', ')}]::TEXT[]` } else if (!!value && typeof value === 'object') { diff --git a/packages/sqlite/src/builder.ts b/packages/sqlite/src/builder.ts index 6df09099..c262992a 100644 --- a/packages/sqlite/src/builder.ts +++ b/packages/sqlite/src/builder.ts @@ -37,6 +37,7 @@ export class SQLiteBuilder extends Builder { if (value instanceof Date) value = +value else if (value instanceof RegExp) value = value.source else if (Binary.is(value)) return `X'${Binary.toHex(value)}'` + else if (Binary.isSource(value)) return `X'${Binary.toHex(Binary.fromSource(value))}'` return super.escapePrimitive(value, type) } diff --git a/packages/tests/src/model.ts b/packages/tests/src/model.ts index 5322d466..7f587d8f 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -324,6 +324,13 @@ namespace ModelOperations { await expect(database.get('dtypes', {})).to.eventually.have.deep.members(table) }) + typeModel && it('pass view to binary', async () => { + const table = await setup(database, 'dtypes', dtypeTable) + table[0].binary = toBinary('this is Buffer') + await database.set('dtypes', table[0].id, { binary: Buffer.from('this is Buffer') }) + await expect(database.get('dtypes', {})).to.eventually.have.deep.members(table) + }) + it('modifier', async () => { const table = await setup(database, 'dtypes', dtypeTable) await database.remove('dtypes', {})