Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(minato): support indexes #102

Merged
merged 10 commits into from
Aug 14, 2024
28 changes: 16 additions & 12 deletions packages/core/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,28 +44,28 @@ export namespace Join2 {
export type Predicate<S, U extends Input<S>> = (args: Parameters<S, U>) => Eval.Expr<boolean>
}

type CreateMap<T, S> = { [K in keyof T]?: Create<T[K], S> }

export type Create<T, S> =
type CreateUnit<T, S> =
| T extends Values<AtomicTypes> ? T
: T extends (infer I extends Values<S>)[] ? CreateMap<I, S>[] |
: T extends (infer I extends Values<S>)[] ? Create<I, S>[] |
{
$literal?: DeepPartial<I>
$create?: MaybeArray<CreateMap<I, S>>
$upsert?: MaybeArray<CreateMap<I, S>>
$create?: MaybeArray<Create<I, S>>
$upsert?: MaybeArray<Create<I, S>>
$connect?: Query.Expr<Flatten<I>>
}
: T extends Values<S> ? CreateMap<T, S> |
: T extends Values<S> ? Create<T, S> |
{
$literal?: DeepPartial<T>
$create?: CreateMap<T, S>
$upsert?: CreateMap<T, S>
$create?: Create<T, S>
$upsert?: Create<T, S>
$connect?: Query.Expr<Flatten<T>>
}
: T extends (infer U)[] ? DeepPartial<U>[]
: T extends object ? CreateMap<T, S>
: T extends object ? Create<T, S>
: T

export type Create<T, S> = { [K in keyof T]?: CreateUnit<T[K], S> }

function mergeQuery<T>(base: Query.FieldExpr<T>, query: Query.Expr<Flatten<T>> | ((row: Row<T>) => Query.Expr<Flatten<T>>)): Selection.Callback<T, boolean> {
if (typeof query === 'function') {
return (row: any) => {
Expand Down Expand Up @@ -129,6 +129,7 @@ export class Database<S = {}, N = {}, C extends Context = Context> extends Servi
Object.values(fields).forEach(field => field?.transformers?.forEach(x => driver.define(x)))

await driver.prepare(name)
await driver.prepareIndexes(name)
}

extend<K extends Keys<S>>(name: K, fields: Field.Extension<S[K], N>, config: Partial<Model.Config<FlatKeys<S[K]>>> = {}) {
Expand Down Expand Up @@ -189,9 +190,12 @@ export class Database<S = {}, N = {}, C extends Context = Context> extends Servi
}
})
// use relation field as primary
if (Array.isArray(model.primary) && model.primary.every(key => model.fields[key]?.relation)) {
model.primary = deduplicate(model.primary.map(key => model.fields[key]!.relation!.fields).flat())
if (Array.isArray(model.primary)) {
model.primary = deduplicate(model.primary.map(key => model.fields[key]!.relation?.fields || key).flat())
}
model.unique = model.unique.map(keys => typeof keys === 'string' ? model.fields[keys]!.relation?.fields || keys
: keys.map(key => model.fields[key]!.relation?.fields || key).flat())

this.prepareTasks[name] = this.prepare(name)
;(this.ctx as Context).emit('model', name)
}
Expand Down
28 changes: 27 additions & 1 deletion packages/core/src/driver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Awaitable, defineProperty, Dict, mapValues, remove } from 'cosmokit'
import { Awaitable, deepEqual, defineProperty, Dict, mapValues, remove } from 'cosmokit'
import { Context, Logger, Service } from 'cordis'
import { Eval, Update } from './eval.ts'
import { Direction, Modifier, Selection } from './selection.ts'
Expand Down Expand Up @@ -35,6 +35,15 @@ export namespace Driver {
removed?: number
}

export interface IndexDef<K extends string = string> {
name?: string
keys: { [P in K]?: 'asc' | 'desc' }
}

export interface Index<K extends string = string> extends IndexDef<K> {
unique?: boolean
}

