diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index 0a12eefe..97b16123 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,38 +118,30 @@ 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 { - if (field === 'array') { - setInitial?.([]) - setField?.({ type: 'json', initial: [] }) - return Type.Array() - } else if (field === 'object') { + private _parseField(field: any, transformers: Driver.Transformer[] = [], setInitial?: (value) => void, setField?: (value) => void): Type { + 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: [this.types[field].type], + 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]) - return Type.fromField(field as any) - } else if (typeof field === 'object' && field.load && field.dump) { - const name = this.define(field) - 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) + setField?.({ ...this.types[field], type: field }) + return Type.fromField(field) + } 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)) @@ -160,19 +152,45 @@ 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) } } - define, Field.Type>>(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: field.type, ...field, type: name } + 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/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..b3aab7c3 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 { Binary, clone, 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 { Flatten, Keys, unravel } from './utils.ts' import { Type } from './type.ts' import { Driver } from './driver.ts' @@ -35,51 +35,54 @@ 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 unknown[] ? 'list' | 'json' | 'array' - : T extends object ? 'json' | 'object' + : T extends ArrayBuffer ? 'binary' + : T extends unknown[] ? 'list' | 'json' + : T extends object ? 'json' : 'expr' type Shorthand = S | `${S}(${any})` export type Object = { type: 'object' - inner: Extension + inner?: Extension } & Omit, 'type'> export type Array = { type: 'array' - inner?: Definition + inner?: Literal | Definition | Transform } & Omit, 'type'> - export type Transform = { - type: Type - dump: (value: S) => T | null - load: (value: T) => S | null + 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 + | (Omit, 'type'> & { type: Type | Keys | NewType }) + | (T extends object ? Object : never) | (T extends (infer I)[] ? Array : never) + + export type Literal = | Shorthand> - | Transform | Keys | NewType + | (T extends object ? 'object' : never) + | (T extends unknown[] ? 'array' : never) + + 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> const NewType = Symbol('newtype') - export type NewType = { [NewType]?: T } + export type NewType = string & { [NewType]: T } export type Config = { [K in keyof O]?: Field @@ -87,14 +90,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 +199,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()) @@ -259,6 +262,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() @@ -271,7 +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) || isUint8Array(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/selection.ts b/packages/core/src/selection.ts index 382b3941..cab05eeb 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' @@ -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 + } }, }) @@ -104,11 +110,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 +269,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/core/src/type.ts b/packages/core/src/type.ts index fabd9f1d..9c71208e 100644 --- a/packages/core/src/type.ts +++ b/packages/core/src/type.ts @@ -1,11 +1,12 @@ -import { defineProperty, 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' -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,14 +43,14 @@ 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 (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}`) } - export function fromField(field: Field | Field.Type): 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] @@ -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..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,58 +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 (isUint8Array(source)) return (hasGlobalBuffer && Buffer.isBuffer(source)) ? Buffer.copyBytesFrom(source) : source.slice() - 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 Uint8ArrayFromHex(source: string) { - if (hasGlobalBuffer) return Buffer.from(source, 'hex') - const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1) - const buffer: number[] = [] - for (let i = 0; i < hex.length; i += 2) { - buffer.push(Number.parseInt(`${hex[i]}${hex[i + 1]}`, 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('') -} - -export function Uint8ArrayFromBase64(source: string) { - return (hasGlobalBuffer) ? Buffer.from(source, 'base64') : Uint8Array.from(atob(source), c => c.charCodeAt(0)) -} - -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 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) - } -} 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/builder.ts b/packages/mongo/src/builder.ts index 9701a51f..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] } @@ -357,7 +360,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 @@ -444,7 +447,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 @@ -456,7 +458,9 @@ 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 } @@ -465,8 +469,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?.load ? converter.load(res) : res if (!isNullable(res) && type.inner) { if (Type.isArray(type)) { @@ -478,7 +484,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/mongo/src/index.ts b/packages/mongo/src/index.ts index 10aef2d1..9dfdfc4c 100644 --- a/packages/mongo/src/index.ts +++ b/packages/mongo/src/index.ts @@ -1,5 +1,5 @@ import { BSONType, ClientSession, Collection, Db, IndexDescription, MongoClient, MongoClientOptions, MongoError } from 'mongodb' -import { Dict, isNullable, makeArray, mapValues, noop, omit, pick } from 'cosmokit' +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' @@ -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 : Binary.fromSource(value.buffer), }) } diff --git a/packages/mysql/src/builder.ts b/packages/mysql/src/builder.ts index a54548d3..89c1d014 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 { Binary, Dict, isNullable, Time } from 'cosmokit' +import { Driver, Field, isEvalExpr, Model, randomId, Selection, Type } from 'minato' export interface Compat { maria?: boolean @@ -43,12 +43,11 @@ 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 : Binary.fromBase64(value), + dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toBase64(value), } 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 @@ -89,8 +86,10 @@ 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 (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/mysql/src/index.ts b/packages/mysql/src/index.ts index 13510869..75060dd2 100644 --- a/packages/mysql/src/index.ts +++ b/packages/mysql/src/index.ts @@ -1,6 +1,6 @@ +import { Binary, 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 { 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 : Binary.fromSource(value), + }) } async stop() { diff --git a/packages/postgres/src/builder.ts b/packages/postgres/src/builder.ts index dbf5f455..52d445fe 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 { Binary, Dict, isNullable, Time } from 'cosmokit' +import { 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) }, @@ -94,28 +91,22 @@ 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'] = { 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 : Binary.fromBase64(value), + dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toBase64(value), } this.transformers['date'] = { - encode: value => value, decode: value => `cast(${value} as date)`, load: value => { if (isNullable(value) || typeof value === 'object') return value @@ -128,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) @@ -254,8 +243,10 @@ 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 (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/postgres/src/index.ts b/packages/postgres/src/index.ts index 578554ca..f189c505 100644 --- a/packages/postgres/src/index.ts +++ b/packages/postgres/src/index.ts @@ -1,5 +1,5 @@ import postgres from 'postgres' -import { Dict, difference, isNullable, makeArray, pick } from 'cosmokit' +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' @@ -165,6 +165,12 @@ export class PostgresDriver extends Driver { return date }, }) + + this.define({ + types: ['binary'], + dump: value => value, + load: value => isNullable(value) ? value : Binary.fromSource(value), + }) } async stop() { diff --git a/packages/sql-utils/src/index.ts b/packages/sql-utils/src/index.ts index 9db36551..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 { @@ -311,8 +311,8 @@ 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!] - return transformer ? transformer[method](value) : (miss ?? value) + const transformer = this.transformers[type.type] ?? this.transformers[this.driver.database.types[type.type]?.type!] + return transformer?.[method] ? transformer[method]!(value) : (miss ?? value) } protected groupObject(_fields: any) { @@ -560,8 +560,10 @@ export class Builder { res = mapValues(res, (x, k) => this.dump(x, Type.getInner(type as Type, k), root)) } } - res = converter ? converter.dump(res) : res - if (!root) res = this.transform(res, type, 'dump') + 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) return res } @@ -583,8 +585,10 @@ 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') - res = converter ? converter.load(res) : res + 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?.load ? converter.load(res) : res if (!isNullable(res) && type.inner) { if (Type.isArray(type)) { @@ -593,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 = {} @@ -614,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/sqlite/src/builder.ts b/packages/sqlite/src/builder.ts index 0acd1962..c262992a 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 { Binary, Dict, isNullable } from 'cosmokit' +import { Driver, Field, Model, randomId, Type } from 'minato' export class SQLiteBuilder extends Builder { protected escapeMap = { @@ -21,22 +21,23 @@ 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 : 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 (isUint8Array(value)) return `X'${Uint8ArrayToHex(value)}'` + 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/sqlite/src/index.ts b/packages/sqlite/src/index.ts index 7ecfee07..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 { clone, Driver, Eval, executeUpdate, Field, Selection, toLocalUint8Array, 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' @@ -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 : Binary.fromSource(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..7f587d8f 100644 --- a/packages/tests/src/model.ts +++ b/packages/tests/src/model.ts @@ -1,5 +1,5 @@ -import { valueMap } from 'cosmokit' -import { $, Database, Field, Type } from 'minato' +import { valueMap, isNullable, deduplicate } from 'cosmokit' +import { $, Database, Field, Type, unravel } from 'minato' import { expect } from 'chai' interface DType { @@ -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 @@ -59,14 +60,31 @@ 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 { + return new TextEncoder().encode(source).buffer } function flatten(type: any, prefix) { @@ -84,24 +102,47 @@ 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, - initial: 123n + 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', + }) + + 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: { @@ -129,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: { @@ -147,22 +185,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': { @@ -192,14 +219,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 +243,7 @@ function ModelOperations(database: Database) { database.extend('dobjects', { id: 'unsigned', - foo: baseObject, + foo: baseObject, ...flatten(baseObject, 'bar'), baz: { type: 'array', @@ -224,12 +251,17 @@ function ModelOperations(database: Database) { initial: [] }, }, { autoInc: true }) + + database.extend('recurxs', { + id: 'unsigned', + y: 'recury', + }, { autoInc: true }) } 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] } @@ -243,12 +275,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 +289,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 } }] }, @@ -292,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', {}) @@ -367,7 +406,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') }) @@ -397,7 +436,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 () => { @@ -418,6 +457,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 = {}) { @@ -454,24 +498,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]]) @@ -523,6 +567,18 @@ 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 = 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/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 diff --git a/packages/tests/src/update.ts b/packages/tests/src/update.ts index f77345f3..1fdd2960 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 }) @@ -187,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 () => { @@ -217,9 +219,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 +319,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) })