Skip to content

Commit

Permalink
feat(minato): impl model.immutable and driver readOnly
Browse files Browse the repository at this point in the history
  • Loading branch information
Hieuzest committed Oct 24, 2024
1 parent b2d7cd6 commit a550fae
Show file tree
Hide file tree
Showing 18 changed files with 160 additions and 43 deletions.
14 changes: 8 additions & 6 deletions packages/core/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,15 @@ export class Database<S = {}, N = {}, C extends Context = Context> extends Servi
static readonly migrate = Symbol('minato.migrate')

public tables: Dict<Model> = Object.create(null)
public drivers: Driver<any, C>[] = []
public drivers: Driver<Driver.Config, C>[] = []
public types: Dict<Field.Transform> = Object.create(null)

private _driver: Driver<any, C> | undefined
private _driver: Driver<Driver.Config, C> | undefined
private stashed = new Set<string>()
private prepareTasks: Dict<Promise<void>> = Object.create(null)
public migrateTasks: Dict<Promise<void>> = Object.create(null)

async connect<T = undefined>(driver: Driver.Constructor<T>, ...args: Spread<T>) {
async connect<T extends Driver.Config = Driver.Config>(driver: Driver.Constructor<T>, ...args: Spread<T>) {
this.ctx.plugin(driver, args[0] as any)
await this.ctx.start()
}
Expand All @@ -109,7 +109,7 @@ export class Database<S = {}, N = {}, C extends Context = Context> extends Servi
await Promise.all(Object.values(this.prepareTasks))
}

