├── readme.md ├── packages ├── sqlite │ ├── src │ │ ├── locales │ │ │ ├── zh-CN.yml │ │ │ └── en-US.yml │ │ ├── builder.ts │ │ └── index.ts │ ├── tsconfig.json │ ├── readme.md │ ├── tests │ │ └── index.spec.ts │ └── package.json ├── mysql │ ├── src │ │ ├── shims.d.ts │ │ ├── locales │ │ │ ├── zh-CN.yml │ │ │ └── en-US.yml │ │ └── builder.ts │ ├── tsconfig.json │ ├── readme.md │ ├── tests │ │ └── index.spec.ts │ └── package.json ├── postgres │ ├── src │ │ ├── locales │ │ │ ├── zh-CN.yml │ │ │ └── en-US.yml │ │ └── builder.ts │ ├── tsconfig.json │ ├── readme.md │ ├── tests │ │ └── index.spec.ts │ └── package.json ├── memory │ ├── tsconfig.json │ ├── readme.md │ ├── package.json │ ├── tests │ │ └── index.spec.ts │ └── src │ │ └── index.ts ├── mongo │ ├── tsconfig.json │ ├── src │ │ └── locales │ │ │ ├── zh-CN.yml │ │ │ └── en-US.yml │ ├── readme.md │ ├── tests │ │ ├── index.spec.ts │ │ └── migration.spec.ts │ └── package.json ├── sql-utils │ ├── tsconfig.json │ ├── readme.md │ └── package.json ├── core │ ├── tsconfig.json │ ├── src │ │ ├── error.ts │ │ ├── index.ts │ │ ├── utils.ts │ │ ├── type.ts │ │ ├── query.ts │ │ ├── driver.ts │ │ ├── selection.ts │ │ └── model.ts │ ├── package.json │ └── readme.md └── tests │ ├── tsconfig.json │ ├── readme.md │ ├── src │ ├── utils.ts │ ├── shims.d.ts │ ├── setup.ts │ ├── index.ts │ ├── shape.ts │ ├── migration.ts │ ├── transaction.ts │ ├── object.ts │ └── json.ts │ └── package.json ├── .nycrc.json ├── .eslintignore ├── .yarnrc.yml ├── .gitattributes ├── .editorconfig ├── .eslintrc.yml ├── yakumo.yml ├── tsconfig.json ├── .gitignore ├── tsconfig.base.json ├── .github └── workflows │ ├── build.yaml │ └── test.yaml ├── LICENSE └── package.json /readme.md: -------------------------------------------------------------------------------- 1 | ./packages/core/readme.md -------------------------------------------------------------------------------- /packages/sqlite/src/locales/zh-CN.yml: -------------------------------------------------------------------------------- 1 | path: 数据库路径。 2 | -------------------------------------------------------------------------------- /packages/sqlite/src/locales/en-US.yml: -------------------------------------------------------------------------------- 1 | path: Database path. 2 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | ".yarn/**" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /external 2 | 3 | temp 4 | dist 5 | lib 6 | tests 7 | 8 | *.js 9 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-4.0.2.cjs 4 | -------------------------------------------------------------------------------- /packages/mysql/src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@vlasky/mysql' { 2 | export * from 'mysql' 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | 3 | *.png -text 4 | *.jpg -text 5 | *.ico -text 6 | *.gif -text 7 | *.webp -text 8 | -------------------------------------------------------------------------------- /packages/postgres/src/locales/zh-CN.yml: -------------------------------------------------------------------------------- 1 | host: 要连接到的主机名。 2 | port: 要连接到的端口号。 3 | username: 要使用的用户名。 4 | password: 要使用的密码。 5 | database: 要访问的数据库名。 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /packages/memory/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | }, 7 | "include": [ 8 | "src", 9 | ], 10 | } -------------------------------------------------------------------------------- /packages/mongo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | }, 7 | "include": [ 8 | "src", 9 | ], 10 | } -------------------------------------------------------------------------------- /packages/mysql/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | }, 7 | "include": [ 8 | "src", 9 | ], 10 | } -------------------------------------------------------------------------------- /packages/sqlite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | }, 7 | "include": [ 8 | "src", 9 | ], 10 | } -------------------------------------------------------------------------------- /packages/sql-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | }, 7 | "include": [ 8 | "src", 9 | ], 10 | } -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outFile": "lib/index.d.ts", 6 | }, 7 | "include": [ 8 | "src", 9 | ], 10 | } -------------------------------------------------------------------------------- /packages/postgres/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | }, 7 | "include": [ 8 | "src", 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | 3 | env: 4 | browser: true 5 | node: true 6 | mocha: true 7 | es2020: true 8 | 9 | globals: 10 | NodeJS: true 11 | 12 | extends: 13 | - '@cordisjs/eslint-config' 14 | 15 | plugins: 16 | - mocha 17 | -------------------------------------------------------------------------------- /yakumo.yml: -------------------------------------------------------------------------------- 1 | - name: yakumo 2 | config: 3 | pipeline: 4 | build: 5 | - tsc 6 | - esbuild 7 | clean: 8 | - tsc --clean 9 | - name: yakumo/run 10 | - name: yakumo-esbuild 11 | - name: yakumo-mocha 12 | - name: yakumo-tsc 13 | -------------------------------------------------------------------------------- /packages/tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "types": [ 7 | "mocha", 8 | "node", 9 | ], 10 | }, 11 | "include": [ 12 | "src", 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /packages/mysql/src/locales/zh-CN.yml: -------------------------------------------------------------------------------- 1 | host: 要连接到的主机名。 2 | port: 要连接到的端口号。 3 | user: 要使用的用户名。 4 | password: 要使用的密码。 5 | database: 要访问的数据库名。 6 | 7 | ssl: 8 | $description: SSL 高级选项。 9 | $value: 10 | - 默认值 11 | - $description: 自定义 12 | rejectUnauthorized: 拒绝使用无效证书的客户端。 13 | -------------------------------------------------------------------------------- /packages/postgres/src/locales/en-US.yml: -------------------------------------------------------------------------------- 1 | host: The hostname of the database you are connecting to. 2 | port: The port number to connect to. 3 | user: The MySQL user to authenticate as. 4 | password: The password of that MySQL user. 5 | database: Name of the database to use for this connection. 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "minato": ["packages/core/src"], 7 | "@minatojs/*": ["packages/*/src"], 8 | "@minatojs/driver-*": ["packages/*/src"], 9 | }, 10 | }, 11 | "files": [], 12 | } 13 | -------------------------------------------------------------------------------- /packages/mongo/src/locales/zh-CN.yml: -------------------------------------------------------------------------------- 1 | protocol: 要使用的协议名。 2 | host: 要连接到的主机名。 3 | port: 要连接到的端口号。 4 | username: 要使用的用户名。 5 | password: 要使用的密码。 6 | database: 要访问的数据库名。 7 | authDatabase: 用于验证身份的数据库名。 8 | writeConcern: 9 | $description: Write Concern 10 | w: 11 | $description: The write concern. 12 | $value: 13 | - Default 14 | - Custom 15 | - Majority 16 | wtimeoutMS: The write concern timeout. 17 | journal: The journal write concern. 18 | -------------------------------------------------------------------------------- /packages/tests/readme.md: -------------------------------------------------------------------------------- 1 | # @minatojs/tests 2 | 3 | [![downloads](https://img.shields.io/npm/dm/@minatojs/tests?style=flat-square)](https://www.npmjs.com/package/@minatojs/tests) 4 | [![npm](https://img.shields.io/npm/v/@minatojs/tests?style=flat-square)](https://www.npmjs.com/package/@minatojs/tests) 5 | [![GitHub](https://img.shields.io/github/license/cordiverse/minato?style=flat-square)](https://github.com/cordiverse/minato/blob/master/LICENSE) 6 | 7 | Unit Tests for Minato. 8 | -------------------------------------------------------------------------------- /packages/mysql/src/locales/en-US.yml: -------------------------------------------------------------------------------- 1 | host: The hostname of the database you are connecting to. 2 | port: The port number to connect to. 3 | user: The MySQL user to authenticate as. 4 | password: The password of that MySQL user. 5 | database: Name of the database to use for this connection. 6 | 7 | ssl: 8 | $description: SSL options. 9 | $value: 10 | - Default 11 | - $description: Custom 12 | rejectUnauthorized: Reject clients with invalid certificates. 13 | -------------------------------------------------------------------------------- /packages/sql-utils/readme.md: -------------------------------------------------------------------------------- 1 | # @minatojs/sql-utils 2 | 3 | [![downloads](https://img.shields.io/npm/dm/@minatojs/sql-utils?style=flat-square)](https://www.npmjs.com/package/@minatojs/sql-utils) 4 | [![npm](https://img.shields.io/npm/v/@minatojs/sql-utils?style=flat-square)](https://www.npmjs.com/package/@minatojs/sql-utils) 5 | [![GitHub](https://img.shields.io/github/license/cordiverse/minato?style=flat-square)](https://github.com/cordiverse/minato/blob/master/LICENSE) 6 | 7 | SQL Utilities for Minato. 8 | -------------------------------------------------------------------------------- /packages/mongo/readme.md: -------------------------------------------------------------------------------- 1 | # @minatojs/driver-mongo 2 | 3 | [![downloads](https://img.shields.io/npm/dm/@minatojs/driver-mongo?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-mongo) 4 | [![npm](https://img.shields.io/npm/v/@minatojs/driver-mongo?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-mongo) 5 | [![GitHub](https://img.shields.io/github/license/cordiverse/minato?style=flat-square)](https://github.com/cordiverse/minato/blob/master/LICENSE) 6 | 7 | MongoDB Driver for Minato. 8 | -------------------------------------------------------------------------------- /packages/sqlite/readme.md: -------------------------------------------------------------------------------- 1 | # @minatojs/driver-sqlite 2 | 3 | [![downloads](https://img.shields.io/npm/dm/@minatojs/driver-sqlite?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-sqlite) 4 | [![npm](https://img.shields.io/npm/v/@minatojs/driver-sqlite?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-sqlite) 5 | [![GitHub](https://img.shields.io/github/license/cordiverse/minato?style=flat-square)](https://github.com/cordiverse/minato/blob/master/LICENSE) 6 | 7 | SQLite Driver for Minato. 8 | -------------------------------------------------------------------------------- /packages/memory/readme.md: -------------------------------------------------------------------------------- 1 | # @minatojs/driver-memory 2 | 3 | [![downloads](https://img.shields.io/npm/dm/@minatojs/driver-memory?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-memory) 4 | [![npm](https://img.shields.io/npm/v/@minatojs/driver-memory?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-memory) 5 | [![GitHub](https://img.shields.io/github/license/cordiverse/minato?style=flat-square)](https://github.com/cordiverse/minato/blob/master/LICENSE) 6 | 7 | In-memory Driver for Minato. 8 | -------------------------------------------------------------------------------- /packages/mysql/readme.md: -------------------------------------------------------------------------------- 1 | # @minatojs/driver-mysql 2 | 3 | [![downloads](https://img.shields.io/npm/dm/@minatojs/driver-mysql?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-mysql) 4 | [![npm](https://img.shields.io/npm/v/@minatojs/driver-mysql?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-mysql) 5 | [![GitHub](https://img.shields.io/github/license/cordiverse/minato?style=flat-square)](https://github.com/cordiverse/minato/blob/master/LICENSE) 6 | 7 | MySQL / MariaDB Driver for Minato. 8 | -------------------------------------------------------------------------------- /packages/postgres/readme.md: -------------------------------------------------------------------------------- 1 | # @minatojs/driver-postgres 2 | 3 | [![downloads](https://img.shields.io/npm/dm/@minatojs/driver-postgres?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-postgres) 4 | [![npm](https://img.shields.io/npm/v/@minatojs/driver-postgres?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-postgres) 5 | [![GitHub](https://img.shields.io/github/license/cordiverse/minato?style=flat-square)](https://github.com/cordiverse/minato/blob/master/LICENSE) 6 | 7 | PostgreSQL Driver for Minato. 8 | -------------------------------------------------------------------------------- /packages/tests/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { mapValues } from 'cosmokit' 2 | import { Database } from 'minato' 3 | 4 | export async function setup(database: Database, name: K, table: Partial[]) { 5 | await database.remove(name, {}) 6 | const result: S[K][] = [] 7 | for (const item of table) { 8 | const data: any = mapValues(item, (v, k) => (v && database.tables[name].fields[k]?.relation) ? { $literal: v } : v) 9 | result.push(await database.create(name, data)) 10 | } 11 | return result 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | dist 3 | temp 4 | .cache 5 | 6 | test.db 7 | 8 | /coverage 9 | /external 10 | 11 | .pnp.* 12 | .yarn/* 13 | !.yarn/patches 14 | !.yarn/plugins 15 | !.yarn/releases 16 | !.yarn/sdks 17 | !.yarn/versions 18 | 19 | yarn.lock 20 | package-lock.json 21 | pnpm-lock.yaml 22 | 23 | todo.md 24 | coverage/ 25 | node_modules/ 26 | npm-debug.log 27 | yarn-debug.log 28 | yarn-error.log 29 | package-lock.json 30 | tsconfig.temp.json 31 | tsconfig.tsbuildinfo 32 | report.*.json 33 | 34 | .eslintcache 35 | .DS_Store 36 | .idea 37 | .vscode 38 | *.suo 39 | *.ntvs* 40 | *.njsproj 41 | *.sln 42 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2023", 4 | "module": "esnext", 5 | "moduleResolution": "bundler", 6 | "allowImportingTsExtensions": true, 7 | "declaration": true, 8 | "emitDeclarationOnly": true, 9 | "composite": true, 10 | "incremental": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "strict": true, 14 | "noImplicitAny": false, 15 | "noImplicitThis": false, 16 | "strictFunctionTypes": false, 17 | "types": [ 18 | "@types/node", 19 | "yml-register/types", 20 | ], 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /packages/core/src/error.ts: -------------------------------------------------------------------------------- 1 | export namespace RuntimeError { 2 | export type Code = 3 | | 'duplicate-entry' 4 | | 'unsupported-expression' 5 | } 6 | 7 | export class RuntimeError extends Error { 8 | name = 'RuntimeError' 9 | 10 | constructor(public code: T, message?: string) { 11 | super(message || code.replace('-', ' ')) 12 | } 13 | 14 | static check(error: any, code?: RuntimeError.Code): error is RuntimeError { 15 | if (!(error instanceof RuntimeError)) return false 16 | return !code || error.message === code 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/mongo/src/locales/en-US.yml: -------------------------------------------------------------------------------- 1 | protocol: The protocol to use. 2 | host: The host to connect to. 3 | port: The port number to be connected. 4 | username: The username used for authentication. 5 | password: The password used for authentication. 6 | database: The name of the database we want to use. 7 | authDatabase: The name of the database for authentication. 8 | writeConcern: 9 | $description: Write Concern 10 | w: 11 | $description: The write concern. 12 | $value: 13 | - Default 14 | - Custom 15 | - Majority 16 | wtimeoutMS: The write concern timeout. 17 | journal: The journal write concern. 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Check out 13 | uses: actions/checkout@v4 14 | - name: Set up Node 15 | uses: actions/setup-node@v4 16 | - name: Install 17 | run: yarn --no-immutable 18 | - name: Lint 19 | run: yarn lint 20 | 21 | build: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Check out 26 | uses: actions/checkout@v4 27 | - name: Set up Node 28 | uses: actions/setup-node@v4 29 | - name: Install 30 | run: yarn --no-immutable 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /packages/sqlite/tests/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { Database } from 'minato' 3 | import SQLiteDriver from '@minatojs/driver-sqlite' 4 | import Logger from 'reggol' 5 | import test from '@minatojs/tests' 6 | 7 | const logger = new Logger('sqlite') 8 | 9 | describe('@minatojs/driver-sqlite', () => { 10 | const database = new Database() 11 | 12 | before(async () => { 13 | logger.level = 3 14 | await database.connect(SQLiteDriver, { 15 | path: join(__dirname, 'test.db'), 16 | }) 17 | }) 18 | 19 | after(async () => { 20 | await database.dropAll() 21 | await database.stopAll() 22 | logger.level = 2 23 | }) 24 | 25 | test(database, { 26 | query: { 27 | list: { 28 | elementQuery: false, 29 | }, 30 | }, 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /packages/mysql/tests/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'minato' 2 | import MySQLDriver from '@minatojs/driver-mysql' 3 | import Logger from 'reggol' 4 | import test from '@minatojs/tests' 5 | 6 | const logger = new Logger('mysql') 7 | 8 | describe('@minatojs/driver-mysql', () => { 9 | const database = new Database() 10 | 11 | before(async () => { 12 | logger.level = 3 13 | await database.connect(MySQLDriver, { 14 | user: 'koishi', 15 | password: 'koishi@114514', 16 | database: 'test', 17 | }) 18 | }) 19 | 20 | after(async () => { 21 | await database.dropAll() 22 | await database.stopAll() 23 | logger.level = 2 24 | }) 25 | 26 | test(database, { 27 | query: { 28 | list: { 29 | elementQuery: false, 30 | }, 31 | }, 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /packages/postgres/tests/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'minato' 2 | import PostgresDriver from '@minatojs/driver-postgres' 3 | import Logger from 'reggol' 4 | import test from '@minatojs/tests' 5 | 6 | const logger = new Logger('postgres') 7 | 8 | describe('@minatojs/driver-postgres', () => { 9 | const database = new Database() 10 | 11 | before(async () => { 12 | logger.level = 3 13 | await database.connect(PostgresDriver, { 14 | host: 'localhost', 15 | port: 5432, 16 | user: 'koishi', 17 | password: 'koishi@114514', 18 | database: 'test', 19 | }) 20 | }) 21 | 22 | after(async () => { 23 | await database.dropAll() 24 | await database.stopAll() 25 | logger.level = 2 26 | }) 27 | 28 | test(database, { 29 | query: { 30 | list: { 31 | elementQuery: false, 32 | }, 33 | }, 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /packages/mongo/tests/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'minato' 2 | import MongoDriver from '@minatojs/driver-mongo' 3 | import test from '@minatojs/tests' 4 | import Logger from 'reggol' 5 | 6 | const logger = new Logger('mongo') 7 | 8 | describe('@minatojs/driver-mongo', () => { 9 | const database = new Database() 10 | 11 | before(async () => { 12 | logger.level = 3 13 | await database.connect(MongoDriver, { 14 | host: 'localhost', 15 | port: 27017, 16 | database: 'test', 17 | optimizeIndex: true, 18 | }) 19 | }) 20 | 21 | after(async () => { 22 | await database.dropAll() 23 | await database.stopAll() 24 | logger.level = 2 25 | }) 26 | 27 | test(database, { 28 | model: { 29 | object: { 30 | aggregateNull: false, 31 | } 32 | }, 33 | transaction: { 34 | abort: false 35 | } 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /packages/sql-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@minatojs/sql-utils", 3 | "version": "6.0.0-alpha.0", 4 | "description": "SQL Utilities for Minato", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "author": "Shigma ", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/cordiverse/minato.git", 16 | "directory": "packages/sql-utils" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/cordiverse/minato/issues" 20 | }, 21 | "homepage": "https://github.com/cordiverse/minato/packages/sql-utils#readme", 22 | "keywords": [ 23 | "orm", 24 | "database", 25 | "driver", 26 | "sql", 27 | "utils", 28 | "utilities", 29 | "query", 30 | "builder" 31 | ], 32 | "devDependencies": { 33 | "cordis": "^4.0.0-alpha.7" 34 | }, 35 | "peerDependencies": { 36 | "minato": "^4.0.0-alpha.0" 37 | }, 38 | "dependencies": { 39 | "cosmokit": "^1.7.4" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/memory/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@minatojs/driver-memory", 3 | "version": "4.0.0-alpha.0", 4 | "description": "In-memory Driver for Minato", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "author": "Shigma ", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/cordiverse/minato.git", 16 | "directory": "packages/memory" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/cordiverse/minato/issues" 20 | }, 21 | "homepage": "https://github.com/cordiverse/minato/packages/memory#readme", 22 | "keywords": [ 23 | "orm", 24 | "database", 25 | "driver", 26 | "memory" 27 | ], 28 | "peerDependencies": { 29 | "minato": "^4.0.0-alpha.0" 30 | }, 31 | "devDependencies": { 32 | "@minatojs/tests": "^3.0.0-alpha.0", 33 | "cordis": "^4.0.0-alpha.7", 34 | "minato": "^4.0.0-alpha.0" 35 | }, 36 | "dependencies": { 37 | "cosmokit": "^1.7.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/mongo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@minatojs/driver-mongo", 3 | "version": "4.0.0-alpha.0", 4 | "description": "MongoDB Driver for Minato", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "author": "Shigma ", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/cordiverse/minato.git", 16 | "directory": "packages/mongo" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/cordiverse/minato/issues" 20 | }, 21 | "homepage": "https://github.com/cordiverse/minato/packages/mongo#readme", 22 | "keywords": [ 23 | "orm", 24 | "database", 25 | "driver", 26 | "mongo", 27 | "mongodb" 28 | ], 29 | "devDependencies": { 30 | "@minatojs/tests": "^3.0.0-alpha.0", 31 | "cordis": "^4.0.0-alpha.7", 32 | "minato": "^4.0.0-alpha.0" 33 | }, 34 | "peerDependencies": { 35 | "minato": "^4.0.0-alpha.0" 36 | }, 37 | "dependencies": { 38 | "cosmokit": "^1.7.4", 39 | "mongodb": "^6.5.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present Shigma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@minatojs/tests", 3 | "version": "3.0.0-alpha.0", 4 | "description": "Test Cases for Minato", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "author": "Shigma ", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/cordiverse/minato.git", 16 | "directory": "packages/tests" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/cordiverse/minato/issues" 20 | }, 21 | "homepage": "https://github.com/cordiverse/minato/packages/tests#readme", 22 | "keywords": [ 23 | "orm", 24 | "database", 25 | "test", 26 | "unit" 27 | ], 28 | "devDependencies": { 29 | "@types/chai": "^4.3.16", 30 | "@types/chai-as-promised": "^7.1.8", 31 | "cordis": "^4.0.0-alpha.7", 32 | "minato": "^4.0.0-alpha.0" 33 | }, 34 | "peerDependencies": { 35 | "minato": "^4.0.0-alpha.0" 36 | }, 37 | "dependencies": { 38 | "chai": "^5.1.1", 39 | "chai-as-promised": "^7.1.1", 40 | "cosmokit": "^1.7.4" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/sqlite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@minatojs/driver-sqlite", 3 | "version": "5.0.0-alpha.0", 4 | "description": "SQLite Driver for Minato", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "author": "Shigma ", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/cordiverse/minato.git", 16 | "directory": "packages/sqlite" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/cordiverse/minato/issues" 20 | }, 21 | "homepage": "https://github.com/cordiverse/minato/packages/sqlite#readme", 22 | "keywords": [ 23 | "orm", 24 | "database", 25 | "driver", 26 | "sqlite" 27 | ], 28 | "peerDependencies": { 29 | "minato": "^4.0.0-alpha.0" 30 | }, 31 | "devDependencies": { 32 | "@minatojs/tests": "^3.0.0-alpha.0", 33 | "cordis": "^4.0.0-alpha.7", 34 | "minato": "^4.0.0-alpha.0" 35 | }, 36 | "dependencies": { 37 | "@minatojs/sql-utils": "^6.0.0-alpha.0", 38 | "@minatojs/sql.js": "^3.1.0", 39 | "cosmokit": "^1.7.4" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/mysql/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@minatojs/driver-mysql", 3 | "version": "4.0.0-alpha.0", 4 | "description": "MySQL Driver for Minato", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "author": "Shigma ", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/cordiverse/minato.git", 16 | "directory": "packages/mysql" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/cordiverse/minato/issues" 20 | }, 21 | "homepage": "https://github.com/cordiverse/minato/packages/mysql#readme", 22 | "keywords": [ 23 | "orm", 24 | "database", 25 | "driver", 26 | "mysql" 27 | ], 28 | "peerDependencies": { 29 | "minato": "^4.0.0-alpha.0" 30 | }, 31 | "devDependencies": { 32 | "@minatojs/tests": "^3.0.0-alpha.0", 33 | "@types/mysql": "^2.15.26", 34 | "cordis": "^4.0.0-alpha.7", 35 | "minato": "^4.0.0-alpha.0" 36 | }, 37 | "dependencies": { 38 | "@minatojs/sql-utils": "^6.0.0-alpha.0", 39 | "@vlasky/mysql": "^2.18.6", 40 | "cosmokit": "^1.7.4" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/tests/src/shims.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface DeepEqualOptions { 4 | comparator?: (leftHandOperand: T1, rightHandOperand: T2) => boolean | null; 5 | } 6 | 7 | declare namespace Chai { 8 | interface Config { 9 | deepEqual: (( 10 | leftHandOperand: T1, 11 | rightHandOperand: T2, 12 | options?: DeepEqualOptions, 13 | ) => boolean) | null | undefined 14 | } 15 | 16 | interface ChaiUtils { 17 | eql: ( 18 | leftHandOperand: T1, 19 | rightHandOperand: T2, 20 | options?: DeepEqualOptions, 21 | ) => boolean 22 | } 23 | 24 | interface Assertion { 25 | shape(expected: any, message?: string): Assertion 26 | } 27 | 28 | interface Ordered { 29 | shape(expected: any, message?: string): Assertion 30 | } 31 | 32 | interface Eventually { 33 | shape(expected: any, message?: string): PromisedAssertion 34 | } 35 | 36 | interface PromisedOrdered { 37 | shape(expected: any, message?: string): PromisedAssertion 38 | } 39 | } 40 | 41 | declare module './shape' { 42 | declare const ChaiShape: Chai.ChaiPlugin 43 | 44 | export = ChaiShape 45 | } 46 | -------------------------------------------------------------------------------- /packages/memory/tests/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'minato' 2 | import MemoryDriver from '@minatojs/driver-memory' 3 | import test from '@minatojs/tests' 4 | 5 | describe('@minatojs/driver-memory', () => { 6 | const database = new Database() 7 | 8 | before(async () => { 9 | await database.connect(MemoryDriver, {}) 10 | }) 11 | 12 | after(async () => { 13 | await database.dropAll() 14 | await database.stopAll() 15 | }) 16 | 17 | test(database, { 18 | migration: false, 19 | update: { 20 | index: false, 21 | }, 22 | json: { 23 | query: { 24 | nullableComparator: false, 25 | }, 26 | }, 27 | model: { 28 | fields: { 29 | cast: false, 30 | typeModel: false, 31 | }, 32 | object: { 33 | nullableComparator: false, 34 | typeModel: false, 35 | }, 36 | }, 37 | query: { 38 | comparison: { 39 | nullableComparator: false, 40 | }, 41 | }, 42 | relation: { 43 | select: { 44 | nullableComparator: false, 45 | }, 46 | create: { 47 | nullableComparator: false, 48 | }, 49 | modify: { 50 | nullableComparator: false, 51 | }, 52 | }, 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@root/minato", 3 | "private": true, 4 | "type": "module", 5 | "version": "1.0.0", 6 | "main": "index.js", 7 | "workspaces": [ 8 | "docs", 9 | "external/*", 10 | "packages/*" 11 | ], 12 | "license": "MIT", 13 | "scripts": { 14 | "build": "yakumo build", 15 | "lint": "eslint packages --ext=ts --cache", 16 | "test": "yakumo mocha --import tsx --import yml-register -t 10000", 17 | "test:text": "shx rm -rf coverage && c8 -r text yarn test", 18 | "test:json": "shx rm -rf coverage && c8 -r json yarn test", 19 | "test:html": "shx rm -rf coverage && c8 -r html yarn test" 20 | }, 21 | "devDependencies": { 22 | "@cordisjs/eslint-config": "^1.1.1", 23 | "@types/mocha": "^10.0.10", 24 | "@types/node": "^22.13.10", 25 | "c8": "^7.14.0", 26 | "esbuild": "^0.25.1", 27 | "eslint": "^8.57.0", 28 | "eslint-plugin-mocha": "^10.4.1", 29 | "mocha": "^11.1.0", 30 | "shx": "^0.3.4", 31 | "tsx": "npm:@cordiverse/tsx@4.19.3-fix.1", 32 | "typescript": "^5.8.2", 33 | "yakumo": "^2.0.0-alpha.3", 34 | "yakumo-esbuild": "^2.0.0-alpha.2", 35 | "yakumo-mocha": "^2.0.0-alpha.2", 36 | "yakumo-tsc": "^2.0.0-alpha.2", 37 | "yml-register": "^1.2.5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/postgres/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@minatojs/driver-postgres", 3 | "version": "3.0.0-alpha.0", 4 | "description": "PostgreSQL Driver for Minato", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "author": "Seidko ", 12 | "contributors": [ 13 | "Hieuzest ", 14 | "Seidko " 15 | ], 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/cordiverse/minato.git", 20 | "directory": "packages/postgres" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/cordiverse/minato/issues" 24 | }, 25 | "homepage": "https://github.com/cordiverse/minato/packages/postgres#readme", 26 | "keywords": [ 27 | "orm", 28 | "database", 29 | "driver", 30 | "postgres", 31 | "postgresql" 32 | ], 33 | "peerDependencies": { 34 | "minato": "^4.0.0-alpha.0" 35 | }, 36 | "devDependencies": { 37 | "@minatojs/tests": "^3.0.0-alpha.0", 38 | "cordis": "^4.0.0-alpha.7", 39 | "minato": "^4.0.0-alpha.0" 40 | }, 41 | "dependencies": { 42 | "@minatojs/sql-utils": "^6.0.0-alpha.0", 43 | "cosmokit": "^1.7.4", 44 | "postgres": "^3.4.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minato", 3 | "version": "4.0.0-alpha.0", 4 | "description": "Type Driven Database Framework", 5 | "type": "module", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "files": [ 9 | "lib" 10 | ], 11 | "author": "Shigma ", 12 | "contributors": [ 13 | "Shigma ", 14 | "Hieuzest " 15 | ], 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/cordiverse/minato.git", 20 | "directory": "packages/core" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/cordiverse/minato/issues" 24 | }, 25 | "homepage": "https://github.com/cordiverse/minato", 26 | "keywords": [ 27 | "orm", 28 | "query", 29 | "database", 30 | "sql", 31 | "mysql", 32 | "sqlite", 33 | "mongo", 34 | "postgres", 35 | "cordis" 36 | ], 37 | "cordis": { 38 | "ecosystem": { 39 | "pattern": [ 40 | "@minatojs/driver-*", 41 | "@minatojs/plugin-*", 42 | "minato-driver-*", 43 | "minato-plugin-*" 44 | ] 45 | }, 46 | "service": { 47 | "implements": [ 48 | "model" 49 | ] 50 | } 51 | }, 52 | "devDependencies": { 53 | "cordis": "^4.0.0-alpha.7" 54 | }, 55 | "peerDependencies": { 56 | "cordis": "^4.0.0-alpha.7" 57 | }, 58 | "dependencies": { 59 | "cosmokit": "^1.7.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Database } from './database.ts' 2 | 3 | export * from './database.ts' 4 | export * from './driver.ts' 5 | export * from './error.ts' 6 | export * from './eval.ts' 7 | export * from './model.ts' 8 | export * from './query.ts' 9 | export * from './selection.ts' 10 | export * from './type.ts' 11 | export * from './utils.ts' 12 | 13 | declare module 'cordis' { 14 | interface Events { 15 | 'minato/model'(name: string): void 16 | } 17 | 18 | interface Context { 19 | [Types]: Types 20 | [Tables]: Tables 21 | [Context.Minato]: Context.Minato 22 | [Context.Database]: Context.Database 23 | minato: Database & this[typeof Context.Minato] 24 | database: Database & this[typeof Context.Database] 25 | } 26 | 27 | namespace Context { 28 | const Minato: unique symbol 29 | const Database: unique symbol 30 | // https://github.com/typescript-eslint/typescript-eslint/issues/6720 31 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 32 | interface Minato {} 33 | // https://github.com/typescript-eslint/typescript-eslint/issues/6720 34 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 35 | interface Database {} 36 | } 37 | } 38 | 39 | export const Types = Symbol.for('minato.types') 40 | export interface Types {} 41 | 42 | export const Tables = Symbol.for('minato.tables') 43 | export interface Tables {} 44 | 45 | export default Database 46 | -------------------------------------------------------------------------------- /packages/tests/src/setup.ts: -------------------------------------------------------------------------------- 1 | import { config, use, util } from 'chai' 2 | import promised from 'chai-as-promised' 3 | import shape from './shape' 4 | import { isNullable } from 'cosmokit' 5 | 6 | use(shape) 7 | use(promised) 8 | 9 | function type(obj) { 10 | if (typeof obj === 'undefined') { 11 | return 'undefined' 12 | } 13 | 14 | if (obj === null) { 15 | return 'null' 16 | } 17 | 18 | const stringTag = obj[Symbol.toStringTag] 19 | if (typeof stringTag === 'string') { 20 | return stringTag 21 | } 22 | const sliceStart = 8 23 | const sliceEnd = -1 24 | return Object.prototype.toString.call(obj).slice(sliceStart, sliceEnd) 25 | } 26 | 27 | function getEnumerableKeys(target) { 28 | const keys: string[] = [] 29 | for (const key in target) { 30 | keys.push(key) 31 | } 32 | return keys 33 | } 34 | 35 | function getEnumerableSymbols(target) { 36 | const keys: symbol[] = [] 37 | const allKeys = Object.getOwnPropertySymbols(target) 38 | for (let i = 0; i < allKeys.length; i += 1) { 39 | const key = allKeys[i] 40 | if (Object.getOwnPropertyDescriptor(target, key)?.enumerable) { 41 | keys.push(key) 42 | } 43 | } 44 | return keys 45 | } 46 | 47 | config.deepEqual = (expected, actual, options) => { 48 | return util.eql(expected, actual, { 49 | comparator: (expected, actual) => { 50 | if (isNullable(expected) && isNullable(actual)) return true 51 | if (type(expected) === 'Object' && type(actual) === 'Object') { 52 | const keys = new Set([ 53 | ...getEnumerableKeys(expected), 54 | ...getEnumerableKeys(actual), 55 | ...getEnumerableSymbols(expected), 56 | ...getEnumerableSymbols(actual), 57 | ]) 58 | return [...keys].every(key => config.deepEqual!(expected[key], actual[key], options)) 59 | } 60 | return null 61 | }, 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /packages/tests/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'minato' 2 | import ModelOperations from './model' 3 | import QueryOperators from './query' 4 | import UpdateOperators from './update' 5 | import ObjectOperations from './object' 6 | import Migration from './migration' 7 | import Selection from './selection' 8 | import Json from './json' 9 | import Transaction from './transaction' 10 | import Relation from './relation' 11 | import './setup' 12 | 13 | export { expect } from 'chai' 14 | 15 | const Keywords = ['name'] 16 | type Keywords = 'name' 17 | 18 | type UnitOptions = (T extends (database: Database, options?: infer R) => any ? R : {}) & { 19 | [K in keyof T as Exclude]?: false | UnitOptions 20 | } 21 | 22 | type Unit = ((database: Database, options?: UnitOptions) => void) & { 23 | [K in keyof T as Exclude]: Unit 24 | } 25 | 26 | function setValue(obj: any, path: string, value: any) { 27 | if (path.includes('.')) { 28 | const index = path.indexOf('.') 29 | setValue(obj[path.slice(0, index)] ??= {}, path.slice(index + 1), value) 30 | } else { 31 | obj[path] = value 32 | } 33 | } 34 | 35 | function createUnit(target: T, root = false): Unit { 36 | const test: any = (database: Database, options: any = {}) => { 37 | function callback() { 38 | if (typeof target === 'function') { 39 | target(database, options) 40 | } 41 | 42 | for (const key in target) { 43 | if (options[key] === false || Keywords.includes(key)) continue 44 | test[key](database, options[key]) 45 | } 46 | } 47 | 48 | process.argv.filter(x => x.startsWith('--+')).forEach(x => setValue(options, x.slice(3), true)) 49 | process.argv.filter(x => x.startsWith('---')).forEach(x => setValue(options, x.slice(3), false)) 50 | 51 | const title = target['name'] 52 | if (!root && title) { 53 | describe(title.replace(/(?=[A-Z])/g, ' ').trimStart(), callback) 54 | } else { 55 | callback() 56 | } 57 | } 58 | 59 | for (const key in target) { 60 | if (Keywords.includes(key)) continue 61 | test[key] = createUnit(target[key]) 62 | } 63 | 64 | return test 65 | } 66 | 67 | namespace Tests { 68 | export const model = ModelOperations 69 | export const query = QueryOperators 70 | export const update = UpdateOperators 71 | export const object = ObjectOperations 72 | export const selection = Selection 73 | export const migration = Migration 74 | export const json = Json 75 | export const transaction = Transaction 76 | export const relation = Relation 77 | } 78 | 79 | export default createUnit(Tests, true) 80 | -------------------------------------------------------------------------------- /packages/core/readme.md: -------------------------------------------------------------------------------- 1 | # minato 2 | 3 | [![Codecov](https://img.shields.io/codecov/c/github/cordiverse/minato?style=flat-square)](https://codecov.io/gh/cordiverse/minato) 4 | [![downloads](https://img.shields.io/npm/dm/minato?style=flat-square)](https://www.npmjs.com/package/minato) 5 | [![npm](https://img.shields.io/npm/v/minato?style=flat-square)](https://www.npmjs.com/package/minato) 6 | [![GitHub](https://img.shields.io/github/license/cordiverse/minato?style=flat-square)](https://github.com/cordiverse/minato/blob/master/LICENSE) 7 | 8 | Type Driven Database Framework. 9 | 10 | ## Features 11 | 12 | - **Compatibility.** Complete driver-independent. Supports many drivers with a unified API. 13 | - **Powerful.** It can do everything that SQL can do, even though you are not using SQL drivers. 14 | - **Well-typed.** Minato is written with TypeScript, and it provides top-level typing support. 15 | - **Extensible.** Simultaneous accesss to different databases based on your needs. 16 | - **Modern.** Perform all the operations with a JavaScript API or even in the browser with low code. 17 | 18 | ## Driver Supports 19 | 20 | | Driver | Version | Notes | 21 | | ------ | ------ | ----- | 22 | | [Memory](https://github.com/cordiverse/minato/tree/master/packages/memory) | [![npm](https://img.shields.io/npm/v/@minatojs/driver-memory?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-memory) | In-memory driver support | 23 | | [MongoDB](https://github.com/cordiverse/minato/tree/master/packages/mongo) | [![npm](https://img.shields.io/npm/v/@minatojs/driver-mongo?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-mongo) | | 24 | | [MySQL](https://github.com/cordiverse/minato/tree/master/packages/mysql) | [![npm](https://img.shields.io/npm/v/@minatojs/driver-mysql?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-mysql) | MySQL 5.7+, MariaDB 10.5 | 25 | | [PostgreSQL](https://github.com/cordiverse/minato/tree/master/packages/postgres) | [![npm](https://img.shields.io/npm/v/@minatojs/driver-postgres?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-postgres) | PostgreSQL 14+ | 26 | | [SQLite](https://github.com/cordiverse/minato/tree/master/packages/sqlite) | [![npm](https://img.shields.io/npm/v/@minatojs/driver-sqlite?style=flat-square)](https://www.npmjs.com/package/@minatojs/driver-sqlite) | | 27 | 28 | ## Basic Usage 29 | 30 | ```ts 31 | import Database from 'minato' 32 | import MySQLDriver from '@minatojs/driver-mysql' 33 | 34 | const database = new Database() 35 | 36 | await database.connect(MySQLDriver, { 37 | host: 'localhost', 38 | port: 3306, 39 | user: 'root', 40 | password: '', 41 | database: 'minato', 42 | }) 43 | ``` 44 | 45 | ## Data Definition 46 | 47 | ```ts 48 | database.extend('user', { 49 | id: 'number', 50 | name: 'string', 51 | age: 'number', 52 | money: { type: 'number', initial: 100 }, 53 | }, { 54 | primary: 'id', 55 | autoInc: true, 56 | }) 57 | ``` 58 | 59 | ## Simple API 60 | 61 | ### create 62 | 63 | ```ts 64 | const user = await driver.create('user', { 65 | name: 'John', 66 | age: 20, 67 | }) // { id: 1, name: 'John', age: 20, money: 100 } 68 | ``` 69 | 70 | ### get 71 | 72 | ### remove 73 | 74 | ### set 75 | 76 | ### upsert 77 | 78 | ## Selection API 79 | 80 | ## Using TypeScript 81 | 82 | ## Using Multiple Drivers 83 | -------------------------------------------------------------------------------- /packages/tests/src/shape.ts: -------------------------------------------------------------------------------- 1 | import { Binary, deepEqual, isNullable } from 'cosmokit' 2 | import { inspect } from 'util' 3 | 4 | function flag(obj, key, value?) { 5 | var flags = obj.__flags || (obj.__flags = Object.create(null)); 6 | if (arguments.length === 3) { 7 | flags[key] = value; 8 | } else { 9 | return flags[key]; 10 | } 11 | }; 12 | 13 | function isSubsetOf(subset, superset, cmp, contains, ordered) { 14 | if (!contains) { 15 | if (subset.length !== superset.length) return false; 16 | superset = superset.slice(); 17 | } 18 | 19 | return subset.every(function (elem, idx) { 20 | if (ordered) return cmp ? cmp(elem, superset[idx]) : elem === superset[idx]; 21 | 22 | if (!cmp) { 23 | var matchIdx = superset.indexOf(elem); 24 | if (matchIdx === -1) return false; 25 | 26 | // Remove match from superset so not counted twice if duplicate in subset. 27 | if (!contains) superset.splice(matchIdx, 1); 28 | return true; 29 | } 30 | 31 | return superset.some(function (elem2, matchIdx) { 32 | if (!cmp(elem, elem2)) return false; 33 | 34 | // Remove match from superset so not counted twice if duplicate in subset. 35 | if (!contains) superset.splice(matchIdx, 1); 36 | return true; 37 | }); 38 | }); 39 | } 40 | 41 | export default (({ Assertion }) => { 42 | function checkShape(expect, actual, path, ordered) { 43 | if (actual === expect || Number.isNaN(expect) && Number.isNaN(actual)) return 44 | 45 | function formatError(expect, actual) { 46 | return `expected to have ${expect} but got ${actual} at path ${path}` 47 | } 48 | 49 | if (isNullable(expect) && isNullable(actual)) return 50 | 51 | if (!expect || ['string', 'number', 'boolean', 'bigint'].includes(typeof expect)) { 52 | return formatError(inspect(expect), inspect(actual)) 53 | } 54 | 55 | // dates 56 | if (expect instanceof Date) { 57 | if (!(actual instanceof Date) || +expect !== +actual) { 58 | return formatError(inspect(expect), inspect(actual)) 59 | } 60 | return 61 | } 62 | 63 | // binary 64 | if (Binary.is(expect)) { 65 | if (!Binary.is(actual) || !deepEqual(actual, expect)) { 66 | return formatError(inspect(expect), inspect(actual)) 67 | } 68 | return 69 | } 70 | 71 | if (actual === null) { 72 | const type = Object.prototype.toString.call(expect).slice(8, -1).toLowerCase() 73 | return formatError(`a ${type}`, 'null') 74 | } 75 | 76 | // array / object 77 | if (!ordered && Array.isArray(expect) && Array.isArray(actual)) { 78 | if (!isSubsetOf(expect, actual, (x, y) => !checkShape(x, y, `${path}/`, ordered), false, false)) { 79 | return `expected same shape of members` 80 | } 81 | return 82 | } 83 | 84 | for (const prop in expect) { 85 | if (isNullable(actual[prop]) && !isNullable(expect[prop])) { 86 | return `expected "${prop}" field to be defined at path ${path}` 87 | } 88 | const message = checkShape(expect[prop], actual[prop], `${path}${prop}/`, ordered) 89 | if (message) return message 90 | } 91 | } 92 | 93 | Assertion.addMethod('shape', function (expect) { 94 | var ordered = flag(this, 'ordered'); 95 | const message = checkShape(expect, this._obj, '/', ordered) 96 | if (message) this.assert(false, message, '', expect, this._obj) 97 | }) 98 | }) as Chai.ChaiPlugin 99 | -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Binary, Intersect, isNullable } from 'cosmokit' 2 | import { Eval, isEvalExpr } from './eval.ts' 3 | 4 | export type Values = S[keyof S] 5 | 6 | export type Keys = Values<{ 7 | [P in keyof O]: O[P] extends T | undefined ? P : never 8 | }> & string 9 | 10 | export type FlatKeys = Keys, T> 11 | 12 | export type FlatPick> = { 13 | [P in string & keyof O as K extends P | `${P}.${any}` ? P : never]: 14 | | P extends K 15 | ? O[P] 16 | : FlatPick>> 17 | } 18 | 19 | export type DeepPartial = 20 | | T extends Values ? T 21 | : T extends (infer U)[] ? DeepPartial[] 22 | : T extends object ? { [K in keyof T]?: DeepPartial } 23 | : T 24 | 25 | export interface AtomicTypes { 26 | Number: number 27 | String: string 28 | Boolean: boolean 29 | BigInt: bigint 30 | Symbol: symbol 31 | Date: Date 32 | RegExp: RegExp 33 | Function: Function 34 | ArrayBuffer: ArrayBuffer 35 | SharedArrayBuffer: SharedArrayBuffer 36 | } 37 | 38 | export type Indexable = string | number | bigint 39 | export type Comparable = string | number | boolean | bigint | Date 40 | 41 | type FlatWrap = { [K in P]: S } 42 | // rule out atomic types 43 | | (S extends Values ? never 44 | // rule out array types 45 | : S extends any[] ? never 46 | // check recursion depth 47 | // rule out dict / infinite types 48 | : string extends keyof S ? never 49 | : A extends [0, ...infer R extends 0[]] ? FlatMap 50 | : never) 51 | 52 | type FlatMap = Values<{ 53 | [K in keyof S & string as `${P}${K}`]: FlatWrap 54 | }> 55 | 56 | type Sequence = A['length'] extends N ? A : Sequence 57 | 58 | export type Flatten = Intersect>> 59 | 60 | export type Row = { 61 | [K in keyof S]-?: Row.Cell> 62 | } 63 | 64 | export namespace Row { 65 | export type Cell = Eval.Expr & (T extends Comparable ? {} : Row) 66 | export type Computed = T | ((row: Row) => T) 67 | } 68 | 69 | export function isComparable(value: any): value is Comparable { 70 | return typeof value === 'string' 71 | || typeof value === 'number' 72 | || typeof value === 'boolean' 73 | || typeof value === 'bigint' 74 | || value instanceof Date 75 | } 76 | 77 | export function isFlat(value: any): value is Values { 78 | return !value 79 | || typeof value !== 'object' 80 | || isEvalExpr(value) 81 | || Object.keys(value).length === 0 82 | || Array.isArray(value) 83 | || value instanceof Date 84 | || value instanceof RegExp 85 | || Binary.isSource(value) 86 | } 87 | 88 | const letters = 'abcdefghijklmnopqrstuvwxyz' 89 | 90 | export function randomId() { 91 | return Array(8).fill(0).map(() => letters[Math.floor(Math.random() * letters.length)]).join('') 92 | } 93 | 94 | export interface RegExpLike { 95 | source: string 96 | flags?: string 97 | } 98 | 99 | export function makeRegExp(source: string | RegExpLike, flags?: string) { 100 | return (source instanceof RegExp && !flags) ? source : new RegExp((source as any).source ?? source, flags ?? (source as any).flags) 101 | } 102 | 103 | export function unravel(source: object, init?: (value) => any) { 104 | const result = {} 105 | for (const key in source) { 106 | let node = result 107 | const segments = key.split('.').reverse() 108 | for (let index = segments.length - 1; index > 0; index--) { 109 | const segment = segments[index] 110 | node = node[segment] ??= {} 111 | if (init) node = init(node) 112 | } 113 | node[segments[0]] = source[key] 114 | } 115 | return result 116 | } 117 | 118 | export function flatten(source: object, prefix = '', ignore: (value: any) => boolean = isFlat) { 119 | const result = {} 120 | for (const key in source) { 121 | const value = source[key] 122 | if (ignore(value)) { 123 | result[`${prefix}${key}`] = value 124 | } else { 125 | Object.assign(result, flatten(value, `${prefix}${key}.`, ignore)) 126 | } 127 | } 128 | return result 129 | } 130 | 131 | export function getCell(row: any, path: any): any { 132 | if (path in row) return row[path] 133 | if (path.includes('.')) { 134 | const index = path.indexOf('.') 135 | return getCell(row[path.slice(0, index)] ?? {}, path.slice(index + 1)) 136 | } else { 137 | return row[path] 138 | } 139 | } 140 | 141 | export function isEmpty(value: any) { 142 | if (isNullable(value)) return true 143 | if (typeof value !== 'object') return false 144 | for (const key in value) { 145 | if (!isEmpty(value[key])) return false 146 | } 147 | return true 148 | } 149 | -------------------------------------------------------------------------------- /packages/core/src/type.ts: -------------------------------------------------------------------------------- 1 | import { Binary, defineProperty, isNullable, mapValues } from 'cosmokit' 2 | import { Field } from './model.ts' 3 | import { Eval, isEvalExpr } from './eval.ts' 4 | import { isEmpty } from './utils.ts' 5 | // import { Keys } from './utils.ts' 6 | 7 | export interface Type { 8 | [Type.kType]?: true 9 | // FIXME 10 | type: Field.Type // | Keys | Field.NewType 11 | inner?: T extends (infer I)[] ? Type : Field.Type extends 'json' ? { [key in keyof T]: Type } : never 12 | array?: boolean 13 | // For left joined unmatched result only 14 | ignoreNull?: boolean 15 | } 16 | 17 | export namespace Type { 18 | export const kType = Symbol.for('minato.type') 19 | 20 | export const Any: Type = fromField('expr') 21 | export const Boolean: Type = fromField('boolean') 22 | export const Number: Type = fromField('double') 23 | export const String: Type = fromField('string') 24 | 25 | type Extract = 26 | | T extends Type ? I 27 | : T extends Field ? I 28 | : T extends Field.Type ? I 29 | : T extends Eval.Term ? I 30 | : never 31 | 32 | export type Object = Type 33 | export const Object = (obj?: T): Object<{ [K in keyof T]: Extract }> => defineProperty({ 34 | type: 'json' as any, 35 | inner: globalThis.Object.keys(obj ?? {}).length ? mapValues(obj!, (value) => isType(value) ? value : fromField(value)) as any : undefined, 36 | }, kType, true) 37 | 38 | export type Array = Type 39 | export const Array = (type?: Type): Type.Array => defineProperty({ 40 | type: 'json', 41 | inner: type, 42 | array: true, 43 | }, kType, true) 44 | 45 | export function fromPrimitive(value: T): Type { 46 | if (isNullable(value)) return fromField('expr' as any) 47 | else if (typeof value === 'number') return Number as any 48 | else if (typeof value === 'string') return String as any 49 | else if (typeof value === 'boolean') return Boolean as any 50 | else if (typeof value === 'bigint') return fromField('bigint' as any) 51 | else if (value instanceof Date) return fromField('timestamp' as any) 52 | else if (Binary.is(value)) return fromField('binary' as any) 53 | else if (globalThis.Array.isArray(value)) return Array(value.length ? fromPrimitive(value[0]) : undefined) as any 54 | else if (typeof value === 'object') return fromField('json' as any) 55 | throw new TypeError(`invalid primitive: ${value}`) 56 | } 57 | 58 | // FIXME: Type | Field | Field.Type | Keys | Field.NewType 59 | export function fromField(field: any): Type { 60 | if (isType(field)) return field 61 | else if (field === 'array') return Array() as never 62 | else if (field === 'object') return Object() as never 63 | else if (typeof field === 'string') return defineProperty({ type: field }, kType, true) as never 64 | else if (field.type) return field.type 65 | else if (field.expr?.[kType]) return field.expr[kType] 66 | throw new TypeError(`invalid field: ${field}`) 67 | } 68 | 69 | export function fromTerm(value: Eval.Term, initial?: Type): Type { 70 | if (isEvalExpr(value)) return value[kType] ?? initial ?? fromField('expr' as any) 71 | else return fromPrimitive(value as T) 72 | } 73 | 74 | export function fromTerms(values: Eval.Term[], initial?: Type): Type { 75 | return values.map((x) => fromTerm(x)).find((type) => type.type !== 'expr') ?? initial ?? fromField('expr') 76 | } 77 | 78 | export function isType(value: any): value is Type { 79 | return value?.[kType] === true 80 | } 81 | 82 | export function isArray(type?: Type) { 83 | return (type?.type === 'json') && type?.array 84 | } 85 | 86 | export function getInner(type?: Type, key?: string): Type | undefined { 87 | if (!type?.inner) return 88 | if (isArray(type)) return type.inner 89 | if (isNullable(key)) return 90 | if (type.inner[key]) return type.inner[key] 91 | if (key.includes('.')) return key.split('.').reduce((t, k) => getInner(t, k), type) 92 | const fields = globalThis.Object.entries(type.inner) 93 | .filter(([k]) => k.startsWith(`${key}.`)) 94 | .map(([k, v]) => [k.slice(key.length + 1), v]) 95 | return fields.length ? Object(globalThis.Object.fromEntries(fields)) : undefined 96 | } 97 | 98 | export function transform(value: any, type: Type, callback: (value: any, type?: Type) => any) { 99 | if (!isNullable(value) && type?.inner) { 100 | if (Type.isArray(type)) { 101 | return (value as any[]).map(x => callback(x, Type.getInner(type))).filter(x => !type.ignoreNull || !isEmpty(x)) 102 | } else { 103 | if (type.ignoreNull && isEmpty(value)) return null 104 | return mapValues(value, (x, k) => callback(x, Type.getInner(type, k))) 105 | } 106 | } 107 | return value 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /packages/mongo/tests/migration.spec.ts: -------------------------------------------------------------------------------- 1 | import { $, Database, Driver, Primary } from 'minato' 2 | import { Context, EffectScope } from 'cordis' 3 | import MongoDriver from '@minatojs/driver-mongo' 4 | import { expect } from '@minatojs/tests' 5 | 6 | interface Foo { 7 | id?: number 8 | text?: string 9 | value?: number 10 | bool?: boolean 11 | list?: number[] 12 | timestamp?: Date 13 | date?: Date 14 | time?: Date 15 | regex?: string 16 | } 17 | 18 | interface Bar { 19 | id?: Primary 20 | text?: string 21 | value?: number 22 | bool?: boolean 23 | list?: number[] 24 | timestamp?: Date 25 | date?: Date 26 | time?: Date 27 | regex?: string 28 | foreign?: Primary 29 | } 30 | 31 | interface Tables { 32 | temp1: Foo 33 | temp2: Bar 34 | } 35 | 36 | describe('@minatojs/driver-mongo/migrate-virtualKey', () => { 37 | const ctx = new Context() 38 | ctx.plugin(Database) 39 | 40 | const database = ctx.minato as Database 41 | let fork: EffectScope | undefined 42 | 43 | const resetConfig = async (optimizeIndex: boolean) => { 44 | fork?.dispose() 45 | fork = ctx.plugin(MongoDriver, { 46 | host: 'localhost', 47 | port: 27017, 48 | database: 'test', 49 | optimizeIndex, 50 | }) 51 | await ctx.events.flush() 52 | } 53 | 54 | before(() => ctx.start()) 55 | 56 | beforeEach(async () => { 57 | fork = ctx.intercept('logger', { level: 3 }).plugin(MongoDriver, { 58 | host: 'localhost', 59 | port: 27017, 60 | database: 'test', 61 | optimizeIndex: false, 62 | }) 63 | await ctx.events.flush() 64 | }) 65 | 66 | afterEach(async () => { 67 | await database.dropAll() 68 | fork?.dispose() 69 | }) 70 | 71 | it('reset optimizeIndex', async () => { 72 | database.extend('temp1', { 73 | id: 'unsigned', 74 | text: 'string', 75 | value: 'integer', 76 | bool: 'boolean', 77 | list: 'list', 78 | timestamp: 'timestamp', 79 | date: 'date', 80 | time: 'time', 81 | regex: 'string', 82 | }, { 83 | autoInc: true, 84 | unique: ['id'], 85 | }) 86 | 87 | const table: Foo[] = [] 88 | table.push(await database.create('temp1', { 89 | text: 'awesome foo', 90 | timestamp: new Date('2000-01-01'), 91 | date: new Date('2020-01-01'), 92 | time: new Date('2020-01-01 12:00:00'), 93 | })) 94 | table.push(await database.create('temp1', { text: 'awesome bar' })) 95 | table.push(await database.create('temp1', { text: 'awesome baz' })) 96 | await expect(database.get('temp1', {})).to.eventually.deep.eq(table) 97 | 98 | await resetConfig(true) 99 | await expect(database.get('temp1', {})).to.eventually.deep.eq(table) 100 | 101 | await resetConfig(false) 102 | await expect(database.get('temp1', {})).to.eventually.deep.eq(table) 103 | 104 | await (Object.values(database.drivers)[0] as Driver).drop('_fields') 105 | await resetConfig(true) 106 | await expect(database.get('temp1', {})).to.eventually.deep.eq(table) 107 | 108 | await (Object.values(database.drivers)[0] as Driver).drop('_fields') 109 | await resetConfig(false) 110 | await expect(database.get('temp1', {})).to.eventually.deep.eq(table) 111 | }) 112 | 113 | it('using primary', async () => { 114 | database.extend('temp2', { 115 | id: 'primary', 116 | text: 'string', 117 | value: 'integer', 118 | bool: 'boolean', 119 | list: 'list', 120 | timestamp: 'timestamp', 121 | date: 'date', 122 | time: 'time', 123 | regex: 'string', 124 | foreign: 'primary', 125 | }) 126 | 127 | await database.remove('temp2', {}) 128 | 129 | const table: Bar[] = [] 130 | table.push(await database.create('temp2', { 131 | text: 'awesome foo', 132 | timestamp: new Date('2000-01-01'), 133 | date: new Date('2020-01-01'), 134 | time: new Date('2020-01-01 12:00:00'), 135 | })) 136 | table.push(await database.create('temp2', { text: 'awesome bar' })) 137 | table.push(await database.create('temp2', { text: 'awesome baz' })) 138 | await expect(database.get('temp2', {})).to.eventually.deep.eq(table) 139 | 140 | await expect(database.get('temp2', table[0].id?.toString() as any)).to.eventually.deep.eq([table[0]]) 141 | await expect(database.get('temp2', { id: table[0].id?.toString() as any })).to.eventually.deep.eq([table[0]]) 142 | await expect(database.get('temp2', row => $.eq(row.id, $.literal(table[0].id?.toString(), 'primary') as any))).to.eventually.deep.eq([table[0]]) 143 | 144 | await (Object.values(database.drivers)[0] as Driver).drop('_fields') 145 | await resetConfig(true) 146 | await expect(database.get('temp2', {})).to.eventually.deep.eq(table) 147 | 148 | await (Object.values(database.drivers)[0] as Driver).drop('_fields') 149 | await resetConfig(false) 150 | await expect(database.get('temp2', {})).to.eventually.deep.eq(table) 151 | 152 | // query & eval 153 | table.push(await database.create('temp2', { foreign: table[0].id })) 154 | await expect(database.get('temp2', {})).to.eventually.deep.eq(table) 155 | await expect(database.get('temp2', { foreign: table[0].id })).to.eventually.deep.eq([table[3]]) 156 | await expect(database.get('temp2', row => $.eq(row.foreign, table[0].id!))).to.eventually.deep.eq([table[3]]) 157 | }) 158 | }) 159 | -------------------------------------------------------------------------------- /packages/core/src/query.ts: -------------------------------------------------------------------------------- 1 | import { Extract, isNullable } from 'cosmokit' 2 | import { Eval, executeEval } from './eval.ts' 3 | import { AtomicTypes, Comparable, Flatten, flatten, getCell, Indexable, isComparable, isFlat, makeRegExp, RegExpLike, Values } from './utils.ts' 4 | import { Selection } from './selection.ts' 5 | 6 | export type Query = Query.Expr> | Query.Shorthand | Selection.Callback 7 | 8 | export namespace Query { 9 | export interface FieldExpr { 10 | // logical 11 | $or?: Field[] 12 | $and?: Field[] 13 | $not?: Field 14 | 15 | // existence 16 | $exists?: boolean 17 | 18 | // membership 19 | $in?: Extract 20 | $nin?: Extract 21 | 22 | // arithmatic 23 | $eq?: Extract 24 | $ne?: Extract 25 | $gt?: Extract 26 | $gte?: Extract 27 | $lt?: Extract 28 | $lte?: Extract 29 | 30 | // list 31 | $el?: T extends (infer U)[] ? Field : never 32 | $size?: Extract 33 | 34 | // regexp 35 | $regex?: Extract 36 | $regexFor?: Extract 37 | 38 | // bitwise 39 | $bitsAllClear?: Extract 40 | $bitsAllSet?: Extract 41 | $bitsAnyClear?: Extract 42 | $bitsAnySet?: Extract 43 | 44 | // relation 45 | $some?: T extends (infer U)[] ? Query : never 46 | $none?: T extends (infer U)[] ? Query : never 47 | $every?: T extends (infer U)[] ? Query : never 48 | } 49 | 50 | export interface LogicalExpr { 51 | $or?: Expr[] 52 | $and?: Expr[] 53 | $not?: Expr 54 | /** @deprecated use query callback instead */ 55 | $expr?: Eval.Term 56 | } 57 | 58 | export type Shorthand = 59 | | Extract 60 | | Extract 61 | | Extract 62 | 63 | export type Field = FieldExpr | Shorthand 64 | 65 | type NonNullExpr = T extends Values | any[] ? Field : T extends object 66 | ? Expr> | Selection.Callback 67 | : Field 68 | 69 | export type Expr = LogicalExpr & { 70 | [K in keyof T]?: (undefined extends T[K] ? null : never) | NonNullExpr> 71 | } 72 | } 73 | 74 | type QueryOperators = { 75 | [K in keyof Query.FieldExpr]?: (query: NonNullable, data: any) => boolean 76 | } 77 | 78 | const queryOperators: QueryOperators = { 79 | // logical 80 | $or: (query, data) => query.reduce((prev, query) => prev || executeFieldQuery(query, data), false), 81 | $and: (query, data) => query.reduce((prev, query) => prev && executeFieldQuery(query, data), true), 82 | $not: (query, data) => !executeFieldQuery(query, data), 83 | 84 | // existence 85 | $exists: (query, data) => query !== isNullable(data), 86 | 87 | // comparison 88 | $eq: (query, data) => data.valueOf() === query.valueOf(), 89 | $ne: (query, data) => data.valueOf() !== query.valueOf(), 90 | $gt: (query, data) => data.valueOf() > query.valueOf(), 91 | $gte: (query, data) => data.valueOf() >= query.valueOf(), 92 | $lt: (query, data) => data.valueOf() < query.valueOf(), 93 | $lte: (query, data) => data.valueOf() <= query.valueOf(), 94 | 95 | // membership 96 | $in: (query, data) => query.includes(data), 97 | $nin: (query, data) => !query.includes(data), 98 | 99 | // regexp 100 | $regex: (query, data) => makeRegExp(query).test(data), 101 | $regexFor: (query, data) => typeof query === 'string' ? makeRegExp(data).test(query) : makeRegExp(data, query.flags).test(query.input), 102 | 103 | // bitwise 104 | $bitsAllSet: (query, data) => (query & data) === query, 105 | $bitsAllClear: (query, data) => (query & data) === 0, 106 | $bitsAnySet: (query, data) => (query & data) !== 0, 107 | $bitsAnyClear: (query, data) => (query & data) !== query, 108 | 109 | // list 110 | $el: (query, data) => data.some(item => executeFieldQuery(query, item)), 111 | $size: (query, data) => data.length === query, 112 | } 113 | 114 | function executeFieldQuery(query: Query.Field, data: any) { 115 | // shorthand syntax 116 | if (Array.isArray(query)) { 117 | return query.includes(data) 118 | } else if (query instanceof RegExp) { 119 | return query.test(data) 120 | } else if (isComparable(query)) { 121 | return data.valueOf() === query.valueOf() 122 | } else if (isNullable(query)) { 123 | return isNullable(data) 124 | } 125 | 126 | for (const key in query) { 127 | if (key in queryOperators) { 128 | if (!queryOperators[key](query[key], data)) return false 129 | } 130 | } 131 | 132 | return true 133 | } 134 | 135 | export function executeQuery(data: any, query: Query.Expr, ref: string, env: any = {}): boolean { 136 | const entries: [string, any][] = Object.entries(query) 137 | return entries.every(([key, value]) => { 138 | // execute logical query 139 | if (key === '$and') { 140 | return (value as Query.Expr[]).reduce((prev, query) => prev && executeQuery(data, query, ref, env), true) 141 | } else if (key === '$or') { 142 | return (value as Query.Expr[]).reduce((prev, query) => prev || executeQuery(data, query, ref, env), false) 143 | } else if (key === '$not') { 144 | return !executeQuery(data, value, ref, env) 145 | } else if (key === '$expr') { 146 | return executeEval({ ...env, [ref]: data, _: data }, value) 147 | } 148 | 149 | // execute field query 150 | try { 151 | const flattenQuery = isFlat(query[key]) ? { [key]: query[key] } : flatten(query[key], `${key}.`) 152 | return Object.entries(flattenQuery).every(([key, value]) => executeFieldQuery(value, getCell(data, key))) 153 | } catch { 154 | return false 155 | } 156 | }) 157 | } 158 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | mysql: 9 | name: ${{ matrix.mysql-image }} (${{ matrix.node-version }}) 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | mysql-image: 16 | - mysql:5.7 17 | - mysql:8.0 18 | - mariadb:10.5 19 | node-version: [18, 20, 22] 20 | 21 | services: 22 | mysql: 23 | image: ${{ matrix.mysql-image }} 24 | ports: 25 | - 3306:3306 26 | options: --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 27 | env: 28 | MYSQL_USER: koishi 29 | MYSQL_DATABASE: test 30 | MYSQL_PASSWORD: koishi@114514 31 | MYSQL_ROOT_PASSWORD: password 32 | 33 | steps: 34 | - name: Check out 35 | uses: actions/checkout@v4 36 | - name: Set up Node 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | - name: Install 41 | run: yarn --no-immutable 42 | - name: Unit Test 43 | run: yarn test:json mysql 44 | - name: Report Coverage 45 | uses: codecov/codecov-action@v4 46 | with: 47 | token: ${{ secrets.CODECOV_TOKEN }} 48 | files: ./coverage/coverage-final.json 49 | name: codecov 50 | 51 | postgres: 52 | name: ${{ matrix.postgres-image }} (${{ matrix.node-version }}) 53 | runs-on: ubuntu-latest 54 | 55 | strategy: 56 | fail-fast: false 57 | matrix: 58 | postgres-image: 59 | - postgres:16 60 | - postgres:15 61 | - postgres:14 62 | node-version: [18, 20, 22] 63 | 64 | services: 65 | postgres: 66 | image: ${{ matrix.postgres-image }} 67 | ports: 68 | - 5432:5432 69 | env: 70 | POSTGRES_PASSWORD: koishi@114514 71 | POSTGRES_USER: koishi 72 | POSTGRES_DB: test 73 | 74 | steps: 75 | - name: Check out 76 | uses: actions/checkout@v4 77 | - name: Set up Node 78 | uses: actions/setup-node@v4 79 | with: 80 | node-version: ${{ matrix.node-version }} 81 | - name: Install 82 | run: yarn --no-immutable 83 | - name: Unit Test 84 | run: yarn test:json postgres 85 | - name: Report Coverage 86 | uses: codecov/codecov-action@v4 87 | with: 88 | token: ${{ secrets.CODECOV_TOKEN }} 89 | files: ./coverage/coverage-final.json 90 | name: codecov 91 | 92 | mongo: 93 | name: ${{ matrix.mongo-image }} (${{ matrix.node-version }}) 94 | runs-on: ubuntu-latest 95 | 96 | strategy: 97 | fail-fast: false 98 | matrix: 99 | mongo-image: 100 | - mongo:6.0 101 | - mongo:latest 102 | node-version: [18, 20, 22] 103 | 104 | services: 105 | mongo: 106 | image: ${{ matrix.mongo-image }} 107 | ports: 108 | - 27017:27017 109 | # https://stackoverflow.com/questions/66317184/github-actions-cannot-connect-to-mongodb-service 110 | options: --health-cmd "echo 'db.runCommand("ping").ok' | mongosh --quiet" --health-interval 10s --health-timeout 5s --health-retries 5 111 | 112 | steps: 113 | - name: Check out 114 | uses: actions/checkout@v4 115 | - name: Set up Node 116 | uses: actions/setup-node@v4 117 | with: 118 | node-version: ${{ matrix.node-version }} 119 | - name: Install 120 | run: yarn --no-immutable 121 | - name: Unit Test 122 | run: yarn test:json mongo 123 | - name: Report Coverage 124 | uses: codecov/codecov-action@v4 125 | with: 126 | token: ${{ secrets.CODECOV_TOKEN }} 127 | files: ./coverage/coverage-final.json 128 | name: codecov 129 | 130 | mongo-replica: 131 | name: mongo-replica:${{ matrix.mongo-version }} (${{ matrix.node-version }}) 132 | runs-on: ubuntu-latest 133 | 134 | strategy: 135 | fail-fast: false 136 | matrix: 137 | mongo-version: 138 | - latest 139 | node-version: [18, 20, 22] 140 | 141 | steps: 142 | - name: Check out 143 | uses: actions/checkout@v4 144 | - name: Set up Node 145 | uses: actions/setup-node@v4 146 | with: 147 | node-version: ${{ matrix.node-version }} 148 | - name: Set up MongoDB 149 | uses: supercharge/mongodb-github-action@1.10.0 150 | with: 151 | mongodb-version: ${{ matrix.mongo-version }} 152 | mongodb-replica-set: test-rs 153 | mongodb-port: 27017 154 | - name: Install 155 | run: yarn --no-immutable 156 | - name: Unit Test 157 | run: yarn test:json mongo --+transaction.abort 158 | - name: Report Coverage 159 | uses: codecov/codecov-action@v4 160 | with: 161 | token: ${{ secrets.CODECOV_TOKEN }} 162 | files: ./coverage/coverage-final.json 163 | name: codecov 164 | 165 | test: 166 | name: ${{ matrix.driver-name }} (${{ matrix.node-version }}) 167 | runs-on: ubuntu-latest 168 | 169 | strategy: 170 | fail-fast: false 171 | matrix: 172 | driver-name: [sqlite, memory] 173 | node-version: [18, 20, 22] 174 | 175 | steps: 176 | - name: Check out 177 | uses: actions/checkout@v4 178 | - name: Set up Node 179 | uses: actions/setup-node@v4 180 | with: 181 | node-version: ${{ matrix.node-version }} 182 | - name: Install 183 | run: yarn --no-immutable 184 | - name: Unit Test 185 | run: yarn test:json ${{ matrix.driver-name }} 186 | - name: Report Coverage 187 | uses: codecov/codecov-action@v4 188 | with: 189 | token: ${{ secrets.CODECOV_TOKEN }} 190 | files: ./coverage/coverage-final.json 191 | name: codecov 192 | -------------------------------------------------------------------------------- /packages/sqlite/src/builder.ts: -------------------------------------------------------------------------------- 1 | import { Builder, escapeId } from '@minatojs/sql-utils' 2 | import { Binary, Dict, isNullable } from 'cosmokit' 3 | import { Driver, Field, Model, randomId, RegExpLike, Type } from 'minato' 4 | 5 | export class SQLiteBuilder extends Builder { 6 | protected escapeMap = { 7 | "'": "''", 8 | } 9 | 10 | constructor(protected driver: Driver, tables?: Dict) { 11 | super(driver, tables) 12 | 13 | this.queryOperators.$regexFor = (key, value) => typeof value === 'string' ? `${this.escape(value)} regexp ${key}` 14 | : value.flags?.includes('i') ? `regexp2(${key}, ${this.escape(value.input)}, 'i')` 15 | : `${this.escape(value.input)} regexp ${key}` 16 | 17 | this.evalOperators.$if = (args) => `iif(${args.map(arg => this.parseEval(arg)).join(', ')})` 18 | this.evalOperators.$regex = ([key, value, flags]) => (flags?.includes('i') || (value instanceof RegExp && value.flags?.includes('i'))) 19 | ? `regexp2(${this.parseEval(value)}, ${this.parseEval(key)}, ${this.escape(flags ?? (value as any).flags)})` 20 | : `regexp(${this.parseEval(value)}, ${this.parseEval(key)})` 21 | 22 | this.evalOperators.$concat = (args) => `(${args.map(arg => this.parseEval(arg)).join('||')})` 23 | this.evalOperators.$modulo = ([left, right]) => `modulo(${this.parseEval(left)}, ${this.parseEval(right)})` 24 | this.evalOperators.$log = ([left, right]) => isNullable(right) 25 | ? `log(${this.parseEval(left)})` 26 | : `log(${this.parseEval(left)}) / log(${this.parseEval(right)})` 27 | this.evalOperators.$length = (expr) => this.createAggr(expr, value => `count(${value})`, value => this.isEncoded() ? this.jsonLength(value) 28 | : this.asEncoded(`iif(${value}, LENGTH(${value}) - LENGTH(REPLACE(${value}, ${this.escape(',')}, ${this.escape('')})) + 1, 0)`, false)) 29 | this.evalOperators.$number = (arg) => { 30 | const type = Type.fromTerm(arg) 31 | const value = this.parseEval(arg) 32 | const res = Field.date.includes(type.type as any) ? `cast(${value} / 1000 as integer)` : `cast(${this.parseEval(arg)} as double)` 33 | return this.asEncoded(`ifnull(${res}, 0)`, false) 34 | } 35 | 36 | const binaryXor = (left: string, right: string) => `((${left} & ~${right}) | (~${left} & ${right}))` 37 | this.evalOperators.$xor = (args) => { 38 | const type = Type.fromTerm(this.state.expr, Type.Boolean) 39 | if (Field.boolean.includes(type.type)) return args.map(arg => this.parseEval(arg)).reduce((prev, curr) => `(${prev} != ${curr})`) 40 | else return args.map(arg => this.parseEval(arg)).reduce((prev, curr) => binaryXor(prev, curr)) 41 | } 42 | this.evalOperators.$get = ([x, key]) => typeof key === 'string' 43 | ? this.asEncoded(`(${this.parseEval(x, false)} -> '$.${key}')`, true) 44 | : this.asEncoded(`(${this.parseEval(x, false)} -> ('$[' || ${this.parseEval(key)} || ']'))`, true) 45 | 46 | this.transformers['bigint'] = { 47 | encode: value => `cast(${value} as text)`, 48 | decode: value => `cast(${value} as integer)`, 49 | load: value => isNullable(value) ? value : BigInt(value), 50 | dump: value => isNullable(value) ? value : `${value}`, 51 | } 52 | 53 | this.transformers['binary'] = { 54 | encode: value => `hex(${value})`, 55 | decode: value => `unhex(${value})`, 56 | load: value => isNullable(value) || typeof value === 'object' ? value : Binary.fromHex(value), 57 | dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toHex(value), 58 | } 59 | } 60 | 61 | escapePrimitive(value: any, type?: Type) { 62 | if (value instanceof Date) value = +value 63 | else if (value instanceof RegExp) value = value.source 64 | else if (Binary.is(value)) return `X'${Binary.toHex(value)}'` 65 | else if (Binary.isSource(value)) return `X'${Binary.toHex(Binary.fromSource(value))}'` 66 | return super.escapePrimitive(value, type) 67 | } 68 | 69 | protected createElementQuery(key: string, value: any) { 70 | if (this.isJsonQuery(key)) { 71 | return this.jsonContains(key, this.escape(value, 'json')) 72 | } else { 73 | return `(',' || ${key} || ',') LIKE ${this.escape('%,' + value + ',%')}` 74 | } 75 | } 76 | 77 | protected createRegExpQuery(key: string, value: string | RegExpLike) { 78 | if (typeof value !== 'string' && value.flags?.includes('i')) { 79 | return `regexp2(${this.escape(typeof value === 'string' ? value : value.source)}, ${key}, ${this.escape(value.flags)})` 80 | } else { 81 | return `regexp(${this.escape(typeof value === 'string' ? value : value.source)}, ${key})` 82 | } 83 | } 84 | 85 | protected jsonLength(value: string) { 86 | return this.asEncoded(`json_array_length(${value})`, false) 87 | } 88 | 89 | protected jsonContains(obj: string, value: string) { 90 | return this.asEncoded(`json_array_contains(${obj}, ${value})`, false) 91 | } 92 | 93 | protected encode(value: string, encoded: boolean, pure: boolean = false, type?: Type) { 94 | return encoded ? super.encode(value, encoded, pure, type) 95 | : (encoded === this.isEncoded() && !pure) ? value 96 | : this.asEncoded(this.transform(`(${value} ->> '$')`, type, 'decode'), pure ? undefined : false) 97 | } 98 | 99 | protected createAggr(expr: any, aggr: (value: string) => string, nonaggr?: (value: string) => string) { 100 | if (!this.state.group && !nonaggr) { 101 | const value = this.parseEval(expr, false) 102 | return `(select ${aggr(escapeId('value'))} from json_each(${value}) ${randomId()})` 103 | } else { 104 | return super.createAggr(expr, aggr, nonaggr) 105 | } 106 | } 107 | 108 | protected groupArray(value: string) { 109 | const res = this.isEncoded() ? `('[' || group_concat(${value}) || ']')` : `('[' || group_concat(json_quote(${value})) || ']')` 110 | return this.asEncoded(`ifnull(${res}, json_array())`, true) 111 | } 112 | 113 | protected transformJsonField(obj: string, path: string) { 114 | return this.asEncoded(`(${obj} -> '$${path}')`, true) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /packages/core/src/driver.ts: -------------------------------------------------------------------------------- 1 | import { Awaitable, deepEqual, defineProperty, Dict, mapValues, remove } from 'cosmokit' 2 | import { Context, Service } from 'cordis' 3 | import { Eval, Update } from './eval.ts' 4 | import { Direction, Modifier, Selection } from './selection.ts' 5 | import { Field, Model, Relation } from './model.ts' 6 | import { Database } from './database.ts' 7 | import { Type } from './type.ts' 8 | import { FlatKeys, Keys, Values } from './utils.ts' 9 | 10 | export namespace Driver { 11 | export interface Stats { 12 | size: number 13 | tables: Dict 14 | } 15 | 16 | export interface TableStats { 17 | count: number 18 | size: number 19 | } 20 | 21 | export type Cursor = any> = K[] | CursorOptions 22 | 23 | export interface CursorOptions = any> { 24 | limit?: number 25 | offset?: number 26 | fields?: K[] 27 | sort?: Partial>> 28 | include?: Relation.Include> 29 | } 30 | 31 | export interface WriteResult { 32 | inserted?: number 33 | matched?: number 34 | modified?: number 35 | removed?: number 36 | } 37 | 38 | export interface IndexDef { 39 | name?: string 40 | keys: { [P in K]?: 'asc' | 'desc' } 41 | } 42 | 43 | export interface Index extends IndexDef { 44 | unique?: boolean 45 | } 46 | 47 | export interface Transformer { 48 | types: Field.Type[] 49 | dump: (value: S | null) => T | null | void 50 | load: (value: T | null) => S | null | void 51 | } 52 | } 53 | 54 | export namespace Driver { 55 | export type Constructor = new (ctx: Context, config: T) => Driver 56 | } 57 | 58 | export abstract class Driver { 59 | static inject = ['minato'] 60 | 61 | abstract start(): Promise 62 | abstract stop(): Promise 63 | abstract drop(table: string): Promise 64 | abstract dropAll(): Promise 65 | abstract stats(): Promise> 66 | abstract prepare(name: string): Promise 67 | abstract get(sel: Selection.Immutable, modifier: Modifier): Promise 68 | abstract eval(sel: Selection.Immutable, expr: Eval.Expr): Promise 69 | abstract set(sel: Selection.Mutable, data: Update): Promise 70 | abstract remove(sel: Selection.Mutable): Promise 71 | abstract create(sel: Selection.Mutable, data: any): Promise 72 | abstract upsert(sel: Selection.Mutable, data: any[], keys: string[]): Promise 73 | abstract withTransaction(callback: (session?: any) => Promise): Promise 74 | abstract getIndexes(table: string): Promise 75 | abstract createIndex(table: string, index: Driver.Index): Promise 76 | abstract dropIndex(table: string, name: string): Promise 77 | 78 | public types: Dict = Object.create(null) 79 | 80 | constructor(public ctx: C, public config: T) {} 81 | 82 | async* [Context.init]() { 83 | this.ctx.minato.drivers.push(this) 84 | yield () => remove(this.ctx.minato.drivers, this) 85 | this.ctx.minato.refresh() 86 | const database: Database = Object.create(this.ctx.minato) // FIXME use original model 87 | defineProperty(database, 'ctx', this.ctx) 88 | defineProperty(database, Service.tracker, { 89 | associate: 'database', 90 | property: 'ctx', 91 | }) 92 | defineProperty(database, '_driver', this) 93 | this.ctx.set('database', database) 94 | } 95 | 96 | model(table: string | Selection.Immutable | Dict): Model { 97 | if (typeof table === 'string') { 98 | const model = this.ctx.minato.tables[table] 99 | if (model) return model 100 | throw new TypeError(`unknown table name "${table}"`) 101 | } 102 | 103 | if (Selection.is(table)) { 104 | if (!table.args[0].fields && (typeof table.table === 'string' || Selection.is(table.table))) { 105 | return table.model 106 | } 107 | const model = new Model('temp') 108 | if (table.args[0].fields) { 109 | model.fields = mapValues(table.args[0].fields, (expr) => ({ 110 | type: Type.fromTerm(expr), 111 | })) 112 | } else { 113 | model.fields = mapValues(table.model.fields, (field) => ({ 114 | type: Type.fromField(field), 115 | })) 116 | } 117 | return model 118 | } 119 | 120 | const model = new Model('temp') 121 | for (const key in table) { 122 | const submodel = this.model(table[key]) 123 | for (const field in submodel.fields) { 124 | if (!Field.available(submodel.fields[field])) continue 125 | model.fields[`${key}.${field}`] = { 126 | expr: Eval('', [table[key].ref, field], Type.fromField(submodel.fields[field]!)), 127 | type: Type.fromField(submodel.fields[field]!), 128 | } 129 | } 130 | } 131 | return model 132 | } 133 | 134 | protected async migrate(name: string, hooks: MigrationHooks) { 135 | const database = this.ctx.minato.makeProxy(Database.migrate) 136 | const model = this.model(name) 137 | await (database.migrateTasks[name] = Promise.resolve(database.migrateTasks[name]).then(() => { 138 | return Promise.all([...model.migrations].map(async ([migrate, keys]) => { 139 | try { 140 | if (!hooks.before(keys)) return 141 | await migrate(database) 142 | hooks.after(keys) 143 | } catch (reason) { 144 | hooks.error(reason) 145 | } 146 | })) 147 | }).then(hooks.finalize).catch(hooks.error)) 148 | } 149 | 150 | define(converter: Driver.Transformer) { 151 | converter.types.forEach(type => this.types[type] = converter) 152 | } 153 | 154 | async _ensureSession() {} 155 | 156 | async prepareIndexes(table: string) { 157 | const oldIndexes = await this.getIndexes(table) 158 | const { indexes } = this.model(table) 159 | for (const index of indexes) { 160 | const oldIndex = oldIndexes.find(info => info.name === index.name) 161 | if (!oldIndex) { 162 | await this.createIndex(table, index) 163 | } else if (!deepEqual(oldIndex, index)) { 164 | await this.dropIndex(table, index.name!) 165 | await this.createIndex(table, index) 166 | } 167 | } 168 | } 169 | } 170 | 171 | export interface MigrationHooks { 172 | before: (keys: string[]) => boolean 173 | after: (keys: string[]) => void 174 | finalize: () => Awaitable 175 | error: (reason: any) => void 176 | } 177 | -------------------------------------------------------------------------------- /packages/tests/src/migration.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'minato' 2 | import { expect } from 'chai' 3 | import { deepEqual, noop, omit } from 'cosmokit' 4 | 5 | interface Qux { 6 | id: number 7 | text: string 8 | number: number 9 | value: number 10 | flag: boolean 11 | obj: object 12 | } 13 | 14 | interface Qux2 { 15 | id: number 16 | flag: boolean 17 | } 18 | 19 | interface Tables { 20 | qux: Qux 21 | qux2: Qux2 22 | } 23 | 24 | function MigrationTests(database: Database) { 25 | beforeEach(async () => { 26 | await database.drop('qux').catch(noop) 27 | }) 28 | 29 | it('alter field', async () => { 30 | Reflect.deleteProperty(database.tables, 'qux') 31 | 32 | database.extend('qux', { 33 | id: 'unsigned', 34 | text: 'string(64)', 35 | }) 36 | 37 | await database.upsert('qux', [ 38 | { id: 1, text: 'foo' }, 39 | { id: 2, text: 'bar' }, 40 | ]) 41 | 42 | await expect(database.get('qux', {})).to.eventually.deep.equal([ 43 | { id: 1, text: 'foo' }, 44 | { id: 2, text: 'bar' }, 45 | ]) 46 | 47 | database.extend('qux', { 48 | id: 'unsigned', 49 | text: 'string(64)', 50 | number: 'unsigned', 51 | }) 52 | 53 | await database.upsert('qux', [ 54 | { id: 1, text: 'foo', number: 100 }, 55 | { id: 2, text: 'bar', number: 200 }, 56 | ]) 57 | 58 | await expect(database.get('qux', {})).to.eventually.deep.equal([ 59 | { id: 1, text: 'foo', number: 100 }, 60 | { id: 2, text: 'bar', number: 200 }, 61 | ]) 62 | 63 | Reflect.deleteProperty(database.tables, 'qux') 64 | 65 | database.extend('qux', { 66 | id: 'unsigned', 67 | text: 'string(64)', 68 | }) 69 | 70 | await expect(database.get('qux', {})).to.eventually.deep.equal([ 71 | { id: 1, text: 'foo' }, 72 | { id: 2, text: 'bar' }, 73 | ]) 74 | }) 75 | 76 | it('should migrate field', async () => { 77 | Reflect.deleteProperty(database.tables, 'qux') 78 | 79 | database.extend('qux', { 80 | id: 'unsigned', 81 | text: 'string(64)', 82 | number: 'unsigned', 83 | flag: 'boolean', 84 | }, { 85 | unique: ['number'], 86 | }) 87 | 88 | await database.upsert('qux', [ 89 | { id: 1, text: 'foo', number: 100, flag: true }, 90 | { id: 2, text: 'bar', number: 200, flag: false }, 91 | ]) 92 | 93 | Reflect.deleteProperty(database.tables, 'qux') 94 | 95 | database.extend('qux', { 96 | id: 'unsigned', 97 | value: { type: 'unsigned', legacy: ['number'] }, 98 | text: { type: 'string', length: 256, legacy: ['string'] }, 99 | }, { 100 | unique: ['value'], 101 | }) 102 | 103 | database.extend('qux2', { 104 | id: 'unsigned', 105 | flag: 'boolean', 106 | }) 107 | 108 | database.migrate('qux', { 109 | flag: 'boolean', 110 | }, async (database) => { 111 | const data = await database.get('qux', {}, ['id', 'flag']) 112 | await database.upsert('qux2', data) 113 | }) 114 | 115 | await expect(database.get('qux', {})).to.eventually.deep.equal([ 116 | { id: 1, text: 'foo', value: 100 }, 117 | { id: 2, text: 'bar', value: 200 }, 118 | ]) 119 | 120 | await expect(database.get('qux2', {})).to.eventually.deep.equal([ 121 | { id: 1, flag: true }, 122 | { id: 2, flag: false }, 123 | ]) 124 | }) 125 | 126 | it('set json initial', async () => { 127 | Reflect.deleteProperty(database.tables, 'qux') 128 | 129 | database.extend('qux', { 130 | id: 'unsigned', 131 | text: 'string(64)', 132 | }) 133 | 134 | await database.upsert('qux', [ 135 | { id: 1, text: 'foo' }, 136 | { id: 2, text: 'bar' }, 137 | ]) 138 | 139 | await expect(database.get('qux', {})).to.eventually.deep.equal([ 140 | { id: 1, text: 'foo' }, 141 | { id: 2, text: 'bar' }, 142 | ]) 143 | 144 | database.extend('qux', { 145 | obj: { 146 | type: 'json', 147 | initial: {}, 148 | nullable: false, 149 | } 150 | }) 151 | 152 | await expect(database.get('qux', {})).to.eventually.deep.equal([ 153 | { id: 1, text: 'foo', obj: {} }, 154 | { id: 2, text: 'bar', obj: {} }, 155 | ]) 156 | }) 157 | 158 | it('indexes', async () => { 159 | const driver = Object.values(database.drivers)[0] 160 | Reflect.deleteProperty(database.tables, 'qux') 161 | 162 | database.extend('qux', { 163 | id: 'unsigned', 164 | number: 'unsigned', 165 | }) 166 | 167 | await database.upsert('qux', [ 168 | { id: 1, number: 1 }, 169 | { id: 2, number: 2 }, 170 | ]) 171 | 172 | await expect(database.get('qux', {})).to.eventually.have.deep.members([ 173 | { id: 1, number: 1 }, 174 | { id: 2, number: 2 }, 175 | ]) 176 | 177 | database.extend('qux', { 178 | id: 'unsigned', 179 | number: 'unsigned', 180 | }, { 181 | indexes: ['number'], 182 | }) 183 | 184 | await expect(database.get('qux', {})).to.eventually.have.deep.members([ 185 | { id: 1, number: 1 }, 186 | { id: 2, number: 2 }, 187 | ]) 188 | 189 | let indexes = await driver.getIndexes('qux') 190 | expect(indexes.find(ind => deepEqual(omit(ind, ['name']), { 191 | unique: false, 192 | keys: { 193 | number: 'asc', 194 | }, 195 | }))).to.not.be.undefined 196 | 197 | Reflect.deleteProperty(database.tables, 'qux') 198 | 199 | database.extend('qux', { 200 | id: 'unsigned', 201 | value: { 202 | type: 'unsigned', 203 | legacy: ['number'], 204 | }, 205 | }, { 206 | indexes: ['value'], 207 | }) 208 | 209 | await expect(database.get('qux', {})).to.eventually.have.deep.members([ 210 | { id: 1, value: 1 }, 211 | { id: 2, value: 2 }, 212 | ]) 213 | 214 | indexes = await driver.getIndexes('qux') 215 | expect(indexes.find(ind => deepEqual(omit(ind, ['name']), { 216 | unique: false, 217 | keys: { 218 | value: 'asc', 219 | }, 220 | }))).to.not.be.undefined 221 | 222 | database.extend('qux', {}, { 223 | indexes: [{ 224 | name: 'named-index', 225 | keys: { 226 | id: 'asc', 227 | value: 'asc', 228 | } 229 | }], 230 | }) 231 | 232 | await expect(database.get('qux', {})).to.eventually.have.deep.members([ 233 | { id: 1, value: 1 }, 234 | { id: 2, value: 2 }, 235 | ]) 236 | 237 | indexes = await driver.getIndexes('qux') 238 | expect(indexes.find(ind => deepEqual(ind, { 239 | name: 'named-index', 240 | unique: false, 241 | keys: { 242 | id: 'asc', 243 | value: 'asc', 244 | }, 245 | }))).to.not.be.undefined 246 | 247 | database.extend('qux', { 248 | text: 'string', 249 | }, { 250 | indexes: [{ 251 | name: 'named-index', 252 | keys: { 253 | text: 'asc', 254 | value: 'asc', 255 | } 256 | }], 257 | }) 258 | 259 | await expect(database.get('qux', {})).to.eventually.have.deep.members([ 260 | { id: 1, value: 1, text: '' }, 261 | { id: 2, value: 2, text: '' }, 262 | ]) 263 | 264 | indexes = await driver.getIndexes('qux') 265 | expect(indexes.find(ind => deepEqual(ind, { 266 | name: 'named-index', 267 | unique: false, 268 | keys: { 269 | text: 'asc', 270 | value: 'asc', 271 | }, 272 | }))).to.not.be.undefined 273 | }) 274 | } 275 | 276 | export default MigrationTests 277 | -------------------------------------------------------------------------------- /packages/memory/src/index.ts: -------------------------------------------------------------------------------- 1 | import { clone, deepEqual, Dict, makeArray, mapValues, noop, omit, pick } from 'cosmokit' 2 | import { Driver, Eval, executeEval, executeQuery, executeSort, executeUpdate, Field, isAggrExpr, RuntimeError, Selection, z } from 'minato' 3 | 4 | export class MemoryDriver extends Driver { 5 | static name = 'memory' 6 | 7 | _store: Dict = { 8 | _fields: [], 9 | } 10 | 11 | _indexes: Dict> = {} 12 | 13 | async prepare(name: string) {} 14 | 15 | async start() { 16 | // await this.#loader?.start(this.#store) 17 | } 18 | 19 | async $save(name: string) { 20 | // await this.#loader?.save(name, this.#store[name]) 21 | } 22 | 23 | async stop() { 24 | // await this.#loader?.stop(this.#store) 25 | } 26 | 27 | table(sel: string | Selection.Immutable | Dict, env: any = {}): any[] { 28 | if (typeof sel === 'string') { 29 | return this._store[sel] ||= [] 30 | } 31 | 32 | if (!Selection.is(sel)) { 33 | throw new Error('Should not reach here') 34 | } 35 | 36 | const { ref, query, table, args, model } = sel 37 | const { fields, group, having, optional = {} } = sel.args[0] 38 | 39 | let data: any[] 40 | 41 | if (typeof table === 'object' && !Selection.is(table)) { 42 | const entries = Object.entries(table).map(([name, sel]) => [name, this.table(sel, env)] as const) 43 | const catesian = (entries: (readonly [string, any[]])[]): any[] => { 44 | if (!entries.length) return [] 45 | const [[name, rows], ...tail] = entries 46 | if (!tail.length) return rows.map(row => ({ [name]: row })) 47 | return rows.flatMap(row => { 48 | let res = catesian(tail).map(tail => ({ ...tail, [name]: row })) 49 | if (Object.keys(table).length === tail.length + 1) { 50 | res = res.map(row => ({ ...env, [ref]: row })).filter(data => executeEval(data, having)).map(x => x[ref]) 51 | } 52 | return !optional[tail[0]?.[0]] || res.length ? res : [{ [name]: row }] 53 | }) 54 | } 55 | data = catesian(entries) 56 | } else { 57 | data = this.table(table, env).filter(row => executeQuery(row, query, ref, env)) 58 | } 59 | 60 | env[ref] = data 61 | 62 | const branches: { index: Dict; table: any[] }[] = [] 63 | const groupFields = group ? pick(fields!, group) : fields 64 | for (let row of executeSort(data, args[0], ref)) { 65 | row = model.format(row, false) 66 | for (const key in model.fields) { 67 | if (!Field.available(model.fields[key])) continue 68 | row[key] ??= null 69 | } 70 | let index = row 71 | if (fields) { 72 | index = mapValues(groupFields!, (expr) => executeEval({ ...env, [ref]: row }, expr)) 73 | } 74 | let branch = branches.find((branch) => { 75 | if (!group || !groupFields) return false 76 | for (const key in groupFields) { 77 | if (!deepEqual(branch.index[key], index[key])) return false 78 | } 79 | return true 80 | }) 81 | if (!branch) { 82 | branch = { index, table: [] } 83 | branches.push(branch) 84 | } 85 | branch.table.push(row) 86 | } 87 | return branches.map(({ index, table }) => { 88 | if (group) { 89 | if (having) { 90 | const value = executeEval(table.map(row => ({ ...env, [ref]: row, _: row })), having) 91 | if (!value) return 92 | } 93 | for (const key in omit(fields!, group)) { 94 | index[key] = executeEval(table.map(row => ({ ...env, [ref]: row, _: row })), fields![key]) 95 | } 96 | } 97 | return model.parse(index, false) 98 | }).filter(Boolean) 99 | } 100 | 101 | async drop(table: string) { 102 | delete this._store[table] 103 | } 104 | 105 | async dropAll() { 106 | this._store = { _fields: [] } 107 | } 108 | 109 | async stats() { 110 | return { tables: mapValues(this._store, (rows, name) => ({ name, count: rows.length, size: 0 })), size: 0 } 111 | } 112 | 113 | async get(sel: Selection.Immutable) { 114 | return this.table(sel as Selection) 115 | } 116 | 117 | async eval(sel: Selection.Immutable, expr: Eval.Expr) { 118 | const { query, table } = sel 119 | const ref = typeof table === 'string' ? sel.ref : table.ref as string 120 | const data = this.table(table).filter(row => executeQuery(row, query, ref)) 121 | return executeEval(data.map(row => ({ [ref]: row, _: row })), expr) 122 | } 123 | 124 | async set(sel: Selection.Mutable, data: {}) { 125 | const { table, ref, query } = sel 126 | const matched = this.table(table) 127 | .filter(row => executeQuery(row, query, ref)) 128 | .map(row => executeUpdate(row, data, ref)) 129 | .length 130 | this.$save(table) 131 | return { matched } 132 | } 133 | 134 | async remove(sel: Selection.Mutable) { 135 | const { ref, query, table } = sel 136 | const data = this.table(table) 137 | this._store[table] = data.filter(row => !executeQuery(row, query, ref)) 138 | this.$save(table) 139 | const count = data.length - this._store[table].length 140 | return { removed: count, matched: count } 141 | } 142 | 143 | async create(sel: Selection.Mutable, data: any) { 144 | const { table, model } = sel 145 | const { primary, autoInc } = model 146 | const store = this.table(table) 147 | if (!Array.isArray(primary) && autoInc && !(primary in data)) { 148 | let meta = this._store._fields.find(row => row.table === table && row.field === primary) 149 | if (!meta) { 150 | meta = { table, field: primary, autoInc: 0 } 151 | this._store._fields.push(meta) 152 | } 153 | meta.autoInc += 1 154 | data[primary] = meta.autoInc 155 | } else { 156 | const duplicated = await this.database.get(table, pick(model.format(data), makeArray(primary))) 157 | if (duplicated.length) { 158 | throw new RuntimeError('duplicate-entry') 159 | } 160 | } 161 | store.push(clone(data)) 162 | this.$save(table) 163 | return clone(clone(data)) 164 | } 165 | 166 | async upsert(sel: Selection.Mutable, data: any, keys: string[]) { 167 | const { table, model, ref } = sel 168 | const result = { inserted: 0, matched: 0 } 169 | for (const update of data) { 170 | const row = this.table(table).find(row => { 171 | return keys.every(key => row[key] === update[key]) 172 | }) 173 | if (row) { 174 | executeUpdate(row, update, ref) 175 | result.matched++ 176 | } else { 177 | const data = executeUpdate(model.create(), update, ref) 178 | await this.create(sel, data).catch(noop) 179 | result.inserted++ 180 | } 181 | } 182 | this.$save(table) 183 | return result 184 | } 185 | 186 | executeSelection(sel: Selection.Immutable, env: any = {}) { 187 | const expr = sel.args[0], table = sel.table as Selection 188 | if (Array.isArray(env)) env = { [sel.ref]: env } 189 | const data = this.table(sel.table, env) 190 | const res = isAggrExpr(expr) ? data.map(row => executeEval({ ...env, [table.ref]: row, _: row }, expr)) 191 | : executeEval(Object.assign(data.map(row => ({ [table.ref]: row, _: row })), env), expr) 192 | return res 193 | } 194 | 195 | async withTransaction(callback: () => Promise) { 196 | const data = clone(this._store) 197 | await callback().catch((e) => { 198 | this._store = data 199 | throw e 200 | }) 201 | } 202 | 203 | async getIndexes(table: string) { 204 | return Object.values(this._indexes[table] ?? {}) 205 | } 206 | 207 | async createIndex(table: string, index: Driver.Index) { 208 | const name = index.name ?? 'index:' + Object.entries(index.keys).map(([key, direction]) => `${key}_${direction}`).join('+') 209 | this._indexes[table] ??= {} 210 | this._indexes[table][name] = { name, unique: false, ...index } 211 | } 212 | 213 | async dropIndex(table: string, name: string) { 214 | this._indexes[table] ??= {} 215 | delete this._indexes[table][name] 216 | } 217 | } 218 | 219 | export namespace MemoryDriver { 220 | export interface Config {} 221 | 222 | export const Config: z = z.object({}) 223 | } 224 | 225 | export default MemoryDriver 226 | -------------------------------------------------------------------------------- /packages/tests/src/transaction.ts: -------------------------------------------------------------------------------- 1 | import { $, Database } from 'minato' 2 | import { expect } from 'chai' 3 | 4 | interface Bar { 5 | id: number 6 | text?: string 7 | num?: number 8 | bool?: boolean 9 | list?: string[] 10 | timestamp?: Date 11 | date?: Date 12 | time?: Date 13 | } 14 | 15 | interface Tables { 16 | temptx: Bar 17 | } 18 | 19 | function TransactionOperations(database: Database) { 20 | database.extend('temptx', { 21 | id: 'unsigned', 22 | text: 'string', 23 | num: 'integer', 24 | bool: 'boolean', 25 | list: 'list', 26 | timestamp: 'timestamp', 27 | date: 'date', 28 | time: 'time', 29 | }, { 30 | autoInc: true, 31 | }) 32 | } 33 | 34 | namespace TransactionOperations { 35 | const merge = (a: T, b: Partial): T => ({ ...a, ...b }) 36 | 37 | const magicBorn = new Date('1970/08/17') 38 | 39 | const barTable: Bar[] = [ 40 | { id: 1, bool: true }, 41 | { id: 2, text: 'pku' }, 42 | { id: 3, num: 1989 }, 43 | { id: 4, list: ['1', '1', '4'] }, 44 | { id: 5, timestamp: magicBorn }, 45 | { id: 6, date: magicBorn }, 46 | { id: 7, time: new Date('1970-01-01 12:00:00') }, 47 | ] 48 | 49 | async function setup(database: Database, name: K, table: Tables[K][]) { 50 | await database.remove(name, {}) 51 | const result: Tables[K][] = [] 52 | for (const item of table) { 53 | result.push(await database.create(name, item as any)) 54 | } 55 | return result 56 | } 57 | 58 | export function commit(database: Database) { 59 | it('create', async () => { 60 | const table = barTable.map(bar => merge(database.tables.temptx.create(), bar)) 61 | let counter = 0 62 | await expect(database.withTransaction(async (database) => { 63 | for (const index in barTable) { 64 | const bar = await database.create('temptx', barTable[index]) 65 | barTable[index].id = bar.id 66 | expect(bar).to.have.shape(table[index]) 67 | counter++ 68 | } 69 | await expect(database.get('temptx', {})).to.eventually.have.length(barTable.length) 70 | })).to.be.fulfilled 71 | expect(counter).to.equal(barTable.length) 72 | await expect(database.get('temptx', {})).to.eventually.have.length(barTable.length) 73 | }) 74 | 75 | it('set', async () => { 76 | const table = await setup(database, 'temptx', barTable) 77 | const data = table.find(bar => bar.timestamp)! 78 | data.list = ['2', '3', '3'] 79 | const magicIds = table.slice(2, 4).map((data) => { 80 | data.list = ['2', '3', '3'] 81 | return data.id 82 | }) 83 | await expect(database.withTransaction(async (database) => { 84 | await database.set('temptx', { 85 | $or: [ 86 | { id: magicIds }, 87 | { timestamp: magicBorn }, 88 | ], 89 | }, { list: ['2', '3', '3'] }) 90 | await expect(database.get('temptx', {})).to.eventually.have.shape(table) 91 | })).to.be.fulfilled 92 | await expect(database.get('temptx', {})).to.eventually.have.shape(table) 93 | }) 94 | 95 | it('upsert new records', async () => { 96 | await database.remove('temptx', {}) 97 | await expect(database.withTransaction(async (database) => { 98 | const table = await setup(database, 'temptx', barTable) 99 | const data = [ 100 | { id: table[table.length - 1].id + 1, text: 'wm"lake' }, 101 | { id: table[table.length - 1].id + 2, text: 'by\'tower' }, 102 | ] 103 | table.push(...data.map(bar => merge(database.tables.temptx.create(), bar))) 104 | await database.upsert('temptx', data) 105 | })).to.be.fulfilled 106 | await expect(database.get('temptx', {})).to.eventually.have.length(9) 107 | }) 108 | 109 | it('upsert using expressions', async () => { 110 | const table = await setup(database, 'temptx', barTable) 111 | const data2 = table.find(item => item.id === 2)! 112 | const data3 = table.find(item => item.id === 3)! 113 | const data9 = table.find(item => item.id === 9) 114 | data2.num = data2.id * 2 115 | data3.num = data3.num! + 3 116 | expect(data9).to.be.undefined 117 | table.push({ id: 9, num: 999 }) 118 | await expect(database.withTransaction(async (database) => { 119 | await database.upsert('temptx', row => [ 120 | { id: 2, num: $.multiply(2, row.id) }, 121 | { id: 3, num: $.add(3, row.num) }, 122 | { id: 9, num: 999 }, 123 | ]) 124 | await expect(database.get('temptx', {})).to.eventually.have.shape(table) 125 | })).to.be.fulfilled 126 | await expect(database.get('temptx', {})).to.eventually.have.shape(table) 127 | }) 128 | 129 | it('remove', async () => { 130 | await setup(database, 'temptx', barTable) 131 | await expect(database.withTransaction(async (database) => { 132 | await database.remove('temptx', { id: 2 }) 133 | await expect(database.get('temptx', {})).eventually.length(6) 134 | await database.remove('temptx', { id: 2 }) 135 | await expect(database.get('temptx', {})).eventually.length(6) 136 | await database.remove('temptx', {}) 137 | await expect(database.get('temptx', {})).eventually.length(0) 138 | })).to.be.fulfilled 139 | await expect(database.get('temptx', {})).eventually.length(0) 140 | }) 141 | } 142 | 143 | export function abort(database: Database) { 144 | it('create', async () => { 145 | const table = barTable.map(bar => merge(database.tables.temptx.create(), bar)) 146 | let counter = 0 147 | await expect(database.withTransaction(async (database) => { 148 | for (const index in barTable) { 149 | const bar = await database.create('temptx', barTable[index]) 150 | barTable[index].id = bar.id 151 | expect(bar).to.have.shape(table[index]) 152 | counter++ 153 | } 154 | await expect(database.get('temptx', {})).to.eventually.have.length(barTable.length) 155 | throw new Error('oops') 156 | })).to.be.rejected 157 | expect(counter).to.equal(barTable.length) 158 | await expect(database.get('temptx', {})).to.eventually.have.length(0) 159 | }) 160 | 161 | it('set', async () => { 162 | const table = await setup(database, 'temptx', barTable) 163 | const data = table.find(bar => bar.timestamp)! 164 | data.list = ['2', '3', '3'] 165 | const magicIds = table.slice(2, 4).map((data) => { 166 | data.list = ['2', '3', '3'] 167 | return data.id 168 | }) 169 | await expect(database.withTransaction(async (database) => { 170 | await database.set('temptx', { 171 | $or: [ 172 | { id: magicIds }, 173 | { timestamp: magicBorn }, 174 | ], 175 | }, { list: ['2', '3', '3'] }) 176 | await expect(database.get('temptx', {})).to.eventually.have.shape(table) 177 | throw new Error('oops') 178 | })).to.be.rejected 179 | await expect(database.get('temptx', {})).to.eventually.have.shape(barTable) 180 | }) 181 | 182 | it('upsert new records', async () => { 183 | await database.remove('temptx', {}) 184 | await expect(database.withTransaction(async (database) => { 185 | const table = await setup(database, 'temptx', barTable) 186 | const data = [ 187 | { id: table[table.length - 1].id + 1, text: 'wm"lake' }, 188 | { id: table[table.length - 1].id + 2, text: 'by\'tower' }, 189 | ] 190 | table.push(...data.map(bar => merge(database.tables.temptx.create(), bar))) 191 | await database.upsert('temptx', data) 192 | throw new Error('oops') 193 | })).to.be.rejected 194 | await expect(database.get('temptx', {})).to.eventually.have.length(0) 195 | }) 196 | 197 | it('upsert using expressions', async () => { 198 | const table = await setup(database, 'temptx', barTable) 199 | const data2 = table.find(item => item.id === 2)! 200 | const data3 = table.find(item => item.id === 3)! 201 | const data9 = table.find(item => item.id === 9) 202 | data2.num = data2.id * 2 203 | data3.num = data3.num! + 3 204 | expect(data9).to.be.undefined 205 | table.push({ id: 9, num: 999 }) 206 | await expect(database.withTransaction(async (database) => { 207 | await database.upsert('temptx', row => [ 208 | { id: 2, num: $.multiply(2, row.id) }, 209 | { id: 3, num: $.add(3, row.num) }, 210 | { id: 9, num: 999 }, 211 | ]) 212 | await expect(database.get('temptx', {})).to.eventually.have.shape(table) 213 | throw new Error('oops') 214 | })).to.be.rejected 215 | await expect(database.get('temptx', {})).to.eventually.have.shape(barTable) 216 | }) 217 | 218 | it('remove', async () => { 219 | await setup(database, 'temptx', barTable) 220 | await expect(database.withTransaction(async (database) => { 221 | await database.remove('temptx', { id: 2 }) 222 | await expect(database.get('temptx', {})).eventually.length(6) 223 | await database.remove('temptx', { id: 2 }) 224 | await expect(database.get('temptx', {})).eventually.length(6) 225 | await database.remove('temptx', {}) 226 | await expect(database.get('temptx', {})).eventually.length(0) 227 | throw new Error('oops') 228 | })).to.be.rejected 229 | await expect(database.get('temptx', {})).to.eventually.have.shape(barTable) 230 | }) 231 | } 232 | } 233 | 234 | export default TransactionOperations 235 | -------------------------------------------------------------------------------- /packages/tests/src/object.ts: -------------------------------------------------------------------------------- 1 | import { $, Database } from 'minato' 2 | import { expect } from 'chai' 3 | 4 | interface ObjectModel { 5 | id: string 6 | meta?: { 7 | a?: string 8 | embed?: { 9 | b?: number 10 | c?: string 11 | d?: { 12 | foo?: number 13 | bar?: object 14 | } 15 | } 16 | } 17 | } 18 | 19 | interface Tables { 20 | object: ObjectModel 21 | } 22 | 23 | function ObjectOperations(database: Database) { 24 | database.extend('object', { 25 | 'id': 'string', 26 | 'meta.a': { type: 'string', initial: '666' }, 27 | 'meta.embed': { type: 'json', initial: { c: 'world' } }, 28 | }) 29 | } 30 | 31 | namespace ObjectOperations { 32 | async function setup(database: Database) { 33 | await database.remove('object', {}) 34 | const result: ObjectModel[] = [] 35 | result.push(await database.create('object', { id: '0', meta: { a: '233', embed: { b: 2, c: 'hello' } } })) 36 | result.push(await database.create('object', { id: '1' })) 37 | expect(result).to.have.length(2) 38 | return result 39 | } 40 | 41 | export const create = function Create(database: Database) { 42 | it('initial value', async () => { 43 | const table = await setup(database) 44 | table.push(await database.create('object', { id: '2', meta: { embed: { b: 999 } } })) 45 | expect(table[table.length - 1]).to.deep.equal({ 46 | id: '2', meta: { a: '666', embed: { b: 999 } } 47 | }) 48 | await expect(database.get('object', {})).to.eventually.deep.equal(table) 49 | }) 50 | } 51 | 52 | export const get = function Get(database: Database) { 53 | it('field extraction', async () => { 54 | await setup(database) 55 | const table = await database.get('object', {}, ['meta']) 56 | expect(table).to.deep.equal([ 57 | { meta: { a: '233', embed: { b: 2, c: 'hello' } } }, 58 | { meta: { a: '666', embed: { c: 'world' } } }, 59 | ]) 60 | }) 61 | 62 | it('selection', async () => { 63 | await setup(database) 64 | await expect(database.select('object', '0').project({ x: row => row.meta.embed.c }).execute()).to.eventually.deep.equal([{ x: 'hello' }]) 65 | }) 66 | } 67 | 68 | export const upsert = function Upsert(database: Database) { 69 | it('object literal', async () => { 70 | const table = await setup(database) 71 | table[0].meta = { a: '233', embed: { b: 114 } } 72 | table[1].meta = { a: '1', embed: { b: 514, c: 'world' } } 73 | table.push({ id: '2', meta: { a: '666', embed: { b: 1919 } } }) 74 | table.push({ id: '3', meta: { a: 'foo', embed: { b: 810, c: 'world' } } }) 75 | await expect(database.upsert('object', (row) => [ 76 | { id: '0', meta: { embed: { b: 114 } } }, 77 | { id: '1', meta: { a: row.id, 'embed.b': $.add(500, 14) } }, 78 | { id: '2', meta: { embed: { b: 1919 } } }, 79 | { id: '3', meta: { a: 'foo', 'embed.b': 810 } }, 80 | ])).eventually.fulfilled 81 | await expect(database.get('object', {})).to.eventually.deep.equal(table) 82 | }) 83 | 84 | it('nested property', async () => { 85 | const table = await setup(database) 86 | table[0].meta = { a: '0', embed: { b: 114, c: 'hello' } } 87 | table[1].meta = { a: '1', embed: { b: 514 } } 88 | table.push({ id: '2', meta: { a: '2', embed: { b: 1919, c: 'world' } } }) 89 | table.push({ id: '3', meta: { a: '3', embed: { b: 810 } } }) 90 | await expect(database.upsert('object', row => [ 91 | { id: '0', 'meta.a': row.id, 'meta.embed.b': 114 }, 92 | { id: '1', 'meta.a': row.id, 'meta.embed': { b: 514 } }, 93 | { id: '2', 'meta.a': row.id, 'meta.embed.b': $.multiply(19, 101) }, 94 | { id: '3', 'meta.a': row.id, 'meta.embed': { b: 810 } }, 95 | ])).eventually.fulfilled 96 | await expect(database.get('object', {})).to.eventually.deep.equal(table) 97 | }) 98 | 99 | it('empty object override', async () => { 100 | const table = await setup(database) 101 | table[0]!.meta!.embed = {} 102 | await database.upsert('object', [{ id: '0', meta: { embed: {} } }]) 103 | await expect(database.get('object', {})).to.eventually.have.deep.members(table) 104 | }) 105 | 106 | it('expressions w/ json object', async () => { 107 | const table = await setup(database) 108 | table[0]!.meta!.a = table[0]!.meta!.embed!.c + 'a' 109 | table[1]!.meta!.embed!.b = 1 110 | await database.upsert('object', row => [ 111 | { id: '0', meta: { a: $.concat(row.meta.embed.c, 'a') } }, 112 | { id: '1', 'meta.embed.b': $.add($.ifNull(row.meta.embed.b, 0), 1) }, 113 | ]) 114 | await expect(database.get('object', {})).to.eventually.have.deep.members(table) 115 | }) 116 | 117 | it('expressions w/o json object', async () => { 118 | const table = await setup(database) 119 | table[0]!.meta!.a = table[0]!.meta!.a + 'a' 120 | await database.upsert('object', row => [{ id: '0', meta: { a: $.concat(row.meta.a, 'a') } }]) 121 | await expect(database.get('object', {})).to.eventually.have.deep.members(table) 122 | }) 123 | } 124 | 125 | export const modify = function Modify(database: Database) { 126 | it('object literal', async () => { 127 | const table = await setup(database) 128 | table[0].meta = { a: '0', embed: { b: 114 } } 129 | table[1].meta = { a: '1', embed: { b: 514, c: 'world' } } 130 | await expect(database.set('object', '0', (row) => ({ 131 | meta: { a: row.id, embed: { b: 114 } }, 132 | }))).eventually.fulfilled 133 | await expect(database.set('object', '1', (row) => ({ 134 | meta: { a: row.id, 'embed.b': 514 }, 135 | }))).eventually.fulfilled 136 | await expect(database.get('object', {})).to.eventually.deep.equal(table) 137 | }) 138 | 139 | it('using subquery', async () => { 140 | const table = await setup(database) 141 | table[0].meta = { a: '0', embed: { b: 114 } } 142 | table[1].meta = { a: '1', embed: { b: 514, c: 'world' } } 143 | await expect(database.set('object', 144 | row => $.eq(row.id, database.select('object', '0').evaluate(r => $.max(r.id))), 145 | row => ({ 146 | meta: { a: row.id, embed: { b: 114 } }, 147 | })), 148 | ).eventually.fulfilled 149 | await expect(database.set('object', 150 | row => $.eq(row.id, database.select('object', '1').evaluate(r => $.max(r.id))), 151 | row => ({ 152 | meta: { a: row.id, 'embed.b': 514 }, 153 | }), 154 | )).eventually.fulfilled 155 | await expect(database.get('object', {})).to.eventually.deep.equal(table) 156 | }) 157 | 158 | it('nested property', async () => { 159 | const table = await setup(database) 160 | table[0].meta = { a: '0', embed: { b: 114, c: 'hello' } } 161 | table[1].meta = { a: '1', embed: { b: 514 } } 162 | await expect(database.set('object', '0', row => ({ 163 | 'meta.a': row.id, 164 | 'meta.embed.b': 114, 165 | }))).eventually.fulfilled 166 | await expect(database.set('object', '1', row => ({ 167 | 'meta.a': row.id, 168 | 'meta.embed': { b: 514 }, 169 | }))).eventually.fulfilled 170 | await expect(database.get('object', {})).to.eventually.deep.equal(table) 171 | }) 172 | 173 | it('empty object override', async () => { 174 | const table = await setup(database) 175 | table[0]!.meta!.embed = {} 176 | await database.set('object', { id: '0' }, { meta: { embed: {} } }) 177 | await expect(database.get('object', {})).to.eventually.have.deep.members(table) 178 | }) 179 | 180 | it('expressions w/ json object', async () => { 181 | const table = await setup(database) 182 | table[0]!.meta!.a = table[0]!.meta!.embed!.c + 'a' 183 | await database.set('object', { id: '0' }, row => ({ meta: { a: $.concat(row.meta.embed.c, 'a') } })) 184 | await expect(database.get('object', {})).to.eventually.have.deep.members(table) 185 | }) 186 | 187 | it('expressions w/o json object', async () => { 188 | const table = await setup(database) 189 | table[0]!.meta!.a = table[0]!.meta!.a + 'a' 190 | await database.set('object', { id: '0' }, row => ({ meta: { a: $.concat(row.meta.a, 'a') } })) 191 | await expect(database.get('object', {})).to.eventually.have.deep.members(table) 192 | }) 193 | 194 | it('object in json', async () => { 195 | const table = await setup(database) 196 | table[1]!.meta!.embed!.d = {} 197 | await database.set('object', { id: '1' }, { 'meta.embed.d': {} }) 198 | await expect(database.get('object', {})).to.eventually.have.deep.members(table) 199 | table[0]!.meta!.embed!.d = { foo: 1, bar: { a: 3, b: 4 } } 200 | await database.set('object', { id: '0' }, { 'meta.embed.d': { foo: 1, bar: { a: 3, b: 4 } } }) 201 | await expect(database.get('object', {})).to.eventually.have.deep.members(table) 202 | }) 203 | 204 | it('nested object in json', async () => { 205 | const table = await setup(database) 206 | table[0]!.meta!.embed!.d = { foo: 2, bar: { a: 1 } } 207 | await database.set('object', { id: '0' }, { 'meta.embed.d.bar': { a: 1 }, 'meta.embed.d.foo': 2 }) 208 | await expect(database.get('object', {})).to.eventually.have.deep.members(table) 209 | }) 210 | 211 | it('$.number in json', async () => { 212 | const table = await setup(database) 213 | table[0]!.meta!.embed!.b = 233 214 | table[1]!.meta!.embed!.b = 666 215 | await database.set('object', {}, row => ({ 'meta.embed.b': $.number(row.meta.a) })) 216 | await expect(database.get('object', {})).to.eventually.have.deep.members(table) 217 | }) 218 | } 219 | 220 | export const misc = function Misc(database: Database) { 221 | it('join selections with dot fields', async () => { 222 | await setup(database) 223 | await database.set('object', '1', { 'meta.embed.b': 3 }) 224 | await expect(database.join({ 225 | x: database.select('object').where(row => $.lt(row.meta.embed.b, 100)), 226 | y: database.select('object').where(row => $.lt(row.meta.embed.b, 100)), 227 | }).execute(row => $.sum(1))).to.eventually.deep.equal(4) 228 | }) 229 | 230 | it('switch model in object query', async () => { 231 | const table = await setup(database) 232 | await expect(database.select('object', { 233 | 'meta.a': '666', 234 | }).project({ 235 | t: 'meta', 236 | }).execute()).to.eventually.have.deep.members([{ t: table[1].meta }]) 237 | }) 238 | } 239 | } 240 | 241 | export default ObjectOperations 242 | -------------------------------------------------------------------------------- /packages/mysql/src/builder.ts: -------------------------------------------------------------------------------- 1 | import { Builder, escapeId, isBracketed } from '@minatojs/sql-utils' 2 | import { Binary, Dict, isNullable, Time } from 'cosmokit' 3 | import { Driver, Field, isAggrExpr, isEvalExpr, Model, randomId, Selection, Type } from 'minato' 4 | 5 | export interface Compat { 6 | maria?: boolean 7 | maria105?: boolean 8 | mysql57?: boolean 9 | timezone?: string 10 | } 11 | 12 | export class MySQLBuilder extends Builder { 13 | // eslint-disable-next-line no-control-regex 14 | protected escapeRegExp = /[\0\b\t\n\r\x1a'"\\]/g 15 | protected escapeMap = { 16 | '\0': '\\0', 17 | '\b': '\\b', 18 | '\t': '\\t', 19 | '\n': '\\n', 20 | '\r': '\\r', 21 | '\x1a': '\\Z', 22 | '\"': '\\\"', 23 | '\'': '\\\'', 24 | '\\': '\\\\', 25 | } 26 | 27 | readonly _localTimezone = `+${(new Date()).getTimezoneOffset() / -60}:00`.replace('+-', '-') 28 | readonly _dbTimezone: string 29 | 30 | prequeries: string[] = [] 31 | 32 | constructor(protected driver: Driver, tables?: Dict, private compat: Compat = {}) { 33 | super(driver, tables) 34 | this._dbTimezone = compat.timezone ?? 'SYSTEM' 35 | 36 | this.evalOperators.$select = (args) => { 37 | if (compat.maria || compat.mysql57) { 38 | return this.asEncoded(`json_object(${args.map(arg => this.parseEval(arg, false)).flatMap((x, i) => [`${i}`, x]).join(', ')})`, true) 39 | } else { 40 | return `${args.map(arg => this.parseEval(arg)).join(', ')}` 41 | } 42 | } 43 | 44 | this.evalOperators.$sum = (expr) => this.createAggr(expr, value => `ifnull(sum(${value}), 0)`, undefined, value => `ifnull(minato_cfunc_sum(${value}), 0)`) 45 | this.evalOperators.$avg = (expr) => this.createAggr(expr, value => `avg(${value})`, undefined, value => `minato_cfunc_avg(${value})`) 46 | this.evalOperators.$min = (expr) => this.createAggr(expr, value => `min(${value})`, undefined, value => `minato_cfunc_min(${value})`) 47 | this.evalOperators.$max = (expr) => this.createAggr(expr, value => `max(${value})`, undefined, value => `minato_cfunc_max(${value})`) 48 | 49 | this.evalOperators.$number = (arg) => { 50 | const value = this.parseEval(arg) 51 | const type = Type.fromTerm(arg) 52 | const res = type.type === 'time' ? `unix_timestamp(convert_tz(addtime('1970-01-01 00:00:00', ${value}), '${this._localTimezone}', '${this._dbTimezone}'))` 53 | : ['timestamp', 'date'].includes(type.type!) ? `unix_timestamp(convert_tz(${value}, '${this._localTimezone}', '${this._dbTimezone}'))` : `(0+${value})` 54 | return this.asEncoded(`ifnull(${res}, 0)`, false) 55 | } 56 | 57 | this.evalOperators.$or = (args) => { 58 | const type = Type.fromTerm(this.state.expr, Type.Boolean) 59 | if (Field.boolean.includes(type.type)) return this.logicalOr(args.map(arg => this.parseEval(arg))) 60 | else return `cast(${args.map(arg => this.parseEval(arg)).join(' | ')} as signed)` 61 | } 62 | this.evalOperators.$and = (args) => { 63 | const type = Type.fromTerm(this.state.expr, Type.Boolean) 64 | if (Field.boolean.includes(type.type)) return this.logicalAnd(args.map(arg => this.parseEval(arg))) 65 | else return `cast(${args.map(arg => this.parseEval(arg)).join(' & ')} as signed)` 66 | } 67 | this.evalOperators.$not = (arg) => { 68 | const type = Type.fromTerm(this.state.expr, Type.Boolean) 69 | if (Field.boolean.includes(type.type)) return this.logicalNot(this.parseEval(arg)) 70 | else return `cast(~(${this.parseEval(arg)}) as signed)` 71 | } 72 | this.evalOperators.$xor = (args) => { 73 | const type = Type.fromTerm(this.state.expr, Type.Boolean) 74 | if (Field.boolean.includes(type.type)) return args.map(arg => this.parseEval(arg)).reduce((prev, curr) => `(${prev} != ${curr})`) 75 | else return `cast(${args.map(arg => this.parseEval(arg)).join(' ^ ')} as signed)` 76 | } 77 | 78 | this.transformers['boolean'] = { 79 | encode: value => `if(${value}=true, 1, 0)`, 80 | decode: value => `if(${value}=1, true, false)`, 81 | load: value => isNullable(value) ? value : !!value, 82 | dump: value => isNullable(value) ? value : value ? 1 : 0, 83 | } 84 | 85 | this.transformers['bigint'] = { 86 | encode: value => `cast(${value} as char)`, 87 | decode: value => `cast(${value} as signed)`, 88 | load: value => isNullable(value) ? value : BigInt(value), 89 | dump: value => isNullable(value) ? value : `${value}`, 90 | } 91 | 92 | this.transformers['binary'] = { 93 | encode: value => `to_base64(${value})`, 94 | decode: value => `from_base64(${value})`, 95 | load: value => isNullable(value) || typeof value === 'object' ? value : Binary.fromBase64(value), 96 | dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toBase64(value), 97 | } 98 | 99 | this.transformers['date'] = { 100 | decode: value => `cast(${value} as date)`, 101 | load: value => { 102 | if (isNullable(value) || typeof value === 'object') return value 103 | const parsed = new Date(value), date = new Date() 104 | date.setFullYear(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()) 105 | date.setHours(0, 0, 0, 0) 106 | return date 107 | }, 108 | dump: value => { 109 | if (isNullable(value)) return value 110 | const date = new Date(0) 111 | date.setFullYear(value.getFullYear(), value.getMonth(), value.getDate()) 112 | date.setHours(0, 0, 0, 0) 113 | return Time.template('yyyy-MM-dd hh:mm:ss.SSS', date) 114 | }, 115 | } 116 | 117 | this.transformers['time'] = { 118 | decode: value => `cast(${value} as time)`, 119 | load: value => this.driver.types['time'].load(value), 120 | dump: value => isNullable(value) ? value : Time.template('yyyy-MM-dd hh:mm:ss.SSS', value), 121 | } 122 | 123 | this.transformers['timestamp'] = { 124 | decode: value => `cast(${value} as datetime)`, 125 | load: value => { 126 | if (isNullable(value) || typeof value === 'object') return value 127 | return new Date(value) 128 | }, 129 | dump: value => isNullable(value) ? value : Time.template('yyyy-MM-dd hh:mm:ss.SSS', value), 130 | } 131 | } 132 | 133 | protected createMemberQuery(key: string, value: any, notStr = '') { 134 | if (Array.isArray(value) && Array.isArray(value[0]) && (this.compat.maria || this.compat.mysql57)) { 135 | const vals = `json_array(${value.map((val: any[]) => `(${this.evalOperators.$select!(val)})`).join(', ')})` 136 | return this.jsonContains(vals, key) 137 | } 138 | if (value.$exec && (this.compat.maria || this.compat.mysql57)) { 139 | const res = this.jsonContains(this.parseEval(value, false), this.encode(key, true, true)) 140 | return notStr ? this.logicalNot(res) : res 141 | } 142 | return super.createMemberQuery(key, value, notStr) 143 | } 144 | 145 | escapePrimitive(value: any, type?: Type) { 146 | if (value instanceof Date) { 147 | value = Time.template('yyyy-MM-dd hh:mm:ss.SSS', value) 148 | } else if (value instanceof RegExp) { 149 | value = value.source 150 | } else if (Binary.is(value)) { 151 | return `X'${Binary.toHex(value)}'` 152 | } else if (Binary.isSource(value)) { 153 | return `X'${Binary.toHex(Binary.fromSource(value))}'` 154 | } else if (!!value && typeof value === 'object') { 155 | return `json_extract(${this.quote(JSON.stringify(value))}, '$')` 156 | } 157 | return super.escapePrimitive(value, type) 158 | } 159 | 160 | protected encode(value: string, encoded: boolean, pure: boolean = false, type?: Type) { 161 | return this.asEncoded(encoded === this.isEncoded() && !pure ? value : encoded 162 | ? `json_extract(json_object('v', ${this.transform(value, type, 'encode')}), '$.v')` 163 | : this.transform(`json_unquote(${value})`, type, 'decode'), pure ? undefined : encoded) 164 | } 165 | 166 | protected createAggr(expr: any, aggr: (value: string) => string, nonaggr?: (value: string) => string, compat?: (value: string) => string) { 167 | if (!this.state.group && compat && (this.compat.mysql57 || this.compat.maria)) { 168 | return compat(this.parseEval(expr, false)) 169 | } else { 170 | return super.createAggr(expr, aggr, nonaggr) 171 | } 172 | } 173 | 174 | protected groupArray(value: string) { 175 | if (!this.compat.maria) return super.groupArray(value) 176 | const res = this.isEncoded() ? `concat('[', group_concat(${value}), ']')` 177 | : `concat('[', group_concat(json_extract(json_object('v', ${value}), '$.v')), ']')` 178 | return this.asEncoded(`ifnull(${res}, json_array())`, true) 179 | } 180 | 181 | protected parseSelection(sel: Selection, inline: boolean = false) { 182 | if (!this.compat.maria && !this.compat.mysql57) return super.parseSelection(sel, inline) 183 | const { args: [expr], ref, table, tables } = sel 184 | const restore = this.saveState({ wrappedSubquery: true, tables }) 185 | const inner = this.get(table as Selection, true, true) as string 186 | const output = this.parseEval(expr, false) 187 | const fields = expr['$select']?.map(x => this.getRecursive(x['$'])) 188 | const where = fields && this.logicalAnd(fields.map(x => `(${x} is not null)`)) 189 | const refFields = this.state.refFields 190 | restore() 191 | let query: string 192 | if (inline || !isAggrExpr(expr as any)) { 193 | query = `(SELECT ${output} FROM ${inner} ${isBracketed(inner) ? ref : ''}${where ? ` WHERE ${where}` : ''})` 194 | } else { 195 | query = [ 196 | `(ifnull((SELECT ${this.groupArray(this.transform(output, Type.getInner(Type.fromTerm(expr)), 'encode'))}`, 197 | `FROM ${inner} ${isBracketed(inner) ? ref : ''}), json_array()))`, 198 | ].join(' ') 199 | } 200 | if (Object.keys(refFields ?? {}).length) { 201 | const funcname = `minato_tfunc_${randomId()}` 202 | const decls = Object.values(refFields ?? {}).map(x => `${x} JSON`).join(',') 203 | const args = Object.keys(refFields ?? {}).map(x => this.state.refFields?.[x] ?? x).map(x => this.encode(x, true, true)).join(',') 204 | query = this.isEncoded() ? `ifnull(${query}, json_array())` : this.encode(query, true) 205 | this.prequeries.push(`DROP FUNCTION IF EXISTS ${funcname}`) 206 | this.prequeries.push(`CREATE FUNCTION ${funcname} (${decls}) RETURNS JSON DETERMINISTIC RETURN ${query}`) 207 | return this.asEncoded(`${funcname}(${args})`, true) 208 | } else return query 209 | } 210 | 211 | toUpdateExpr(item: any, key: string, field?: Field, upsert?: boolean) { 212 | const escaped = escapeId(key) 213 | 214 | // update directly 215 | if (key in item) { 216 | if (!isEvalExpr(item[key]) && upsert) { 217 | return `VALUES(${escaped})` 218 | } else if (isEvalExpr(item[key])) { 219 | return this.parseEval(item[key]) 220 | } else { 221 | return this.escape(item[key], field) 222 | } 223 | } 224 | 225 | // prepare nested layout 226 | const jsonInit = {} 227 | for (const prop in item) { 228 | if (!prop.startsWith(key + '.')) continue 229 | const rest = prop.slice(key.length + 1).split('.') 230 | if (rest.length === 1) continue 231 | rest.slice(0, -1).reduce((obj, k) => obj[k] ??= {}, jsonInit) 232 | } 233 | 234 | // update with json_set 235 | const valueInit = `ifnull(${escaped}, '{}')` 236 | let value = valueInit 237 | 238 | // json_set cannot create deeply nested property when non-exist 239 | // therefore we merge a layout to it 240 | if (Object.keys(jsonInit).length !== 0) { 241 | value = `json_merge_patch(${this.escape(jsonInit, 'json')}, ${value})` 242 | } 243 | 244 | for (const prop in item) { 245 | if (!prop.startsWith(key + '.')) continue 246 | const rest = prop.slice(key.length + 1).split('.') 247 | const type = Type.getInner(field?.type, prop.slice(key.length + 1)) 248 | const v = isEvalExpr(item[prop]) ? this.transform(this.parseEval(item[prop]), item[prop], 'encode') 249 | : this.transform(this.escape(item[prop], type), type, 'encode') 250 | value = `json_set(${value}, '$${rest.map(key => `."${key}"`).join('')}', ${v})` 251 | } 252 | 253 | if (value === valueInit) { 254 | return escaped 255 | } else { 256 | return value 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /packages/core/src/selection.ts: -------------------------------------------------------------------------------- 1 | import { defineProperty, Dict, filterKeys, mapValues } from 'cosmokit' 2 | import { Driver } from './driver.ts' 3 | import { Eval, executeEval, isAggrExpr, isEvalExpr } from './eval.ts' 4 | import { Field, Model } from './model.ts' 5 | import { Query } from './query.ts' 6 | import { FlatKeys, FlatPick, Flatten, getCell, Keys, randomId, Row } from './utils.ts' 7 | import { Type } from './type.ts' 8 | 9 | declare module './eval.ts' { 10 | export namespace Eval { 11 | export interface Static { 12 | exec(value: Executable): Expr 13 | } 14 | } 15 | } 16 | 17 | export type Direction = 'asc' | 'desc' 18 | 19 | export interface Modifier { 20 | limit: number 21 | offset: number 22 | sort: [Eval.Expr, Direction][] 23 | group?: string[] 24 | having: Eval.Expr 25 | fields?: Dict 26 | optional: Dict 27 | } 28 | 29 | namespace Executable { 30 | export type Action = 'get' | 'set' | 'remove' | 'create' | 'upsert' | 'eval' 31 | 32 | export interface Payload { 33 | type: Action 34 | table: string | Selection | Dict 35 | ref: string 36 | query: Query.Expr 37 | args: any[] 38 | } 39 | } 40 | 41 | const createRow = (ref: string, expr = {}, prefix = '', model?: Model, intermediate?: Eval.Expr) => new Proxy(expr, { 42 | get(target, key) { 43 | if (key === '$prefix') return prefix 44 | if (key === '$model') return model 45 | if (typeof key === 'symbol' || key in target || key.startsWith('$')) return Reflect.get(target, key) 46 | 47 | if (intermediate) { 48 | if (Type.isArray(expr?.[Type.kType]) && Number.isInteger(+key)) { 49 | return createRow(ref, Eval.get(expr as any, +key), '', model, Eval.get(expr as any, +key)) 50 | } else { 51 | return createRow(ref, Eval.get(intermediate as any, `${prefix}${key}`), `${prefix}${key}.`, model, intermediate) 52 | } 53 | } 54 | 55 | let type: Type 56 | const field = model?.fields[prefix + key as string] 57 | if (Type.isArray(expr?.[Type.kType]) && Number.isInteger(+key)) { 58 | // indexing array 59 | type = Type.getInner(expr?.[Type.kType]) ?? Type.fromField('expr') 60 | return createRow(ref, Eval.get(expr as any, +key), '', model, Eval.get(expr as any, +key)) 61 | } else if (Type.getInner(expr?.[Type.kType], key)) { 62 | // type may conatins object layout 63 | type = Type.getInner(expr?.[Type.kType], key)! 64 | } else if (field) { 65 | type = Type.fromField(field) 66 | } else if (Object.keys(model?.fields!).some(k => k.startsWith(`${prefix}${key}.`))) { 67 | type = Type.Object(Object.fromEntries(Object.entries(model?.fields!) 68 | .filter(([k]) => k.startsWith(`${prefix}${key}`)) 69 | .map(([k, field]) => [k.slice(prefix.length + key.length + 1), Type.fromField(field!)]))) 70 | } else { 71 | // unknown field inside json 72 | type = model?.getType(`${prefix}${key}`) ?? Type.fromField('expr') 73 | } 74 | 75 | const row = createRow(ref, Eval('', [ref, `${prefix}${key}`], type), `${prefix}${key}.`, model) 76 | if (!field && Object.keys(model?.fields!).some(k => k.startsWith(`${prefix}${key}.`))) { 77 | return createRow(ref, Eval.object(row), `${prefix}${key}.`, model) 78 | } else { 79 | return row 80 | } 81 | }, 82 | }) 83 | 84 | interface Executable extends Executable.Payload {} 85 | 86 | class Executable { 87 | public readonly row!: Row 88 | public readonly model!: Model 89 | public readonly driver!: Driver 90 | 91 | constructor(driver: Driver, payload: Executable.Payload) { 92 | Object.assign(this, payload) 93 | defineProperty(this, 'driver', driver) 94 | defineProperty(this, 'model', driver.model(this.table)) 95 | defineProperty(this, 'row', createRow(this.ref, {}, '', this.model)) 96 | } 97 | 98 | protected resolveQuery(query?: Query): Query.Expr 99 | protected resolveQuery(query: Query = {}): any { 100 | if (typeof query === 'function') { 101 | const expr = query(this.row) 102 | return expr['$expr'] ? expr : isEvalExpr(expr) ? { $expr: expr } : expr 103 | } 104 | if (Array.isArray(query) || query instanceof RegExp || ['string', 'number', 'bigint'].includes(typeof query)) { 105 | const { primary } = this.model 106 | if (Array.isArray(primary)) { 107 | throw new TypeError('invalid shorthand for composite primary key') 108 | } 109 | return { [primary]: query } 110 | } 111 | return query 112 | } 113 | 114 | protected resolveField(field: FieldLike): Eval.Expr { 115 | if (typeof field === 'string') { 116 | return this.row[field] 117 | } else if (typeof field === 'function') { 118 | return field(this.row) 119 | } else { 120 | throw new TypeError('invalid field definition') 121 | } 122 | } 123 | 124 | protected resolveFields(fields: string | string[] | Dict>) { 125 | if (typeof fields === 'string') fields = [fields] 126 | if (Array.isArray(fields)) { 127 | const modelFields = Object.keys(this.model.fields) 128 | const entries = fields.flatMap((key) => { 129 | if (this.model.fields[key]) return [[key, this.row[key]]] 130 | else if (modelFields.some(path => path.startsWith(key + '.'))) { 131 | return modelFields.filter(path => path.startsWith(key + '.')).map(path => [path, this.row[path]]) 132 | } 133 | return [[key, key.split('.').reduce((row, k) => row[k], this.row)]] 134 | }) 135 | return Object.fromEntries(entries) 136 | } else { 137 | const entries = Object.entries(fields).flatMap(([key, field]) => { 138 | const expr = this.resolveField(field) 139 | if (expr['$object'] && !Type.fromTerm(expr).ignoreNull) { 140 | return Object.entries(expr['$object']).map(([key2, expr2]) => [`${key}.${key2}`, expr2]) 141 | } 142 | return [[key, expr]] 143 | }) 144 | return Object.fromEntries(entries) 145 | } 146 | } 147 | 148 | async execute(): Promise { 149 | await this.driver.ctx.minato.prepared() 150 | await this.driver._ensureSession() 151 | return this.driver[this.type as any](this, ...this.args) 152 | } 153 | } 154 | 155 | type FieldLike = FlatKeys | Selection.Callback 156 | 157 | type FieldType> = 158 | | T extends FlatKeys ? Flatten[T] 159 | : T extends Selection.Callback ? Eval> 160 | : never 161 | 162 | type FieldMap>> = { 163 | [K in keyof M]: FieldType 164 | } 165 | 166 | export namespace Selection { 167 | export type Callback = (row: Row) => Eval.Expr 168 | 169 | export interface Immutable extends Executable, Executable.Payload { 170 | tables: Dict 171 | } 172 | 173 | export interface Mutable extends Executable, Executable.Payload { 174 | tables: Dict 175 | table: string 176 | } 177 | } 178 | 179 | export interface Selection extends Executable.Payload { 180 | args: [Modifier] 181 | } 182 | 183 | export class Selection extends Executable { 184 | public tables: Dict = {} 185 | 186 | constructor(driver: Driver, table: string | Selection | Dict, query?: Query) { 187 | super(driver, { 188 | type: 'get', 189 | ref: randomId(), 190 | table, 191 | query: null as never, 192 | args: [{ sort: [], limit: Infinity, offset: 0, group: undefined, having: Eval.and(), optional: {} }], 193 | }) 194 | this.tables[this.ref] = this.model 195 | this.query = this.resolveQuery(query) 196 | if (typeof table !== 'string') { 197 | Object.assign(this.tables, table.tables) 198 | } 199 | } 200 | 201 | where(query: Query) { 202 | this.query.$and ||= [] 203 | this.query.$and.push(this.resolveQuery(query)) 204 | return this 205 | } 206 | 207 | limit(limit: number): this 208 | limit(offset: number, limit: number): this 209 | limit(...args: [number] | [number, number]) { 210 | if (args.length > 1) this.offset(args.shift()!) 211 | this.args[0].limit = args[0] 212 | return this 213 | } 214 | 215 | offset(offset: number) { 216 | this.args[0].offset = offset 217 | return this 218 | } 219 | 220 | orderBy(field: FieldLike, direction: Direction = 'asc') { 221 | this.args[0].sort.push([this.resolveField(field), direction]) 222 | return this 223 | } 224 | 225 | groupBy>(fields: K | readonly K[], query?: Selection.Callback): Selection> 226 | groupBy, U extends Dict>>( 227 | fields: K | K[], 228 | extra?: U, 229 | query?: Selection.Callback, 230 | ): Selection & FieldMap> 231 | 232 | groupBy>>(fields: K, query?: Selection.Callback): Selection> 233 | groupBy>, U extends Dict>>( 234 | fields: K, 235 | extra?: U, 236 | query?: Selection.Callback, 237 | ): Selection> 238 | 239 | groupBy(fields: any, ...args: any[]) { 240 | this.args[0].fields = this.resolveFields(fields) 241 | this.args[0].group = Object.keys(this.args[0].fields!) 242 | const extra = typeof args[0] === 'function' ? undefined : args.shift() 243 | Object.assign(this.args[0].fields!, this.resolveFields(extra || {})) 244 | if (args[0]) this.having(args[0]) 245 | return new Selection(this.driver, this) 246 | } 247 | 248 | having(query: Selection.Callback) { 249 | this.args[0].having['$and'].push(this.resolveField(query)) 250 | return this 251 | } 252 | 253 | project>(fields: K | readonly K[]): Selection> 254 | project>>(fields: U): Selection> 255 | project(fields: Keys[] | Dict>) { 256 | this.args[0].fields = this.resolveFields(fields) 257 | return new Selection(this.driver, this) 258 | } 259 | 260 | join( 261 | name: K, 262 | selection: Selection, 263 | callback: (self: Row, other: Row) => Eval.Expr = () => Eval.and(), 264 | optional: boolean = false, 265 | ): Selection { 266 | const fields = Object.fromEntries(Object.entries(this.model.fields) 267 | .filter(([key, field]) => Field.available(field) && !key.startsWith(name + '.')) 268 | .map(([key]) => [key, (row) => getCell(row[this.ref], key)])) 269 | const joinFields = Object.fromEntries(Object.entries(selection.model.fields) 270 | .filter(([key, field]) => Field.available(field) || Field.available(this.model.fields[`${name}.${key}`])) 271 | .map(([key]) => [key, 272 | (row) => Field.available(this.model.fields[`${name}.${key}`]) ? getCell(row[this.ref], `${name}.${key}`) : getCell(row[name], key), 273 | ])) 274 | if (optional) { 275 | return this.driver.ctx.minato 276 | .join({ [this.ref]: this as Selection, [name]: selection }, (t: any) => callback(t[this.ref], t[name]), { [this.ref]: false, [name]: true }) 277 | .project({ ...fields, [name]: (row) => Eval.ignoreNull(Eval.object(mapValues(joinFields, x => x(row)))) }) as any 278 | } else { 279 | return this.driver.ctx.minato 280 | .join({ [this.ref]: this as Selection, [name]: selection }, (t: any) => callback(t[this.ref], t[name])) 281 | .project({ ...fields, [name]: (row) => Eval.ignoreNull(Eval.object(mapValues(joinFields, x => x(row)))) }) as any 282 | } 283 | } 284 | 285 | _action(type: Executable.Action, ...args: any[]) { 286 | return new Executable(this.driver, { ...this, type, args }) 287 | } 288 | 289 | evaluate(callback: Selection.Callback): Eval.Expr 290 | evaluate>(field: K): Eval.Expr 291 | evaluate>(field: K[]): Eval.Expr 292 | evaluate(): Eval.Expr 293 | evaluate(callback?: any): any { 294 | const selection = new Selection(this.driver, this) 295 | if (!callback) callback = (row: any) => Eval.array(Eval.object(row)) 296 | const expr = Array.isArray(callback) ? Eval.select(...callback.map(x => this.resolveField(x))) : this.resolveField(callback) 297 | if (isAggrExpr(expr)) defineProperty(expr, Type.kType, Type.Array(Type.fromTerm(expr))) 298 | return Eval.exec(selection._action('eval', expr)) 299 | } 300 | 301 | execute(): Promise 302 | execute = any>(cursor?: Driver.Cursor): Promise[]> 303 | execute(callback: Selection.Callback): Promise 304 | async execute(cursor?: any) { 305 | if (typeof cursor === 'function') { 306 | const selection = new Selection(this.driver, this) 307 | return selection._action('eval', this.resolveField(cursor)).execute() 308 | } 309 | if (Array.isArray(cursor)) { 310 | cursor = { fields: cursor } 311 | } else if (!cursor) { 312 | cursor = {} 313 | } 314 | if (cursor.fields) this.project(cursor.fields) 315 | if (cursor.limit !== undefined) this.limit(cursor.limit) 316 | if (cursor.offset !== undefined) this.offset(cursor.offset) 317 | if (cursor.sort) { 318 | for (const field in cursor.sort) { 319 | this.orderBy(field as any, cursor.sort[field]) 320 | } 321 | } 322 | const rows = await super.execute() 323 | if (!cursor.fields) return rows 324 | return rows.map((row) => { 325 | return filterKeys(row as any, key => { 326 | return (cursor.fields as string[]).some(k => k === key || k.startsWith(`${key}.`)) 327 | }) 328 | }) 329 | } 330 | } 331 | 332 | export namespace Selection { 333 | export function is(sel: any): sel is Selection { 334 | return sel && !!sel.tables as any 335 | } 336 | } 337 | 338 | export function executeSort(data: any[], modifier: Modifier, name: string) { 339 | const { limit, offset, sort } = modifier 340 | 341 | // step 1: sort data 342 | data.sort((a, b) => { 343 | for (const [field, direction] of sort) { 344 | const sign = direction === 'asc' ? 1 : -1 345 | const x = executeEval({ [name]: a, _: a }, field) 346 | const y = executeEval({ [name]: b, _: b }, field) 347 | if (x < y) return -sign 348 | if (x > y) return sign 349 | } 350 | return 0 351 | }) 352 | 353 | // step 2: truncate data 354 | return data.slice(offset, offset + limit) 355 | } 356 | -------------------------------------------------------------------------------- /packages/postgres/src/builder.ts: -------------------------------------------------------------------------------- 1 | import { Builder, isBracketed } from '@minatojs/sql-utils' 2 | import { Binary, Dict, isNullable, Time } from 'cosmokit' 3 | import { Driver, Field, isAggrExpr, isEvalExpr, Model, randomId, RegExpLike, Selection, Type, unravel } from 'minato' 4 | 5 | export function escapeId(value: string) { 6 | return '"' + value.replace(/"/g, '""') + '"' 7 | } 8 | 9 | export function formatTime(time: Date) { 10 | const year = time.getFullYear().toString() 11 | const month = Time.toDigits(time.getMonth() + 1) 12 | const date = Time.toDigits(time.getDate()) 13 | const hour = Time.toDigits(time.getHours()) 14 | const min = Time.toDigits(time.getMinutes()) 15 | const sec = Time.toDigits(time.getSeconds()) 16 | const ms = Time.toDigits(time.getMilliseconds(), 3) 17 | let timezone = Time.toDigits(time.getTimezoneOffset() / -60) 18 | if (!timezone.startsWith('-')) timezone = `+${timezone}` 19 | return `${year}-${month}-${date} ${hour}:${min}:${sec}.${ms}${timezone}` 20 | } 21 | 22 | export class PostgresBuilder extends Builder { 23 | protected escapeMap = { 24 | "'": "''", 25 | } 26 | 27 | protected $true = 'TRUE' 28 | protected $false = 'FALSE' 29 | 30 | constructor(protected driver: Driver, public tables?: Dict) { 31 | super(driver, tables) 32 | 33 | this.queryOperators = { 34 | ...this.queryOperators, 35 | $regex: (key, value) => this.createRegExpQuery(key, value), 36 | $regexFor: (key, value) => typeof value === 'string' ? `${this.escape(value)} ~ ${key}` 37 | : `${this.escape(value.input)} ${value.flags?.includes('i') ? '~*' : '~'} ${key}`, 38 | $size: (key, value) => { 39 | if (this.isJsonQuery(key)) { 40 | return `${this.jsonLength(key)} = ${this.escape(value)}` 41 | } else { 42 | if (!value) return `COALESCE(ARRAY_LENGTH(${key}, 1), 0) = 0` 43 | return `${key} IS NOT NULL AND ARRAY_LENGTH(${key}, 1) = ${value}` 44 | } 45 | }, 46 | } 47 | 48 | this.evalOperators = { 49 | ...this.evalOperators, 50 | $select: (args) => `${args.map(arg => this.parseEval(arg, this.transformType(arg))).join(', ')}`, 51 | $if: (args) => { 52 | const type = this.transformType(args[1]) ?? this.transformType(args[2]) ?? 'text' 53 | return `(SELECT CASE WHEN ${this.parseEval(args[0], 'boolean')} THEN ${this.parseEval(args[1], type)} ELSE ${this.parseEval(args[2], type)} END)` 54 | }, 55 | $ifNull: (args) => { 56 | const type = args.map(this.transformType).find(x => x) ?? 'text' 57 | return `coalesce(${args.map(arg => this.parseEval(arg, type)).join(', ')})` 58 | }, 59 | 60 | $regex: ([key, value, flags]) => `(${this.parseEval(key)} ${ 61 | (flags?.includes('i') || (value instanceof RegExp && value.flags.includes('i'))) ? '~*' : '~' 62 | } ${this.parseEval(value)})`, 63 | 64 | // number 65 | $add: (args) => `(${args.map(arg => this.parseEval(arg, 'double precision')).join(' + ')})`, 66 | $multiply: (args) => `(${args.map(arg => this.parseEval(arg, 'double precision')).join(' * ')})`, 67 | $modulo: ([left, right]) => { 68 | const dividend = this.parseEval(left, 'double precision'), divisor = this.parseEval(right, 'double precision') 69 | return `(${dividend} - (${divisor} * floor(${dividend} / ${divisor})))` 70 | }, 71 | $log: ([left, right]) => isNullable(right) 72 | ? `ln(${this.parseEval(left, 'double precision')})` 73 | : `(ln(${this.parseEval(left, 'double precision')}) / ln(${this.parseEval(right, 'double precision')}))`, 74 | $random: () => `random()`, 75 | 76 | $or: (args) => { 77 | const type = Type.fromTerm(this.state.expr, Type.Boolean) 78 | if (Field.boolean.includes(type.type)) return this.logicalOr(args.map(arg => this.parseEval(arg, 'boolean'))) 79 | else return `(${args.map(arg => this.parseEval(arg, 'bigint')).join(' | ')})` 80 | }, 81 | $and: (args) => { 82 | const type = Type.fromTerm(this.state.expr, Type.Boolean) 83 | if (Field.boolean.includes(type.type)) return this.logicalAnd(args.map(arg => this.parseEval(arg, 'boolean'))) 84 | else return `(${args.map(arg => this.parseEval(arg, 'bigint')).join(' & ')})` 85 | }, 86 | $not: (arg) => { 87 | const type = Type.fromTerm(this.state.expr, Type.Boolean) 88 | if (Field.boolean.includes(type.type)) return this.logicalNot(this.parseEval(arg, 'boolean')) 89 | else return `(~(${this.parseEval(arg, 'bigint')}))` 90 | }, 91 | $xor: (args) => { 92 | const type = Type.fromTerm(this.state.expr, Type.Boolean) 93 | if (Field.boolean.includes(type.type)) return args.map(arg => this.parseEval(arg, 'boolean')).reduce((prev, curr) => `(${prev} != ${curr})`) 94 | else return `(${args.map(arg => this.parseEval(arg, 'bigint')).join(' # ')})` 95 | }, 96 | 97 | $get: ([x, key]) => { 98 | const type = Type.fromTerm(this.state.expr, Type.Any) 99 | const res = typeof key === 'string' 100 | ? this.asEncoded(`jsonb_extract_path(${this.parseEval(x, false)}, ${(key as string).split('.').map(this.escapeKey).join(',')})`, true) 101 | : this.asEncoded(`(${this.parseEval(x, false)})->(${this.parseEval(key, 'integer')})`, true) 102 | return type.type === 'expr' ? res : `(${res})::${this.transformType(type)}` 103 | }, 104 | 105 | $number: (arg) => { 106 | const value = this.parseEval(arg) 107 | const type = Type.fromTerm(arg) 108 | const res = Field.date.includes(type.type as any) ? `extract(epoch from ${value})::bigint` : `${value}::double precision` 109 | return this.asEncoded(`coalesce(${res}, 0)`, false) 110 | }, 111 | 112 | $sum: (expr) => this.createAggr(expr, value => `coalesce(sum(${value})::double precision, 0)`, undefined, 'double precision'), 113 | $avg: (expr) => this.createAggr(expr, value => `avg(${value})::double precision`, undefined, 'double precision'), 114 | $min: (expr) => this.createAggr(expr, value => `min(${value})`, undefined, 'double precision'), 115 | $max: (expr) => this.createAggr(expr, value => `max(${value})`, undefined, 'double precision'), 116 | $count: (expr) => this.createAggr(expr, value => `count(distinct ${value})::integer`), 117 | $length: (expr) => this.createAggr(expr, value => `count(${value})::integer`, 118 | value => this.isEncoded() ? this.jsonLength(value) : this.asEncoded(`COALESCE(ARRAY_LENGTH(${value}, 1), 0)`, false), 119 | ), 120 | 121 | $concat: (args) => `(${args.map(arg => this.parseEval(arg, 'text')).join('||')})`, 122 | } 123 | 124 | this.transformers['boolean'] = { 125 | decode: value => `(${value})::boolean`, 126 | } 127 | 128 | this.transformers['decimal'] = { 129 | decode: value => `(${value})::double precision`, 130 | load: value => isNullable(value) ? value : +value, 131 | } 132 | 133 | this.transformers['bigint'] = { 134 | encode: value => `cast(${value} as text)`, 135 | decode: value => `cast(${value} as bigint)`, 136 | load: value => isNullable(value) ? value : BigInt(value), 137 | dump: value => isNullable(value) ? value : `${value}`, 138 | } 139 | 140 | this.transformers['binary'] = { 141 | encode: value => `encode(${value}, 'base64')`, 142 | decode: value => `decode(${value}, 'base64')`, 143 | load: value => isNullable(value) || typeof value === 'object' ? value : Binary.fromBase64(value), 144 | dump: value => isNullable(value) || typeof value === 'string' ? value : Binary.toBase64(value), 145 | } 146 | 147 | this.transformers['date'] = { 148 | decode: value => `cast(${value} as date)`, 149 | load: value => { 150 | if (isNullable(value) || typeof value === 'object') return value 151 | const parsed = new Date(value), date = new Date() 152 | date.setFullYear(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()) 153 | date.setHours(0, 0, 0, 0) 154 | return date 155 | }, 156 | dump: value => isNullable(value) ? value : formatTime(value), 157 | } 158 | 159 | this.transformers['time'] = { 160 | decode: value => `cast(${value} as time)`, 161 | load: value => this.driver.types['time'].load(value), 162 | dump: value => this.driver.types['time'].dump(value), 163 | } 164 | 165 | this.transformers['timestamp'] = { 166 | decode: value => `cast(${value} as timestamp)`, 167 | load: value => { 168 | if (isNullable(value) || typeof value === 'object') return value 169 | return new Date(value) 170 | }, 171 | dump: value => isNullable(value) ? value : formatTime(value), 172 | } 173 | } 174 | 175 | upsert(table: string) { 176 | this.modifiedTable = table 177 | } 178 | 179 | protected binary(operator: string, eltype: true | string = 'double precision') { 180 | return ([left, right]) => { 181 | const type = this.transformType(left) ?? this.transformType(right) ?? eltype 182 | return `(${this.parseEval(left, type)} ${operator} ${this.parseEval(right, type)})` 183 | } 184 | } 185 | 186 | private transformType(source: any) { 187 | const type = Type.isType(source) ? source : Type.fromTerm(source) 188 | if (Field.string.includes(type.type) || typeof source === 'string') return 'text' 189 | else if (['integer', 'unsigned', 'bigint'].includes(type.type) || typeof source === 'bigint') return 'bigint' 190 | else if (Field.number.includes(type.type) || typeof source === 'number') return 'double precision' 191 | else if (Field.boolean.includes(type.type) || typeof source === 'boolean') return 'boolean' 192 | else if (type.type === 'json') return 'jsonb' 193 | else if (type.type !== 'expr') return true 194 | } 195 | 196 | parseEval(expr: any, outtype: boolean | string = true): string { 197 | this.state.encoded = false 198 | if (typeof expr === 'string' || typeof expr === 'number' || typeof expr === 'boolean' || expr instanceof Date || expr instanceof RegExp) { 199 | return this.escape(expr) 200 | } 201 | return outtype ? this.encode(this.parseEvalExpr(expr), false, false, Type.fromTerm(expr), typeof outtype === 'string' ? outtype : undefined) 202 | : this.parseEvalExpr(expr) 203 | } 204 | 205 | protected createRegExpQuery(key: string, value: string | RegExpLike) { 206 | if (typeof value !== 'string' && value.flags?.includes('i')) { 207 | return `${key} ~* ${this.escape(typeof value === 'string' ? value : value.source)}` 208 | } else { 209 | return `${key} ~ ${this.escape(typeof value === 'string' ? value : value.source)}` 210 | } 211 | } 212 | 213 | protected createElementQuery(key: string, value: any) { 214 | if (this.isJsonQuery(key)) { 215 | return this.jsonContains(key, this.encode(value, true, true)) 216 | } else { 217 | return `${key} && ARRAY['${value}']::TEXT[]` 218 | } 219 | } 220 | 221 | protected createAggr(expr: any, aggr: (value: string) => string, nonaggr?: (value: string) => string, eltype?: string) { 222 | if (!this.state.group && !nonaggr) { 223 | const value = this.parseEval(expr, false) 224 | return `(select ${aggr(`(${this.encode(this.escapeId('value'), false, true, undefined)})${eltype ? `::${eltype}` : ''}`)} 225 | from jsonb_array_elements(${value}) ${randomId()})` 226 | } else { 227 | return super.createAggr(expr, aggr, nonaggr) 228 | } 229 | } 230 | 231 | protected transformJsonField(obj: string, path: string) { 232 | return this.asEncoded(`jsonb_extract_path(${obj}, ${path.slice(1).replaceAll('.', ',')})`, true) 233 | } 234 | 235 | protected jsonLength(value: string) { 236 | return this.asEncoded(`jsonb_array_length(${value})`, false) 237 | } 238 | 239 | protected jsonContains(obj: string, value: string) { 240 | return this.asEncoded(`(${obj} @> ${value})`, false) 241 | } 242 | 243 | protected encode(value: string, encoded: boolean, pure: boolean = false, type?: Type, outtype?: true | string) { 244 | outtype ??= this.transformType(type) 245 | return this.asEncoded((encoded === this.isEncoded() && !pure) ? value 246 | : encoded ? `to_jsonb(${this.transform(value, type, 'encode')})` 247 | : this.transform(`(jsonb_build_object('v', ${value})->>'v')`, type, 'decode') + `${typeof outtype === 'string' ? `::${outtype}` : ''}` 248 | , pure ? undefined : encoded) 249 | } 250 | 251 | protected groupObject(_fields: any) { 252 | const _groupObject = (fields: any, type?: Type, prefix: string = '') => { 253 | const parse = (expr, key) => { 254 | const value = (!_fields[`${prefix}${key}`] && type && Type.getInner(type, key)?.inner) 255 | ? _groupObject(expr, Type.getInner(type, key), `${prefix}${key}.`) 256 | : this.parseEval(expr, false) 257 | return this.isEncoded() ? this.encode(`to_jsonb(${value})`, true) : this.transform(value, expr, 'encode') 258 | } 259 | return `jsonb_build_object(` + Object.entries(fields).map(([key, expr]) => `'${key}', ${parse(expr, key)}`).join(',') + `)` 260 | } 261 | return this.asEncoded(_groupObject(unravel(_fields), Type.fromTerm(this.state.expr), ''), true) 262 | } 263 | 264 | protected groupArray(value: string) { 265 | return this.asEncoded(`coalesce(jsonb_agg(${value}), '[]'::jsonb)`, true) 266 | } 267 | 268 | protected parseSelection(sel: Selection, inline: boolean = false) { 269 | const { args: [expr], ref, table, tables } = sel 270 | const restore = this.saveState({ tables }) 271 | const inner = this.get(table as Selection, true, true) as string 272 | const output = this.parseEval(expr, false) 273 | const fields = expr['$select']?.map(x => this.getRecursive(x['$'])) 274 | const where = fields && this.logicalAnd(fields.map(x => `(${x} is not null)`)) 275 | restore() 276 | if (inline || !isAggrExpr(expr as any)) { 277 | return `(SELECT ${output} FROM ${inner} ${isBracketed(inner) ? ref : ''}${where ? ` WHERE ${where}` : ''})` 278 | } else { 279 | return [ 280 | `(coalesce((SELECT ${this.groupArray(this.transform(output, Type.getInner(Type.fromTerm(expr)), 'encode'))}`, 281 | `FROM ${inner} ${isBracketed(inner) ? ref : ''}), '[]'::jsonb))`, 282 | ].join(' ') 283 | } 284 | } 285 | 286 | escapeId = escapeId 287 | 288 | escapeKey(value: string) { 289 | return `'${value}'` 290 | } 291 | 292 | escapePrimitive(value: any, type?: Type) { 293 | if (value instanceof Date) { 294 | value = formatTime(value) 295 | } else if (value instanceof RegExp) { 296 | value = value.source 297 | } else if (Binary.is(value)) { 298 | return `'\\x${Binary.toHex(value)}'::bytea` 299 | } else if (Binary.isSource(value)) { 300 | return `'\\x${Binary.toHex(Binary.fromSource(value))}'::bytea` 301 | } else if (type?.type === 'list' && Array.isArray(value)) { 302 | return `ARRAY[${value.map(x => this.escape(x)).join(', ')}]::TEXT[]` 303 | } else if (!!value && typeof value === 'object') { 304 | return `${this.quote(JSON.stringify(value))}::jsonb` 305 | } 306 | return super.escapePrimitive(value, type) 307 | } 308 | 309 | toUpdateExpr(item: any, key: string, field?: Field, upsert?: boolean) { 310 | const escaped = this.escapeId(key) 311 | // update directly 312 | if (key in item) { 313 | if (!isEvalExpr(item[key]) && upsert) { 314 | return `excluded.${escaped}` 315 | } else if (isEvalExpr(item[key])) { 316 | return this.parseEval(item[key]) 317 | } else { 318 | return this.escape(item[key], field) 319 | } 320 | } 321 | 322 | // prepare nested layout 323 | const jsonInit = {} 324 | for (const prop in item) { 325 | if (!prop.startsWith(key + '.')) continue 326 | const rest = prop.slice(key.length + 1).split('.') 327 | if (rest.length === 1) continue 328 | rest.reduce((obj, k) => obj[k] ??= {}, jsonInit) 329 | } 330 | 331 | // update with json_set 332 | const valueInit = this.modifiedTable ? `coalesce(${this.escapeId(this.modifiedTable)}.${escaped}, '{}')::jsonb` : `coalesce(${escaped}, '{}')::jsonb` 333 | let value = valueInit 334 | 335 | // json_set cannot create deeply nested property when non-exist 336 | // therefore we merge a layout to it 337 | if (Object.keys(jsonInit).length !== 0) { 338 | value = `(jsonb ${this.escape(jsonInit, 'json')} || ${value})` 339 | } 340 | 341 | for (const prop in item) { 342 | if (!prop.startsWith(key + '.')) continue 343 | const rest = prop.slice(key.length + 1).split('.') 344 | const type = Type.getInner(field?.type, prop.slice(key.length + 1)) 345 | let escaped: string 346 | 347 | const v = isEvalExpr(item[prop]) ? this.encode(this.parseEval(item[prop]), true, true, Type.fromTerm(item[prop])) 348 | : (escaped = this.transform(this.escape(item[prop], type), type, 'encode'), escaped.endsWith('::jsonb') ? escaped 349 | : escaped.startsWith(`'`) ? this.encode(`(${escaped})::text`, true, true) // not passing type to prevent duplicated transform 350 | : this.encode(escaped, true, true)) 351 | value = `jsonb_set(${value}, '{${rest.map(key => `"${key}"`).join(',')}}', ${v}, true)` 352 | } 353 | 354 | if (value === valueInit) { 355 | return this.modifiedTable ? `${this.escapeId(this.modifiedTable)}.${escaped}` : escaped 356 | } else { 357 | return value 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /packages/core/src/model.ts: -------------------------------------------------------------------------------- 1 | import { clone, deepEqual, defineProperty, filterKeys, isNullable, makeArray, mapValues, MaybeArray } from 'cosmokit' 2 | import { Context } from 'cordis' 3 | import { Eval, isEvalExpr, Update } from './eval.ts' 4 | import { DeepPartial, FlatKeys, Flatten, isFlat, Keys, Row, unravel } from './utils.ts' 5 | import { Type } from './type.ts' 6 | import { Driver } from './driver.ts' 7 | import { Query } from './query.ts' 8 | import { Selection } from './selection.ts' 9 | import { Create } from './database.ts' 10 | 11 | const Primary = Symbol('minato.primary') 12 | export type Primary = (string | number) & { [Primary]: true } 13 | 14 | export namespace Relation { 15 | const Marker = Symbol('minato.relation') 16 | export type Marker = { [Marker]: true } 17 | 18 | export const Type = ['oneToOne', 'oneToMany', 'manyToOne', 'manyToMany'] as const 19 | export type Type = typeof Type[number] 20 | 21 | export interface Config = Keys, K extends string = string> { 22 | type: Type 23 | table: T 24 | references: Keys[] 25 | fields: K[] 26 | shared: Record> 27 | required: boolean 28 | } 29 | 30 | export interface Definition { 31 | type: 'oneToOne' | 'manyToOne' | 'manyToMany' 32 | table?: string 33 | target?: string 34 | references?: MaybeArray 35 | fields?: MaybeArray 36 | shared?: MaybeArray | Partial> 37 | } 38 | 39 | export type Include = boolean | { 40 | [P in keyof T]?: T[P] extends MaybeArray | undefined ? U extends S ? Include : Query.Expr> : never 41 | } 42 | 43 | export type SetExpr = ((row: Row) => Update) | { 44 | where: Query.Expr> | Selection.Callback 45 | update: Row.Computed> 46 | } 47 | 48 | export interface Modifier { 49 | $create?: MaybeArray> 50 | $upsert?: MaybeArray> 51 | $set?: MaybeArray> 52 | $remove?: Query.Expr> | Selection.Callback 53 | $connect?: Query.Expr> | Selection.Callback 54 | $disconnect?: Query.Expr> | Selection.Callback 55 | } 56 | 57 | export function buildAssociationTable(...tables: [string, string]) { 58 | return '_' + tables.sort().join('_') 59 | } 60 | 61 | export function buildAssociationKey(key: string, table: string) { 62 | return `${table}.${key}` 63 | } 64 | 65 | export function buildSharedKey(field: string, reference: string) { 66 | return [field, reference].sort().join('_') 67 | } 68 | 69 | export function parse(def: Definition, key: string, model: Model, relmodel: Model, subprimary?: boolean): [Config, Config] { 70 | const shared = !def.shared ? {} 71 | : typeof def.shared === 'string' ? { [def.shared]: def.shared } 72 | : Array.isArray(def.shared) ? Object.fromEntries(def.shared.map(x => [x, x])) 73 | : def.shared 74 | const fields = def.fields ?? ((subprimary || def.type === 'manyToOne' 75 | || (def.type === 'oneToOne' && (model.name === relmodel.name || !makeArray(relmodel.primary).every(key => !relmodel.fields[key]?.nullable)))) 76 | ? makeArray(relmodel.primary).map(x => `${key}.${x}`) : model.primary) 77 | const relation: Config = { 78 | type: def.type, 79 | table: def.table ?? relmodel.name, 80 | fields: makeArray(fields), 81 | shared: shared as any, 82 | references: makeArray(def.references ?? relmodel.primary), 83 | required: def.type !== 'manyToOne' && model.name !== relmodel.name 84 | && makeArray(fields).every(key => !model.fields[key]?.nullable || makeArray(model.primary).includes(key)), 85 | } 86 | // remove shared keys from fields and references 87 | Object.entries(shared).forEach(([k, v]) => { 88 | relation.fields = relation.fields.filter(x => x !== k) 89 | relation.references = relation.references.filter(x => x !== v) 90 | }) 91 | const inverse: Config = { 92 | type: relation.type === 'oneToMany' ? 'manyToOne' 93 | : relation.type === 'manyToOne' ? 'oneToMany' 94 | : relation.type, 95 | table: model.name, 96 | fields: relation.references, 97 | references: relation.fields, 98 | shared: Object.fromEntries(Object.entries(shared).map(([k, v]) => [v, k])), 99 | required: relation.type !== 'oneToMany' 100 | && relation.references.every(key => !relmodel.fields[key]?.nullable || makeArray(relmodel.primary).includes(key)), 101 | } 102 | if (inverse.required) relation.required = false 103 | return [relation, inverse] 104 | } 105 | } 106 | 107 | export interface Field { 108 | type: Type 109 | deftype?: Field.Type 110 | length?: number 111 | nullable?: boolean 112 | initial?: T 113 | precision?: number 114 | scale?: number 115 | expr?: Eval.Expr 116 | legacy?: string[] 117 | deprecated?: boolean 118 | relation?: Relation.Config 119 | transformers?: Driver.Transformer[] 120 | } 121 | 122 | export namespace Field { 123 | export const number: Type[] = ['integer', 'unsigned', 'float', 'double', 'decimal'] 124 | export const string: Type[] = ['char', 'string', 'text'] 125 | export const boolean: Type[] = ['boolean'] 126 | export const date: Type[] = ['timestamp', 'date', 'time'] 127 | export const object: Type[] = ['list', 'json'] 128 | 129 | export type Type = 130 | | T extends Primary ? 'primary' 131 | : T extends number ? 'integer' | 'unsigned' | 'float' | 'double' | 'decimal' 132 | : T extends string ? 'char' | 'string' | 'text' 133 | : T extends boolean ? 'boolean' 134 | : T extends Date ? 'timestamp' | 'date' | 'time' 135 | : T extends ArrayBuffer ? 'binary' 136 | : T extends bigint ? 'bigint' 137 | : T extends unknown[] ? 'list' | 'json' | 'oneToMany' | 'manyToMany' 138 | : T extends object ? 'json' | 'oneToOne' | 'manyToOne' 139 | : 'expr' 140 | 141 | type Shorthand = S | `${S}(${any})` 142 | 143 | export type Object = { 144 | type: 'object' 145 | inner?: Extension 146 | } & Omit, 'type'> 147 | 148 | export type Array = { 149 | type: 'array' 150 | inner?: Literal | Definition | Transform 151 | } & Omit, 'type'> 152 | 153 | export type Transform = { 154 | type: Type | Keys | NewType | 'object' | 'array' 155 | dump: (value: S | null) => T | null | void 156 | load: (value: T | null) => S | null | void 157 | initial?: S 158 | } & Omit, 'type' | 'initial'> 159 | 160 | export type Definition = 161 | | (Omit, 'type'> & { type: Type | Keys | NewType }) 162 | | (T extends object ? Object : never) 163 | | (T extends (infer I)[] ? Array : never) 164 | 165 | export type Literal = 166 | | Shorthand> 167 | | Keys 168 | | NewType 169 | | (T extends object ? 'object' : never) 170 | | (T extends unknown[] ? 'array' : never) 171 | 172 | export type Parsable = { 173 | type: Type | Field['type'] 174 | } & Omit, 'type'> 175 | 176 | type MapField = { 177 | [K in keyof O]?: 178 | | Literal 179 | | Definition 180 | | Transform 181 | | (O[K] extends object | undefined ? Relation.Definition> : never) 182 | } 183 | 184 | export type Extension = MapField, N> 185 | 186 | const NewType = Symbol('minato.newtype') 187 | export type NewType = string & { [NewType]: T } 188 | 189 | export type Config = { 190 | [K in keyof O]?: Field 191 | } 192 | 193 | const regexp = /^(\w+)(?:\((.+)\))?$/ 194 | 195 | export function parse(source: string | Parsable): Field { 196 | if (typeof source === 'function') throw new TypeError('view field is not supported') 197 | if (typeof source !== 'string') { 198 | return { 199 | initial: null, 200 | deftype: source.type as any, 201 | ...source, 202 | type: Type.fromField(source.type), 203 | } 204 | } 205 | 206 | // parse string definition 207 | const capture = regexp.exec(source) 208 | if (!capture) throw new TypeError('invalid field definition') 209 | const type = capture[1] as Type 210 | const args = (capture[2] || '').split(',') 211 | const field: Field = { deftype: type, type: Type.fromField(type) } 212 | 213 | // set default initial value 214 | if (field.initial === undefined) field.initial = getInitial(type) 215 | 216 | // set length information 217 | if (type === 'decimal') { 218 | field.precision = +args[0] 219 | field.scale = +args[1] 220 | } else if (args[0]) { 221 | field.length = +args[0] 222 | } 223 | 224 | return field 225 | } 226 | 227 | export function getInitial(type: Field.Type, initial?: any) { 228 | if (initial === undefined) { 229 | if (Field.number.includes(type)) return 0 230 | if (Field.string.includes(type)) return '' 231 | if (type === 'list') return [] 232 | if (type === 'json') return {} 233 | } 234 | return initial 235 | } 236 | 237 | export function available(field?: Field) { 238 | return !!field && !field.deprecated && !field.relation && field.deftype !== 'expr' 239 | } 240 | } 241 | 242 | export namespace Model { 243 | export type Migration = (database: D) => Promise 244 | 245 | export interface Config { 246 | callback?: Migration 247 | autoInc: boolean 248 | primary: MaybeArray 249 | unique: MaybeArray[] 250 | indexes: (MaybeArray | Driver.IndexDef)[] 251 | foreign: { 252 | [P in K]?: [string, string] 253 | } 254 | } 255 | 256 | export interface Intercept extends Partial>> { 257 | create?: boolean 258 | fields: Field.Extension 259 | } 260 | } 261 | 262 | export interface Model extends Model.Config {} 263 | 264 | export class Model { 265 | declare ctx?: Context 266 | declare indexes: Driver.Index>[] 267 | fields: Field.Config = {} 268 | migrations = new Map() 269 | 270 | declare private type: Type | undefined 271 | 272 | constructor(public name: string) { 273 | this.autoInc = false 274 | this.primary = 'id' as never 275 | this.unique = [] 276 | this.indexes = [] 277 | this.foreign = {} 278 | } 279 | 280 | extend(fields: Field.Extension, config?: Partial): void 281 | extend(fields = {}, config: Partial = {}) { 282 | const { primary, autoInc, unique = [], indexes = [], foreign, callback } = config 283 | 284 | this.primary = primary || this.primary 285 | this.autoInc = autoInc || this.autoInc 286 | unique.forEach(key => this.unique.includes(key) || this.unique.push(key)) 287 | indexes.map(x => this.parseIndex(x)).forEach(index => (this.indexes.some(ind => deepEqual(ind, index))) || this.indexes.push(index)) 288 | Object.assign(this.foreign, foreign) 289 | 290 | if (callback) this.migrations.set(callback, Object.keys(fields)) 291 | 292 | for (const key in fields) { 293 | this.fields[key] = Field.parse(fields[key]) 294 | this.fields[key].deprecated = !!callback 295 | } 296 | 297 | if (typeof this.primary === 'string' && this.fields[this.primary]?.deftype === 'primary') { 298 | this.autoInc = true 299 | } 300 | 301 | // check index 302 | this.checkIndex(this.primary) 303 | this.unique.forEach(index => this.checkIndex(index)) 304 | this.indexes.forEach(index => this.checkIndex(index)) 305 | } 306 | 307 | private parseIndex(index: MaybeArray | Driver.Index): Driver.Index { 308 | if (typeof index === 'string' || Array.isArray(index)) { 309 | return { 310 | name: `index:${this.name}:` + makeArray(index).join('+'), 311 | unique: false, 312 | keys: Object.fromEntries(makeArray(index).map(key => [key, 'asc'])), 313 | } 314 | } else { 315 | return { 316 | name: index.name ?? `index:${this.name}:` + Object.keys(index.keys).join('+'), 317 | unique: index.unique ?? false, 318 | keys: index.keys, 319 | } 320 | } 321 | } 322 | 323 | private checkIndex(index: MaybeArray | Driver.Index) { 324 | for (const key of typeof index === 'string' || Array.isArray(index) ? makeArray(index) : Object.keys(index.keys)) { 325 | if (!this.fields[key]) { 326 | throw new TypeError(`missing field definition for index key "${key}"`) 327 | } 328 | } 329 | } 330 | 331 | resolveValue(field: string | Field | Type, value: any) { 332 | if (isNullable(value)) return value 333 | if (typeof field === 'string') field = this.fields[field] as Field 334 | if (field) field = Type.fromField(field) 335 | if (field?.type === 'time') { 336 | const date = new Date(0) 337 | date.setHours(value.getHours(), value.getMinutes(), value.getSeconds(), value.getMilliseconds()) 338 | return date 339 | } else if (field?.type === 'date') { 340 | const date = new Date(value) 341 | date.setHours(0, 0, 0, 0) 342 | return date 343 | } 344 | return value 345 | } 346 | 347 | resolveModel(obj: any, model?: Type) { 348 | if (!model) model = this.getType() 349 | if (isNullable(obj) || !model.inner) return obj 350 | if (Type.isArray(model) && Array.isArray(obj)) { 351 | return obj.map(x => this.resolveModel(x, Type.getInner(model)!)) 352 | } 353 | 354 | const result = {} 355 | for (const key in obj) { 356 | const type = Type.getInner(model, key) 357 | if (!type || isNullable(obj[key])) { 358 | result[key] = obj[key] 359 | } else if (type.type !== 'json') { 360 | result[key] = this.resolveValue(type, obj[key]) 361 | } else if (isEvalExpr(obj[key])) { 362 | result[key] = obj[key] 363 | } else if (type.inner && Type.isArray(type) && Array.isArray(obj[key])) { 364 | result[key] = obj[key].map(x => this.resolveModel(x, Type.getInner(type))) 365 | } else if (type.inner) { 366 | result[key] = this.resolveModel(obj[key], type) 367 | } else { 368 | result[key] = obj[key] 369 | } 370 | } 371 | return result 372 | } 373 | 374 | format(source: object, strict = true, prefix = '', result = {} as S) { 375 | const fields = Object.keys(this.fields).filter(key => !this.fields[key].relation) 376 | Object.entries(source).map(([key, value]) => { 377 | key = prefix + key 378 | if (value === undefined) return 379 | if (fields.includes(key)) { 380 | result[key] = value 381 | return 382 | } 383 | const field = fields.find(field => key.startsWith(field + '.')) 384 | if (field) { 385 | result[key] = value 386 | } else if (isFlat(value)) { 387 | if (strict && (typeof value !== 'object' || Object.keys(value).length)) { 388 | throw new TypeError(`unknown field "${key}" in model ${this.name}`) 389 | } 390 | } else { 391 | this.format(value, strict, key + '.', result) 392 | } 393 | }) 394 | return (strict && prefix === '') ? this.resolveModel(result) : result 395 | } 396 | 397 | parse(source: object, strict = true, prefix = '', result = {} as S) { 398 | const fields = Object.keys(this.fields).filter(key => !this.fields[key].relation) 399 | if (strict && prefix === '') { 400 | // initialize object layout 401 | Object.assign(result as any, unravel(Object.fromEntries(fields 402 | .filter(key => key.includes('.')) 403 | .map(key => [key.slice(0, key.lastIndexOf('.')), {}])), 404 | )) 405 | } 406 | for (const key in source) { 407 | let node = result 408 | const segments = key.split('.').reverse() 409 | for (let index = segments.length - 1; index > 0; index--) { 410 | const segment = segments[index] 411 | node = node[segment] ??= {} 412 | } 413 | if (key in source) { 414 | const fullKey = prefix + key, value = source[key] 415 | const field = fields.find(field => fullKey === field || fullKey.startsWith(field + '.')) 416 | if (field) { 417 | node[segments[0]] = value 418 | } else if (isFlat(value)) { 419 | if (strict) { 420 | throw new TypeError(`unknown field "${fullKey}" in model ${this.name}`) 421 | } else { 422 | node[segments[0]] = value 423 | } 424 | } else { 425 | this.parse(value, strict, fullKey + '.', node[segments[0]] ??= {}) 426 | } 427 | } 428 | } 429 | return (strict && prefix === '') ? this.resolveModel(result) : result 430 | } 431 | 432 | create(data?: {}) { 433 | const result = {} as S 434 | const keys = makeArray(this.primary) 435 | for (const key in this.fields) { 436 | if (!Field.available(this.fields[key])) continue 437 | const { initial } = this.fields[key]! 438 | if (!keys.includes(key) && !isNullable(initial)) { 439 | result[key] = clone(initial) 440 | } 441 | } 442 | return this.parse({ ...result, ...data }) 443 | } 444 | 445 | avaiableFields() { 446 | return filterKeys(this.fields, (_, field) => Field.available(field)) 447 | } 448 | 449 | getType(): Type 450 | getType(key: string): Type | undefined 451 | getType(key?: string): Type | undefined { 452 | if (!this.type) defineProperty(this, 'type', Type.Object(mapValues(this.fields, field => Type.fromField(field!))) as any) 453 | return key ? Type.getInner(this.type, key) : this.type 454 | } 455 | } 456 | -------------------------------------------------------------------------------- /packages/tests/src/json.ts: -------------------------------------------------------------------------------- 1 | import { $, Database } from 'minato' 2 | import { expect } from 'chai' 3 | import { setup } from './utils' 4 | 5 | interface Foo { 6 | id: number 7 | value: number 8 | } 9 | 10 | interface Bar { 11 | id: number 12 | uid: number 13 | pid: number 14 | value: number 15 | s: string 16 | obj: { 17 | x: number 18 | y: string 19 | z: string 20 | o: { 21 | a: number 22 | b: string 23 | } 24 | } 25 | l: string[] 26 | } 27 | 28 | interface Baz { 29 | id: number 30 | nums: number[] 31 | } 32 | 33 | interface Bax { 34 | id: number 35 | array: { 36 | text: string 37 | }[] 38 | object: { 39 | num: number 40 | } 41 | } 42 | 43 | interface Tables { 44 | foo: Foo 45 | bar: Bar 46 | baz: Baz 47 | bax: Bax 48 | } 49 | 50 | function JsonTests(database: Database) { 51 | database.extend('foo', { 52 | id: 'unsigned', 53 | value: 'integer', 54 | }) 55 | 56 | database.extend('bar', { 57 | id: 'unsigned', 58 | uid: 'unsigned', 59 | pid: 'unsigned', 60 | value: 'integer', 61 | obj: 'json', 62 | s: 'string', 63 | l: 'list', 64 | }, { 65 | autoInc: true, 66 | }) 67 | 68 | database.extend('baz', { 69 | id: 'unsigned', 70 | nums: { 71 | type: 'array', 72 | inner: 'unsigned', 73 | } 74 | }) 75 | 76 | database.extend('bax', { 77 | id: 'unsigned', 78 | array: { 79 | type: 'array', 80 | inner: { 81 | type: 'object', 82 | inner: { 83 | text: 'string', 84 | }, 85 | }, 86 | }, 87 | object: { 88 | type: 'object', 89 | inner: { 90 | num: 'unsigned', 91 | }, 92 | }, 93 | }) 94 | 95 | before(async () => { 96 | await setup(database, 'foo', [ 97 | { id: 1, value: 0 }, 98 | { id: 2, value: 2 }, 99 | { id: 3, value: 2 }, 100 | ]) 101 | 102 | await setup(database, 'bar', [ 103 | { uid: 1, pid: 1, value: 0, obj: { x: 1, y: 'a', z: '1', o: { a: 1, b: '1' } }, s: '1', l: ['1', '2'] }, 104 | { uid: 1, pid: 1, value: 1, obj: { x: 2, y: 'b', z: '2', o: { a: 2, b: '2' } }, s: '2', l: ['5', '3', '4'] }, 105 | { uid: 1, pid: 2, value: 0, obj: { x: 3, y: 'c', z: '3', o: { a: 3, b: '3' } }, s: '3', l: ['2'] }, 106 | ]) 107 | 108 | await setup(database, 'baz', [ 109 | { id: 1, nums: [4, 5, 6] }, 110 | { id: 2, nums: [5, 6, 7] }, 111 | { id: 3, nums: [7, 8] }, 112 | ]) 113 | }) 114 | } 115 | 116 | namespace JsonTests { 117 | const Bax = [{ 118 | id: 1, 119 | array: [{ text: 'foo' }], 120 | }] 121 | 122 | export interface RelationOptions { 123 | nullableComparator?: boolean 124 | } 125 | 126 | export function query(database: Database, options: RelationOptions = {}) { 127 | const { nullableComparator = true } = options 128 | 129 | it('$size', async () => { 130 | await expect(database.get('baz', { 131 | nums: { $size: 3 }, 132 | })).to.eventually.deep.equal([ 133 | { id: 1, nums: [4, 5, 6] }, 134 | { id: 2, nums: [5, 6, 7] }, 135 | ]) 136 | 137 | await expect(database.select('baz', { 138 | nums: { $size: 3 }, 139 | }).project({ 140 | size: row => $.length(row.nums), 141 | }).execute()).to.eventually.deep.equal([ 142 | { size: 3 }, 143 | { size: 3 }, 144 | ]) 145 | 146 | await expect(database.select('baz', { 147 | nums: { $size: 0 }, 148 | }).project({ 149 | size: row => $.length(row.nums), 150 | }).execute()).to.eventually.have.length(0) 151 | }) 152 | 153 | it('$el', async () => { 154 | await expect(database.get('baz', { 155 | nums: { $el: 5 }, 156 | })).to.eventually.deep.equal([ 157 | { id: 1, nums: [4, 5, 6] }, 158 | { id: 2, nums: [5, 6, 7] }, 159 | ]) 160 | }) 161 | 162 | it('$in', async () => { 163 | await expect(database.get('baz', row => $.in($.add(3, row.id), row.nums))) 164 | .to.eventually.deep.equal([ 165 | { id: 1, nums: [4, 5, 6] }, 166 | { id: 2, nums: [5, 6, 7] }, 167 | ]) 168 | }) 169 | 170 | it('$nin', async () => { 171 | await expect(database.get('baz', row => $.nin($.add(3, row.id), row.nums))) 172 | .to.eventually.deep.equal([ 173 | { id: 3, nums: [7, 8] }, 174 | ]) 175 | }) 176 | 177 | it('execute nested selection', async () => { 178 | await expect(database.eval('bar', row => $.max($.add(1, row.value)))).to.eventually.deep.equal(2) 179 | await expect(database.eval('bar', row => $.max($.add(1, row.obj.x)))).to.eventually.deep.equal(4) 180 | }) 181 | 182 | it('$get array', async () => { 183 | await expect(database.get('baz', row => $.eq($.get(row.nums, 0), 4))) 184 | .to.eventually.deep.equal([ 185 | { id: 1, nums: [4, 5, 6] }, 186 | ]) 187 | 188 | await expect(database.get('baz', row => $.eq(row.nums[0], 4))) 189 | .to.eventually.deep.equal([ 190 | { id: 1, nums: [4, 5, 6] }, 191 | ]) 192 | }) 193 | 194 | nullableComparator && it('$get array with expressions', async () => { 195 | await expect(database.get('baz', row => $.eq($.get(row.nums, $.add(row.id, -1)), 4))) 196 | .to.eventually.deep.equal([ 197 | { id: 1, nums: [4, 5, 6] }, 198 | ]) 199 | }) 200 | 201 | it('$get object', async () => { 202 | await expect(database.get('bar', row => $.eq(row.obj.o.a, 2))) 203 | .to.eventually.have.shape([ 204 | { value: 1 }, 205 | ]) 206 | 207 | await expect(database.get('bar', row => $.eq($.get(row.obj.o, 'a'), 2))) 208 | .to.eventually.have.shape([ 209 | { value: 1 }, 210 | ]) 211 | }) 212 | } 213 | 214 | export function modify(database: Database) { 215 | it('$.object', async () => { 216 | await setup(database, 'bax', Bax) 217 | await database.set('bax', 1, row => ({ 218 | object: $.object({ 219 | num: row.id, 220 | }), 221 | })) 222 | await expect(database.get('bax', 1)).to.eventually.deep.equal([ 223 | { id: 1, array: [{ text: 'foo' }], object: { num: 1 } }, 224 | ]) 225 | }) 226 | 227 | it('$.literal', async () => { 228 | await setup(database, 'bax', Bax) 229 | 230 | await database.set('bax', 1, { 231 | array: $.literal([{ text: 'foo2' }]), 232 | }) 233 | await expect(database.get('bax', 1)).to.eventually.deep.equal([ 234 | { id: 1, array: [{ text: 'foo2' }], object: { num: 0 } }, 235 | ]) 236 | 237 | await database.set('bax', 1, { 238 | object: $.literal({ num: 2 }), 239 | }) 240 | await expect(database.get('bax', 1)).to.eventually.deep.equal([ 241 | { id: 1, array: [{ text: 'foo2' }], object: { num: 2 } }, 242 | ]) 243 | 244 | await database.set('bax', 1, { 245 | 'object.num': $.literal(3), 246 | }) 247 | await expect(database.get('bax', 1)).to.eventually.deep.equal([ 248 | { id: 1, array: [{ text: 'foo2' }], object: { num: 3 } }, 249 | ]) 250 | }) 251 | 252 | it('$.literal cast', async () => { 253 | await setup(database, 'bax', Bax) 254 | 255 | await database.set('bax', 1, { 256 | array: $.literal([{ text: 'foo2' }], 'array'), 257 | }) 258 | await expect(database.get('bax', 1)).to.eventually.deep.equal([ 259 | { id: 1, array: [{ text: 'foo2' }], object: { num: 0 } }, 260 | ]) 261 | 262 | await database.set('bax', 1, { 263 | object: $.literal({ num: 2 }, 'object'), 264 | }) 265 | await expect(database.get('bax', 1)).to.eventually.deep.equal([ 266 | { id: 1, array: [{ text: 'foo2' }], object: { num: 2 } }, 267 | ]) 268 | }) 269 | 270 | it('nested illegal string', async () => { 271 | await setup(database, 'bax', Bax) 272 | await database.set('bax', 1, row => ({ 273 | array: [{ text: '$foo2' }], 274 | })) 275 | await expect(database.get('bax', 1)).to.eventually.deep.equal([ 276 | { id: 1, array: [{ text: '$foo2' }], object: { num: 0 } }, 277 | ]) 278 | }) 279 | } 280 | 281 | export function selection(database: Database) { 282 | it('$.object', async () => { 283 | const res = await database.select('foo') 284 | .project({ 285 | obj: row => $.object({ 286 | id: row.id, 287 | value: row.value, 288 | }) 289 | }) 290 | .orderBy(row => row.obj.id) 291 | .execute() 292 | 293 | expect(res).to.deep.equal([ 294 | { obj: { id: 1, value: 0 } }, 295 | { obj: { id: 2, value: 2 } }, 296 | { obj: { id: 3, value: 2 } }, 297 | ]) 298 | }) 299 | 300 | it('$.object in json', async () => { 301 | const res = await database.select('bar') 302 | .project({ 303 | obj: row => $.object({ 304 | num: row.obj.x, 305 | str: row.obj.y, 306 | str2: row.obj.z, 307 | obj: row.obj.o, 308 | a: row.obj.o.a, 309 | }), 310 | }) 311 | .execute() 312 | 313 | expect(res).to.deep.equal([ 314 | { obj: { a: 1, num: 1, obj: { a: 1, b: '1' }, str: 'a', str2: '1' } }, 315 | { obj: { a: 2, num: 2, obj: { a: 2, b: '2' }, str: 'b', str2: '2' } }, 316 | { obj: { a: 3, num: 3, obj: { a: 3, b: '3' }, str: 'c', str2: '3' } }, 317 | ]) 318 | }) 319 | 320 | it('project in json with nested object', async () => { 321 | const res = await database.select('bar') 322 | .project({ 323 | 'obj.num': row => row.obj.x, 324 | 'obj.str': row => row.obj.y, 325 | 'obj.str2': row => row.obj.z, 326 | 'obj.obj': row => row.obj.o, 327 | 'obj.a': row => row.obj.o.a, 328 | }) 329 | .execute() 330 | 331 | expect(res).to.deep.equal([ 332 | { obj: { a: 1, num: 1, obj: { a: 1, b: '1' }, str: 'a', str2: '1' } }, 333 | { obj: { a: 2, num: 2, obj: { a: 2, b: '2' }, str: 'b', str2: '2' } }, 334 | { obj: { a: 3, num: 3, obj: { a: 3, b: '3' }, str: 'c', str2: '3' } }, 335 | ]) 336 | }) 337 | 338 | it('$.object on row', async () => { 339 | const res = await database.select('foo') 340 | .project({ 341 | obj: row => $.object(row), 342 | }) 343 | .orderBy(row => row.obj.id) 344 | .execute() 345 | 346 | expect(res).to.deep.equal([ 347 | { obj: { id: 1, value: 0 } }, 348 | { obj: { id: 2, value: 2 } }, 349 | { obj: { id: 3, value: 2 } }, 350 | ]) 351 | }) 352 | 353 | it('$.object on cell', async () => { 354 | const res = await database.join(['foo', 'bar'], (foo, bar) => $.eq(foo.id, bar.pid)) 355 | .groupBy('bar', { 356 | x: row => $.array($.object(row.foo)), 357 | }) 358 | .execute(['x']) 359 | 360 | expect(res).to.have.deep.members([ 361 | { x: [{ id: 1, value: 0 }] }, 362 | { x: [{ id: 1, value: 0 }] }, 363 | { x: [{ id: 2, value: 2 }] }, 364 | ]) 365 | }) 366 | 367 | it('$.array groupBy', async () => { 368 | await expect(database.join(['foo', 'bar'], (foo, bar) => $.eq(foo.id, bar.pid)) 369 | .groupBy(['foo'], { 370 | x: row => $.array(row.bar.obj.x), 371 | y: row => $.array(row.bar.obj.y), 372 | }) 373 | .orderBy(row => row.foo.id) 374 | .execute() 375 | ).to.eventually.have.shape([ 376 | { foo: { id: 1, value: 0 }, x: [1, 2], y: ['a', 'b'] }, 377 | { foo: { id: 2, value: 2 }, x: [3], y: ['c'] }, 378 | ]) 379 | 380 | await expect(database.join(['foo', 'bar'], (foo, bar) => $.eq(foo.id, bar.pid)) 381 | .groupBy(['foo'], { 382 | x: row => $.array(row.bar.obj.x), 383 | y: row => $.array(row.bar.obj.y), 384 | }) 385 | .orderBy(row => row.foo.id) 386 | .execute(row => $.array(row.y)) 387 | ).to.eventually.have.shape([ 388 | ['a', 'b'], 389 | ['c'], 390 | ]) 391 | 392 | await expect(database.join(['foo', 'bar'], (foo, bar) => $.eq(foo.id, bar.pid)) 393 | .groupBy(['foo'], { 394 | x: row => $.array(row.bar.obj.x), 395 | y: row => $.array(row.bar.obj.y), 396 | }) 397 | .orderBy(row => row.foo.id) 398 | .execute(row => $.count(row.y)) 399 | ).to.eventually.deep.equal(2) 400 | }) 401 | 402 | it('$.array groupFull', async () => { 403 | const res = await database.select('bar') 404 | .groupBy({}, { 405 | count2: row => $.array(row.s), 406 | countnumber: row => $.array(row.value), 407 | x: row => $.array(row.obj.x), 408 | y: row => $.array(row.obj.y), 409 | }) 410 | .execute() 411 | 412 | expect(res).to.deep.equal([ 413 | { 414 | count2: ['1', '2', '3'], 415 | countnumber: [0, 1, 0], 416 | x: [1, 2, 3], 417 | y: ['a', 'b', 'c'], 418 | }, 419 | ]) 420 | }) 421 | 422 | it('$.array in json', async () => { 423 | const res = await database.join(['foo', 'bar'], (foo, bar) => $.eq(foo.id, bar.pid)) 424 | .groupBy('foo', { 425 | bars: row => $.array($.object({ 426 | value: row.bar.value, 427 | obj: row.bar.obj, 428 | })), 429 | x: row => $.array(row.bar.obj.x), 430 | y: row => $.array(row.bar.obj.y), 431 | z: row => $.array(row.bar.obj.z), 432 | o: row => $.array(row.bar.obj.o), 433 | }) 434 | .orderBy(row => row.foo.id) 435 | .execute() 436 | 437 | expect(res).to.have.shape([ 438 | { 439 | foo: { id: 1, value: 0 }, 440 | bars: [{ 441 | obj: { o: { a: 1, b: '1' }, x: 1, y: 'a', z: '1' }, 442 | value: 0, 443 | }, { 444 | obj: { o: { a: 2, b: '2' }, x: 2, y: 'b', z: '2' }, 445 | value: 1, 446 | }], 447 | x: [1, 2], 448 | y: ['a', 'b'], 449 | z: ['1', '2'], 450 | o: [{ a: 1, b: '1' }, { a: 2, b: '2' }], 451 | }, 452 | { 453 | foo: { id: 2, value: 2 }, 454 | bars: [{ 455 | obj: { o: { a: 3, b: '3' }, x: 3, y: 'c', z: '3' }, 456 | value: 0, 457 | }], 458 | x: [3], 459 | y: ['c'], 460 | z: ['3'], 461 | o: [{ a: 3, b: '3' }], 462 | }, 463 | ]) 464 | }) 465 | 466 | it('$.array with expressions', async () => { 467 | const res = await database.join(['foo', 'bar'], (foo, bar) => $.eq(foo.id, bar.pid)) 468 | .groupBy('foo', { 469 | bars: row => $.array($.object({ 470 | value: row.bar.value, 471 | value2: $.add(row.bar.value, row.foo.value), 472 | })), 473 | x: row => $.array($.add(1, row.bar.obj.x)), 474 | y: row => $.array(row.bar.obj.y), 475 | }) 476 | .orderBy(row => row.foo.id) 477 | .execute() 478 | 479 | expect(res).to.have.shape([ 480 | { 481 | foo: { id: 1, value: 0 }, 482 | bars: [{ value: 0, value2: 0 }, { value: 1, value2: 1 }], 483 | x: [2, 3], 484 | y: ['a', 'b'], 485 | }, 486 | { 487 | foo: { id: 2, value: 2 }, 488 | bars: [{ value: 0, value2: 2 }], 489 | x: [4], 490 | y: ['c'], 491 | }, 492 | ]) 493 | }) 494 | 495 | it('$.array nested', async () => { 496 | const res = await database.join(['foo', 'bar'], (foo, bar) => $.eq(foo.id, bar.pid)) 497 | .orderBy(row => row.foo.id) 498 | .groupBy('foo', { 499 | y: row => $.array(row.bar.obj.x), 500 | }) 501 | .groupBy({}, { 502 | z: row => $.array(row.y), 503 | }) 504 | .execute() 505 | 506 | expect(res).to.have.shape([ 507 | { 508 | z: [[1, 2], [3]], 509 | }, 510 | ]) 511 | }) 512 | 513 | it('non-aggr func', async () => { 514 | const res = await database.join(['foo', 'bar'], (foo, bar) => $.eq(foo.id, bar.pid)) 515 | .groupBy('foo', { 516 | y: row => $.array(row.bar.obj.x), 517 | }) 518 | .project({ 519 | sum: row => $.sum(row.y), 520 | avg: row => $.avg(row.y), 521 | min: row => $.min(row.y), 522 | max: row => $.max(row.y), 523 | count: row => $.length(row.y), 524 | }) 525 | .orderBy(row => row.count) 526 | .execute() 527 | 528 | expect(res).to.deep.equal([ 529 | { sum: 3, avg: 3, min: 3, max: 3, count: 1 }, 530 | { sum: 3, avg: 1.5, min: 1, max: 2, count: 2 }, 531 | ]) 532 | }) 533 | 534 | it('non-aggr func inside aggr', async () => { 535 | const res = await database.join(['foo', 'bar'], (foo, bar) => $.eq(foo.id, bar.pid)) 536 | .orderBy(row => row.foo.id) 537 | .groupBy('foo', { 538 | y: row => $.array(row.bar.obj.x), 539 | }) 540 | .groupBy({}, { 541 | sum: row => $.avg($.sum(row.y)), 542 | avg: row => $.avg($.avg(row.y)), 543 | min: row => $.min($.min(row.y)), 544 | max: row => $.max($.max(row.y)), 545 | }) 546 | .execute() 547 | 548 | expect(res).to.deep.equal([ 549 | { sum: 3, avg: 2.25, min: 1, max: 3 }, 550 | ]) 551 | }) 552 | 553 | it('pass sqlType', async () => { 554 | const res = await database.select('bar') 555 | .project({ 556 | x: row => row.l, 557 | y: row => row.obj, 558 | }) 559 | .execute() 560 | 561 | expect(res).to.deep.equal([ 562 | { x: ['1', '2'], y: { x: 1, y: 'a', z: '1', o: { a: 1, b: '1' } } }, 563 | { x: ['5', '3', '4'], y: { x: 2, y: 'b', z: '2', o: { a: 2, b: '2' } } }, 564 | { x: ['2'], y: { x: 3, y: 'c', z: '3', o: { a: 3, b: '3' } } }, 565 | ]) 566 | }) 567 | 568 | it('pass sqlType in join', async () => { 569 | const res = await database.join({ 570 | foo: 'foo', 571 | bar: 'bar', 572 | }, ({ foo, bar }) => $.eq(foo.id, bar.pid)) 573 | .project({ 574 | x: row => row.bar.l, 575 | y: row => row.bar.obj, 576 | }) 577 | .execute() 578 | 579 | expect(res).to.have.deep.members([ 580 | { x: ['1', '2'], y: { x: 1, y: 'a', z: '1', o: { a: 1, b: '1' } } }, 581 | { x: ['5', '3', '4'], y: { x: 2, y: 'b', z: '2', o: { a: 2, b: '2' } } }, 582 | { x: ['2'], y: { x: 3, y: 'c', z: '3', o: { a: 3, b: '3' } } }, 583 | ]) 584 | }) 585 | } 586 | } 587 | 588 | export default JsonTests 589 | -------------------------------------------------------------------------------- /packages/sqlite/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Binary, deepEqual, Dict, difference, isNullable, makeArray, mapValues } from 'cosmokit' 2 | import { Driver, Eval, executeUpdate, Field, getCell, hasSubquery, isEvalExpr, Selection, z } from 'minato' 3 | import { escapeId } from '@minatojs/sql-utils' 4 | import { resolve } from 'node:path' 5 | import { access, readFile, writeFile } from 'node:fs/promises' 6 | import { createRequire } from 'node:module' 7 | import init from '@minatojs/sql.js' 8 | import enUS from './locales/en-US.yml' 9 | import zhCN from './locales/zh-CN.yml' 10 | import { SQLiteBuilder } from './builder' 11 | import { pathToFileURL } from 'node:url' 12 | 13 | function getTypeDef({ deftype: type }: Field) { 14 | switch (type) { 15 | case 'primary': 16 | case 'boolean': 17 | case 'integer': 18 | case 'unsigned': 19 | case 'bigint': 20 | case 'date': 21 | case 'time': 22 | case 'timestamp': return `INTEGER` 23 | case 'float': 24 | case 'double': 25 | case 'decimal': return `REAL` 26 | case 'char': 27 | case 'string': 28 | case 'text': 29 | case 'list': 30 | case 'json': return `TEXT` 31 | case 'binary': return `BLOB` 32 | default: throw new Error(`unsupported type: ${type}`) 33 | } 34 | } 35 | 36 | interface SQLiteFieldInfo { 37 | cid: number 38 | name: string 39 | type: string 40 | notnull: number 41 | dflt_value: string 42 | pk: boolean 43 | } 44 | 45 | interface SQLiteMasterInfo { 46 | type: string 47 | name: string 48 | tbl_name: string 49 | sql: string 50 | } 51 | 52 | export class SQLiteDriver extends Driver { 53 | static name = 'sqlite' 54 | 55 | path!: string 56 | db!: init.Database 57 | sql = new SQLiteBuilder(this) 58 | beforeUnload?: () => void 59 | 60 | private _transactionTask?: Promise 61 | 62 | /** synchronize table schema */ 63 | async prepare(table: string, dropKeys?: string[]) { 64 | const columns = this._all(`PRAGMA table_info(${escapeId(table)})`) as SQLiteFieldInfo[] 65 | const model = this.model(table) 66 | const columnDefs: string[] = [] 67 | const indexDefs: string[] = [] 68 | const alter: string[] = [] 69 | const mapping: Dict = {} 70 | let shouldMigrate = false 71 | 72 | // field definitions 73 | for (const key in model.fields) { 74 | if (!Field.available(model.fields[key])) { 75 | if (dropKeys?.includes(key)) shouldMigrate = true 76 | continue 77 | } 78 | 79 | const legacy = [key, ...model.fields[key]!.legacy || []] 80 | const column = columns.find(({ name }) => legacy.includes(name)) 81 | const { initial, nullable = true } = model.fields[key]! 82 | const typedef = getTypeDef(model.fields[key]!) 83 | let def = `${escapeId(key)} ${typedef}` 84 | if (key === model.primary && model.autoInc) { 85 | def += ' NOT NULL PRIMARY KEY AUTOINCREMENT' 86 | } else { 87 | def += (nullable ? ' ' : ' NOT ') + 'NULL' 88 | if (!isNullable(initial)) { 89 | def += ' DEFAULT ' + this.sql.escape(this.sql.dump({ [key]: initial }, model)[key]) 90 | } 91 | } 92 | columnDefs.push(def) 93 | if (!column) { 94 | alter.push('ADD ' + def) 95 | } else { 96 | mapping[column.name] = key 97 | shouldMigrate ||= column.name !== key || column.type !== typedef 98 | } 99 | } 100 | 101 | // index definitions 102 | if (model.primary && !model.autoInc) { 103 | indexDefs.push(`PRIMARY KEY (${this._joinKeys(makeArray(model.primary))})`) 104 | } 105 | if (model.unique) { 106 | indexDefs.push(...model.unique.map(keys => `UNIQUE (${this._joinKeys(makeArray(keys))})`)) 107 | } 108 | if (model.foreign) { 109 | indexDefs.push(...Object.entries(model.foreign).map(([key, value]) => { 110 | const [table, key2] = value! 111 | return `FOREIGN KEY (\`${key}\`) REFERENCES ${escapeId(table)} (\`${key2}\`)` 112 | })) 113 | } 114 | 115 | if (!columns.length) { 116 | this.logger.info('auto creating table %c', table) 117 | this._run(`CREATE TABLE ${escapeId(table)} (${[...columnDefs, ...indexDefs].join(', ')})`) 118 | } else if (shouldMigrate) { 119 | // preserve old columns 120 | for (const { name, type, notnull, pk, dflt_value: value } of columns) { 121 | if (mapping[name] || dropKeys?.includes(name)) continue 122 | let def = `${escapeId(name)} ${type}` 123 | def += (notnull ? ' NOT ' : ' ') + 'NULL' 124 | if (pk) def += ' PRIMARY KEY' 125 | if (value !== null) def += ' DEFAULT ' + this.sql.escape(value) 126 | columnDefs.push(def) 127 | mapping[name] = name 128 | } 129 | 130 | const temp = table + '_temp' 131 | const fields = Object.keys(mapping).map(escapeId).join(', ') 132 | this.logger.info('auto migrating table %c', table) 133 | this._run(`CREATE TABLE ${escapeId(temp)} (${[...columnDefs, ...indexDefs].join(', ')})`) 134 | try { 135 | this._run(`INSERT INTO ${escapeId(temp)} SELECT ${fields} FROM ${escapeId(table)}`) 136 | this._run(`DROP TABLE ${escapeId(table)}`) 137 | } catch (error) { 138 | this._run(`DROP TABLE ${escapeId(temp)}`) 139 | throw error 140 | } 141 | this._run(`ALTER TABLE ${escapeId(temp)} RENAME TO ${escapeId(table)}`) 142 | } else if (alter.length) { 143 | this.logger.info('auto updating table %c', table) 144 | for (const def of alter) { 145 | this._run(`ALTER TABLE ${escapeId(table)} ${def}`) 146 | } 147 | } 148 | 149 | if (dropKeys) return 150 | dropKeys = [] 151 | await this.migrate(table, { 152 | error: this.logger.warn, 153 | before: keys => keys.every(key => columns.some(({ name }) => name === key)), 154 | after: keys => dropKeys!.push(...keys), 155 | finalize: () => { 156 | if (!dropKeys!.length) return 157 | this.prepare(table, dropKeys) 158 | }, 159 | }) 160 | } 161 | 162 | async start() { 163 | this.path = this.config.path 164 | if (this.path !== ':memory:') { 165 | this.path = resolve(this.ctx.baseDir, this.path) 166 | } 167 | const isBrowser = process.env.KOISHI_ENV === 'browser' 168 | const sqlite = await init({ 169 | locateFile: (file: string) => process.env.KOISHI_BASE 170 | ? process.env.KOISHI_BASE + '/' + file 171 | : isBrowser 172 | ? '/modules/@koishijs/plugin-database-sqlite/' + file 173 | // @ts-ignore 174 | : createRequire(import.meta.url || pathToFileURL(__filename).href).resolve('@minatojs/sql.js/dist/' + file), 175 | }) 176 | 177 | if (this.path !== ':memory:') { 178 | const dir = resolve(this.path, '..') 179 | try { 180 | await access(dir) 181 | } catch { 182 | throw new Error(`The database directory '${resolve(this.path, '..')}' is not accessible. You may have to create it first.`) 183 | } 184 | } 185 | if (!isBrowser || this.path === ':memory:') { 186 | this.db = new sqlite.Database(this.path) 187 | } else { 188 | const buffer = await readFile(this.path).catch(() => null) 189 | this.db = new sqlite.Database(this.path, buffer) 190 | if (isBrowser) { 191 | window.addEventListener('beforeunload', this.beforeUnload = () => { 192 | this._export() 193 | }) 194 | } 195 | } 196 | this.db.create_function('regexp', (pattern, str) => +new RegExp(pattern).test(str)) 197 | this.db.create_function('regexp2', (pattern, str, flags) => +new RegExp(pattern, flags).test(str)) 198 | this.db.create_function('json_array_contains', (array, value) => +(JSON.parse(array) as any[]).includes(JSON.parse(value))) 199 | this.db.create_function('modulo', (left, right) => left % right) 200 | this.db.create_function('rand', () => Math.random()) 201 | 202 | this.define({ 203 | types: ['boolean'], 204 | dump: value => isNullable(value) ? value : +value, 205 | load: (value) => isNullable(value) ? value : !!value, 206 | }) 207 | 208 | this.define({ 209 | types: ['json'], 210 | dump: value => JSON.stringify(value), 211 | load: value => typeof value === 'string' ? JSON.parse(value) : value, 212 | }) 213 | 214 | this.define({ 215 | types: ['list'], 216 | dump: value => Array.isArray(value) ? value.join(',') : value, 217 | load: value => value ? value.split(',') : [], 218 | }) 219 | 220 | this.define({ 221 | types: ['date', 'time', 'timestamp'], 222 | dump: value => isNullable(value) ? value as any : +new Date(value), 223 | load: value => isNullable(value) ? value : new Date(Number(value)), 224 | }) 225 | 226 | this.define({ 227 | types: ['binary'], 228 | dump: value => isNullable(value) ? value : new Uint8Array(value), 229 | load: value => isNullable(value) ? value : Binary.fromSource(value), 230 | }) 231 | 232 | this.define({ 233 | types: ['primary', ...Field.number as any], 234 | dump: value => value, 235 | load: value => isNullable(value) ? value : Number(value), 236 | }) 237 | } 238 | 239 | _joinKeys(keys?: string[]) { 240 | return keys?.length ? keys.map(key => `\`${key}\``).join(', ') : '*' 241 | } 242 | 243 | async stop() { 244 | await new Promise(resolve => setTimeout(resolve, 0)) 245 | this.db?.close() 246 | if (this.beforeUnload) { 247 | this.beforeUnload() 248 | window.removeEventListener('beforeunload', this.beforeUnload) 249 | } 250 | } 251 | 252 | _exec(sql: string, params: any, callback: (stmt: init.Statement) => any) { 253 | try { 254 | const stmt = this.db.prepare(sql) 255 | const result = callback(stmt) 256 | stmt.free() 257 | this.logger.debug('> %s', sql, params) 258 | return result 259 | } catch (e) { 260 | this.logger.warn('> %s', sql, params) 261 | throw e 262 | } 263 | } 264 | 265 | _all(sql: string, params: any = [], config?: { useBigInt: boolean }) { 266 | return this._exec(sql, params, (stmt) => { 267 | stmt.bind(params) 268 | const result: any[] = [] 269 | while (stmt.step()) { 270 | // @ts-ignore 271 | result.push(stmt.getAsObject(null, config)) 272 | } 273 | return result 274 | }) 275 | } 276 | 277 | _get(sql: string, params: any = [], config?: { useBigInt: boolean }) { 278 | // @ts-ignore 279 | return this._exec(sql, params, stmt => stmt.getAsObject(params, config)) 280 | } 281 | 282 | _export() { 283 | const data = this.db.export() 284 | return writeFile(this.path, data) 285 | } 286 | 287 | _run(sql: string, params: any = [], callback?: () => any) { 288 | this._exec(sql, params, stmt => stmt.run(params)) 289 | const result = callback?.() 290 | return result 291 | } 292 | 293 | async drop(table: string) { 294 | this._run(`DROP TABLE ${escapeId(table)}`) 295 | } 296 | 297 | async dropAll() { 298 | const tables = Object.keys(this.database.tables) 299 | for (const table of tables) { 300 | this._run(`DROP TABLE ${escapeId(table)}`) 301 | } 302 | } 303 | 304 | async stats() { 305 | const stats: Driver.Stats = { size: this.db.size(), tables: {} } 306 | const tableNames: { name: string }[] = this._all('SELECT name FROM sqlite_master WHERE type="table" ORDER BY name;') 307 | const dbstats: { name: string; size: number }[] = this._all('SELECT name, pgsize as size FROM "dbstat" WHERE aggregate=TRUE;') 308 | tableNames.forEach(tbl => { 309 | stats.tables[tbl.name] = this._get(`SELECT COUNT(*) as count FROM ${escapeId(tbl.name)};`) 310 | stats.tables[tbl.name].size = dbstats.find(o => o.name === tbl.name)!.size 311 | }) 312 | return stats 313 | } 314 | 315 | async remove(sel: Selection.Mutable) { 316 | const { query, table, tables } = sel 317 | const builder = new SQLiteBuilder(this, tables) 318 | const filter = builder.parseQuery(query) 319 | if (filter === '0') return {} 320 | const result = this._run(`DELETE FROM ${escapeId(table)} WHERE ${filter}`, [], () => this._get(`SELECT changes() AS count`)) 321 | return { matched: result.count, removed: result.count } 322 | } 323 | 324 | async get(sel: Selection.Immutable) { 325 | const { model, tables } = sel 326 | const builder = new SQLiteBuilder(this, tables) 327 | const sql = builder.get(sel) 328 | if (!sql) return [] 329 | const rows: any[] = this._all(sql, [], { useBigInt: true }) 330 | return rows.map(row => builder.load(row, model)) 331 | } 332 | 333 | async eval(sel: Selection.Immutable, expr: Eval.Expr) { 334 | const builder = new SQLiteBuilder(this, sel.tables) 335 | const inner = builder.get(sel.table as Selection, true, true) 336 | const output = builder.parseEval(expr, false) 337 | const { value } = this._get(`SELECT ${output} AS value FROM ${inner}`, [], { useBigInt: true }) 338 | return builder.load(value, expr) 339 | } 340 | 341 | _update(sel: Selection.Mutable, indexFields: string[], updateFields: string[], update: {}, data: {}) { 342 | const { ref, table, tables, model } = sel 343 | const builder = new SQLiteBuilder(this, tables) 344 | executeUpdate(data, update, ref) 345 | const row = builder.dump(data, model) 346 | const assignment = updateFields.map((key) => `${escapeId(key)} = ?`).join(',') 347 | const query = Object.fromEntries(indexFields.map(key => [key, row[key]])) 348 | const filter = builder.parseQuery(query) 349 | this._run(`UPDATE ${escapeId(table)} SET ${assignment} WHERE ${filter}`, updateFields.map((key) => row[key] ?? null)) 350 | } 351 | 352 | async set(sel: Selection.Mutable, update: {}) { 353 | const { model, table, query } = sel 354 | const { primary } = model, fields = model.avaiableFields() 355 | const updateFields = [...new Set(Object.keys(update).map((key) => { 356 | return Object.keys(fields).find(field => field === key || key.startsWith(field + '.'))! 357 | }))] 358 | const primaryFields = makeArray(primary) 359 | if (query.$expr || hasSubquery(sel.query) || Object.values(update).some(x => hasSubquery(x))) { 360 | const sel2 = this.database.select(table as never, query) 361 | sel2.tables[sel.ref] = sel2.tables[sel2.ref] 362 | delete sel2.tables[sel2.ref] 363 | sel2.ref = sel.ref 364 | const project = mapValues(update as any, (value, key) => () => (isEvalExpr(value) ? value : Eval.literal(value, model.getType(key)))) 365 | const rawUpsert = await sel2.project({ 366 | ...project, 367 | // do not touch sel2.row since it is not patched 368 | ...Object.fromEntries(primaryFields.map(x => [x, () => Eval('', [sel.ref, x], sel2.model.getType(x)!)])), 369 | }).execute() 370 | const upsert = rawUpsert.map(row => ({ 371 | ...mapValues(update, (_, key) => getCell(row, key)), 372 | ...Object.fromEntries(primaryFields.map(x => [x, getCell(row, x)])), 373 | })) 374 | return this.database.upsert(table, upsert) 375 | } else { 376 | const data = await this.database.get(table as never, query) 377 | for (const row of data) { 378 | this._update(sel, primaryFields, updateFields, update, row) 379 | } 380 | return { matched: data.length } 381 | } 382 | } 383 | 384 | _create(table: string, data: {}) { 385 | const model = this.model(table) 386 | data = this.sql.dump(data, model) 387 | const keys = Object.keys(data) 388 | const sql = `INSERT INTO ${escapeId(table)} (${this._joinKeys(keys)}) VALUES (${Array(keys.length).fill('?').join(', ')})` 389 | return this._run(sql, keys.map(key => data[key] ?? null), () => this._get(`SELECT last_insert_rowid() AS id`)) 390 | } 391 | 392 | async create(sel: Selection.Mutable, data: {}) { 393 | const { model, table } = sel 394 | const { id } = this._create(table, data) 395 | const { autoInc, primary } = model 396 | if (!autoInc || Array.isArray(primary)) return data as any 397 | return { ...data, [primary]: id } 398 | } 399 | 400 | async upsert(sel: Selection.Mutable, data: any[], keys: string[]) { 401 | if (!data.length) return {} 402 | const { model, table, ref } = sel 403 | const fields = model.avaiableFields() 404 | const result = { inserted: 0, matched: 0, modified: 0 } 405 | const dataFields = [...new Set(Object.keys(Object.assign({}, ...data)).map((key) => { 406 | return Object.keys(fields).find(field => field === key || key.startsWith(field + '.'))! 407 | }))] 408 | let updateFields = difference(dataFields, keys) 409 | if (!updateFields.length) updateFields = [dataFields[0]] 410 | // Error: Expression tree is too large (maximum depth 1000) 411 | const step = Math.floor(960 / keys.length) 412 | for (let i = 0; i < data.length; i += step) { 413 | const chunk = data.slice(i, i + step) 414 | const results = await this.database.get(table as never, { 415 | $or: chunk.map(item => Object.fromEntries(keys.map(key => [key, item[key]]))), 416 | }) 417 | for (const item of chunk) { 418 | const row = results.find(row => { 419 | // flatten key to respect model 420 | row = model.format(row) 421 | return keys.every(key => deepEqual(row[key], item[key], true)) 422 | }) 423 | if (row) { 424 | this._update(sel, keys, updateFields, item, row) 425 | result.matched++ 426 | } else { 427 | this._create(table, executeUpdate(model.create(), item, ref)) 428 | result.inserted++ 429 | } 430 | } 431 | } 432 | return result 433 | } 434 | 435 | async withTransaction(callback: () => Promise) { 436 | if (this._transactionTask) await this._transactionTask.catch(() => {}) 437 | return this._transactionTask = new Promise((resolve, reject) => { 438 | this._run('BEGIN TRANSACTION') 439 | callback().then( 440 | () => resolve(this._run('COMMIT')), 441 | (e) => (this._run('ROLLBACK'), reject(e)), 442 | ) 443 | }) 444 | } 445 | 446 | async getIndexes(table: string) { 447 | const indexes = this._all(`SELECT type,name,tbl_name,sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ?`, [table]) as SQLiteMasterInfo[] 448 | const result: Driver.Index[] = [] 449 | for (const { name, sql } of indexes) { 450 | result.push({ 451 | name, 452 | unique: !sql || sql.toUpperCase().startsWith('CREATE UNIQUE'), 453 | keys: this._parseIndexDef(sql), 454 | }) 455 | } 456 | return result 457 | } 458 | 459 | async createIndex(table: string, index: Driver.Index) { 460 | const name = index.name ?? Object.entries(index.keys).map(([key, direction]) => `${key}_${direction ?? 'asc'}`).join('+') 461 | const keyFields = Object.entries(index.keys).map(([key, direction]) => `${escapeId(key)} ${direction ?? 'asc'}`).join(', ') 462 | await this._run(`create ${index.unique ? 'UNIQUE' : ''} index ${escapeId(name)} ON ${escapeId(table)} (${keyFields})`) 463 | } 464 | 465 | async dropIndex(table: string, name: string) { 466 | await this._run(`DROP INDEX ${escapeId(name)}`) 467 | } 468 | 469 | _parseIndexDef(def: string) { 470 | if (!def) return {} 471 | try { 472 | const keys = {}, matches = def.match(/\((.*)\)/)! 473 | matches[1].split(',').forEach((key) => { 474 | const [name, direction] = key.trim().split(' ') 475 | keys[name.startsWith('`') ? name.slice(1, -1) : name] = direction?.toLowerCase() === 'desc' ? 'desc' : 'asc' 476 | }) 477 | return keys 478 | } catch { 479 | return {} 480 | } 481 | } 482 | } 483 | 484 | export namespace SQLiteDriver { 485 | export interface Config { 486 | path: string 487 | } 488 | 489 | export const Config: z = z.object({ 490 | path: z.string().role('path').required(), 491 | }).i18n({ 492 | 'en-US': enUS, 493 | 'zh-CN': zhCN, 494 | }) 495 | } 496 | 497 | export default SQLiteDriver 498 | --------------------------------------------------------------------------------