export interface Transformer<S = any, T = any> {
types: Field.Type<S>[]
dump: (value: S | null) => T | null | void
Expand Down Expand Up @@ -62,6 +71,9 @@ export abstract class Driver<T = any, C extends Context = Context> {
abstract create(sel: Selection.Mutable, data: any): Promise<any>
abstract upsert(sel: Selection.Mutable, data: any[], keys: string[]): Promise<Driver.WriteResult>
abstract withTransaction(callback: (session?: any) => Promise<void>): Promise<void>
abstract getIndexes(table: string): Promise<Driver.Index[]>
abstract createIndex(table: string, index: Driver.Index): Promise<void>
abstract dropIndex(table: string, name: string): Promise<void>

public database: Database<any, any, C>
public logger: Logger
Expand Down Expand Up @@ -151,6 +163,20 @@ export abstract class Driver<T = any, C extends Context = Context> {
}

async _ensureSession() {}

async prepareIndexes(table: string) {
const oldIndexes = await this.getIndexes(table)
const { indexes } = this.model(table)
for (const index of indexes) {
const oldIndex = oldIndexes.find(info => info.name === index.name)
if (!oldIndex) {
await this.createIndex(table, index)
} else if (!deepEqual(oldIndex, index)) {
await this.dropIndex(table, index.name!)
await this.createIndex(table, index)
}
}
}
}

export interface MigrationHooks {
Expand Down
33 changes: 27 additions & 6 deletions packages/core/src/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { clone, filterKeys, isNullable, makeArray, mapValues, MaybeArray } from 'cosmokit'
import { clone, deepEqual, filterKeys, isNullable, makeArray, mapValues, MaybeArray } from 'cosmokit'
import { Context } from 'cordis'
import { Eval, Update } from './eval.ts'
import { DeepPartial, FlatKeys, Flatten, isFlat, Keys, Row, unravel } from './utils.ts'
Expand Down Expand Up @@ -71,8 +71,8 @@ export namespace Relation {
: typeof def.shared === 'string' ? { [def.shared]: def.shared }
: Array.isArray(def.shared) ? Object.fromEntries(def.shared.map(x => [x, x]))
: def.shared
const fields = def.fields ?? ((subprimary || model.name === relmodel.name || def.type === 'manyToOne'
|| (def.type === 'oneToOne' && !makeArray(relmodel.primary).every(key => !relmodel.fields[key]?.nullable)))
const fields = def.fields ?? ((subprimary || def.type === 'manyToOne'
|| (def.type === 'oneToOne' && (model.name === relmodel.name || !makeArray(relmodel.primary).every(key => !relmodel.fields[key]?.nullable))))
? makeArray(relmodel.primary).map(x => `${key}.${x}`) : model.primary)
const relation: Config = {
type: def.type,
Expand Down Expand Up @@ -247,6 +247,7 @@ export namespace Model {
autoInc: boolean
primary: MaybeArray<K>
unique: MaybeArray<K>[]
indexes: (MaybeArray<K> | Driver.IndexDef<K>)[]
foreign: {
[P in K]?: [string, string]
}
Expand All @@ -257,6 +258,7 @@ export interface Model extends Model.Config {}

export class Model<S = any> {
declare ctx?: Context
declare indexes: Driver.Index<FlatKeys<S>>[]
fields: Field.Config<S> = {}
migrations = new Map<Model.Migration, string[]>()

Expand All @@ -266,16 +268,18 @@ export class Model<S = any> {
this.autoInc = false
this.primary = 'id' as never
this.unique = []
this.indexes = []
this.foreign = {}
}

extend(fields: Field.Extension<S>, config?: Partial<Model.Config>): void
extend(fields = {}, config: Partial<Model.Config> = {}) {
const { primary, autoInc, unique = [] as [], foreign, callback } = config
const { primary, autoInc, unique = [], indexes = [], foreign, callback } = config

this.primary = primary || this.primary
this.autoInc = autoInc || this.autoInc
unique.forEach(key => this.unique.includes(key) || this.unique.push(key))
indexes.map(x => this.parseIndex(x)).forEach(index => (this.indexes.some(ind => deepEqual(ind, index))) || this.indexes.push(index))
Object.assign(this.foreign, foreign)

if (callback) this.migrations.set(callback, Object.keys(fields))
Expand All @@ -292,10 +296,27 @@ export class Model<S = any> {
// check index
this.checkIndex(this.primary)
this.unique.forEach(index => this.checkIndex(index))
this.indexes.forEach(index => this.checkIndex(index))
}

private checkIndex(index: MaybeArray<string>) {
for (const key of makeArray(index)) {
private parseIndex(index: MaybeArray<string> | Driver.Index): Driver.Index {
if (typeof index === 'string' || Array.isArray(index)) {
return {
name: `index:${this.name}:` + makeArray(index).join('+'),
unique: false,
keys: Object.fromEntries(makeArray(index).map(key => [key, 'asc'])),
}
} else {
return {
name: index.name ?? `index:${this.name}:` + Object.keys(index.keys).join('+'),
unique: index.unique ?? false,
keys: index.keys,
}
}
}

private checkIndex(index: MaybeArray<string> | Driver.Index) {
for (const key of typeof index === 'string' || Array.isArray(index) ? makeArray(index) : Object.keys(index.keys)) {
if (!this.fields[key]) {
throw new TypeError(`missing field definition for index key "${key}"`)
}
Expand Down
17 changes: 17 additions & 0 deletions packages/memory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
_fields: [],
}

_indexes: Dict<Dict<any>> = {}

async prepare(name: string) {}

async start() {
Expand Down Expand Up @@ -197,6 +199,21 @@
throw e
})
}

async getIndexes(table: string) {
return Object.values(this._indexes[table] ?? {})
}

async createIndex(table: string, index: Driver.Index) {
const name = index.name ?? 'index:' + Object.entries(index.keys).map(([key, direction]) => `${key}_${direction}`).join('+')
this._indexes[table] ??= {}
this._indexes[table][name] = { name, unique: false, ...index }
}

async dropIndex(table: string, name: string) {
this._indexes[table] ??= {}
delete this._indexes[table][name]
}

Check warning on line 216 in packages/memory/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/memory/src/index.ts#L214-L216

Added lines #L214 - L216 were not covered by tests
}

export namespace MemoryDriver {
Expand Down
3 changes: 3 additions & 0 deletions packages/memory/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ describe('@minatojs/driver-memory', () => {

test(database, {
migration: false,
update: {
index: false,
},
model: {
fields: {
cast: false,
Expand Down
40 changes: 36 additions & 4 deletions packages/mongo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export class MongoDriver extends Driver<MongoDriver.Config> {
* https://www.mongodb.com/docs/manual/indexes/
*/
private async _createIndexes(table: string) {
const { primary, unique } = this.model(table)
const { fields, primary, unique } = this.model(table)
const coll = this.db.collection(table)
const newSpecs: IndexDescription[] = []
const oldSpecs = await coll.indexes()
Expand All @@ -96,6 +96,7 @@ export class MongoDriver extends Driver<MongoDriver.Config> {
const name = (index ? 'unique:' : 'primary:') + keys.join('+')
if (oldSpecs.find(spec => spec.name === name)) return

const nullable = Object.entries(fields).filter(([key]) => keys.includes(key)).every(([, field]) => field?.nullable)
newSpecs.push({
name,
key: Object.fromEntries(keys.map(key => [key, 1])),
Expand All @@ -104,9 +105,11 @@ export class MongoDriver extends Driver<MongoDriver.Config> {
// mongodb seems to not support $ne in partialFilterExpression
// so we cannot simply use `{ $ne: null }` to filter out null values
// below is a workaround for https://github.com/koishijs/koishi/issues/893
partialFilterExpression: Object.fromEntries(keys.map((key) => [key, {
$type: [BSONType.date, BSONType.int, BSONType.long, BSONType.string, BSONType.objectId],
}])),
...(nullable || index > unique.length) ? {} : {
partialFilterExpression: Object.fromEntries(keys.map((key) => [key, {
$type: [BSONType.date, BSONType.int, BSONType.long, BSONType.string, BSONType.objectId],
}])),
},
})
})

Expand Down Expand Up @@ -208,6 +211,7 @@ export class MongoDriver extends Driver<MongoDriver.Config> {
const [latest] = await coll.find().sort(this.getVirtualKey(table) ? '_id' : primary, -1).limit(1).toArray()
await fields.updateOne(meta, {
$set: { autoInc: latest ? +latest[this.getVirtualKey(table) ? '_id' : primary] : 0 },
$setOnInsert: { virtual: !!this.getVirtualKey(table) },
}, { upsert: true })
}

Expand Down Expand Up @@ -492,6 +496,34 @@ export class MongoDriver extends Driver<MongoDriver.Config> {
}
}

async getIndexes(table: string) {
const indexes = await this.db.collection(table).listIndexes().toArray()
return indexes.map(({ name, key, unique }) => ({
name,
unique: !!unique,
keys: mapValues(key, value => value === 1 ? 'asc' : value === -1 ? 'desc' : value),
} as Driver.Index))
}

async createIndex(table: string, index: Driver.Index) {
const keys = mapValues(index.keys, (value) => value === 'asc' ? 1 : value === 'desc' ? -1 : isNullable(value) ? 1 : value)
const { fields } = this.model(table)
const nullable = Object.keys(index.keys).every(key => fields[key]?.nullable)
await this.db.collection(table).createIndex(keys, {
name: index.name,
unique: !!index.unique,
...nullable ? {} : {
partialFilterExpression: Object.fromEntries(Object.keys(index.keys).map((key) => [key, {
$type: [BSONType.date, BSONType.int, BSONType.long, BSONType.string, BSONType.objectId],
}])),
},
})
}

async dropIndex(table: string, name: string) {
await this.db.collection(table).dropIndex(name)
}

logPipeline(table: string, pipeline: any) {
this.logger.debug('%s %s', table, JSON.stringify(pipeline, (_, value) => typeof value === 'bigint' ? `${value}n` : value))
}
Expand Down
33 changes: 31 additions & 2 deletions packages/mysql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ interface ColumnInfo {
interface IndexInfo {
INDEX_NAME: string
COLUMN_NAME: string
SEQ_IN_INDEX: string
COLLATION: 'A' | 'D'
NULLABLE: string
NON_UNIQUE: string
}

interface QueryTask {
Expand Down Expand Up @@ -181,11 +185,11 @@ export class MySQLDriver extends Driver<MySQLDriver.Config> {
def += (nullable ? ' ' : ' not ') + 'null'
}
// blob, text, geometry or json columns cannot have default values
if (initial && !typedef.startsWith('text') && !typedef.endsWith('blob')) {
if (!isNullable(initial) && !typedef.startsWith('text') && !typedef.endsWith('blob')) {
def += ' default ' + this.sql.escape(initial, fields[key])
}

if (!column && initial && (typedef.startsWith('text') || typedef.endsWith('blob'))) {
if (!column && !isNullable(initial) && (typedef.startsWith('text') || typedef.endsWith('blob'))) {
alterInit[key] = this.sql.escape(initial, fields[key])
}
}
Expand Down Expand Up @@ -507,6 +511,31 @@ INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WH
})
}

async getIndexes(table: string) {
const indexes = await this.queue<IndexInfo[]>([
`SELECT *`,
`FROM information_schema.statistics`,
`WHERE TABLE_SCHEMA = ? && TABLE_NAME = ?`,
].join(' '), [this.config.database, table])
const result: Dict<Driver.Index> = {}
for (const { INDEX_NAME: name, COLUMN_NAME: key, COLLATION: direction, NON_UNIQUE: unique } of indexes) {
if (!result[name]) result[name] = { name, unique: unique !== '1', keys: {} }
result[name].keys[key] = direction === 'A' ? 'asc' : direction === 'D' ? 'desc' : direction
}
return Object.values(result)
}

async createIndex(table: string, index: Driver.Index) {
const keyFields = Object.entries(index.keys).map(([key, direction]) => `${escapeId(key)} ${direction ?? 'asc'}`).join(', ')
await this.query(
`ALTER TABLE ${escapeId(table)} ADD ${index.unique ? 'UNIQUE' : ''} INDEX ${index.name ? escapeId(index.name) : ''} (${keyFields})`,
)
}

async dropIndex(table: string, name: string) {
await this.query(`DROP INDEX ${escapeId(name)} ON ${escapeId(table)}`)
}

private getTypeDef({ deftype: type, length, precision, scale }: Field) {
const getIntegerType = (length = 4) => {
if (length <= 1) return 'tinyint'
Expand Down
Loading