private getDriver(table: string | Selection): Driver<any, C> {
private getDriver(table: string | Selection): Driver<Driver.Config, C> {
if (Selection.is(table)) return table.driver as any
const model: Model = this.tables[table]
if (!model) throw new Error(`cannot resolve table "${table}"`)
Expand Down Expand Up @@ -602,12 +602,14 @@ export class Database<S = {}, N = {}, C extends Context = Context> extends Servi

async drop<K extends Keys<S>>(table: K) {
if (this[Database.transact]) throw new Error('cannot drop table in transaction')
await this.getDriver(table).drop(table)
const driver = this.getDriver(table)
if (driver.config.readOnly) throw new Error('cannot drop table in read-only mode')
await driver.drop(table)
}

async dropAll() {
if (this[Database.transact]) throw new Error('cannot drop table in transaction')
await Promise.all(Object.values(this.drivers).map(driver => driver.dropAll()))
await Promise.all(Object.values(this.drivers).filter(driver => !driver.config.readOnly).map(driver => driver.dropAll()))
}

async stats() {
Expand Down
19 changes: 15 additions & 4 deletions packages/core/src/driver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Awaitable, deepEqual, defineProperty, Dict, mapValues, remove } from 'cosmokit'
import { Context, Logger, Service } from 'cordis'
import { Context, Logger, Service, z } from 'cordis'
import { Eval, Update } from './eval.ts'
import { Direction, Modifier, Selection } from './selection.ts'
import { Field, Model, Relation } from './model.ts'
Expand Down Expand Up @@ -52,10 +52,10 @@ export namespace Driver {
}

export namespace Driver {
export type Constructor<T> = new (ctx: Context, config: T) => Driver<T>
export type Constructor<T extends Driver.Config> = new (ctx: Context, config: T) => Driver<T>
}

export abstract class Driver<T = any, C extends Context = Context> {
export abstract class Driver<T extends Driver.Config = Driver.Config, C extends Context = Context> {
static inject = ['model']

abstract start(): Promise<void>
Expand Down Expand Up @@ -165,8 +165,9 @@ export abstract class Driver<T = any, C extends Context = Context> {
async _ensureSession() {}

async prepareIndexes(table: string) {
const { immutable, indexes } = this.model(table)
if (immutable || this.config.readOnly) return
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) {
Expand All @@ -179,6 +180,16 @@ export abstract class Driver<T = any, C extends Context = Context> {
}
}

export namespace Driver {
export interface Config {
readOnly?: boolean
}

export const Config: z<Config> = z.object({
readOnly: z.boolean().default(false),
})
}

export interface MigrationHooks {
before: (keys: string[]) => boolean
after: (keys: string[]) => void
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ export namespace Model {
export interface Config<K extends string = string> {
callback?: Migration
autoInc: boolean
immutable: boolean
primary: MaybeArray<K>
unique: MaybeArray<K>[]
indexes: (MaybeArray<K> | Driver.IndexDef<K>)[]
Expand All @@ -266,6 +267,7 @@ export class Model<S = any> {

constructor(public name: string) {
this.autoInc = false
this.immutable = false
this.primary = 'id' as never
this.unique = []
this.indexes = []
Expand All @@ -274,10 +276,11 @@ export class Model<S = any> {

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

this.primary = primary || this.primary
this.autoInc = autoInc || this.autoInc
this.immutable = immutable || this.immutable
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)
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ class Executable<S = any, T = any> {
}

async execute(): Promise<T> {
if (this.driver.config.readOnly && !['get', 'eval'].includes(this.type)) {
throw new Error(`database is in read-only mode`)
}

Check warning on line 151 in packages/core/src/selection.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/selection.ts#L150-L151

Added lines #L150 - L151 were not covered by tests
await this.driver.database.prepared()
await this.driver._ensureSession()
return this.driver[this.type as any](this, ...this.args)
Expand Down
48 changes: 30 additions & 18 deletions packages/mongo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,15 @@ export class MongoDriver extends Driver<MongoDriver.Config> {

/** synchronize table schema */
async prepare(table: string) {
const { immutable } = this.model(table)

if (immutable || this.config.readOnly) {
if (immutable && this.shouldEnsurePrimary(table)) {
throw new Error(`immutable table ${table} cannot be autoInc`)
}
return
}

Check warning on line 240 in packages/mongo/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/mongo/src/index.ts#L235-L240

Added lines #L235 - L240 were not covered by tests
await Promise.all([
this._createInternalTable(),
this.db.createCollection(table).catch(noop),
Expand Down Expand Up @@ -536,7 +545,7 @@ export class MongoDriver extends Driver<MongoDriver.Config> {
}

export namespace MongoDriver {
export interface Config extends MongoClientOptions {
export interface Config extends Driver.Config, MongoClientOptions {
username?: string
password?: string
protocol?: string
Expand All @@ -556,24 +565,27 @@ export namespace MongoDriver {
optimizeIndex?: boolean
}

export const Config: z<Config> = z.object({
protocol: z.string().default('mongodb'),
host: z.string().default('localhost'),
port: z.natural().max(65535),
username: z.string(),
password: z.string().role('secret'),
database: z.string().required(),
authDatabase: z.string(),
writeConcern: z.object({
w: z.union([
z.const(undefined),
z.number().required(),
z.const('majority').required(),
]),
wtimeoutMS: z.number(),
journal: z.boolean(),
export const Config: z<Config> = z.intersect([
Driver.Config,
z.object({
protocol: z.string().default('mongodb'),
host: z.string().default('localhost'),
port: z.natural().max(65535),
username: z.string(),
password: z.string().role('secret'),
database: z.string().required(),
authDatabase: z.string(),
writeConcern: z.object({
w: z.union([
z.const(undefined),
z.number().required(),
z.const('majority').required(),
]),
wtimeoutMS: z.number(),
journal: z.boolean(),
}) as any,
}),
}).i18n({
]).i18n({
'en-US': enUS,
'zh-CN': zhCN,
})
Expand Down
1 change: 1 addition & 0 deletions packages/mongo/src/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ writeConcern:
- Majority
wtimeoutMS: The write concern timeout.
journal: The journal write concern.
readOnly: Connect in read-only mode.
1 change: 1 addition & 0 deletions packages/mongo/src/locales/zh-CN.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ writeConcern:
- Majority
wtimeoutMS: The write concern timeout.
journal: The journal write concern.
readOnly: 以只读模式连接。
3 changes: 3 additions & 0 deletions packages/mongo/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ describe('@minatojs/driver-mongo', () => {
aggregateNull: false,
}
},
migration: {
definition: false,
},
transaction: {
abort: false
}
Expand Down
11 changes: 10 additions & 1 deletion packages/mysql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class MySQLDriver extends Driver<MySQLDriver.Config> {

const table = this.model(name)
const { primary, foreign, autoInc } = table
const immutable = table.immutable || this.config.readOnly
const fields = table.avaiableFields()
const unique = [...table.unique]
const create: string[] = []
Expand Down Expand Up @@ -232,6 +233,13 @@ export class MySQLDriver extends Driver<MySQLDriver.Config> {
}
}

if (immutable) {
if (create.length || update.length) {
throw new Error(`immutable table ${name} cannot be migrated`)
}
return
}

if (!columns.length) {
this.logger.info('auto creating table %c', name)
return this.query(`CREATE TABLE ${escapeId(name)} (${create.join(', ')}) COLLATE = ${this.sql.escape(this.config.charset ?? 'utf8mb4_general_ci')}`)
Expand Down Expand Up @@ -593,9 +601,10 @@ INSERT INTO mtt VALUES(json_extract(j, concat('$[', i, ']'))); SET i=i+1; END WH
}

export namespace MySQLDriver {
export interface Config extends PoolConfig {}
export interface Config extends Driver.Config, PoolConfig {}

export const Config: z<Config> = z.intersect([
Driver.Config,
z.object({
host: z.string().default('localhost'),
port: z.natural().max(65535).default(3306),
Expand Down
1 change: 1 addition & 0 deletions packages/mysql/src/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ ssl:
- Default
- $description: Custom
rejectUnauthorized: Reject clients with invalid certificates.
readOnly: Connect in read-only mode.
1 change: 1 addition & 0 deletions packages/mysql/src/locales/zh-CN.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ ssl:
- 默认值
- $description: 自定义
rejectUnauthorized: 拒绝使用无效证书的客户端。
readOnly: 以只读模式连接。
27 changes: 19 additions & 8 deletions packages/postgres/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export class PostgresDriver extends Driver<PostgresDriver.Config> {

const table = this.model(name)
const { primary, foreign } = table
const immutable = table.immutable || this.config.readOnly
const fields = { ...table.avaiableFields() }
const unique = [...table.unique]
const create: string[] = []
Expand Down Expand Up @@ -236,6 +237,13 @@ export class PostgresDriver extends Driver<PostgresDriver.Config> {
}
}

if (immutable) {
if (create.length || update.length) {
throw new Error(`immutable table ${name} cannot be migrated`)
}
return

Check warning on line 244 in packages/postgres/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/postgres/src/index.ts#L244

Added line #L244 was not covered by tests
}

if (!columns.length) {
this.logger.info('auto creating table %c', name)
return this.query<any>(`CREATE TABLE ${escapeId(name)} (${create.join(', ')}, _pg_mtime BIGINT)`)
Expand Down Expand Up @@ -522,21 +530,24 @@ export class PostgresDriver extends Driver<PostgresDriver.Config> {
}

export namespace PostgresDriver {
export interface Config<T extends Record<string, postgres.PostgresType> = {}> extends postgres.Options<T> {
export interface Config<T extends Record<string, postgres.PostgresType> = {}> extends Driver.Config, postgres.Options<T> {
host: string
port: number
user: string
password: string
database: string
}

export const Config: z<Config> = z.object({
host: z.string().default('localhost'),
port: z.natural().max(65535).default(5432),
user: z.string().default('root'),
password: z.string().role('secret'),
database: z.string().required(),
}).i18n({
export const Config: z<Config> = z.intersect([
Driver.Config,
z.object({
host: z.string().default('localhost'),
port: z.natural().max(65535).default(5432),
user: z.string().default('root'),
password: z.string().role('secret'),
database: z.string().required(),
}),
]).i18n({
'en-US': enUS,
'zh-CN': zhCN,
})
Expand Down
1 change: 1 addition & 0 deletions packages/postgres/src/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ port: The port number to connect to.
user: The MySQL user to authenticate as.
password: The password of that MySQL user.
database: Name of the database to use for this connection.
readOnly: Connect in read-only mode.
1 change: 1 addition & 0 deletions packages/postgres/src/locales/zh-CN.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ port: 要连接到的端口号。
username: 要使用的用户名。
password: 要使用的密码。
database: 要访问的数据库名。
readOnly: 以只读模式连接。
19 changes: 15 additions & 4 deletions packages/sqlite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class SQLiteDriver extends Driver<SQLiteDriver.Config> {
async prepare(table: string, dropKeys?: string[]) {
const columns = this._all(`PRAGMA table_info(${escapeId(table)})`) as SQLiteFieldInfo[]
const model = this.model(table)
const immutable = model.immutable || this.config.readOnly
const columnDefs: string[] = []
const indexDefs: string[] = []
const alter: string[] = []
Expand Down Expand Up @@ -112,6 +113,13 @@ export class SQLiteDriver extends Driver<SQLiteDriver.Config> {
}))
}

if (immutable) {
if (!columns.length || shouldMigrate || alter.length) {
throw new Error(`immutable table ${table} cannot be migrated`)
}
return

Check warning on line 120 in packages/sqlite/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/sqlite/src/index.ts#L120

Added line #L120 was not covered by tests
}

if (!columns.length) {
this.logger.info('auto creating table %c', table)
this._run(`CREATE TABLE ${escapeId(table)} (${[...columnDefs, ...indexDefs].join(', ')})`)
Expand Down Expand Up @@ -482,13 +490,16 @@ export class SQLiteDriver extends Driver<SQLiteDriver.Config> {
}

export namespace SQLiteDriver {
export interface Config {
export interface Config extends Driver.Config {
path: string
}

export const Config: z<Config> = z.object({
path: z.string().role('path').required(),
}).i18n({
export const Config: z<Config> = z.intersect([
Driver.Config,
z.object({
path: z.string().role('path').required(),
}),
]).i18n({
'en-US': enUS,
'zh-CN': zhCN,
})
Expand Down
1 change: 1 addition & 0 deletions packages/sqlite/src/locales/en-US.yml
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
path: Database path.
readOnly: Connect in read-only mode.
1 change: 1 addition & 0 deletions packages/sqlite/src/locales/zh-CN.yml
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
path: 数据库路径。
readOnly: 以只读模式连接。
Loading

0 comments on commit a550fae

Please sign in to comment.