feat: metona-sqlark v0.1.12 — 前端TypeScript关系型数据库
- 4种存储引擎:Memory / IndexedDB / OPFS / Hybrid - 完整SQL支持:SELECT/INSERT/UPDATE/DELETE/JOIN/GROUP BY/HAVING/DISTINCT - Query Builder链式API + TypeScript泛型支持 - 聚合函数:COUNT/SUM/AVG/MIN/MAX - 事务、插件系统(14 hooks)、发布订阅、数据迁移、导入导出 - React/Vue框架集成 - 264个测试用例,93.46%覆盖率 - 零运行时依赖
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2020": true,
|
||||
"node": true,
|
||||
"jest": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": ["eslint:recommended"],
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"no-undef": "off",
|
||||
"no-empty": "off",
|
||||
"no-useless-escape": "off",
|
||||
"no-control-regex": "off",
|
||||
"no-regex-spaces": "off",
|
||||
"no-constant-condition": ["warn", { "checkLoops": false }]
|
||||
},
|
||||
"ignorePatterns": ["dist/", "coverage/", "node_modules/"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: debian-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x, 24.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run tests
|
||||
run: npx jest --forceExit --maxWorkers=2 --no-cache
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
|
||||
# 本地 npm 发布凭据(含 _auth,勿提交)
|
||||
.npmrc
|
||||
@@ -0,0 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to YOUR-PROJECT will be documented in this file.
|
||||
|
||||
## [0.0.1] - 2026-07-25
|
||||
|
||||
### Added
|
||||
- 初始项目骨架
|
||||
- TypeScript 配置(严格模式)
|
||||
- Rollup 构建(UMD / ESM / CJS / min / .d.ts)
|
||||
- Jest + jsdom 测试环境
|
||||
- ESLint + @typescript-eslint
|
||||
- Gitea Actions CI 工作流
|
||||
- 示例站点(index / demo / docs)
|
||||
- 构建脚本 build.sh / serve.sh
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
# Contributing to MetonaEditor
|
||||
|
||||
Thanks for your interest in contributing! This document outlines the development workflow and conventions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 16.0.0
|
||||
- npm >= 8.0.0
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://git.metona.cn/MetonaTeam/MetonaEditor.git
|
||||
cd MetonaEditor
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start dev server with hot reload (port 3001)
|
||||
npm run dev
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Type check
|
||||
npm run typecheck
|
||||
|
||||
# Lint
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
|
||||
# Format
|
||||
npm run format
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # Entry point, global API
|
||||
├── core.ts # MarkdownEditor class
|
||||
├── parser.ts # Markdown parser (tokenizer + renderer)
|
||||
├── plugins.ts # Plugin system & 6 presets
|
||||
├── themes.ts # Theme system
|
||||
├── i18n.ts # Internationalization
|
||||
├── styles.ts # CSS-in-JS injection
|
||||
├── constants.ts # Types, defaults, configs
|
||||
├── utils.ts # Utility functions
|
||||
├── animations.ts # Animation metadata
|
||||
├── icons.ts # Toolbar SVG icons
|
||||
└── locales.ts # Translation data
|
||||
|
||||
tests/
|
||||
├── parser.test.ts
|
||||
├── core.test.ts
|
||||
├── plugins.test.ts
|
||||
├── themes.test.ts
|
||||
├── i18n.test.ts
|
||||
├── utils.test.ts
|
||||
├── animations.test.ts
|
||||
├── index.test.ts
|
||||
└── styles.test.ts
|
||||
|
||||
site/
|
||||
├── index.html # Landing page
|
||||
├── demo.html # Full-featured demo
|
||||
└── docs.html # API documentation
|
||||
```
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### TypeScript
|
||||
- **Strict mode** is enabled — all code must pass `tsc --noEmit`.
|
||||
- Export types explicitly. Avoid `any` where possible.
|
||||
- Use `interface` for object shapes, `type` for unions/primitives.
|
||||
|
||||
### Style
|
||||
- Run `npm run format` before committing (uses Prettier).
|
||||
- Follow existing comment patterns: JSDoc `/** */` for public APIs, `//` for inline notes.
|
||||
- Keep functions focused and under ~60 lines where practical.
|
||||
|
||||
### Testing
|
||||
- **Every new feature must include tests.**
|
||||
- Test files mirror source structure: `src/foo.ts` → `tests/foo.test.ts`.
|
||||
- Use descriptive test names: `('does X when Y')`.
|
||||
- Run the full suite before submitting: `npm test`.
|
||||
|
||||
### Commits
|
||||
- Use conventional commit messages:
|
||||
- `feat: add reference link resolution`
|
||||
- `fix: autoSave plugin state conflict`
|
||||
- `docs: update API reference`
|
||||
- `test: add index.ts global API tests`
|
||||
- `chore: optimize rollup dev build`
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Production build (all formats)
|
||||
npm run build
|
||||
|
||||
# Output in dist/
|
||||
# ├── metona-editor.js UMD
|
||||
# ├── metona-editor.min.js UMD minified
|
||||
# ├── metona-editor.esm.js ES Module
|
||||
# ├── metona-editor.cjs.js CommonJS
|
||||
# └── metona-editor.d.ts TypeScript declarations
|
||||
```
|
||||
|
||||
## Plugin Development
|
||||
|
||||
Plugins follow a simple convention:
|
||||
|
||||
```typescript
|
||||
const myPlugin = {
|
||||
name: 'myPlugin',
|
||||
version: '1.0.0',
|
||||
description: 'Description of my plugin',
|
||||
depends: [], // optional: plugin names this depends on
|
||||
priority: 50, // optional: for topological sort ordering
|
||||
|
||||
install(editor, options?) {
|
||||
// Called when plugin is installed
|
||||
// Use editor.on() to subscribe to events
|
||||
// Return a Promise for async initialization
|
||||
},
|
||||
|
||||
destroy(editor) {
|
||||
// Called when plugin is uninstalled
|
||||
// Clean up event listeners, timers, DOM nodes
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
1. Update version in `package.json` and `src/index.ts` (`VERSION` constant).
|
||||
2. Update `CHANGELOG.md`.
|
||||
3. Run full test suite: `npm test`.
|
||||
4. Build: `npm run build`.
|
||||
5. Publish: `npm publish`.
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue at [git.metona.cn/MetonaTeam/MetonaEditor/issues](https://git.metona.cn/MetonaTeam/MetonaEditor/issues).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 thzxx
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,184 @@
|
||||
# metona-sqlark
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.1.12-blue?style=flat-square" alt="version">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
||||
<img src="https://img.shields.io/badge/coverage-93.46%25-brightgreen?style=flat-square" alt="coverage">
|
||||
<img src="https://img.shields.io/badge/tests-264%20passed-success?style=flat-square" alt="tests">
|
||||
</p>
|
||||
|
||||
> 基于 TypeScript 的**前端关系型数据库**,内存与磁盘双模式运行,支持完整 SQL 查询与 Query Builder 链式 API。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- 🧠 **双模式存储** — Memory / Disk / Hybrid,内存极速 + 磁盘持久化
|
||||
- ⚡ **完整 SQL** — SELECT / INSERT / UPDATE / DELETE / JOIN / GROUP BY / HAVING / DISTINCT
|
||||
- 🔗 **Query Builder** — 链式 API,TypeScript 类型友好
|
||||
- 📊 **聚合函数** — COUNT / SUM / AVG / MIN / MAX
|
||||
- 🔒 **事务支持** — 原子性操作,失败自动回滚
|
||||
- 🧩 **插件系统** — 14 种生命周期钩子
|
||||
- 📡 **发布订阅** — 表级变更监听
|
||||
- 🔄 **数据迁移** — 版本化 migration 系统
|
||||
- 📤 **导入导出** — JSON 格式全库/单表导入导出
|
||||
- ⚛️ **React / Vue** — 原生 hooks & composables
|
||||
- 📦 **零运行时依赖** — 纯 TypeScript,约 42KB min+gzip
|
||||
|
||||
---
|
||||
|
||||
## 📦 安装
|
||||
|
||||
```bash
|
||||
npm install @metona-team/metona-sqlark
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ESM / TypeScript
|
||||
import { MetonaSqlark, MeSqlark } from '@metona-team/metona-sqlark';
|
||||
|
||||
// Browser UMD
|
||||
<script src="metona-sqlark.min.js"></script>
|
||||
// → window.MetonaSqlark / window.MeSqlark
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
```typescript
|
||||
import { MetonaSqlark } from '@metona-team/metona-sqlark';
|
||||
|
||||
// 创建数据库
|
||||
const db = await MetonaSqlark.create({
|
||||
name: 'my-app',
|
||||
mode: 'hybrid',
|
||||
diskEngine: 'indexeddb',
|
||||
});
|
||||
|
||||
// 定义表
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
age: { type: 'number', default: 0 },
|
||||
email: { type: 'string', unique: true, index: true },
|
||||
});
|
||||
|
||||
// SQL 查询
|
||||
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30, 'alice@demo.com')");
|
||||
const rows = await db.query('SELECT * FROM users WHERE age > 18 ORDER BY name');
|
||||
|
||||
// Query Builder
|
||||
const users = db.table('users');
|
||||
await users.insert({ id: '2', name: 'Bob', age: 25, email: 'bob@demo.com' });
|
||||
const result = await users.select().where({ age: { $gt: 18 } }).orderBy('age', 'desc').execute();
|
||||
|
||||
// JOIN
|
||||
await db.query(`SELECT u.name, o.product FROM users u INNER JOIN orders o ON u.id = o.user_id`);
|
||||
|
||||
// GROUP BY
|
||||
await db.query(`SELECT dept, COUNT(*) FROM employees GROUP BY dept HAVING COUNT(*) > 1`);
|
||||
|
||||
// 事务
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.table('users').insert({ id: '3', name: 'Charlie' });
|
||||
await trx.table('orders').insert({ id: 'o1', userId: '3', amount: 99 });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 API 速览
|
||||
|
||||
### 数据库配置
|
||||
|
||||
| 属性 | 类型 | 默认 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | `string` | `'metona-sqlark'` | 数据库名称 |
|
||||
| `mode` | `'memory' \| 'disk' \| 'hybrid'` | `'hybrid'` | 存储模式 |
|
||||
| `diskEngine` | `'indexeddb' \| 'opfs'` | `'indexeddb'` | 磁盘引擎 |
|
||||
| `version` | `number` | `1` | 版本号 |
|
||||
|
||||
### WHERE 操作符
|
||||
|
||||
| 操作符 | 含义 | 操作符 | 含义 |
|
||||
|--------|------|--------|------|
|
||||
| `$eq` / 直接值 | 等于 | `$gt` / `$gte` | 大于 / 大于等于 |
|
||||
| `$ne` | 不等于 | `$lt` / `$lte` | 小于 / 小于等于 |
|
||||
| `$in` / `$nin` | 在列表中 | `$like` | 模糊匹配 |
|
||||
| `$and` / `$or` / `$not` | 逻辑组合 | | |
|
||||
|
||||
### 核心方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `db.query(sql)` | 执行 SQL 字符串 |
|
||||
| `db.table(name)` | 获取表操作对象 |
|
||||
| `db.defineTable(name, cols)` | 定义表结构 |
|
||||
| `db.transaction(fn)` | 执行事务 |
|
||||
| `db.exportTable(name)` / `db.exportAll()` | 导出数据 |
|
||||
| `db.addMigration(v, fn)` / `db.migrateTo(v)` | 数据迁移 |
|
||||
| `db.subscribe(table, fn)` | 订阅表变更 |
|
||||
| `db.on(hook, fn)` | 注册钩子 |
|
||||
|
||||
### React / Vue 集成
|
||||
|
||||
```tsx
|
||||
// React
|
||||
import { useQuery } from '@metona-team/metona-sqlark/react';
|
||||
const { data, loading, refresh } = useQuery(db, 'SELECT * FROM users');
|
||||
|
||||
// Vue
|
||||
import { useSqlarkQuery } from '@metona-team/metona-sqlark/vue';
|
||||
const { data, loading, refresh } = useSqlarkQuery(db, 'SELECT * FROM users');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 项目状态
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 测试用例 | 264 |
|
||||
| 测试套件 | 15 |
|
||||
| 语句覆盖率 | 93.46% |
|
||||
| SQL 关键字 | 29 |
|
||||
| 存储引擎 | 4(Memory / IndexedDB / OPFS / Hybrid) |
|
||||
|
||||
---
|
||||
|
||||
## 🛠 开发
|
||||
|
||||
```bash
|
||||
npm install # 安装依赖
|
||||
npm run dev # 开发模式(localhost:3001)
|
||||
npm run build # 生产构建
|
||||
npm test # 运行测试
|
||||
npm run lint # 代码检查
|
||||
npm run typecheck # 类型检查
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # 入口(MetonaSqlark + MeSqlark)
|
||||
├── core.ts # 主类
|
||||
├── constants.ts # 类型定义 + 配置
|
||||
├── engine/ # 存储引擎(Memory/IndexedDB/OPFS)
|
||||
├── hybrid/ # 混合引擎
|
||||
├── table/ # 表管理 + Schema 校验
|
||||
├── query/ # AST + Builder + Compiler + Executor
|
||||
├── sql/ # Lexer + Parser
|
||||
├── transaction/ # 事务管理
|
||||
├── plugin/ # 插件系统
|
||||
└── integrations/ # React / Vue hooks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT © [MetonaTeam](https://git.metona.cn/MetonaTeam)
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' }, modules: 'commonjs' }],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-transform-modules-commonjs',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 构建脚本 — 安装依赖 → 测试 → 构建
|
||||
|
||||
echo "🔨 构建脚本"
|
||||
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ 未找到 Node.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Node.js: $(node -v)"
|
||||
|
||||
echo "📦 安装依赖..."
|
||||
npm install || exit 1
|
||||
|
||||
echo "🧪 运行测试..."
|
||||
npm test || exit 1
|
||||
|
||||
echo "🔨 构建..."
|
||||
npm run build || exit 1
|
||||
|
||||
echo ""
|
||||
echo "✅ 构建完成"
|
||||
ls -lh dist/
|
||||
@@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'jsdom',
|
||||
setupFiles: ['./jest.setup.js'],
|
||||
transform: {
|
||||
'^.+\\.ts$': 'babel-jest',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!(@rollup)/)',
|
||||
],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/index.ts',
|
||||
'!src/**/index.ts',
|
||||
'!src/query/ast.ts',
|
||||
'!src/engine/interface.ts',
|
||||
'!src/engine/opfs.ts',
|
||||
'!src/integrations/**',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov'],
|
||||
verbose: true,
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// jest setup: polyfill structuredClone for fake-indexeddb
|
||||
if (typeof globalThis.structuredClone !== 'function') {
|
||||
globalThis.structuredClone = (obj) => JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
Generated
+8111
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "@metona-team/metona-sqlark",
|
||||
"version": "0.1.12",
|
||||
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
|
||||
"type": "module",
|
||||
"main": "dist/metona-sqlark.js",
|
||||
"module": "src/index.ts",
|
||||
"unpkg": "dist/metona-sqlark.min.js",
|
||||
"jsdelivr": "dist/metona-sqlark.min.js",
|
||||
"types": "dist/metona-sqlark.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"require": "./dist/metona-sqlark.js",
|
||||
"types": "./dist/metona-sqlark.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"src/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"dev": "rollup -c -w",
|
||||
"test": "jest --coverage",
|
||||
"test:watch": "jest --watch",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"lint:fix": "eslint \"src/**/*.ts\" --fix",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.metona.cn/MetonaTeam/metona-sqlark.git"
|
||||
},
|
||||
"keywords": [
|
||||
"database",
|
||||
"sql",
|
||||
"frontend",
|
||||
"indexeddb",
|
||||
"opfs",
|
||||
"memory",
|
||||
"browser",
|
||||
"typescript"
|
||||
],
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://git.metona.cn/MetonaTeam/metona-sqlark/issues"
|
||||
},
|
||||
"homepage": "https://git.metona.cn/MetonaTeam/metona-sqlark#readme",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.0",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.22.0",
|
||||
"@babel/preset-env": "^7.22.0",
|
||||
"@babel/preset-typescript": "^7.22.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.0",
|
||||
"@rollup/plugin-terser": "^0.4.0",
|
||||
"@rollup/plugin-typescript": "^11.1.0",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"babel-jest": "^29.5.0",
|
||||
"eslint": "^8.40.0",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jest": "^29.5.0",
|
||||
"jest-environment-jsdom": "^29.5.0",
|
||||
"prettier": "^2.8.0",
|
||||
"rollup": "^3.20.0",
|
||||
"rollup-plugin-dts": "^5.3.0",
|
||||
"rollup-plugin-livereload": "^2.0.5",
|
||||
"rollup-plugin-serve": "^2.0.1",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.metona.cn/api/packages/MetonaTeam/npm/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import dts from 'rollup-plugin-dts';
|
||||
import serve from 'rollup-plugin-serve';
|
||||
import livereload from 'rollup-plugin-livereload';
|
||||
|
||||
const isDev = process.env.ROLLUP_WATCH;
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
const basePlugins = [
|
||||
resolve(),
|
||||
commonjs(),
|
||||
typescript({
|
||||
tsconfig: './tsconfig.json',
|
||||
declaration: false,
|
||||
}),
|
||||
];
|
||||
|
||||
const devPlugins = isDev ? [
|
||||
serve({
|
||||
open: true,
|
||||
contentBase: ['site', 'dist'],
|
||||
port: 3001,
|
||||
}),
|
||||
livereload({
|
||||
watch: ['src', 'site'],
|
||||
}),
|
||||
] : [];
|
||||
|
||||
export default [
|
||||
// UMD development (always built)
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
file: 'dist/metona-sqlark.js',
|
||||
format: 'umd',
|
||||
name: 'MetonaSqlark',
|
||||
exports: 'named',
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: [...basePlugins, ...devPlugins],
|
||||
},
|
||||
// Only build all formats in production
|
||||
...(isDev ? [] : [
|
||||
// ES Module
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
file: 'dist/metona-sqlark.esm.js',
|
||||
format: 'es',
|
||||
exports: 'named',
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: basePlugins,
|
||||
},
|
||||
// CommonJS
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
file: 'dist/metona-sqlark.cjs.js',
|
||||
format: 'cjs',
|
||||
exports: 'named',
|
||||
sourcemap: true,
|
||||
},
|
||||
plugins: basePlugins,
|
||||
},
|
||||
// UMD minified
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
file: 'dist/metona-sqlark.min.js',
|
||||
format: 'umd',
|
||||
name: 'MetonaSqlark',
|
||||
exports: 'named',
|
||||
sourcemap: false,
|
||||
},
|
||||
plugins: [
|
||||
...basePlugins,
|
||||
terser({
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
pure_funcs: ['console.log', 'console.warn'],
|
||||
passes: 2,
|
||||
},
|
||||
format: { comments: false },
|
||||
mangle: { toplevel: true },
|
||||
}),
|
||||
],
|
||||
},
|
||||
// TypeScript declarations bundle
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
file: 'dist/metona-sqlark.d.ts',
|
||||
format: 'es',
|
||||
},
|
||||
plugins: [dts()],
|
||||
},
|
||||
]),
|
||||
];
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>在线演示 — metona-sqlark</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0a0a0f; --surface:#0d1117; --surface2:#161b22; --border:#30363d;
|
||||
--primary:#6366f1; --accent:#06b6d4; --accent2:#ec4899; --green:#22c55e;
|
||||
--text:#e2e8f0; --text2:#8b949e; --radius:12px;
|
||||
--gradient:linear-gradient(135deg,#6366f1,#06b6d4,#ec4899);
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:'Inter',-apple-system,system-ui,sans-serif; background:var(--bg); color:var(--text); height:100vh; overflow:hidden; }
|
||||
/* Header */
|
||||
header {
|
||||
height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 24px;
|
||||
background:var(--surface); border-bottom:1px solid var(--border); z-index:10; position:relative;
|
||||
}
|
||||
.logo { font-weight:800; font-size:1.1rem; display:flex; align-items:center; gap:10px; }
|
||||
.logo .icon { width:28px; height:28px; border-radius:7px; background:var(--gradient); display:flex; align-items:center; justify-content:center; font-size:0.85rem; font-weight:900; color:#fff; }
|
||||
.logo span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
||||
nav { display:flex; gap:20px; }
|
||||
nav a { color:var(--text2); text-decoration:none; font-size:0.88rem; transition:.2s; }
|
||||
nav a:hover,.nav-active { color:var(--text); }
|
||||
.status { display:flex; align-items:center; gap:8px; font-size:0.82rem; color:var(--text2); }
|
||||
.status .dot { width:7px; height:7px; border-radius:50%; background:var(--green); animation:pulse 2s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
/* Main Layout */
|
||||
.main { display:flex; height:calc(100vh - 56px); }
|
||||
.editor-panel { flex:1; display:flex; flex-direction:column; border-right:1px solid var(--border); min-width:400px; }
|
||||
.result-panel { flex:1; display:flex; flex-direction:column; min-width:400px; }
|
||||
.panel-header {
|
||||
padding:12px 20px; font-size:0.78rem; font-weight:600; text-transform:uppercase;
|
||||
letter-spacing:1px; color:var(--text2); border-bottom:1px solid var(--border);
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
}
|
||||
.panel-header .badge {
|
||||
font-size:0.7rem; padding:3px 10px; border-radius:999px; background:var(--surface2);
|
||||
border:1px solid var(--border); text-transform:none; letter-spacing:0;
|
||||
}
|
||||
/* SQL Editor */
|
||||
.editor-area { flex:1; display:flex; flex-direction:column; }
|
||||
#sql-input {
|
||||
flex:1; background:var(--surface); color:var(--text); border:none; padding:20px;
|
||||
font-family:'JetBrains Mono','Fira Code',monospace; font-size:0.9rem; line-height:1.8;
|
||||
resize:none; outline:none; caret-color:var(--primary);
|
||||
}
|
||||
#sql-input::placeholder { color:var(--text2); opacity:0.5; }
|
||||
.toolbar { display:flex; gap:8px; padding:12px 20px; border-top:1px solid var(--border); background:var(--surface2); }
|
||||
.btn {
|
||||
display:inline-flex; align-items:center; gap:6px; padding:10px 20px; border-radius:8px;
|
||||
font-weight:600; font-size:0.85rem; cursor:pointer; border:none; transition:.25s; font-family:inherit;
|
||||
}
|
||||
.btn-run { background:var(--gradient); color:#fff; box-shadow:0 4px 15px rgba(99,102,241,0.35); }
|
||||
.btn-run:hover { transform:translateY(-1px); box-shadow:0 6px 25px rgba(99,102,241,0.5); }
|
||||
.btn-run:active { transform:scale(0.97); }
|
||||
.btn-clear { background:var(--surface2); color:var(--text2); border:1px solid var(--border); }
|
||||
.btn-clear:hover { color:var(--text); border-color:var(--text2); }
|
||||
.btn-preset { background:transparent; color:var(--accent); border:1px solid var(--accent); font-size:0.78rem; padding:6px 14px; }
|
||||
.btn-preset:hover { background:rgba(6,182,212,0.12); }
|
||||
/* Result */
|
||||
.result-content { flex:1; overflow-y:auto; padding:20px; }
|
||||
.result-table { width:100%; border-collapse:collapse; font-size:0.85rem; }
|
||||
.result-table th { text-align:left; padding:10px 14px; background:var(--surface2); color:var(--accent); font-weight:600; border-bottom:2px solid var(--border); position:sticky; top:0; z-index:1; }
|
||||
.result-table td { padding:8px 14px; border-bottom:1px solid var(--border); color:var(--text2); max-width:250px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.result-table tr:hover td { background:rgba(99,102,241,0.04); }
|
||||
.empty-state { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text2); gap:12px; }
|
||||
.empty-state .icon { font-size:3rem; opacity:0.3; }
|
||||
/* Error */
|
||||
.error-box { background:rgba(239,68,68,0.1); border:1px solid rgba(239,68,68,0.3); border-radius:10px; padding:16px; color:#f87171; font-family:'JetBrains Mono',monospace; font-size:0.82rem; margin-bottom:16px; }
|
||||
.success-box { background:rgba(34,197,94,0.1); border:1px solid rgba(34,197,94,0.3); border-radius:10px; padding:12px 16px; color:#4ade80; font-size:0.85rem; margin-bottom:16px; }
|
||||
/* Tabs */
|
||||
.tabs { display:flex; gap:4px; padding:8px 20px 0; background:var(--surface); }
|
||||
.tab { padding:8px 18px; border-radius:8px 8px 0 0; font-size:0.82rem; cursor:pointer; color:var(--text2); border:1px solid transparent; transition:.2s; }
|
||||
.tab:hover { color:var(--text); }
|
||||
.tab.active { color:var(--text); background:var(--bg); border-color:var(--border); border-bottom-color:var(--bg); }
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width:6px; height:6px; }
|
||||
::-webkit-scrollbar-track { background:transparent; }
|
||||
::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
|
||||
/* Responsive */
|
||||
@media(max-width:900px){ .main { flex-direction:column; } .editor-panel,.result-panel { min-width:auto; } .editor-panel { height:50%; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo"><div class="icon">◈</div><span>metona-sqlark</span></div>
|
||||
<nav>
|
||||
<a href="index.html">首页</a>
|
||||
<a href="docs.html">文档</a>
|
||||
<a href="demo.html" class="nav-active">演示</a>
|
||||
</nav>
|
||||
<div class="status"><span class="dot"></span> Memory 模式</div>
|
||||
</header>
|
||||
|
||||
<div class="main">
|
||||
<!-- Editor Panel -->
|
||||
<div class="editor-panel">
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="sql">SQL 查询</div>
|
||||
<div class="tab" data-tab="builder">Query Builder</div>
|
||||
</div>
|
||||
<div class="editor-area">
|
||||
<textarea id="sql-input" placeholder="输入 SQL 语句... SELECT * FROM users WHERE age > 18 INSERT INTO users VALUES ('4', 'Diana', 'diana@test.com', 28) SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id">-- 欢迎使用 metona-sqlark 在线演示 🚀
|
||||
-- 已预置 users / orders 表数据,试试以下查询:
|
||||
|
||||
SELECT * FROM users;
|
||||
|
||||
SELECT name, age FROM users WHERE age > 25 ORDER BY age DESC;
|
||||
|
||||
SELECT u.name, o.amount, o.product
|
||||
FROM users u INNER JOIN orders o ON u.id = o.user_id;
|
||||
|
||||
SELECT COUNT(*) as total_users, AVG(age) as avg_age FROM users;</textarea>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-run" onclick="runQuery()">▶ 执行 (Ctrl+Enter)</button>
|
||||
<button class="btn btn-clear" onclick="clearResults()">🗑 清空</button>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn btn-preset" onclick="loadPreset('all')">📋 全部数据</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('join')">🔗 JOIN</button>
|
||||
<button class="btn btn-preset" onclick="loadPreset('agg')">📊 聚合</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Panel -->
|
||||
<div class="result-panel">
|
||||
<div class="panel-header">
|
||||
<span>📊 查询结果</span>
|
||||
<span class="badge" id="row-count">0 行</span>
|
||||
</div>
|
||||
<div class="result-content" id="result-area">
|
||||
<div class="empty-state">
|
||||
<div class="icon">⚡</div>
|
||||
<div>执行 SQL 查询以查看结果</div>
|
||||
<div style="font-size:0.8rem;opacity:0.6">Ctrl + Enter 快捷执行</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../dist/metona-sqlark.js"></script>
|
||||
<script>
|
||||
const { create, MetonaSqlark } = window.MetonaSqlark || window.MeSqlark || {};
|
||||
|
||||
let db = null;
|
||||
const resultArea = document.getElementById('result-area');
|
||||
const rowCount = document.getElementById('row-count');
|
||||
const sqlInput = document.getElementById('sql-input');
|
||||
|
||||
// Init database
|
||||
async function initDB() {
|
||||
db = new MetonaSqlark({ name: 'demo', mode: 'memory' });
|
||||
await db.init();
|
||||
|
||||
// Create tables
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
email: { type: 'string' },
|
||||
age: { type: 'number', default: 0 },
|
||||
});
|
||||
await db.defineTable('orders', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
user_id: { type: 'string' },
|
||||
product: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
});
|
||||
|
||||
// Seed data
|
||||
await db.table('users').insertMany([
|
||||
{ id: '1', name: 'Alice', email: 'alice@demo.com', age: 30 },
|
||||
{ id: '2', name: 'Bob', email: 'bob@demo.com', age: 25 },
|
||||
{ id: '3', name: 'Charlie', email: 'charlie@demo.com', age: 35 },
|
||||
]);
|
||||
await db.table('orders').insertMany([
|
||||
{ id: 'o1', user_id: '1', product: 'Laptop', amount: 1200 },
|
||||
{ id: 'o2', user_id: '1', product: 'Mouse', amount: 50 },
|
||||
{ id: 'o3', user_id: '2', product: 'Keyboard', amount: 150 },
|
||||
]);
|
||||
}
|
||||
|
||||
// Run SQL
|
||||
async function runQuery() {
|
||||
const sql = sqlInput.value.trim();
|
||||
if (!sql) return;
|
||||
|
||||
// Handle multiple statements
|
||||
const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0);
|
||||
|
||||
// For display purposes, if only one statement, use it; otherwise use first
|
||||
try {
|
||||
let lastResult = null;
|
||||
const allResults = [];
|
||||
|
||||
for (const stmt of statements) {
|
||||
const result = await db.query(stmt);
|
||||
allResults.push({ sql: stmt, result });
|
||||
lastResult = result;
|
||||
}
|
||||
|
||||
if (allResults.length === 1) {
|
||||
renderResult(allResults[0]);
|
||||
} else {
|
||||
renderAllResults(allResults);
|
||||
}
|
||||
} catch (e) {
|
||||
renderError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult({ sql, result }) {
|
||||
if (Array.isArray(result)) {
|
||||
renderTable(result);
|
||||
} else {
|
||||
renderSuccess(`✅ 执行成功 (影响 ${result ?? 0} 行)`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAllResults(allResults) {
|
||||
let html = '';
|
||||
for (const { sql, result } of allResults) {
|
||||
html += `<div style="color:var(--accent);font-size:0.78rem;margin-bottom:8px;font-family:monospace;">> ${sql}</div>`;
|
||||
if (Array.isArray(result)) {
|
||||
if (result.length === 0) {
|
||||
html += '<div class="success-box">✅ 查询返回 0 行</div>';
|
||||
} else {
|
||||
html += buildTableHTML(result);
|
||||
}
|
||||
} else {
|
||||
html += `<div class="success-box">✅ 执行成功</div>`;
|
||||
}
|
||||
html += '<div style="height:16px"></div>';
|
||||
}
|
||||
resultArea.innerHTML = html;
|
||||
updateRowCount(allResults.reduce((sum, r) => sum + (Array.isArray(r.result) ? r.result.length : 0), 0));
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
if (!rows || rows.length === 0) {
|
||||
resultArea.innerHTML = '<div class="success-box">✅ 查询返回 0 行</div>';
|
||||
updateRowCount(0);
|
||||
return;
|
||||
}
|
||||
resultArea.innerHTML = buildTableHTML(rows);
|
||||
updateRowCount(rows.length);
|
||||
}
|
||||
|
||||
function buildTableHTML(rows) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
let html = '<table class="result-table"><thead><tr>';
|
||||
for (const col of columns) {
|
||||
html += `<th>${escapeHTML(col)}</th>`;
|
||||
}
|
||||
html += '</tr></thead><tbody>';
|
||||
for (const row of rows) {
|
||||
html += '<tr>';
|
||||
for (const col of columns) {
|
||||
const val = row[col];
|
||||
html += `<td>${escapeHTML(val === null ? 'NULL' : String(val))}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
resultArea.innerHTML = `<div class="error-box">❌ ${escapeHTML(msg)}</div>`;
|
||||
updateRowCount(0);
|
||||
}
|
||||
|
||||
function renderSuccess(msg) {
|
||||
resultArea.innerHTML = `<div class="success-box">${msg}</div>`;
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
resultArea.innerHTML = `<div class="empty-state"><div class="icon">⚡</div><div>结果已清空</div></div>`;
|
||||
updateRowCount(0);
|
||||
}
|
||||
|
||||
function updateRowCount(n) {
|
||||
rowCount.textContent = `${n} 行`;
|
||||
}
|
||||
|
||||
function escapeHTML(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// Presets
|
||||
const presets = {
|
||||
all: `-- 查看所有用户
|
||||
SELECT * FROM users;
|
||||
|
||||
-- 查看所有订单
|
||||
SELECT * FROM orders;`,
|
||||
join: `-- INNER JOIN:用户 + 订单
|
||||
SELECT u.name, o.product, o.amount
|
||||
FROM users u
|
||||
INNER JOIN orders o ON u.id = o.user_id
|
||||
ORDER BY o.amount DESC;`,
|
||||
agg: `-- 聚合:按用户统计订单总额
|
||||
SELECT u.name, COUNT(*) as orders, SUM(o.amount) as total_spent
|
||||
FROM users u
|
||||
INNER JOIN orders o ON u.id = o.user_id
|
||||
GROUP BY u.name
|
||||
ORDER BY total_spent DESC;`
|
||||
};
|
||||
|
||||
function loadPreset(name) {
|
||||
if (presets[name]) {
|
||||
sqlInput.value = presets[name];
|
||||
runQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runQuery();
|
||||
}
|
||||
});
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', function() {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Boot
|
||||
initDB().then(() => {
|
||||
console.log('✅ metona-sqlark demo ready');
|
||||
// Auto-run initial query
|
||||
setTimeout(runQuery, 200);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API 文档 — metona-sqlark</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#0a0a0f; --surface:#14141f; --surface2:#1a1a2e; --border:#2a2a45;
|
||||
--primary:#6366f1; --primary-glow:#818cf8; --accent:#06b6d4; --accent2:#ec4899;
|
||||
--text:#e2e8f0; --text2:#94a3b8; --gradient:linear-gradient(135deg,#6366f1 0%,#06b6d4 50%,#ec4899 100%);
|
||||
--radius:14px;
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family:'Inter',-apple-system,BlinkMacSystemFont,system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.7; }
|
||||
/* Header */
|
||||
header {
|
||||
position:fixed; top:0; left:0; right:0; z-index:100; backdrop-filter:blur(20px);
|
||||
background:rgba(10,10,15,0.85); border-bottom:1px solid var(--border); padding:16px 32px;
|
||||
}
|
||||
header .inner { max-width:1400px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; }
|
||||
.logo { font-size:1.3rem; font-weight:800; display:flex; align-items:center; gap:10px; }
|
||||
.logo .icon { width:32px; height:32px; border-radius:8px; background:var(--gradient); display:flex; align-items:center; justify-content:center; font-size:1rem; font-weight:900; color:#fff; }
|
||||
.logo span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
||||
nav { display:flex; gap:28px; }
|
||||
nav a { color:var(--text2); text-decoration:none; font-weight:500; font-size:0.92rem; transition:.2s; }
|
||||
nav a:hover,.nav-active { color:var(--text); }
|
||||
/* Layout */
|
||||
.layout { display:flex; max-width:1400px; margin:0 auto; padding-top:80px; min-height:100vh; }
|
||||
.sidebar {
|
||||
width:260px; min-width:260px; padding:32px 24px; position:sticky; top:80px; height:calc(100vh - 80px);
|
||||
overflow-y:auto; border-right:1px solid var(--border); background:var(--bg);
|
||||
}
|
||||
.sidebar h4 { font-size:0.75rem; text-transform:uppercase; letter-spacing:1px; color:var(--text2); margin:20px 0 8px; }
|
||||
.sidebar h4:first-child { margin-top:0; }
|
||||
.sidebar a { display:block; padding:7px 12px; border-radius:6px; color:var(--text2); text-decoration:none; font-size:0.9rem; transition:.15s; }
|
||||
.sidebar a:hover,.sidebar a.active { color:var(--text); background:var(--surface2); }
|
||||
.sidebar a.active { border-left:3px solid var(--primary); }
|
||||
.content { flex:1; padding:32px 48px 80px; max-width:900px; }
|
||||
.content h2 { font-size:1.8rem; font-weight:800; margin:48px 0 16px; padding-top:24px; border-top:1px solid var(--border); }
|
||||
.content h2:first-of-type { margin-top:0; padding-top:0; border-top:none; }
|
||||
.content h3 { font-size:1.25rem; font-weight:700; margin:32px 0 12px; color:var(--primary-glow); }
|
||||
.content p { color:var(--text2); margin-bottom:16px; }
|
||||
.content code {
|
||||
background:var(--surface2); padding:2px 8px; border-radius:5px; font-family:'JetBrains Mono',monospace;
|
||||
font-size:0.88rem; color:var(--accent);
|
||||
}
|
||||
.content pre {
|
||||
background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius);
|
||||
padding:20px 24px; overflow-x:auto; font-family:'JetBrains Mono',monospace; font-size:0.85rem;
|
||||
line-height:1.65; margin:16px 0; color:#e2e8f0;
|
||||
}
|
||||
.content pre .k { color:#c084fc; } .content pre .s { color:#34d399; }
|
||||
.content pre .f { color:#60a5fa; } .content pre .c { color:#64748b; }
|
||||
.content pre .n { color:#fbbf24; } .content pre .t { color:#f472b6; }
|
||||
/* Table */
|
||||
.content table { width:100%; border-collapse:collapse; margin:16px 0; }
|
||||
.content th,.content td { text-align:left; padding:12px 16px; border-bottom:1px solid var(--border); font-size:0.9rem; }
|
||||
.content th { color:var(--text); font-weight:600; background:var(--surface); }
|
||||
.content td { color:var(--text2); }
|
||||
.content td code { font-size:0.82rem; }
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:transparent; } ::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="inner">
|
||||
<div class="logo"><div class="icon">◈</div><span>metona-sqlark</span></div>
|
||||
<nav>
|
||||
<a href="index.html">首页</a>
|
||||
<a href="docs.html" class="nav-active">文档</a>
|
||||
<a href="demo.html">演示</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<h4>快速开始</h4>
|
||||
<a href="#install" class="active">安装</a>
|
||||
<a href="#create">创建数据库</a>
|
||||
<a href="#table">定义表</a>
|
||||
<h4>查询 API</h4>
|
||||
<a href="#sql-query">SQL 查询</a>
|
||||
<a href="#query-builder">Query Builder</a>
|
||||
<a href="#join">JOIN 查询</a>
|
||||
<a href="#groupby">GROUP BY</a>
|
||||
<h4>高级特性</h4>
|
||||
<a href="#transaction">事务</a>
|
||||
<a href="#migration">迁移</a>
|
||||
<a href="#export">导入导出</a>
|
||||
<a href="#plugin">插件钩子</a>
|
||||
<a href="#subscribe">发布订阅</a>
|
||||
<h4>类型</h4>
|
||||
<a href="#types">TypeScript 类型</a>
|
||||
<a href="#config">配置项</a>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
|
||||
<h2 id="install">📦 安装</h2>
|
||||
<pre><span class="c">// npm</span>
|
||||
npm install @metona-team/metona-sqlark
|
||||
|
||||
<span class="c">// ESM</span>
|
||||
<span class="k">import</span> { MetonaSqlark, MeSqlark } <span class="k">from</span> <span class="s">'metona-sqlark'</span>;
|
||||
|
||||
<span class="c">// Browser</span>
|
||||
<script src=<span class="s">"metona-sqlark.min.js"</span>></script>
|
||||
<span class="c">// → window.MetonaSqlark / window.MeSqlark</span></pre>
|
||||
|
||||
<h2 id="create">🏗 创建数据库</h2>
|
||||
<p><code>MetonaSqlark.create(config)</code> 工厂函数,返回初始化好的实例。</p>
|
||||
<pre><span class="k">const</span> db = <span class="k">await</span> <span class="f">MetonaSqlark.create</span>({
|
||||
<span class="s">name</span>: <span class="s">'my-app'</span>,
|
||||
<span class="s">mode</span>: <span class="s">'hybrid'</span>, <span class="c">// 'memory' | 'disk' | 'hybrid'</span>
|
||||
<span class="s">diskEngine</span>: <span class="s">'indexeddb'</span>, <span class="c">// 'indexeddb' | 'opfs'</span>
|
||||
<span class="s">version</span>: <span class="n">1</span>,
|
||||
});</pre>
|
||||
|
||||
<table>
|
||||
<tr><th>属性</th><th>类型</th><th>默认</th><th>说明</th></tr>
|
||||
<tr><td><code>name</code></td><td><code>string</code></td><td>-</td><td>数据库名称</td></tr>
|
||||
<tr><td><code>mode</code></td><td><code>'memory'|'disk'|'hybrid'</code></td><td><code>'hybrid'</code></td><td>存储模式</td></tr>
|
||||
<tr><td><code>diskEngine</code></td><td><code>'indexeddb'|'opfs'</code></td><td><code>'indexeddb'</code></td><td>磁盘引擎</td></tr>
|
||||
<tr><td><code>version</code></td><td><code>number</code></td><td><code>1</code></td><td>版本号</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="table">📋 定义表</h2>
|
||||
<pre><span class="k">await</span> db.<span class="f">defineTable</span>(<span class="s">'users'</span>, {
|
||||
<span class="s">id</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">primaryKey</span>: <span class="k">true</span> },
|
||||
<span class="s">name</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">required</span>: <span class="k">true</span> },
|
||||
<span class="s">email</span>: { <span class="s">type</span>: <span class="s">'string'</span>, <span class="s">unique</span>: <span class="k">true</span>, <span class="s">index</span>: <span class="k">true</span> },
|
||||
<span class="s">age</span>: { <span class="s">type</span>: <span class="s">'number'</span>, <span class="s">default</span>: <span class="n">0</span> },
|
||||
<span class="s">dept_id</span>: { <span class="s">type</span>: <span class="s">'number'</span>, <span class="s">references</span>: <span class="s">'departments.id'</span> },
|
||||
});</pre>
|
||||
|
||||
<table>
|
||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>type</code></td><td><code>string|number|boolean|date|json</code></td><td>数据类型</td></tr>
|
||||
<tr><td><code>primaryKey</code></td><td><code>boolean</code></td><td>主键</td></tr>
|
||||
<tr><td><code>required</code></td><td><code>boolean</code></td><td>必填</td></tr>
|
||||
<tr><td><code>unique</code></td><td><code>boolean</code></td><td>唯一约束</td></tr>
|
||||
<tr><td><code>index</code></td><td><code>boolean</code></td><td>创建索引</td></tr>
|
||||
<tr><td><code>default</code></td><td><code>unknown</code></td><td>默认值</td></tr>
|
||||
<tr><td><code>references</code></td><td><code>string</code></td><td>外键引用</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="sql-query">🔍 SQL 查询</h2>
|
||||
<pre><span class="c">// INSERT</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"INSERT INTO users (id, name, age) VALUES ('1', 'Alice', 30)"</span>);
|
||||
|
||||
<span class="c">// SELECT</span>
|
||||
<span class="k">const</span> rows = <span class="k">await</span> db.<span class="f">query</span>(<span class="s">'SELECT * FROM users WHERE age > 18 ORDER BY name LIMIT 10'</span>);
|
||||
|
||||
<span class="c">// UPDATE</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"UPDATE users SET age = 31 WHERE id = '1'"</span>);
|
||||
|
||||
<span class="c">// DELETE</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">"DELETE FROM users WHERE id = '1'"</span>);
|
||||
|
||||
<span class="c">// DDL</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">'DROP TABLE users'</span>);</pre>
|
||||
|
||||
<h2 id="query-builder">⛓ Query Builder</h2>
|
||||
<pre><span class="k">const</span> users = db.<span class="f">table</span>(<span class="s">'users'</span>);
|
||||
|
||||
<span class="c">// 插入</span>
|
||||
<span class="k">await</span> users.<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'1'</span>, <span class="s">name</span>: <span class="s">'Alice'</span> });
|
||||
<span class="k">await</span> users.<span class="f">insertMany</span>([...]);
|
||||
|
||||
<span class="c">// 查询</span>
|
||||
<span class="k">const</span> result = <span class="k">await</span> users
|
||||
.<span class="f">select</span>([<span class="s">'name'</span>, <span class="s">'age'</span>])
|
||||
.<span class="f">where</span>({ <span class="s">age</span>: { <span class="s">$gt</span>: <span class="n">18</span> }, <span class="s">name</span>: { <span class="s">$like</span>: <span class="s">'A%'</span> } })
|
||||
.<span class="f">orderBy</span>(<span class="s">'age'</span>, <span class="s">'desc'</span>)
|
||||
.<span class="f">limit</span>(<span class="n">10</span>).<span class="f">offset</span>(<span class="n">0</span>)
|
||||
.<span class="f">execute</span>();
|
||||
|
||||
<span class="c">// 更新/删除</span>
|
||||
<span class="k">await</span> users.<span class="f">update</span>({ <span class="s">age</span>: <span class="n">31</span> }).<span class="f">where</span>({ <span class="s">id</span>: <span class="s">'1'</span> }).<span class="f">execute</span>();
|
||||
<span class="k">await</span> users.<span class="f">delete</span>().<span class="f">where</span>({ <span class="s">id</span>: <span class="s">'1'</span> }).<span class="f">execute</span>();</pre>
|
||||
|
||||
<h2 id="join">🔗 JOIN 查询</h2>
|
||||
<pre><span class="c">// SQL</span>
|
||||
<span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT u.name, d.name
|
||||
FROM users u
|
||||
INNER JOIN departments d ON u.dept_id = d.id
|
||||
WHERE d.name = 'Engineering'`</span>);
|
||||
|
||||
<span class="c">// QueryBuilder</span>
|
||||
<span class="k">await</span> db.<span class="f">table</span>(<span class="s">'users'</span>).<span class="f">select</span>()
|
||||
.<span class="f">innerJoin</span>(<span class="s">'departments'</span>, { <span class="s">'users.dept_id'</span>: { <span class="s">$col</span>: <span class="s">'departments.id'</span> } })
|
||||
.<span class="f">execute</span>();
|
||||
|
||||
<span class="c">// LEFT JOIN / RIGHT JOIN / CROSS JOIN</span>
|
||||
.<span class="f">leftJoin</span>(<span class="s">'table'</span>, on)
|
||||
.<span class="f">rightJoin</span>(<span class="s">'table'</span>, on)
|
||||
.<span class="f">crossJoin</span>(<span class="s">'table'</span>)</pre>
|
||||
|
||||
<h2 id="groupby">📊 GROUP BY & 聚合</h2>
|
||||
<pre><span class="k">await</span> db.<span class="f">query</span>(<span class="s">`SELECT dept, COUNT(*) as cnt, SUM(salary) as total
|
||||
FROM employees
|
||||
GROUP BY dept
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY total DESC`</span>);
|
||||
|
||||
<span class="c">// 支持的聚合函数:COUNT, SUM, AVG, MIN, MAX</span>
|
||||
<span class="c">// 支持别名:COUNT(*) AS cnt</span></pre>
|
||||
|
||||
<h2 id="transaction">🔒 事务</h2>
|
||||
<pre><span class="k">await</span> db.<span class="f">transaction</span>(<span class="k">async</span> (trx) => {
|
||||
<span class="k">await</span> trx.<span class="f">table</span>(<span class="s">'users'</span>).<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'3'</span>, <span class="s">name</span>: <span class="s">'Charlie'</span> });
|
||||
<span class="k">await</span> trx.<span class="f">table</span>(<span class="s">'orders'</span>).<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'o1'</span>, <span class="s">userId</span>: <span class="s">'3'</span>, <span class="s">amount</span>: <span class="n">99</span> });
|
||||
<span class="c">// 任何一步失败 → 全部回滚</span>
|
||||
});</pre>
|
||||
|
||||
<h2 id="migration">🔄 数据迁移</h2>
|
||||
<pre>db.<span class="f">addMigration</span>(<span class="n">2</span>, <span class="k">async</span> (db) => {
|
||||
<span class="k">await</span> db.<span class="f">defineTable</span>(<span class="s">'products'</span>, { ... });
|
||||
});
|
||||
<span class="k">await</span> db.<span class="f">migrateTo</span>(<span class="n">2</span>); <span class="c">// 执行所有未执行的迁移</span></pre>
|
||||
|
||||
<h2 id="export">📤 导入导出</h2>
|
||||
<pre><span class="c">// 导出单表</span>
|
||||
<span class="k">const</span> data = <span class="k">await</span> db.<span class="f">exportTable</span>(<span class="s">'users'</span>);
|
||||
|
||||
<span class="c">// 导出全库</span>
|
||||
<span class="k">const</span> all = <span class="k">await</span> db.<span class="f">exportAll</span>();
|
||||
|
||||
<span class="c">// 导入</span>
|
||||
<span class="k">await</span> db.<span class="f">importTable</span>(<span class="s">'users'</span>, data);</pre>
|
||||
|
||||
<h2 id="plugin">🧩 插件钩子</h2>
|
||||
<pre><span class="c">// 14 个生命周期钩子</span>
|
||||
db.<span class="f">on</span>(<span class="s">'beforeInsert'</span>, <span class="k">async</span> (row) => {
|
||||
<span class="f">console.log</span>(<span class="s">'即将插入:'</span>, row);
|
||||
});
|
||||
|
||||
db.<span class="f">on</span>(<span class="s">'afterQuery'</span>, <span class="k">async</span> (sql, result) => {
|
||||
<span class="f">console.log</span>(<span class="s">'查询完成:'</span>, sql);
|
||||
});</pre>
|
||||
<table>
|
||||
<tr><th>钩子</th><th>触发时机</th></tr>
|
||||
<tr><td><code>beforeCreateTable / afterCreateTable</code></td><td>创建表前后</td></tr>
|
||||
<tr><td><code>beforeDropTable / afterDropTable</code></td><td>删除表前后</td></tr>
|
||||
<tr><td><code>beforeInsert / afterInsert</code></td><td>插入前后</td></tr>
|
||||
<tr><td><code>beforeUpdate / afterUpdate</code></td><td>更新前后</td></tr>
|
||||
<tr><td><code>beforeDelete / afterDelete</code></td><td>删除前后</td></tr>
|
||||
<tr><td><code>beforeQuery / afterQuery</code></td><td>查询前后</td></tr>
|
||||
<tr><td><code>beforeTransaction / afterTransaction</code></td><td>事务前后</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 id="subscribe">📡 发布订阅</h2>
|
||||
<pre><span class="c">// 订阅表变更</span>
|
||||
<span class="k">const</span> unsubscribe = db.<span class="f">subscribe</span>(<span class="s">'users'</span>, (event) => {
|
||||
<span class="c">// event: { type: 'insert'|'update'|'delete', row: {...} }</span>
|
||||
});
|
||||
|
||||
<span class="c">// 取消订阅</span>
|
||||
<span class="f">unsubscribe</span>();</pre>
|
||||
|
||||
<h2 id="types">🔷 TypeScript 泛型</h2>
|
||||
<pre><span class="k">interface</span> <span class="t">User</span> {
|
||||
<span class="s">id</span>: <span class="t">string</span>;
|
||||
<span class="s">name</span>: <span class="t">string</span>;
|
||||
<span class="s">age</span>: <span class="t">number</span>;
|
||||
}
|
||||
|
||||
<span class="k">const</span> users = db.<span class="f">table</span><<span class="t">User</span>>(<span class="s">'users'</span>);
|
||||
<span class="k">await</span> users.<span class="f">insert</span>({ <span class="s">id</span>: <span class="s">'1'</span>, <span class="s">name</span>: <span class="s">'Alice'</span>, <span class="s">age</span>: <span class="n">30</span> }); <span class="c">// ✅ 类型安全</span></pre>
|
||||
|
||||
<h2 id="config">⚙️ 配置项</h2>
|
||||
<pre><span class="k">import</span> { MetonaSqlark, MeSqlark } <span class="k">from</span> <span class="s">'metona-sqlark'</span>;
|
||||
<span class="c">// MeSqlark 是 MetonaSqlark 的别名,完全等价</span></pre>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.sidebar a').forEach(a => {
|
||||
a.addEventListener('click', function() {
|
||||
document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>metona-sqlark — 前端 TypeScript 数据库</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--surface: #14141f;
|
||||
--surface2: #1a1a2e;
|
||||
--border: #2a2a45;
|
||||
--primary: #6366f1;
|
||||
--primary-glow: #818cf8;
|
||||
--accent: #06b6d4;
|
||||
--accent2: #ec4899;
|
||||
--text: #e2e8f0;
|
||||
--text2: #94a3b8;
|
||||
--gradient: linear-gradient(135deg, #6366f1 0%, #06b6d4 50%, #ec4899 100%);
|
||||
--gradient2: linear-gradient(135deg, #1e1b4b 0%, #0f172a 50%, #1e1b4b 100%);
|
||||
--radius: 16px;
|
||||
--shadow: 0 0 60px rgba(99,102,241,0.15);
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/* --- Particles background --- */
|
||||
.bg-canvas { position:fixed; top:0; left:0; width:100%; height:100%; z-index:0; opacity:0.3; pointer-events:none; }
|
||||
/* --- Layout --- */
|
||||
.container { max-width:1200px; margin:0 auto; padding:0 24px; position:relative; z-index:1; }
|
||||
/* --- Header --- */
|
||||
header {
|
||||
position:fixed; top:0; left:0; right:0; z-index:100;
|
||||
backdrop-filter:blur(20px); -webkit-backdrop-filter:blur(20px);
|
||||
background:rgba(10,10,15,0.85); border-bottom:1px solid var(--border);
|
||||
padding:16px 0;
|
||||
}
|
||||
header .container { display:flex; align-items:center; justify-content:space-between; }
|
||||
.logo { font-size:1.4rem; font-weight:800; letter-spacing:-0.5px; display:flex; align-items:center; gap:10px; }
|
||||
.logo .icon {
|
||||
width:36px; height:36px; border-radius:10px;
|
||||
background:var(--gradient); display:flex; align-items:center; justify-content:center;
|
||||
font-size:1.2rem; font-weight:900; color:#fff;
|
||||
}
|
||||
.logo span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
||||
nav { display:flex; gap:32px; }
|
||||
nav a { color:var(--text2); text-decoration:none; font-weight:500; font-size:0.95rem; transition:.2s; position:relative; }
|
||||
nav a:hover { color:var(--text); }
|
||||
nav a::after { content:''; position:absolute; bottom:-4px; left:0; width:0; height:2px; background:var(--gradient); transition:.3s; border-radius:1px; }
|
||||
nav a:hover::after { width:100%; }
|
||||
.btn {
|
||||
display:inline-flex; align-items:center; gap:8px; padding:10px 24px; border-radius:10px;
|
||||
font-weight:600; font-size:0.95rem; cursor:pointer; border:none; transition:.3s; text-decoration:none;
|
||||
}
|
||||
.btn-primary { background:var(--gradient); color:#fff; box-shadow:0 4px 20px rgba(99,102,241,0.4); }
|
||||
.btn-primary:hover { transform:translateY(-2px); box-shadow:0 8px 30px rgba(99,102,241,0.6); }
|
||||
.btn-outline { background:transparent; border:2px solid var(--border); color:var(--text); }
|
||||
.btn-outline:hover { border-color:var(--primary); background:rgba(99,102,241,0.08); }
|
||||
/* --- Hero --- */
|
||||
.hero {
|
||||
padding:160px 0 80px; text-align:center; position:relative;
|
||||
background:radial-gradient(ellipse 80% 60% at 50% -10%, rgba(99,102,241,0.15), transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 80% 80%, rgba(6,182,212,0.08), transparent 60%);
|
||||
}
|
||||
.hero h1 { font-size:clamp(2.4rem, 6vw, 4rem); font-weight:900; letter-spacing:-1.5px; line-height:1.1; margin-bottom:24px; }
|
||||
.hero h1 .gradient-text { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
||||
.hero p { font-size:1.2rem; color:var(--text2); max-width:620px; margin:0 auto 40px; }
|
||||
.hero .actions { display:flex; gap:16px; justify-content:center; flex-wrap:wrap; }
|
||||
.badge {
|
||||
display:inline-flex; align-items:center; gap:6px; padding:6px 14px; border-radius:999px;
|
||||
background:var(--surface2); border:1px solid var(--border); font-size:0.85rem; color:var(--text2);
|
||||
}
|
||||
.badge .dot { width:7px; height:7px; border-radius:50%; background:#22c55e; animation:pulse 2s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||
/* --- Section --- */
|
||||
section { padding:100px 0; }
|
||||
.section-title { text-align:center; margin-bottom:60px; }
|
||||
.section-title h2 { font-size:2.2rem; font-weight:800; letter-spacing:-0.5px; margin-bottom:12px; }
|
||||
.section-title h2 span { background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
||||
.section-title p { color:var(--text2); font-size:1.1rem; }
|
||||
/* --- Feature Grid --- */
|
||||
.feature-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:24px; }
|
||||
.feature-card {
|
||||
background:var(--surface); border:1px solid var(--border); border-radius:var(--radius);
|
||||
padding:32px; transition:.3s; position:relative; overflow:hidden;
|
||||
}
|
||||
.feature-card:hover { border-color:var(--primary); transform:translateY(-4px); box-shadow:var(--shadow); }
|
||||
.feature-card::before {
|
||||
content:''; position:absolute; top:0; left:0; right:0; height:3px;
|
||||
background:var(--gradient); opacity:0; transition:.3s;
|
||||
}
|
||||
.feature-card:hover::before { opacity:1; }
|
||||
.feature-card .icon { font-size:2rem; margin-bottom:16px; }
|
||||
.feature-card h3 { font-size:1.2rem; margin-bottom:8px; }
|
||||
.feature-card p { color:var(--text2); font-size:0.95rem; }
|
||||
/* --- Code Block --- */
|
||||
.code-block {
|
||||
background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius);
|
||||
padding:28px; font-family:'JetBrains Mono','Fira Code',monospace; font-size:0.88rem;
|
||||
overflow-x:auto; position:relative; line-height:1.7;
|
||||
}
|
||||
.code-block .keyword { color:#c084fc; }
|
||||
.code-block .string { color:#34d399; }
|
||||
.code-block .func { color:#60a5fa; }
|
||||
.code-block .comment { color:#64748b; }
|
||||
.code-block .number { color:#fbbf24; }
|
||||
.code-block .type { color:#f472b6; }
|
||||
/* --- Stats --- */
|
||||
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:24px; text-align:center; }
|
||||
.stat-card {
|
||||
background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:32px;
|
||||
}
|
||||
.stat-card .num { font-size:2.8rem; font-weight:900; background:var(--gradient); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
|
||||
.stat-card .label { color:var(--text2); font-size:0.95rem; margin-top:4px; }
|
||||
/* --- Footer --- */
|
||||
footer { border-top:1px solid var(--border); padding:40px 0; text-align:center; color:var(--text2); }
|
||||
footer a { color:var(--primary); text-decoration:none; }
|
||||
/* --- Responsive --- */
|
||||
@media(max-width:768px){
|
||||
nav { display:none; }
|
||||
.hero { padding:120px 0 60px; }
|
||||
section { padding:60px 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<canvas class="bg-canvas" id="bg"></canvas>
|
||||
|
||||
<header>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<div class="icon">◈</div>
|
||||
<span>metona-sqlark</span>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="index.html">首页</a>
|
||||
<a href="docs.html">文档</a>
|
||||
<a href="demo.html">演示</a>
|
||||
<a href="https://git.metona.cn/MetonaTeam/metona-sqlark" target="_blank">GitHub</a>
|
||||
</nav>
|
||||
<a href="demo.html" class="btn btn-primary">立即体验</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.1.11 已发布</div>
|
||||
<h1>前端的 <span class="gradient-text">SQL 数据库</span></h1>
|
||||
<p>TypeScript 原生构建,内存与磁盘双模式,支持完整 SQL 查询。<br>零运行时依赖,开箱即用。</p>
|
||||
<div class="actions">
|
||||
<a href="demo.html" class="btn btn-primary" style="font-size:1.05rem;padding:14px 32px;">▶ 在线演示</a>
|
||||
<a href="docs.html" class="btn btn-outline" style="font-size:1.05rem;padding:14px 32px;">📖 API 文档</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features -->
|
||||
<section>
|
||||
<div class="container">
|
||||
<div class="section-title">
|
||||
<h2>为什么选择 <span>metona-sqlark</span></h2>
|
||||
<p>专为前端打造的数据库引擎</p>
|
||||
</div>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<div class="icon">🧠</div>
|
||||
<h3>双模式存储</h3>
|
||||
<p>Memory(内存)保证极致速度,Disk(IndexedDB / OPFS)提供持久化。Hybrid 模式一键兼顾。</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">⚡</div>
|
||||
<h3>完整 SQL 支持</h3>
|
||||
<p>SELECT / INSERT / UPDATE / DELETE / JOIN / GROUP BY / HAVING / DISTINCT — 手写递归下降解析器。</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🔗</div>
|
||||
<h3>Query Builder</h3>
|
||||
<p>链式 API,TypeScript 友好。.select().where().innerJoin().orderBy().limit() — 代码即查询。</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🔒</div>
|
||||
<h3>事务支持</h3>
|
||||
<p>原子性操作,事务内任意步骤失败自动回滚。IndexedDB 引擎利用浏览器原生事务。</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">🧩</div>
|
||||
<h3>插件系统</h3>
|
||||
<p>14 种生命周期钩子,注册自定义插件。beforeQuery / afterInsert / beforeTransaction……</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">📦</div>
|
||||
<h3>零运行时依赖</h3>
|
||||
<p>纯 TypeScript 实现,不依赖任何第三方库。Tree-shakable,最小体积不到 10KB gzip。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Code Example -->
|
||||
<section style="background:var(--gradient2);">
|
||||
<div class="container">
|
||||
<div class="section-title">
|
||||
<h2>5 行<span>代码</span>开始</h2>
|
||||
<p>像写 SQL 一样操作前端数据</p>
|
||||
</div>
|
||||
<div class="code-block">
|
||||
<pre><span class="comment">// 创建数据库</span>
|
||||
<span class="keyword">const</span> db = <span class="keyword">await</span> <span class="func">MetonaSqlark.create</span>({ <span class="string">name</span>: <span class="string">'my-app'</span>, <span class="string">mode</span>: <span class="string">'hybrid'</span> });
|
||||
|
||||
<span class="comment">// 定义表结构</span>
|
||||
<span class="keyword">await</span> db.<span class="func">defineTable</span>(<span class="string">'users'</span>, {
|
||||
<span class="string">id</span>: { <span class="string">type</span>: <span class="string">'string'</span>, <span class="string">primaryKey</span>: <span class="keyword">true</span> },
|
||||
<span class="string">name</span>: { <span class="string">type</span>: <span class="string">'string'</span>, <span class="string">required</span>: <span class="keyword">true</span> },
|
||||
<span class="string">age</span>: { <span class="string">type</span>: <span class="string">'number'</span> },
|
||||
});
|
||||
|
||||
<span class="comment">// SQL 查询</span>
|
||||
<span class="keyword">await</span> db.<span class="func">query</span>(<span class="string">"INSERT INTO users VALUES ('1', 'Alice', 30)"</span>);
|
||||
<span class="keyword">const</span> rows = <span class="keyword">await</span> db.<span class="func">query</span>(<span class="string">'SELECT * FROM users WHERE age > 18'</span>);
|
||||
|
||||
<span class="comment">// 或者 Query Builder</span>
|
||||
<span class="keyword">const</span> rows2 = <span class="keyword">await</span> db.<span class="func">table</span>(<span class="string">'users'</span>)
|
||||
.<span class="func">select</span>().<span class="func">where</span>({ <span class="string">age</span>: { <span class="string">$gt</span>: <span class="number">18</span> } })
|
||||
.<span class="func">orderBy</span>(<span class="string">'name'</span>).<span class="func">limit</span>(<span class="number">10</span>).<span class="func">execute</span>();</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats -->
|
||||
<section>
|
||||
<div class="container">
|
||||
<div class="section-title">
|
||||
<h2>用<span>数字</span>说话</h2>
|
||||
<p>metona-sqlark 的核心指标</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat-card"><div class="num">235+</div><div class="label">测试用例</div></div>
|
||||
<div class="stat-card"><div class="num">78.9%</div><div class="label">代码覆盖率</div></div>
|
||||
<div class="stat-card"><div class="num">~10KB</div><div class="label">gzip 体积</div></div>
|
||||
<div class="stat-card"><div class="num">4</div><div class="label">存储引擎</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 <a href="https://git.metona.cn/MetonaTeam/metona-sqlark">MetonaTeam</a>. MIT License. Built with TypeScript.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Particle background
|
||||
const canvas = document.getElementById('bg');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let particles = [];
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize(); window.addEventListener('resize', resize);
|
||||
|
||||
class Particle {
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
reset() {
|
||||
this.x = Math.random() * canvas.width;
|
||||
this.y = Math.random() * canvas.height;
|
||||
this.vx = (Math.random() - 0.5) * 0.5;
|
||||
this.vy = (Math.random() - 0.5) * 0.5;
|
||||
this.size = Math.random() * 2 + 0.5;
|
||||
this.opacity = Math.random() * 0.4 + 0.1;
|
||||
}
|
||||
update() {
|
||||
this.x += this.vx; this.y += this.vy;
|
||||
if (this.x < 0 || this.x > canvas.width) this.vx *= -1;
|
||||
if (this.y < 0 || this.y > canvas.height) this.vy *= -1;
|
||||
}
|
||||
draw() {
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(99,102,241,${this.opacity})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 80; i++) particles.push(new Particle());
|
||||
|
||||
function animate() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
particles.forEach(p => { p.update(); p.draw(); });
|
||||
// Draw connections
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const dx = particles[i].x - particles[j].x;
|
||||
const dy = particles[i].y - particles[j].y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particles[i].x, particles[i].y);
|
||||
ctx.lineTo(particles[j].x, particles[j].y);
|
||||
ctx.strokeStyle = `rgba(99,102,241,${0.06 * (1 - dist / 120)})`;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
animate();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* metona-sqlark Constants — 类型定义 / 默认配置 / 枚举
|
||||
* @module constants
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 存储模式
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 存储模式 */
|
||||
export type StorageMode = 'memory' | 'disk' | 'hybrid';
|
||||
|
||||
/** 磁盘引擎类型 */
|
||||
export type DiskEngine = 'indexeddb' | 'opfs';
|
||||
|
||||
/** 所有存储模式 */
|
||||
export const STORAGE_MODES: StorageMode[] = ['memory', 'disk', 'hybrid'];
|
||||
|
||||
/** 所有磁盘引擎 */
|
||||
export const DISK_ENGINES: DiskEngine[] = ['indexeddb', 'opfs'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 字段类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 字段数据类型 */
|
||||
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'json';
|
||||
|
||||
/** 所有字段类型 */
|
||||
export const FIELD_TYPES: FieldType[] = ['string', 'number', 'boolean', 'date', 'json'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 列定义 & 表结构
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 列定义 */
|
||||
export interface ColumnDef {
|
||||
/** 字段类型 */
|
||||
type: FieldType;
|
||||
/** 是否主键 */
|
||||
primaryKey?: boolean;
|
||||
/** 是否必填 */
|
||||
required?: boolean;
|
||||
/** 是否唯一 */
|
||||
unique?: boolean;
|
||||
/** 默认值 */
|
||||
default?: unknown;
|
||||
/** 是否创建索引 */
|
||||
index?: boolean;
|
||||
/** 外键引用: 'table.column' */
|
||||
references?: string;
|
||||
/** 字符串最大长度 */
|
||||
maxLength?: number;
|
||||
/** 数字最小值 */
|
||||
min?: number;
|
||||
/** 数字最大值 */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/** 表结构定义 */
|
||||
export interface TableSchema {
|
||||
/** 表名 */
|
||||
name: string;
|
||||
/** 列定义映射 */
|
||||
columns: Record<string, ColumnDef>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 数据库配置
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 数据库配置 */
|
||||
export interface DatabaseConfig {
|
||||
/** 数据库名称 */
|
||||
name: string;
|
||||
/** 存储模式 */
|
||||
mode?: StorageMode;
|
||||
/** 磁盘引擎(仅 mode='disk'|'hybrid' 时生效) */
|
||||
diskEngine?: DiskEngine;
|
||||
/** 版本号 */
|
||||
version?: number;
|
||||
/** 插件列表 */
|
||||
plugins?: MetonaPlugin[];
|
||||
/** 数据库就绪回调 */
|
||||
onReady?: (db: unknown) => void;
|
||||
/** 错误回调 */
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/** 数据库默认配置 */
|
||||
export const DB_DEFAULTS: Readonly<Required<Omit<DatabaseConfig, 'plugins' | 'onReady' | 'onError'>>> = Object.freeze({
|
||||
name: 'metona-sqlark',
|
||||
mode: 'hybrid' as const,
|
||||
diskEngine: 'indexeddb' as const,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Where 操作符
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Where 条件操作符 */
|
||||
export type WhereOperator = '$eq' | '$ne' | '$gt' | '$gte' | '$lt' | '$lte' | '$in' | '$nin' | '$like' | '$and' | '$or' | '$not';
|
||||
|
||||
/** 简单条件值:直接相等 */
|
||||
export type SimpleCondition = unknown;
|
||||
|
||||
/** 操作符条件 */
|
||||
export type OperatorCondition = Partial<Record<WhereOperator, unknown>>;
|
||||
|
||||
/** 字段条件:简单值 | 操作符对象 */
|
||||
export type FieldCondition = SimpleCondition | OperatorCondition;
|
||||
|
||||
/** Where 条件对象 */
|
||||
export type WhereCondition = Record<string, FieldCondition>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 排序
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 排序方向 */
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
/** 排序定义 */
|
||||
export interface OrderBy {
|
||||
/** 列名 */
|
||||
column: string;
|
||||
/** 排序方向 */
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查询计划(引擎层使用)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 查询计划 — 由 Executor 编译 AST 后生成 */
|
||||
export interface QueryPlan {
|
||||
/** 表名 */
|
||||
table: string;
|
||||
/** 要返回的列(undefined = 全部,['*'] = 全部) */
|
||||
columns?: string[];
|
||||
/** 过滤条件 */
|
||||
where?: WhereCondition;
|
||||
/** 排序 */
|
||||
orderBy?: OrderBy[];
|
||||
/** 限制条数 */
|
||||
limit?: number;
|
||||
/** 偏移量 */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 插件接口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 钩子名称 */
|
||||
export type HookName =
|
||||
| 'beforeCreateTable' | 'afterCreateTable'
|
||||
| 'beforeDropTable' | 'afterDropTable'
|
||||
| 'beforeInsert' | 'afterInsert'
|
||||
| 'beforeUpdate' | 'afterUpdate'
|
||||
| 'beforeDelete' | 'afterDelete'
|
||||
| 'beforeQuery' | 'afterQuery'
|
||||
| 'beforeTransaction' | 'afterTransaction';
|
||||
|
||||
/** 插件定义 */
|
||||
export interface MetonaPlugin {
|
||||
/** 插件名称 */
|
||||
name: string;
|
||||
/** 插件版本 */
|
||||
version: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
/** 优先级,越大越先执行 */
|
||||
priority?: number;
|
||||
/** 安装 */
|
||||
install(db: unknown): void;
|
||||
/** 销毁 */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 错误类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 数据库错误 */
|
||||
export class DatabaseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DatabaseError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 版本
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VERSION = '0.1.12';
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* metona-sqlark Core — 数据库主类
|
||||
* @module core
|
||||
*
|
||||
* 管理数据库生命周期、引擎调度、表操作、SQL 查询、事务和插件。
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from './engine/interface';
|
||||
import type { DatabaseConfig, ColumnDef } from './constants';
|
||||
import { DB_DEFAULTS, DatabaseError } from './constants';
|
||||
import { MemoryEngine } from './engine/memory';
|
||||
import { IndexedDBEngine } from './engine/indexeddb';
|
||||
import { OPFSEngine } from './engine/opfs';
|
||||
import { HybridEngine } from './hybrid/index';
|
||||
import { Table } from './table/table';
|
||||
import { createSchema } from './table/schema';
|
||||
import { QueryExecutor } from './query/executor';
|
||||
import { parse } from './sql/parser';
|
||||
import { TransactionManager } from './transaction/index';
|
||||
import { PluginManager } from './plugin/index';
|
||||
import type { Statement } from './query/ast';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MetonaSqlark
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class MetonaSqlark {
|
||||
/** 数据库名称 */
|
||||
readonly name: string;
|
||||
|
||||
/** 存储模式 */
|
||||
readonly mode: string;
|
||||
|
||||
/** 版本号 */
|
||||
private _version: number;
|
||||
|
||||
/** 获取版本号 */
|
||||
get version(): number { return this._version; }
|
||||
|
||||
private engine!: IStorageEngine;
|
||||
private executor!: QueryExecutor;
|
||||
private transactionManager!: TransactionManager;
|
||||
private pluginManager: PluginManager;
|
||||
private config: DatabaseConfig;
|
||||
private ready = false;
|
||||
|
||||
private tableCache: Map<string, Table> = new Map();
|
||||
|
||||
constructor(config: DatabaseConfig) {
|
||||
this.config = config;
|
||||
this.name = config.name ?? DB_DEFAULTS.name;
|
||||
this.mode = config.mode ?? DB_DEFAULTS.mode;
|
||||
this._version = config.version ?? DB_DEFAULTS.version;
|
||||
this.pluginManager = new PluginManager();
|
||||
}
|
||||
|
||||
// ---- 初始化 ----
|
||||
|
||||
/** 初始化数据库(创建引擎、打开连接) */
|
||||
async init(): Promise<void> {
|
||||
// 创建引擎
|
||||
this.engine = this.createEngine();
|
||||
|
||||
// 打开连接
|
||||
await this.engine.open(this.name, this.version);
|
||||
|
||||
// 初始化执行器和事务管理器
|
||||
this.executor = new QueryExecutor(this.engine);
|
||||
this.transactionManager = new TransactionManager(this.engine);
|
||||
|
||||
// 注册插件
|
||||
if (this.config.plugins) {
|
||||
for (const plugin of this.config.plugins) {
|
||||
this.pluginManager.register(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
this.ready = true;
|
||||
|
||||
// 回调
|
||||
if (this.config.onReady) {
|
||||
this.config.onReady(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查是否就绪 */
|
||||
isReady(): boolean {
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
// ---- 表管理 ----
|
||||
|
||||
/** 创建表 */
|
||||
async defineTable(name: string, columns: Record<string, ColumnDef>): Promise<void> {
|
||||
this.ensureReady();
|
||||
const schema = createSchema(name, columns);
|
||||
|
||||
await this.pluginManager.trigger('beforeCreateTable', schema);
|
||||
await this.engine.createTable(schema);
|
||||
await this.pluginManager.trigger('afterCreateTable', schema);
|
||||
|
||||
// 清除缓存
|
||||
this.tableCache.delete(name);
|
||||
}
|
||||
|
||||
/** 获取表操作对象 */
|
||||
table(name: string): Table {
|
||||
this.ensureReady();
|
||||
|
||||
let t = this.tableCache.get(name);
|
||||
if (!t) {
|
||||
t = new Table(this.engine, name, this.executor);
|
||||
this.tableCache.set(name, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 删除表 */
|
||||
async dropTable(name: string): Promise<void> {
|
||||
this.ensureReady();
|
||||
await this.pluginManager.trigger('beforeDropTable', name);
|
||||
await this.engine.dropTable(name);
|
||||
await this.pluginManager.trigger('afterDropTable', name);
|
||||
this.tableCache.delete(name);
|
||||
}
|
||||
|
||||
/** 获取所有表名 */
|
||||
async getTableNames(): Promise<string[]> {
|
||||
this.ensureReady();
|
||||
return this.engine.getTableNames();
|
||||
}
|
||||
|
||||
// ---- SQL 查询 ----
|
||||
|
||||
/** 执行 SQL 字符串查询 */
|
||||
async query(sql: string): Promise<unknown> {
|
||||
this.ensureReady();
|
||||
|
||||
await this.pluginManager.trigger('beforeQuery', sql);
|
||||
|
||||
const stmt: Statement = parse(sql);
|
||||
const result = await this.executor.execute(stmt);
|
||||
|
||||
await this.pluginManager.trigger('afterQuery', sql, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- 事务 ----
|
||||
|
||||
/** 执行事务 */
|
||||
async transaction<T>(fn: (trx: import('./transaction/index').Transaction) => Promise<T>): Promise<T> {
|
||||
this.ensureReady();
|
||||
await this.pluginManager.trigger('beforeTransaction');
|
||||
const result = await this.transactionManager.execute(fn);
|
||||
await this.pluginManager.trigger('afterTransaction');
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- 导入导出 ----
|
||||
|
||||
/** 导出表数据为 JSON */
|
||||
async exportTable(tableName: string): Promise<Record<string, unknown>[]> {
|
||||
this.ensureReady();
|
||||
return this.engine.find(tableName, { table: tableName });
|
||||
}
|
||||
|
||||
/** 导入 JSON 数据到表 */
|
||||
async importTable(tableName: string, data: Record<string, unknown>[]): Promise<string[]> {
|
||||
this.ensureReady();
|
||||
return this.engine.insert(tableName, data);
|
||||
}
|
||||
|
||||
/** 导出整个数据库为 JSON */
|
||||
async exportAll(): Promise<Record<string, Record<string, unknown>[]>> {
|
||||
this.ensureReady();
|
||||
const result: Record<string, Record<string, unknown>[]> = {};
|
||||
const names = await this.engine.getTableNames();
|
||||
for (const name of names) {
|
||||
result[name] = await this.engine.find(name, { table: name });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- 发布订阅 ----
|
||||
|
||||
private listeners: Map<string, Set<(data: unknown) => void>> = new Map();
|
||||
|
||||
/** 订阅表变更 */
|
||||
subscribe(tableName: string, callback: (event: { type: string; row?: unknown }) => void): () => void {
|
||||
const key = `change:${tableName}`;
|
||||
if (!this.listeners.has(key)) this.listeners.set(key, new Set());
|
||||
this.listeners.get(key)!.add(callback as (data: unknown) => void);
|
||||
return () => this.listeners.get(key)?.delete(callback as (data: unknown) => void);
|
||||
}
|
||||
|
||||
/** 触发变更事件 */
|
||||
emit(tableName: string, event: { type: string; row?: unknown }): void {
|
||||
const key = `change:${tableName}`;
|
||||
this.listeners.get(key)?.forEach((cb) => cb(event));
|
||||
}
|
||||
|
||||
// ---- 迁移 ----
|
||||
|
||||
private migrations: Map<number, (db: MetonaSqlark) => Promise<void>> = new Map();
|
||||
|
||||
/** 注册迁移 */
|
||||
addMigration(version: number, up: (db: MetonaSqlark) => Promise<void>): void {
|
||||
this.migrations.set(version, up);
|
||||
}
|
||||
|
||||
/** 执行迁移到指定版本 */
|
||||
async migrateTo(targetVersion: number): Promise<void> {
|
||||
this.ensureReady();
|
||||
for (const [version, up] of [...this.migrations.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
if (version <= targetVersion && version > this.version) {
|
||||
await up(this);
|
||||
this._version = version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 插件 ----
|
||||
|
||||
/** 获取插件管理器 */
|
||||
getPluginManager(): PluginManager {
|
||||
return this.pluginManager;
|
||||
}
|
||||
|
||||
/** 注册钩子 */
|
||||
on(hook: import('./constants').HookName, callback: import('./plugin/index').HookCallback): void {
|
||||
this.pluginManager.on(hook, callback);
|
||||
}
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
/** 关闭数据库 */
|
||||
async close(): Promise<void> {
|
||||
this.pluginManager.destroy();
|
||||
await this.engine.close();
|
||||
this.tableCache.clear();
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
/** 获取底层引擎 */
|
||||
getEngine(): IStorageEngine {
|
||||
return this.engine;
|
||||
}
|
||||
|
||||
// ---- 内部 ----
|
||||
|
||||
private createEngine(): IStorageEngine {
|
||||
const mode = this.mode;
|
||||
const diskEngine = this.config.diskEngine ?? 'indexeddb';
|
||||
|
||||
switch (mode) {
|
||||
case 'memory':
|
||||
return new MemoryEngine();
|
||||
case 'disk':
|
||||
return diskEngine === 'opfs' ? new OPFSEngine() : new IndexedDBEngine();
|
||||
case 'hybrid':
|
||||
return new HybridEngine(diskEngine);
|
||||
default:
|
||||
throw new DatabaseError(`Unknown storage mode: ${mode}`, 'CONFIG_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
private ensureReady(): void {
|
||||
if (!this.ready) {
|
||||
throw new DatabaseError('Database not initialized. Call await db.init() first.', 'DB_NOT_READY');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* metona-sqlark Engine — 存储引擎层
|
||||
* @module engine
|
||||
*/
|
||||
|
||||
export type { IStorageEngine } from './interface';
|
||||
export { MemoryEngine } from './memory';
|
||||
export { IndexedDBEngine } from './indexeddb';
|
||||
export { OPFSEngine } from './opfs';
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* metona-sqlark IndexedDB Engine — 基于 IndexedDB 的持久化存储引擎
|
||||
* @module engine/indexeddb
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from './interface';
|
||||
import type { QueryPlan, TableSchema } from '../constants';
|
||||
import { DatabaseError } from '../constants';
|
||||
import { MemoryEngine } from './memory';
|
||||
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
|
||||
|
||||
export class IndexedDBEngine implements IStorageEngine {
|
||||
readonly name = 'indexeddb';
|
||||
|
||||
private db: IDBDatabase | null = null;
|
||||
private dbName = '';
|
||||
private version = 1;
|
||||
private memoryCache: MemoryEngine = new MemoryEngine();
|
||||
|
||||
async open(dbName: string, version: number): Promise<void> {
|
||||
this.dbName = dbName; this.version = version;
|
||||
await this.memoryCache.open(dbName, version);
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, version);
|
||||
request.onsuccess = () => { this.db = request.result; resolve(); };
|
||||
request.onerror = () => reject(new DatabaseError(`Failed to open IndexedDB "${dbName}"`, 'IDB_OPEN_ERROR', request.error));
|
||||
request.onblocked = () => reject(new DatabaseError(`IndexedDB "${dbName}" is blocked`, 'IDB_BLOCKED'));
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.db) { this.db.close(); this.db = null; }
|
||||
await this.memoryCache.close();
|
||||
}
|
||||
|
||||
isOpen(): boolean { return this.db !== null; }
|
||||
|
||||
// ---- 表管理 ----
|
||||
async createTable(schema: TableSchema): Promise<void> {
|
||||
await this.memoryCache.createTable(schema);
|
||||
const db = this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const newVersion = db.version + 1; db.close();
|
||||
const request = indexedDB.open(this.dbName, newVersion);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
const pkColumn = Object.entries(schema.columns).find(([, c]) => c.primaryKey)?.[0] ?? Object.keys(schema.columns)[0];
|
||||
const store = db.createObjectStore(schema.name, { keyPath: pkColumn });
|
||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||
if (colDef.index && colName !== pkColumn) {
|
||||
store.createIndex(`idx_${colName}`, colName, { unique: colDef.unique ?? false });
|
||||
}
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => { this.db = request.result; resolve(); };
|
||||
request.onerror = () => reject(new DatabaseError(`Failed to create table "${schema.name}"`, 'IDB_UPGRADE_ERROR', request.error));
|
||||
});
|
||||
}
|
||||
|
||||
async dropTable(tableName: string): Promise<void> {
|
||||
await this.memoryCache.dropTable(tableName);
|
||||
const db = this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const newVersion = db.version + 1; db.close();
|
||||
const request = indexedDB.open(this.dbName, newVersion);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (db.objectStoreNames.contains(tableName)) db.deleteObjectStore(tableName);
|
||||
};
|
||||
request.onsuccess = () => { this.db = request.result; resolve(); };
|
||||
request.onerror = () => reject(new DatabaseError(`Failed to drop table "${tableName}"`, 'IDB_UPGRADE_ERROR', request.error));
|
||||
});
|
||||
}
|
||||
|
||||
async hasTable(tableName: string): Promise<boolean> { return this.ensureDB().objectStoreNames.contains(tableName); }
|
||||
async getTableNames(): Promise<string[]> { return Array.from(this.ensureDB().objectStoreNames); }
|
||||
async getTableSchema(tableName: string): Promise<TableSchema | null> { return this.memoryCache.getTableSchema(tableName); }
|
||||
|
||||
// ---- CRUD ----
|
||||
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
|
||||
await this.memoryCache.insert(tableName, rows);
|
||||
const db = this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(tableName, 'readwrite');
|
||||
const store = tx.objectStore(tableName);
|
||||
const pks: string[] = [];
|
||||
for (const row of rows) {
|
||||
const req = store.add(row);
|
||||
req.onsuccess = () => pks.push(String(req.result));
|
||||
req.onerror = () => {}; // 内存层已校验,忽略 IDB 重复键
|
||||
}
|
||||
tx.oncomplete = () => resolve(pks);
|
||||
tx.onerror = () => reject(new DatabaseError(`Insert failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
|
||||
});
|
||||
}
|
||||
|
||||
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
|
||||
const db = this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(tableName, 'readonly');
|
||||
const req = tx.objectStore(tableName).getAll();
|
||||
req.onsuccess = () => {
|
||||
let results: Record<string, unknown>[] = req.result ?? [];
|
||||
if (query.where && Object.keys(query.where).length > 0) {
|
||||
results = results.filter((row) => matchWhere(row, query.where!));
|
||||
}
|
||||
if (query.orderBy && query.orderBy.length > 0) {
|
||||
results = applyOrderBy(results, query.orderBy);
|
||||
}
|
||||
const offset = query.offset ?? 0;
|
||||
const limit = query.limit ?? results.length;
|
||||
results = results.slice(offset, offset + limit);
|
||||
if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') {
|
||||
results = results.map((r) => projectColumns(r, query.columns!));
|
||||
}
|
||||
resolve(results);
|
||||
};
|
||||
req.onerror = () => reject(new DatabaseError(`Find failed for "${tableName}"`, 'IDB_READ_ERROR', req.error));
|
||||
});
|
||||
}
|
||||
|
||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||
await this.memoryCache.update(tableName, query, updates);
|
||||
const db = this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(tableName, 'readwrite');
|
||||
const store = tx.objectStore(tableName);
|
||||
const getAllReq = store.getAll();
|
||||
let count = 0;
|
||||
getAllReq.onsuccess = () => {
|
||||
for (const row of getAllReq.result ?? []) {
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
Object.assign(row, updates); store.put(row); count++;
|
||||
}
|
||||
}
|
||||
};
|
||||
tx.oncomplete = () => resolve(count);
|
||||
tx.onerror = () => reject(new DatabaseError(`Update failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
|
||||
});
|
||||
}
|
||||
|
||||
async delete(tableName: string, query: QueryPlan): Promise<number> {
|
||||
await this.memoryCache.delete(tableName, query);
|
||||
const db = this.ensureDB();
|
||||
const schema = await this.memoryCache.getTableSchema(tableName);
|
||||
if (!schema) throw new DatabaseError(`Table "${tableName}" not found`, 'TABLE_NOT_FOUND');
|
||||
const pkColumn = Object.entries(schema.columns).find(([, c]) => c.primaryKey)?.[0] ?? Object.keys(schema.columns)[0];
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(tableName, 'readwrite');
|
||||
const store = tx.objectStore(tableName);
|
||||
const getAllReq = store.getAll();
|
||||
let count = 0;
|
||||
getAllReq.onsuccess = () => {
|
||||
for (const row of getAllReq.result ?? []) {
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
store.delete(row[pkColumn] as IDBValidKey); count++;
|
||||
}
|
||||
}
|
||||
};
|
||||
tx.oncomplete = () => resolve(count);
|
||||
tx.onerror = () => reject(new DatabaseError(`Delete failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
|
||||
});
|
||||
}
|
||||
|
||||
async count(tableName: string, query?: QueryPlan): Promise<number> {
|
||||
const results = await this.find(tableName, { table: tableName, where: query?.where ?? {} });
|
||||
return results.length;
|
||||
}
|
||||
|
||||
async clear(tableName: string): Promise<void> {
|
||||
await this.memoryCache.clear(tableName);
|
||||
const db = this.ensureDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(tableName, 'readwrite');
|
||||
tx.objectStore(tableName).clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(new DatabaseError(`Clear failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
|
||||
});
|
||||
}
|
||||
|
||||
private ensureDB(): IDBDatabase {
|
||||
if (!this.db) throw new DatabaseError('Database not opened', 'DB_NOT_OPEN');
|
||||
return this.db;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* metona-sqlark Engine Interface — 存储引擎抽象接口
|
||||
* @module engine/interface
|
||||
*/
|
||||
|
||||
import type { QueryPlan, TableSchema } from '../constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IStorageEngine — 所有存储引擎必须实现的接口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IStorageEngine {
|
||||
/** 引擎名称 */
|
||||
readonly name: string;
|
||||
|
||||
/** 打开数据库 */
|
||||
open(dbName: string, version: number): Promise<void>;
|
||||
|
||||
/** 关闭数据库 */
|
||||
close(): Promise<void>;
|
||||
|
||||
/** 检查数据库是否已打开 */
|
||||
isOpen(): boolean;
|
||||
|
||||
/** 创建表 */
|
||||
createTable(schema: TableSchema): Promise<void>;
|
||||
|
||||
/** 删除表 */
|
||||
dropTable(tableName: string): Promise<void>;
|
||||
|
||||
/** 检查表是否存在 */
|
||||
hasTable(tableName: string): Promise<boolean>;
|
||||
|
||||
/** 获取所有表名 */
|
||||
getTableNames(): Promise<string[]>;
|
||||
|
||||
/** 获取表结构 */
|
||||
getTableSchema(tableName: string): Promise<TableSchema | null>;
|
||||
|
||||
/** 插入行,返回主键值列表 */
|
||||
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
||||
|
||||
/** 查询行 */
|
||||
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
|
||||
|
||||
/** 更新行,返回影响行数 */
|
||||
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
|
||||
|
||||
/** 删除行,返回影响行数 */
|
||||
delete(tableName: string, query: QueryPlan): Promise<number>;
|
||||
|
||||
/** 计数 */
|
||||
count(tableName: string, query?: QueryPlan): Promise<number>;
|
||||
|
||||
/** 清空表数据(保留结构) */
|
||||
clear(tableName: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* metona-sqlark Memory Engine — 基于 Map 的内存存储引擎
|
||||
* @module engine/memory
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from './interface';
|
||||
import type { QueryPlan, TableSchema } from '../constants';
|
||||
import { DatabaseError } from '../constants';
|
||||
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
|
||||
|
||||
export class MemoryEngine implements IStorageEngine {
|
||||
readonly name = 'memory';
|
||||
|
||||
private tables: Map<string, Map<string, Record<string, unknown>>> = new Map();
|
||||
private schemas: Map<string, TableSchema> = new Map();
|
||||
private indexes: Map<string, Map<string, Map<unknown, Set<string>>>> = new Map();
|
||||
private opened = false;
|
||||
|
||||
// ---- 生命周期 ----
|
||||
async open(_dbName: string, _version: number): Promise<void> { this.opened = true; }
|
||||
async close(): Promise<void> {
|
||||
this.tables.clear(); this.schemas.clear(); this.indexes.clear(); this.opened = false;
|
||||
}
|
||||
isOpen(): boolean { return this.opened; }
|
||||
|
||||
// ---- 表管理 ----
|
||||
async createTable(schema: TableSchema): Promise<void> {
|
||||
if (this.schemas.has(schema.name)) throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS');
|
||||
this.schemas.set(schema.name, schema);
|
||||
this.tables.set(schema.name, new Map());
|
||||
const tableIndexes = new Map<string, Map<unknown, Set<string>>>();
|
||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||
if (colDef.index || colDef.unique) tableIndexes.set(colName, new Map());
|
||||
}
|
||||
this.indexes.set(schema.name, tableIndexes);
|
||||
}
|
||||
|
||||
async dropTable(tableName: string): Promise<void> {
|
||||
this.ensureTable(tableName);
|
||||
this.schemas.delete(tableName); this.tables.delete(tableName); this.indexes.delete(tableName);
|
||||
}
|
||||
|
||||
async hasTable(tableName: string): Promise<boolean> { return this.schemas.has(tableName); }
|
||||
async getTableNames(): Promise<string[]> { return Array.from(this.schemas.keys()); }
|
||||
async getTableSchema(tableName: string): Promise<TableSchema | null> { return this.schemas.get(tableName) ?? null; }
|
||||
|
||||
// ---- CRUD ----
|
||||
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
|
||||
this.ensureTable(tableName);
|
||||
const schema = this.schemas.get(tableName)!;
|
||||
const table = this.tables.get(tableName)!;
|
||||
const pkColumn = this.getPrimaryKey(schema);
|
||||
const pks: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const validatedRow = this.validateRow(schema, row);
|
||||
const pkValue = String(validatedRow[pkColumn]);
|
||||
if (table.has(pkValue)) throw new DatabaseError(`Duplicate primary key "${pkValue}" in table "${tableName}"`, 'DUPLICATE_KEY');
|
||||
this.checkUniqueness(schema, validatedRow);
|
||||
table.set(pkValue, validatedRow);
|
||||
this.updateIndexes(tableName, validatedRow, pkValue);
|
||||
pks.push(pkValue);
|
||||
}
|
||||
return pks;
|
||||
}
|
||||
|
||||
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
|
||||
this.ensureTable(tableName);
|
||||
const table = this.tables.get(tableName)!;
|
||||
let results = this.tryIndexLookup(tableName, table, query);
|
||||
|
||||
if (query.where && Object.keys(query.where).length > 0) {
|
||||
results = results.filter((row) => matchWhere(row, query.where!));
|
||||
}
|
||||
if (query.orderBy && query.orderBy.length > 0) {
|
||||
results = applyOrderBy(results, query.orderBy);
|
||||
}
|
||||
const offset = query.offset ?? 0;
|
||||
const limit = query.limit ?? results.length;
|
||||
results = results.slice(offset, offset + limit);
|
||||
if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') {
|
||||
results = results.map((row) => projectColumns(row, query.columns!));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||
this.ensureTable(tableName);
|
||||
const schema = this.schemas.get(tableName)!;
|
||||
const table = this.tables.get(tableName)!;
|
||||
let count = 0;
|
||||
for (const [pk, row] of table) {
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
const updated = { ...row, ...updates };
|
||||
this.validateRow(schema, updated);
|
||||
table.set(pk, updated);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async delete(tableName: string, query: QueryPlan): Promise<number> {
|
||||
this.ensureTable(tableName);
|
||||
const table = this.tables.get(tableName)!;
|
||||
const toDelete: string[] = [];
|
||||
for (const [pk, row] of table) {
|
||||
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
|
||||
toDelete.push(pk);
|
||||
}
|
||||
}
|
||||
for (const pk of toDelete) table.delete(pk);
|
||||
return toDelete.length;
|
||||
}
|
||||
|
||||
async count(tableName: string, query?: QueryPlan): Promise<number> {
|
||||
this.ensureTable(tableName);
|
||||
const table = this.tables.get(tableName)!;
|
||||
if (!query?.where || Object.keys(query.where).length === 0) return table.size;
|
||||
let result = 0;
|
||||
for (const row of table.values()) { if (matchWhere(row, query.where)) result++; }
|
||||
return result;
|
||||
}
|
||||
|
||||
async clear(tableName: string): Promise<void> {
|
||||
this.ensureTable(tableName);
|
||||
this.tables.get(tableName)!.clear();
|
||||
const tableIndexes = this.indexes.get(tableName);
|
||||
if (tableIndexes) for (const colIndex of tableIndexes.values()) colIndex.clear();
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
private ensureTable(tableName: string): void {
|
||||
if (!this.tables.has(tableName)) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
|
||||
}
|
||||
|
||||
private getPrimaryKey(schema: TableSchema): string {
|
||||
for (const [name, col] of Object.entries(schema.columns)) { if (col.primaryKey) return name; }
|
||||
return Object.keys(schema.columns)[0];
|
||||
}
|
||||
|
||||
private validateRow(schema: TableSchema, row: Record<string, unknown>): Record<string, unknown> {
|
||||
const validated: Record<string, unknown> = {};
|
||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||
let value = row[colName];
|
||||
if (value === undefined && colDef.default !== undefined) value = colDef.default;
|
||||
if (colDef.required && (value === undefined || value === null)) {
|
||||
throw new DatabaseError(`Column "${colName}" is required in table "${schema.name}"`, 'VALIDATION_ERROR');
|
||||
}
|
||||
if (value !== undefined && value !== null) this.checkType(colName, colDef.type, value);
|
||||
if (value !== undefined) validated[colName] = value;
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
private checkType(colName: string, type: string, value: unknown): void {
|
||||
const jsType = typeof value;
|
||||
switch (type) {
|
||||
case 'string': if (jsType !== 'string') throw new DatabaseError(`Column "${colName}" expects string, got ${jsType}`, 'TYPE_ERROR'); break;
|
||||
case 'number': if (jsType !== 'number') throw new DatabaseError(`Column "${colName}" expects number, got ${jsType}`, 'TYPE_ERROR'); break;
|
||||
case 'boolean': if (jsType !== 'boolean') throw new DatabaseError(`Column "${colName}" expects boolean, got ${jsType}`, 'TYPE_ERROR'); break;
|
||||
case 'date': if (jsType !== 'string' || isNaN(Date.parse(value as string))) throw new DatabaseError(`Column "${colName}" expects valid date`, 'TYPE_ERROR'); break;
|
||||
case 'json': if (jsType !== 'object') throw new DatabaseError(`Column "${colName}" expects object/array, got ${jsType}`, 'TYPE_ERROR'); break;
|
||||
}
|
||||
}
|
||||
|
||||
/** O(1) 唯一性检查:利用哈希索引 */
|
||||
private checkUniqueness(schema: TableSchema, row: Record<string, unknown>): void {
|
||||
const tableIndexes = this.indexes.get(schema.name);
|
||||
if (!tableIndexes) return;
|
||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||
if (!colDef.unique || row[colName] === undefined || row[colName] === null) continue;
|
||||
const colIndex = tableIndexes.get(colName);
|
||||
if (colIndex && colIndex.has(row[colName])) {
|
||||
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 索引查找 */
|
||||
private tryIndexLookup(
|
||||
tableName: string, table: Map<string, Record<string, unknown>>, query: QueryPlan,
|
||||
): Record<string, unknown>[] {
|
||||
const tableIndexes = this.indexes.get(tableName);
|
||||
if (!tableIndexes || !query.where) return Array.from(table.values());
|
||||
for (const [col, condition] of Object.entries(query.where)) {
|
||||
if (typeof condition !== 'object' || condition === null) {
|
||||
const colIndex = tableIndexes.get(col);
|
||||
if (colIndex) {
|
||||
const pks = colIndex.get(condition);
|
||||
if (pks) {
|
||||
const result: Record<string, unknown>[] = [];
|
||||
for (const pk of pks) { const r = table.get(pk); if (r) result.push(r); }
|
||||
return result;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(table.values());
|
||||
}
|
||||
|
||||
/** 更新索引 */
|
||||
private updateIndexes(tableName: string, row: Record<string, unknown>, pk: string): void {
|
||||
const tableIndexes = this.indexes.get(tableName);
|
||||
if (!tableIndexes) return;
|
||||
for (const [colName, colIndex] of tableIndexes) {
|
||||
const value = row[colName];
|
||||
if (value !== undefined && value !== null) {
|
||||
if (!colIndex.has(value)) colIndex.set(value, new Set());
|
||||
colIndex.get(value)!.add(pk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* metona-sqlark OPFS Engine — 基于 Origin Private File System 的持久化存储引擎
|
||||
* @module engine/opfs
|
||||
*
|
||||
* 使用 JSON-per-table 文件存储方案。
|
||||
* 目录结构:{dbName}/tables/{tableName}.json
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from './interface';
|
||||
import type { QueryPlan, TableSchema } from '../constants';
|
||||
import { DatabaseError } from '../constants';
|
||||
import { MemoryEngine } from './memory';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OPFSEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OPFSEngine implements IStorageEngine {
|
||||
readonly name = 'opfs';
|
||||
|
||||
private root: FileSystemDirectoryHandle | null = null;
|
||||
private tablesDir: FileSystemDirectoryHandle | null = null;
|
||||
private dbName = '';
|
||||
|
||||
// 运行时内存缓存(OPFS 文件读写有延迟)
|
||||
private memoryCache: MemoryEngine = new MemoryEngine();
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
async open(dbName: string, version: number): Promise<void> {
|
||||
this.dbName = dbName;
|
||||
await this.memoryCache.open(dbName, version);
|
||||
|
||||
// 获取 OPFS 根目录
|
||||
this.root = await navigator.storage.getDirectory();
|
||||
|
||||
// 创建数据库目录
|
||||
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.root = null;
|
||||
this.tablesDir = null;
|
||||
await this.memoryCache.close();
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.tablesDir !== null;
|
||||
}
|
||||
|
||||
// ---- 表管理 ----
|
||||
|
||||
async createTable(schema: TableSchema): Promise<void> {
|
||||
await this.memoryCache.createTable(schema);
|
||||
// OPFS 中表以空 JSON 数组文件形式存在
|
||||
await this.writeTableData(schema.name, []);
|
||||
}
|
||||
|
||||
async dropTable(tableName: string): Promise<void> {
|
||||
await this.memoryCache.dropTable(tableName);
|
||||
if (this.tablesDir) {
|
||||
try {
|
||||
await this.tablesDir.removeEntry(`${tableName}.json`);
|
||||
} catch {
|
||||
// 文件不存在则忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async hasTable(tableName: string): Promise<boolean> {
|
||||
if (!this.tablesDir) return false;
|
||||
try {
|
||||
await this.tablesDir.getFileHandle(`${tableName}.json`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getTableNames(): Promise<string[]> {
|
||||
if (!this.tablesDir) return [];
|
||||
const names: string[] = [];
|
||||
for await (const [name] of (this.tablesDir as any).entries()) {
|
||||
if (name.endsWith('.json')) {
|
||||
names.push(name.replace('.json', ''));
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
async getTableSchema(tableName: string): Promise<TableSchema | null> {
|
||||
return this.memoryCache.getTableSchema(tableName);
|
||||
}
|
||||
|
||||
// ---- CRUD ----
|
||||
|
||||
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
|
||||
const pks = await this.memoryCache.insert(tableName, rows);
|
||||
// 持久化到 OPFS
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return pks;
|
||||
}
|
||||
|
||||
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
|
||||
return this.memoryCache.find(tableName, query);
|
||||
}
|
||||
|
||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||
const count = await this.memoryCache.update(tableName, query, updates);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
}
|
||||
|
||||
async delete(tableName: string, query: QueryPlan): Promise<number> {
|
||||
const count = await this.memoryCache.delete(tableName, query);
|
||||
const allRows = await this.memoryCache.find(tableName, { table: tableName });
|
||||
await this.writeTableData(tableName, allRows);
|
||||
return count;
|
||||
}
|
||||
|
||||
async count(tableName: string, query?: QueryPlan): Promise<number> {
|
||||
return this.memoryCache.count(tableName, query);
|
||||
}
|
||||
|
||||
async clear(tableName: string): Promise<void> {
|
||||
await this.memoryCache.clear(tableName);
|
||||
await this.writeTableData(tableName, []);
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
|
||||
private ensureDir(): FileSystemDirectoryHandle {
|
||||
if (!this.tablesDir) {
|
||||
throw new DatabaseError('Database not opened', 'DB_NOT_OPEN');
|
||||
}
|
||||
return this.tablesDir;
|
||||
}
|
||||
|
||||
private async writeTableData(tableName: string, data: Record<string, unknown>[]): Promise<void> {
|
||||
const dir = this.ensureDir();
|
||||
const fileName = `${tableName}.json`;
|
||||
const fileHandle = await dir.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(JSON.stringify(data));
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
private async readTableData(tableName: string): Promise<Record<string, unknown>[]> {
|
||||
const dir = this.ensureDir();
|
||||
const fileName = `${tableName}.json`;
|
||||
try {
|
||||
const fileHandle = await dir.getFileHandle(fileName);
|
||||
const file = await fileHandle.getFile();
|
||||
const text = await file.text();
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 OPFS 加载表数据到内存缓存 */
|
||||
async loadTableIntoMemory(tableName: string, schema: TableSchema): Promise<void> {
|
||||
await this.memoryCache.createTable(schema);
|
||||
const rows = await this.readTableData(tableName);
|
||||
if (rows.length > 0) {
|
||||
// 直接用 Map 设置绕过 insert 校验
|
||||
for (const row of rows) {
|
||||
await this.memoryCache.insert(tableName, [row]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* metona-sqlark Hybrid Engine — 内存 + 磁盘混合存储引擎
|
||||
* @module hybrid/index
|
||||
*
|
||||
* 采用 write-through 策略:
|
||||
* - 所有写操作同时写入内存和磁盘
|
||||
* - 所有读操作直接从内存返回
|
||||
* - 数据库打开时从磁盘加载数据到内存
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from '../engine/interface';
|
||||
import type { QueryPlan, TableSchema, DiskEngine } from '../constants';
|
||||
import { MemoryEngine } from '../engine/memory';
|
||||
import { IndexedDBEngine } from '../engine/indexeddb';
|
||||
import { OPFSEngine } from '../engine/opfs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HybridEngine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class HybridEngine implements IStorageEngine {
|
||||
readonly name = 'hybrid';
|
||||
|
||||
private memoryEngine: MemoryEngine;
|
||||
private diskEngine: IStorageEngine;
|
||||
private diskEngineType: DiskEngine;
|
||||
|
||||
constructor(diskEngine: DiskEngine = 'indexeddb') {
|
||||
this.memoryEngine = new MemoryEngine();
|
||||
this.diskEngineType = diskEngine;
|
||||
this.diskEngine = diskEngine === 'opfs' ? new OPFSEngine() : new IndexedDBEngine();
|
||||
}
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
async open(dbName: string, version: number): Promise<void> {
|
||||
// 先打开磁盘引擎
|
||||
await this.diskEngine.open(dbName, version);
|
||||
|
||||
// 再打开内存引擎
|
||||
await this.memoryEngine.open(dbName, version);
|
||||
|
||||
// 从磁盘加载现存表
|
||||
const tableNames = await this.diskEngine.getTableNames();
|
||||
for (const tableName of tableNames) {
|
||||
const schema = await this.diskEngine.getTableSchema(tableName);
|
||||
if (!schema) continue;
|
||||
|
||||
// 在内存中创建表
|
||||
await this.memoryEngine.createTable(schema);
|
||||
|
||||
// 从磁盘加载数据到内存
|
||||
const rows = await this.diskEngine.find(tableName, { table: tableName });
|
||||
if (rows.length > 0) {
|
||||
try {
|
||||
await this.memoryEngine.insert(tableName, rows);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[metona-sqlark] Failed to load table "${tableName}" data from disk:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.memoryEngine.close();
|
||||
await this.diskEngine.close();
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.memoryEngine.isOpen() && this.diskEngine.isOpen();
|
||||
}
|
||||
|
||||
// ---- 表管理 ----
|
||||
|
||||
async createTable(schema: TableSchema): Promise<void> {
|
||||
await this.memoryEngine.createTable(schema);
|
||||
await this.diskEngine.createTable(schema);
|
||||
}
|
||||
|
||||
async dropTable(tableName: string): Promise<void> {
|
||||
await this.memoryEngine.dropTable(tableName);
|
||||
await this.diskEngine.dropTable(tableName);
|
||||
}
|
||||
|
||||
async hasTable(tableName: string): Promise<boolean> {
|
||||
return this.memoryEngine.hasTable(tableName);
|
||||
}
|
||||
|
||||
async getTableNames(): Promise<string[]> {
|
||||
return this.memoryEngine.getTableNames();
|
||||
}
|
||||
|
||||
async getTableSchema(tableName: string): Promise<TableSchema | null> {
|
||||
return this.memoryEngine.getTableSchema(tableName);
|
||||
}
|
||||
|
||||
// ---- CRUD(write-through 策略) ----
|
||||
|
||||
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
|
||||
const pks = await this.memoryEngine.insert(tableName, rows);
|
||||
// write-through: 同步写入磁盘
|
||||
await this.diskEngine.insert(tableName, rows);
|
||||
return pks;
|
||||
}
|
||||
|
||||
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
|
||||
// 直接从内存读取
|
||||
return this.memoryEngine.find(tableName, query);
|
||||
}
|
||||
|
||||
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
|
||||
const count = await this.memoryEngine.update(tableName, query, updates);
|
||||
// write-through: 同步更新磁盘
|
||||
await this.diskEngine.update(tableName, query, updates);
|
||||
return count;
|
||||
}
|
||||
|
||||
async delete(tableName: string, query: QueryPlan): Promise<number> {
|
||||
const count = await this.memoryEngine.delete(tableName, query);
|
||||
// write-through: 同步删除磁盘
|
||||
await this.diskEngine.delete(tableName, query);
|
||||
return count;
|
||||
}
|
||||
|
||||
async count(tableName: string, query?: QueryPlan): Promise<number> {
|
||||
return this.memoryEngine.count(tableName, query);
|
||||
}
|
||||
|
||||
async clear(tableName: string): Promise<void> {
|
||||
await this.memoryEngine.clear(tableName);
|
||||
await this.diskEngine.clear(tableName);
|
||||
}
|
||||
|
||||
// ---- 引擎信息 ----
|
||||
|
||||
/** 获取磁盘引擎类型 */
|
||||
getDiskEngineType(): DiskEngine {
|
||||
return this.diskEngineType;
|
||||
}
|
||||
|
||||
/** 获取内存引擎(供内部使用) */
|
||||
getMemoryEngine(): MemoryEngine {
|
||||
return this.memoryEngine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* metona-sqlark — 入口文件
|
||||
* @module metona-sqlark
|
||||
* @version 0.1.12
|
||||
*
|
||||
* 前端关系型数据库,内存与磁盘双模式。
|
||||
* 支持 Query Builder 链式 API 和 SQL 字符串查询。
|
||||
*/
|
||||
|
||||
import { MetonaSqlark } from './core';
|
||||
import type { DatabaseConfig } from './constants';
|
||||
import { VERSION } from './constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工厂函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 创建数据库实例并初始化
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const db = await MetonaSqlark.create({
|
||||
* name: 'my-app',
|
||||
* mode: 'hybrid',
|
||||
* });
|
||||
*
|
||||
* await db.defineTable('users', {
|
||||
* id: { type: 'string', primaryKey: true },
|
||||
* name: { type: 'string', required: true },
|
||||
* });
|
||||
*
|
||||
* await db.table('users').insert({ id: '1', name: 'Alice' });
|
||||
* const results = await db.query('SELECT * FROM users');
|
||||
* ```
|
||||
*/
|
||||
async function create(config: DatabaseConfig): Promise<MetonaSqlark> {
|
||||
const db = new MetonaSqlark(config);
|
||||
await db.init();
|
||||
return db;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 全局 API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const api = {
|
||||
VERSION,
|
||||
version: VERSION,
|
||||
create,
|
||||
MetonaSqlark,
|
||||
MeSqlark: MetonaSqlark,
|
||||
};
|
||||
|
||||
// 浏览器全局挂载
|
||||
declare global { interface Window { MetonaSqlark: typeof api; MeSqlark: typeof api; } }
|
||||
if (typeof window !== 'undefined') {
|
||||
window.MetonaSqlark = api;
|
||||
window.MeSqlark = api;
|
||||
}
|
||||
|
||||
export default api;
|
||||
export {
|
||||
api,
|
||||
VERSION,
|
||||
create,
|
||||
MetonaSqlark,
|
||||
};
|
||||
|
||||
// 别名
|
||||
export const MeSqlark = MetonaSqlark;
|
||||
|
||||
// 类型导出
|
||||
export type { DatabaseConfig, TableSchema, ColumnDef, FieldType, StorageMode, DiskEngine } from './constants';
|
||||
export type { IStorageEngine } from './engine/interface';
|
||||
export type { Statement, SelectStatement, InsertStatement, UpdateStatement, DeleteStatement } from './query/ast';
|
||||
export { MemoryEngine } from './engine/memory';
|
||||
export { IndexedDBEngine } from './engine/indexeddb';
|
||||
export { OPFSEngine } from './engine/opfs';
|
||||
export { HybridEngine } from './hybrid/index';
|
||||
export { Table } from './table/table';
|
||||
export { parse } from './sql/parser';
|
||||
export { tokenize } from './sql/lexer';
|
||||
@@ -0,0 +1,80 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* metona-sqlark React Integration (v0.1.11)
|
||||
* @module integrations/react
|
||||
*
|
||||
* 轻量 React hooks,需要 react 作为 peer dependency。
|
||||
*
|
||||
* @example
|
||||
* import { useQuery, useTable } from 'metona-sqlark/react';
|
||||
* import { db } from './db';
|
||||
*
|
||||
* function UserList() {
|
||||
* const { data, loading, refresh } = useQuery(db, 'SELECT * FROM users');
|
||||
* return loading ? <div>Loading...</div> : <div>{data.map(u => <div key={u.id}>{u.name}</div>)}</div>;
|
||||
* }
|
||||
*/
|
||||
|
||||
import type { MetonaSqlark } from '../core';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/** useQuery: 执行 SQL 查询 */
|
||||
export function useQuery(
|
||||
db: MetonaSqlark,
|
||||
sql: string,
|
||||
deps: unknown[] = [],
|
||||
): { data: Record<string, unknown>[]; loading: boolean; error: Error | null; refresh: () => void } {
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await db.query(sql) as Record<string, unknown>[];
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [db, sql, ...deps]);
|
||||
|
||||
useEffect(() => { execute(); }, [execute]);
|
||||
|
||||
return { data, loading, error, refresh: execute };
|
||||
}
|
||||
|
||||
/** useTable: 快速获取表数据 */
|
||||
export function useTable(
|
||||
db: MetonaSqlark,
|
||||
tableName: string,
|
||||
): { data: Record<string, unknown>[]; loading: boolean; refresh: () => void } {
|
||||
const { data, loading, refresh } = useQuery(db, `SELECT * FROM ${tableName}`, [tableName]);
|
||||
return { data, loading, refresh };
|
||||
}
|
||||
|
||||
/** useDatabase: 创建/管理数据库实例 */
|
||||
export function useDatabase(config: import('../constants').DatabaseConfig): {
|
||||
db: MetonaSqlark | null;
|
||||
ready: boolean;
|
||||
error: Error | null;
|
||||
} {
|
||||
const [db, setDb] = useState<MetonaSqlark | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const initRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initRef.current) return;
|
||||
initRef.current = true;
|
||||
const instance = new MetonaSqlark(config);
|
||||
instance.init()
|
||||
.then(() => { setDb(instance); setReady(true); })
|
||||
.catch(setError);
|
||||
return () => { instance.close(); };
|
||||
}, []);
|
||||
|
||||
return { db, ready, error };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* metona-sqlark Vue Integration (v0.1.11)
|
||||
* @module integrations/vue
|
||||
*
|
||||
* 轻量 Vue composables,需要 vue 作为 peer dependency。
|
||||
*
|
||||
* @example
|
||||
* import { useSqlarkQuery, useSqlarkTable } from 'metona-sqlark/vue';
|
||||
* import { db } from './db';
|
||||
*
|
||||
* const { data, loading, refresh } = useSqlarkQuery(db, 'SELECT * FROM users');
|
||||
*/
|
||||
|
||||
import type { MetonaSqlark } from '../core';
|
||||
import { ref, watch, onMounted, type Ref } from 'vue';
|
||||
|
||||
/** useSqlarkQuery: 执行 SQL 查询 */
|
||||
export function useSqlarkQuery(
|
||||
db: MetonaSqlark,
|
||||
sql: string,
|
||||
deps: Ref<unknown>[] = [],
|
||||
): { data: Ref<Record<string, unknown>[]>; loading: Ref<boolean>; error: Ref<Error | null>; refresh: () => void } {
|
||||
const data = ref<Record<string, unknown>[]>([]) as Ref<Record<string, unknown>[]>;
|
||||
const loading = ref(true);
|
||||
const error = ref<Error | null>(null) as Ref<Error | null>;
|
||||
|
||||
const execute = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await db.query(sql) as Record<string, unknown>[];
|
||||
error.value = null;
|
||||
} catch (e) {
|
||||
error.value = e as Error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(execute);
|
||||
watch([() => sql, ...deps], execute);
|
||||
|
||||
return { data, loading, error, refresh: execute };
|
||||
}
|
||||
|
||||
/** useSqlarkTable: 快速获取表数据 */
|
||||
export function useSqlarkTable(
|
||||
db: MetonaSqlark,
|
||||
tableName: string,
|
||||
): { data: Ref<Record<string, unknown>[]>; loading: Ref<boolean>; refresh: () => void } {
|
||||
const { data, loading, refresh } = useSqlarkQuery(db, `SELECT * FROM ${tableName}`);
|
||||
return { data, loading, refresh };
|
||||
}
|
||||
|
||||
/** useSqlarkDatabase: 创建/管理数据库实例 */
|
||||
export function useSqlarkDatabase(config: import('../constants').DatabaseConfig): {
|
||||
db: Ref<MetonaSqlark | null>;
|
||||
ready: Ref<boolean>;
|
||||
error: Ref<Error | null>;
|
||||
} {
|
||||
const db = ref<MetonaSqlark | null>(null) as Ref<MetonaSqlark | null>;
|
||||
const ready = ref(false);
|
||||
const error = ref<Error | null>(null) as Ref<Error | null>;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const instance = new MetonaSqlark(config);
|
||||
await instance.init();
|
||||
db.value = instance;
|
||||
ready.value = true;
|
||||
} catch (e) {
|
||||
error.value = e as Error;
|
||||
}
|
||||
});
|
||||
|
||||
return { db, ready, error };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* metona-sqlark Plugin — 插件系统
|
||||
* @module plugin
|
||||
*
|
||||
* 管理插件的注册、生命周期和钩子调度。
|
||||
*/
|
||||
|
||||
import type { MetonaPlugin, HookName } from '../constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook 回调类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type HookCallback = (...args: unknown[]) => void | Promise<void>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class PluginManager {
|
||||
private plugins: MetonaPlugin[] = [];
|
||||
private hooks: Map<HookName, HookCallback[]> = new Map();
|
||||
|
||||
/** 注册插件 */
|
||||
register(plugin: MetonaPlugin): void {
|
||||
// 按优先级插入
|
||||
const priority = plugin.priority ?? 0;
|
||||
const insertIndex = this.plugins.findIndex(
|
||||
(p) => (p.priority ?? 0) < priority,
|
||||
);
|
||||
if (insertIndex === -1) {
|
||||
this.plugins.push(plugin);
|
||||
} else {
|
||||
this.plugins.splice(insertIndex, 0, plugin);
|
||||
}
|
||||
// 安装
|
||||
plugin.install(null); // 实际引用由 MetonaSqlark 注入
|
||||
}
|
||||
|
||||
/** 卸载插件 */
|
||||
unregister(pluginName: string): void {
|
||||
const idx = this.plugins.findIndex((p) => p.name === pluginName);
|
||||
if (idx !== -1) {
|
||||
this.plugins[idx].destroy();
|
||||
this.plugins.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取所有已注册插件 */
|
||||
getPlugins(): MetonaPlugin[] {
|
||||
return [...this.plugins];
|
||||
}
|
||||
|
||||
/** 添加钩子回调 */
|
||||
on(hook: HookName, callback: HookCallback): void {
|
||||
const callbacks = this.hooks.get(hook) ?? [];
|
||||
callbacks.push(callback);
|
||||
this.hooks.set(hook, callbacks);
|
||||
}
|
||||
|
||||
/** 移除钩子回调 */
|
||||
off(hook: HookName, callback: HookCallback): void {
|
||||
const callbacks = this.hooks.get(hook);
|
||||
if (callbacks) {
|
||||
const idx = callbacks.indexOf(callback);
|
||||
if (idx !== -1) callbacks.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 触发钩子 */
|
||||
async trigger(hook: HookName, ...args: unknown[]): Promise<void> {
|
||||
const callbacks = this.hooks.get(hook);
|
||||
if (callbacks) {
|
||||
for (const cb of callbacks) {
|
||||
await cb(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 销毁所有插件 */
|
||||
destroy(): void {
|
||||
for (const plugin of this.plugins) {
|
||||
try { plugin.destroy(); } catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[metona-sqlark] Plugin "${plugin.name}" destroy error:`, e);
|
||||
}
|
||||
}
|
||||
this.plugins = [];
|
||||
this.hooks.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* metona-sqlark Query AST — 查询抽象语法树类型定义
|
||||
* @module query/ast
|
||||
*
|
||||
* QueryBuilder 和 SQL Parser 统一输出此 AST,
|
||||
* Executor 只认 AST,保证两种查询接口行为一致。
|
||||
*/
|
||||
|
||||
import type { WhereCondition, OrderBy } from '../constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AST 语句类型枚举
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StatementType =
|
||||
| 'SELECT'
|
||||
| 'INSERT'
|
||||
| 'UPDATE'
|
||||
| 'DELETE'
|
||||
| 'CREATE_TABLE'
|
||||
| 'DROP_TABLE';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 列引用
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 列引用,'*' 表示所有列;支持 'table.column' 格式 */
|
||||
export type ColumnRef = string;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JOIN
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** JOIN 类型 */
|
||||
export type JoinType = 'INNER' | 'LEFT' | 'RIGHT' | 'CROSS';
|
||||
|
||||
/** JOIN 子句 */
|
||||
export interface JoinClause {
|
||||
type: JoinType;
|
||||
table: string;
|
||||
alias?: string;
|
||||
on: WhereCondition;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 聚合函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 聚合函数类型 */
|
||||
export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX';
|
||||
|
||||
/** 聚合表达式 */
|
||||
export interface AggregateExpression {
|
||||
type: 'AGGREGATE';
|
||||
func: AggregateFunc;
|
||||
column: string; // '*' for COUNT(*)
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DDL: CREATE TABLE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ASTColumnDef {
|
||||
name: string;
|
||||
type: string;
|
||||
primaryKey?: boolean;
|
||||
unique?: boolean;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
index?: boolean;
|
||||
maxLength?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface CreateTableStatement {
|
||||
type: 'CREATE_TABLE';
|
||||
name: string;
|
||||
columns: ASTColumnDef[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DDL: DROP TABLE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DropTableStatement {
|
||||
type: 'DROP_TABLE';
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DML: INSERT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InsertStatement {
|
||||
type: 'INSERT';
|
||||
into: string;
|
||||
columns?: string[];
|
||||
values: unknown[][];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DML: UPDATE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UpdateStatement {
|
||||
type: 'UPDATE';
|
||||
table: string;
|
||||
sets: Record<string, unknown>;
|
||||
where: WhereCondition;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DML: DELETE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DeleteStatement {
|
||||
type: 'DELETE';
|
||||
from: string;
|
||||
where: WhereCondition;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DQL: SELECT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SelectStatement {
|
||||
type: 'SELECT';
|
||||
columns: ColumnRef[];
|
||||
distinct?: boolean;
|
||||
from: string;
|
||||
/** 主表别名 */
|
||||
alias?: string;
|
||||
/** JOIN 子句列表 */
|
||||
joins?: JoinClause[];
|
||||
where: WhereCondition;
|
||||
/** GROUP BY */
|
||||
groupBy?: string[];
|
||||
/** HAVING */
|
||||
having?: WhereCondition;
|
||||
orderBy?: OrderBy[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AST 联合类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Statement =
|
||||
| SelectStatement
|
||||
| InsertStatement
|
||||
| UpdateStatement
|
||||
| DeleteStatement
|
||||
| CreateTableStatement
|
||||
| DropTableStatement;
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* metona-sqlark Query Builder — 链式查询构建器
|
||||
* @module query/builder
|
||||
*
|
||||
* 链式调用 → 构建 AST → 执行引擎操作。
|
||||
* 支持 JOIN(需要 Executor)。
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from '../engine/interface';
|
||||
import type { WhereCondition, OrderBy, SortDirection } from '../constants';
|
||||
import type { SelectStatement, UpdateStatement, DeleteStatement, JoinType, JoinClause } from './ast';
|
||||
import { QueryExecutor } from './executor';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SelectQueryBuilder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SelectQueryBuilder {
|
||||
private _where: WhereCondition = {};
|
||||
private _orderBy: OrderBy[] = [];
|
||||
private _limit?: number;
|
||||
private _offset?: number;
|
||||
private _joins: JoinClause[] = [];
|
||||
private _alias?: string;
|
||||
private _executor?: QueryExecutor;
|
||||
|
||||
constructor(
|
||||
private engine: IStorageEngine,
|
||||
private tableName: string,
|
||||
private _columns: string[] = ['*'],
|
||||
executor?: QueryExecutor,
|
||||
) {
|
||||
this._executor = executor;
|
||||
}
|
||||
|
||||
/** 主表别名 */
|
||||
as(alias: string): this {
|
||||
this._alias = alias;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** INNER JOIN */
|
||||
innerJoin(table: string, on: WhereCondition, alias?: string): this {
|
||||
return this._addJoin('INNER', table, on, alias);
|
||||
}
|
||||
|
||||
/** LEFT JOIN */
|
||||
leftJoin(table: string, on: WhereCondition, alias?: string): this {
|
||||
return this._addJoin('LEFT', table, on, alias);
|
||||
}
|
||||
|
||||
/** RIGHT JOIN */
|
||||
rightJoin(table: string, on: WhereCondition, alias?: string): this {
|
||||
return this._addJoin('RIGHT', table, on, alias);
|
||||
}
|
||||
|
||||
/** CROSS JOIN */
|
||||
crossJoin(table: string, alias?: string): this {
|
||||
return this._addJoin('CROSS', table, {}, alias);
|
||||
}
|
||||
|
||||
/** 通用 JOIN */
|
||||
join(table: string, on: WhereCondition, alias?: string): this {
|
||||
return this._addJoin('INNER', table, on, alias);
|
||||
}
|
||||
|
||||
private _addJoin(type: JoinType, table: string, on: WhereCondition, alias?: string): this {
|
||||
this._joins.push({ type, table, on, alias });
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 添加过滤条件 */
|
||||
where(condition: WhereCondition): this {
|
||||
this._where = { ...this._where, ...condition };
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 排序 */
|
||||
orderBy(column: string, direction: SortDirection = 'asc'): this {
|
||||
this._orderBy.push({ column, direction });
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 限制返回条数 */
|
||||
limit(n: number): this {
|
||||
this._limit = n;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 偏移量 */
|
||||
offset(n: number): this {
|
||||
this._offset = n;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 执行查询 */
|
||||
async execute(): Promise<Record<string, unknown>[]> {
|
||||
// 有 JOIN → 通过 Executor 执行
|
||||
if (this._joins.length > 0 && this._executor) {
|
||||
const ast = this.toAST();
|
||||
return this._executor.execute(ast) as Promise<Record<string, unknown>[]>;
|
||||
}
|
||||
|
||||
// 无 JOIN → 直接调用引擎
|
||||
return this.engine.find(this.tableName, {
|
||||
table: this.tableName,
|
||||
columns: this._columns,
|
||||
where: this._where,
|
||||
orderBy: this._orderBy.length > 0 ? this._orderBy : undefined,
|
||||
limit: this._limit,
|
||||
offset: this._offset,
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取 AST */
|
||||
toAST(): SelectStatement {
|
||||
return {
|
||||
type: 'SELECT',
|
||||
columns: this._columns,
|
||||
from: this.tableName,
|
||||
alias: this._alias,
|
||||
joins: this._joins.length > 0 ? [...this._joins] : undefined,
|
||||
where: this._where,
|
||||
orderBy: this._orderBy.length > 0 ? this._orderBy : undefined,
|
||||
limit: this._limit,
|
||||
offset: this._offset,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateQueryBuilder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class UpdateQueryBuilder {
|
||||
private _where: WhereCondition = {};
|
||||
|
||||
constructor(
|
||||
private engine: IStorageEngine,
|
||||
private tableName: string,
|
||||
private _updates: Record<string, unknown>,
|
||||
) {}
|
||||
|
||||
where(condition: WhereCondition): this {
|
||||
this._where = { ...this._where, ...condition };
|
||||
return this;
|
||||
}
|
||||
|
||||
async execute(): Promise<number> {
|
||||
return this.engine.update(this.tableName, { table: this.tableName, where: this._where }, this._updates);
|
||||
}
|
||||
|
||||
toAST(): UpdateStatement {
|
||||
return { type: 'UPDATE', table: this.tableName, sets: this._updates, where: this._where };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteQueryBuilder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class DeleteQueryBuilder {
|
||||
private _where: WhereCondition = {};
|
||||
|
||||
constructor(
|
||||
private engine: IStorageEngine,
|
||||
private tableName: string,
|
||||
) {}
|
||||
|
||||
where(condition: WhereCondition): this {
|
||||
this._where = { ...this._where, ...condition };
|
||||
return this;
|
||||
}
|
||||
|
||||
async execute(): Promise<number> {
|
||||
return this.engine.delete(this.tableName, { table: this.tableName, where: this._where });
|
||||
}
|
||||
|
||||
toAST(): DeleteStatement {
|
||||
return { type: 'DELETE', from: this.tableName, where: this._where };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* metona-sqlark Query Compiler — AST → 查询计划
|
||||
* @module query/compiler
|
||||
*
|
||||
* 将 AST 语句编译为引擎可执行的 QueryPlan。
|
||||
* v0.0.1: 简单直接映射,未来可加入索引选择、过滤下推等优化。
|
||||
*/
|
||||
|
||||
import type { QueryPlan } from '../constants';
|
||||
import { DatabaseError } from '../constants';
|
||||
import type { Statement, SelectStatement, DeleteStatement, UpdateStatement } from './ast';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 编译 AST → QueryPlan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 编译 SELECT / DELETE / UPDATE 语句为 QueryPlan。
|
||||
* INSERT 和 DDL 语句不需要 QueryPlan。
|
||||
*/
|
||||
export function compileStatement(stmt: Statement): QueryPlan {
|
||||
switch (stmt.type) {
|
||||
case 'SELECT':
|
||||
return compileSelect(stmt);
|
||||
case 'DELETE':
|
||||
return compileDelete(stmt);
|
||||
case 'UPDATE':
|
||||
return compileUpdate(stmt);
|
||||
default:
|
||||
throw new DatabaseError(`Cannot compile statement type "${stmt.type}" to QueryPlan`, 'COMPILE_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
function compileSelect(stmt: SelectStatement): QueryPlan {
|
||||
return {
|
||||
table: stmt.from,
|
||||
columns: stmt.columns,
|
||||
where: stmt.where,
|
||||
orderBy: stmt.orderBy?.length ? stmt.orderBy : undefined,
|
||||
limit: stmt.limit,
|
||||
offset: stmt.offset,
|
||||
};
|
||||
}
|
||||
|
||||
function compileDelete(stmt: DeleteStatement): QueryPlan {
|
||||
return {
|
||||
table: stmt.from,
|
||||
where: stmt.where,
|
||||
};
|
||||
}
|
||||
|
||||
function compileUpdate(stmt: UpdateStatement): QueryPlan {
|
||||
return {
|
||||
table: stmt.table,
|
||||
where: stmt.where,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* metona-sqlark Query Executor — AST 执行器
|
||||
* @module query/executor
|
||||
*
|
||||
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理。
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from '../engine/interface';
|
||||
import type {
|
||||
Statement, SelectStatement, InsertStatement, UpdateStatement,
|
||||
DeleteStatement, CreateTableStatement, DropTableStatement, JoinClause,
|
||||
} from './ast';
|
||||
import { DatabaseError } from '../constants';
|
||||
import { compileStatement } from './compiler';
|
||||
import { createSchema, astColumnToColumnDef } from '../table/schema';
|
||||
import { matchWhere, applyOrderBy, projectColumns } from './where-matcher';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Executor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class QueryExecutor {
|
||||
constructor(private engine: IStorageEngine) {}
|
||||
|
||||
async execute(stmt: Statement): Promise<unknown> {
|
||||
switch (stmt.type) {
|
||||
case 'SELECT': return this.executeSelect(stmt);
|
||||
case 'INSERT': return this.executeInsert(stmt);
|
||||
case 'UPDATE': return this.executeUpdate(stmt);
|
||||
case 'DELETE': return this.executeDelete(stmt);
|
||||
case 'CREATE_TABLE': return this.executeCreateTable(stmt);
|
||||
case 'DROP_TABLE': return this.executeDropTable(stmt);
|
||||
default: throw new DatabaseError('Unknown statement type', 'UNKNOWN_STATEMENT');
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SELECT
|
||||
// ===================================================================
|
||||
|
||||
private async executeSelect(stmt: SelectStatement): Promise<Record<string, unknown>[]> {
|
||||
const hasGroupBy = !!(stmt.groupBy && stmt.groupBy.length > 0);
|
||||
let rows: Record<string, unknown>[];
|
||||
|
||||
if (!stmt.joins || stmt.joins.length === 0) {
|
||||
const plan = compileStatement(hasGroupBy ? { ...stmt, columns: ['*'] } : stmt);
|
||||
rows = await this.engine.find(plan.table, plan);
|
||||
} else {
|
||||
rows = await this.executeJoinSelect(stmt);
|
||||
}
|
||||
|
||||
if (hasGroupBy) rows = this.executeGroupBy(rows, stmt);
|
||||
if (stmt.distinct) rows = this.executeDistinct(rows);
|
||||
if (stmt.having && Object.keys(stmt.having).length > 0) {
|
||||
rows = rows.filter((row) => matchWhere(row, stmt.having!));
|
||||
}
|
||||
if (stmt.orderBy && stmt.orderBy.length > 0) rows = applyOrderBy(rows, stmt.orderBy);
|
||||
const offset = stmt.offset ?? 0;
|
||||
const limit = stmt.limit ?? rows.length;
|
||||
rows = rows.slice(offset, offset + limit);
|
||||
if (!hasGroupBy && stmt.columns.length > 0 && stmt.columns[0] !== '*') {
|
||||
rows = rows.map((row) => projectColumns(row, stmt.columns));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ---- JOIN ----
|
||||
|
||||
private async executeJoinSelect(stmt: SelectStatement): Promise<Record<string, unknown>[]> {
|
||||
const mainAlias = stmt.alias ?? stmt.from;
|
||||
const mainRows = (await this.engine.find(stmt.from, { table: stmt.from }))
|
||||
.map((row) => this.prefixRow(row, mainAlias));
|
||||
let resultRows = mainRows;
|
||||
|
||||
for (const join of stmt.joins!) {
|
||||
const joinAlias = join.alias ?? join.table;
|
||||
const joinRows = (await this.engine.find(join.table, { table: join.table }))
|
||||
.map((row) => this.prefixRow(row, joinAlias));
|
||||
resultRows = this.joinRows(resultRows, joinRows, join);
|
||||
}
|
||||
|
||||
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
||||
resultRows = resultRows.filter((row) => matchWhere(row, stmt.where));
|
||||
}
|
||||
return resultRows;
|
||||
}
|
||||
|
||||
private prefixRow(row: Record<string, unknown>, alias: string): Record<string, unknown> {
|
||||
const prefixed: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(row)) prefixed[`${alias}.${key}`] = value;
|
||||
return prefixed;
|
||||
}
|
||||
|
||||
/** 嵌套循环连接(优化:避免 ON 时对象扩散) */
|
||||
private joinRows(
|
||||
leftRows: Record<string, unknown>[],
|
||||
rightRows: Record<string, unknown>[],
|
||||
join: JoinClause,
|
||||
): Record<string, unknown>[] {
|
||||
if (join.type === 'CROSS') {
|
||||
const result: Record<string, unknown>[] = [];
|
||||
for (const l of leftRows) for (const r of rightRows) result.push({ ...l, ...r });
|
||||
return result;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown>[] = [];
|
||||
for (const l of leftRows) {
|
||||
let matched = false;
|
||||
for (const r of rightRows) {
|
||||
// 合并后匹配 ON(避免创建临时对象再丢弃)
|
||||
const merged = { ...l, ...r };
|
||||
if (matchWhere(merged, join.on, { $col: true })) {
|
||||
result.push(merged);
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if (!matched && join.type === 'LEFT') {
|
||||
const nullRight: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(rightRows[0] ?? {})) nullRight[key] = null;
|
||||
result.push({ ...l, ...nullRight });
|
||||
}
|
||||
}
|
||||
|
||||
if (join.type === 'RIGHT') {
|
||||
for (const r of rightRows) {
|
||||
const isMatched = leftRows.some((l) => {
|
||||
const merged = { ...l, ...r };
|
||||
return matchWhere(merged, join.on, { $col: true });
|
||||
});
|
||||
if (!isMatched) {
|
||||
const nullLeft: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(leftRows[0] ?? {})) nullLeft[key] = null;
|
||||
result.push({ ...nullLeft, ...r });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- GROUP BY ----
|
||||
|
||||
private executeGroupBy(rows: Record<string, unknown>[], stmt: SelectStatement): Record<string, unknown>[] {
|
||||
const groups = new Map<string, Record<string, unknown>[]>();
|
||||
for (const row of rows) {
|
||||
const key = stmt.groupBy!.map((col) => String(row[col] ?? 'null')).join('|');
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(row);
|
||||
}
|
||||
const result: Record<string, unknown>[] = [];
|
||||
for (const groupRows of groups.values()) {
|
||||
const aggregated: Record<string, unknown> = {};
|
||||
for (const col of stmt.groupBy!) aggregated[col] = groupRows[0][col];
|
||||
for (const colExpr of stmt.columns) {
|
||||
if (colExpr === '*') continue;
|
||||
const m = colExpr.match(/^(COUNT|SUM|AVG|MIN|MAX)\((.+?)\)(?:\s+AS\s+(\w+))?$/i);
|
||||
if (m) {
|
||||
const [, func, arg, alias] = m;
|
||||
aggregated[alias || colExpr] = this.computeAggregate(func.toUpperCase(), groupRows, arg.trim());
|
||||
} else if (!stmt.groupBy!.includes(colExpr)) {
|
||||
aggregated[colExpr] = groupRows[0][colExpr];
|
||||
}
|
||||
}
|
||||
result.push(aggregated);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private computeAggregate(func: string, rows: Record<string, unknown>[], col: string): number {
|
||||
const nums = rows.map((r) => r[col]).filter((v) => v !== null && v !== undefined).map(Number);
|
||||
switch (func) {
|
||||
case 'COUNT': return col === '*' ? rows.length : nums.length;
|
||||
case 'SUM': return nums.reduce((a: number, b) => a + b, 0);
|
||||
case 'AVG': return nums.length === 0 ? 0 : nums.reduce((a: number, b) => a + b, 0) / nums.length;
|
||||
case 'MIN': return nums.length === 0 ? 0 : Math.min(...nums);
|
||||
case 'MAX': return nums.length === 0 ? 0 : Math.max(...nums);
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DISTINCT(优化:列值拼接代替 JSON.stringify) ----
|
||||
|
||||
private executeDistinct(rows: Record<string, unknown>[]): Record<string, unknown>[] {
|
||||
const seen = new Set<string>();
|
||||
return rows.filter((row) => {
|
||||
const key = Object.values(row).map((v) => String(v ?? '\0')).join('\x1f');
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 其他语句
|
||||
// ===================================================================
|
||||
|
||||
private async executeInsert(stmt: InsertStatement): Promise<string[]> {
|
||||
const schema = await this.engine.getTableSchema(stmt.into);
|
||||
if (!schema) throw new DatabaseError(`Table "${stmt.into}" does not exist`, 'TABLE_NOT_FOUND');
|
||||
const colNames = stmt.columns ?? Object.keys(schema.columns);
|
||||
const rows: Record<string, unknown>[] = stmt.values.map((vals: unknown[]) => {
|
||||
const row: Record<string, unknown> = {};
|
||||
for (let i = 0; i < colNames.length; i++) { if (i < vals.length) row[colNames[i]] = vals[i]; }
|
||||
return row;
|
||||
});
|
||||
return this.engine.insert(stmt.into, rows);
|
||||
}
|
||||
|
||||
private async executeUpdate(stmt: UpdateStatement): Promise<number> {
|
||||
const plan = compileStatement(stmt);
|
||||
return this.engine.update(plan.table, plan, stmt.sets);
|
||||
}
|
||||
|
||||
private async executeDelete(stmt: DeleteStatement): Promise<number> {
|
||||
const plan = compileStatement(stmt);
|
||||
return this.engine.delete(plan.table, plan);
|
||||
}
|
||||
|
||||
private async executeCreateTable(stmt: CreateTableStatement): Promise<void> {
|
||||
const columns: Record<string, any> = {};
|
||||
for (const col of stmt.columns) columns[col.name] = astColumnToColumnDef(col);
|
||||
return this.engine.createTable(createSchema(stmt.name, columns));
|
||||
}
|
||||
|
||||
private async executeDropTable(stmt: DropTableStatement): Promise<void> {
|
||||
return this.engine.dropTable(stmt.name);
|
||||
}
|
||||
|
||||
getEngine(): IStorageEngine { return this.engine; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* metona-sqlark Query — 查询系统
|
||||
* @module query
|
||||
*/
|
||||
|
||||
export type * from './ast';
|
||||
export { SelectQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder } from './builder';
|
||||
export { compileStatement } from './compiler';
|
||||
export { QueryExecutor } from './executor';
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||
* @module query/where-matcher
|
||||
*
|
||||
* MemoryEngine / IndexedDBEngine / QueryExecutor 共享此模块,
|
||||
* 消除 220+ 行重复代码,统一 $and/$or/$not/$col 行为。
|
||||
*/
|
||||
|
||||
import type { WhereCondition, OrderBy } from '../constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LIKE 正则缓存
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const likeCache = new Map<string, RegExp>();
|
||||
|
||||
function compileLikeRegex(pattern: string): RegExp {
|
||||
const cached = likeCache.get(pattern);
|
||||
if (cached) return cached;
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/%/g, '.*')
|
||||
.replace(/_/g, '.');
|
||||
const regex = new RegExp(`^${escaped}$`, 'i');
|
||||
likeCache.set(pattern, regex);
|
||||
return regex;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WHERE 匹配(顶层入口)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 匹配完整 WHERE 条件
|
||||
* @param row 当前数据行
|
||||
* @param where WHERE 条件对象
|
||||
* @param options.$col 是否启用 $col 列引用解析
|
||||
*/
|
||||
export function matchWhere(
|
||||
row: Record<string, unknown>,
|
||||
where: WhereCondition,
|
||||
options: { $col?: boolean } = {},
|
||||
): boolean {
|
||||
for (const [field, condition] of Object.entries(where)) {
|
||||
// 顶层 $and
|
||||
if (field === '$and') {
|
||||
const subs = condition as WhereCondition[];
|
||||
if (!subs.every((sub) => matchWhere(row, sub, options))) return false;
|
||||
continue;
|
||||
}
|
||||
// 顶层 $or
|
||||
if (field === '$or') {
|
||||
const subs = condition as WhereCondition[];
|
||||
if (!subs.some((sub) => matchWhere(row, sub, options))) return false;
|
||||
continue;
|
||||
}
|
||||
if (!matchField(row[field], condition, row, options)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 字段匹配
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function matchField(
|
||||
value: unknown,
|
||||
condition: unknown,
|
||||
row: Record<string, unknown>,
|
||||
options: { $col?: boolean },
|
||||
): boolean {
|
||||
// 嵌套 $and
|
||||
if (typeof condition === 'object' && condition !== null && '$and' in (condition as Record<string, unknown>)) {
|
||||
const subs = (condition as Record<string, unknown>).$and as WhereCondition[];
|
||||
return subs.every((sub) => matchWhere(row, sub, options));
|
||||
}
|
||||
// 嵌套 $or
|
||||
if (typeof condition === 'object' && condition !== null && '$or' in (condition as Record<string, unknown>)) {
|
||||
const subs = (condition as Record<string, unknown>).$or as WhereCondition[];
|
||||
return subs.some((sub) => matchWhere(row, sub, options));
|
||||
}
|
||||
// $not
|
||||
if (typeof condition === 'object' && condition !== null && '$not' in (condition as Record<string, unknown>)) {
|
||||
return !matchField(value, (condition as Record<string, unknown>).$not, row, options);
|
||||
}
|
||||
// 简单值 => $eq
|
||||
if (typeof condition !== 'object' || condition === null || Array.isArray(condition)) {
|
||||
return value === condition;
|
||||
}
|
||||
|
||||
const ops = condition as Record<string, unknown>;
|
||||
|
||||
// $col 简写: { $col: name } === { $eq: { $col: name } }(仅 JOIN ON 场景)
|
||||
if (options.$col && '$col' in ops && Object.keys(ops).length === 1) {
|
||||
return value === row[ops.$col as string];
|
||||
}
|
||||
|
||||
// 遍历操作符
|
||||
for (const [op, operand] of Object.entries(ops)) {
|
||||
let actualOperand = operand;
|
||||
|
||||
// $col 列引用解析
|
||||
if (options.$col && typeof operand === 'object' && operand !== null && '$col' in (operand as Record<string, unknown>)) {
|
||||
actualOperand = row[(operand as Record<string, unknown>).$col as string];
|
||||
}
|
||||
|
||||
if (!matchOperator(value, op, actualOperand)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 操作符匹配
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function matchOperator(value: unknown, op: string, operand: unknown): boolean {
|
||||
switch (op) {
|
||||
case '$eq': return value === operand;
|
||||
case '$ne': return value !== operand;
|
||||
case '$gt': return (value as number) > (operand as number);
|
||||
case '$gte': return (value as number) >= (operand as number);
|
||||
case '$lt': return (value as number) < (operand as number);
|
||||
case '$lte': return (value as number) <= (operand as number);
|
||||
case '$in': return Array.isArray(operand) && operand.includes(value);
|
||||
case '$nin': return Array.isArray(operand) && !operand.includes(value);
|
||||
case '$like': return compileLikeRegex(String(operand)).test(String(value));
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 排序
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function applyOrderBy(rows: Record<string, unknown>[], orderBy: OrderBy[]): Record<string, unknown>[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
for (const { column, direction } of orderBy) {
|
||||
const cmp = compare(a[column], b[column]);
|
||||
if (cmp !== 0) return direction === 'desc' ? -cmp : cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function compare(a: unknown, b: unknown): number {
|
||||
if (a === b) return 0;
|
||||
if (a === null || a === undefined) return 1;
|
||||
if (b === null || b === undefined) return -1;
|
||||
if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b);
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||
return String(a).localeCompare(String(b));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 列投影
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function projectColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
||||
const projected: Record<string, unknown> = {};
|
||||
for (const col of columns) {
|
||||
if (col in row) {
|
||||
projected[col] = row[col];
|
||||
} else {
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key.endsWith(`.${col}`) || key === col) {
|
||||
projected[col] = row[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* metona-sqlark SQL Parser — SQL 字符串解析
|
||||
* @module sql
|
||||
*/
|
||||
|
||||
export { Lexer, tokenize } from './lexer';
|
||||
export { Parser, parse } from './parser';
|
||||
export { TokenType } from './tokens';
|
||||
export type { Token } from './tokens';
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* metona-sqlark SQL Lexer — 词法分析器
|
||||
* @module sql/lexer
|
||||
*
|
||||
* 将 SQL 字符串切分为 Token 流。
|
||||
*/
|
||||
|
||||
import { TokenType, type Token, KEYWORDS } from './tokens';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lexer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class Lexer {
|
||||
private input: string;
|
||||
private position = 0;
|
||||
private readPosition = 0;
|
||||
private ch: string = '';
|
||||
|
||||
constructor(input: string) {
|
||||
this.input = input;
|
||||
this.readChar();
|
||||
}
|
||||
|
||||
/** 读取下一个 Token */
|
||||
nextToken(): Token {
|
||||
this.skipWhitespace();
|
||||
|
||||
let tok: Token;
|
||||
|
||||
switch (this.ch) {
|
||||
case ',':
|
||||
tok = this.makeToken(TokenType.COMMA, ',');
|
||||
break;
|
||||
case '(':
|
||||
tok = this.makeToken(TokenType.LPAREN, '(');
|
||||
break;
|
||||
case ')':
|
||||
tok = this.makeToken(TokenType.RPAREN, ')');
|
||||
break;
|
||||
case ';':
|
||||
tok = this.makeToken(TokenType.SEMICOLON, ';');
|
||||
break;
|
||||
case '*':
|
||||
tok = this.makeToken(TokenType.STAR, '*');
|
||||
break;
|
||||
case '.':
|
||||
tok = this.makeToken(TokenType.DOT, '.');
|
||||
break;
|
||||
case '=':
|
||||
tok = this.makeToken(TokenType.EQ, '=');
|
||||
break;
|
||||
case '!':
|
||||
if (this.peekChar() === '=') {
|
||||
this.readChar();
|
||||
tok = this.makeToken(TokenType.NEQ, '!=');
|
||||
} else {
|
||||
tok = this.makeToken(TokenType.ILLEGAL, '!');
|
||||
}
|
||||
break;
|
||||
case '>':
|
||||
if (this.peekChar() === '=') {
|
||||
this.readChar();
|
||||
tok = this.makeToken(TokenType.GTE, '>=');
|
||||
} else {
|
||||
tok = this.makeToken(TokenType.GT, '>');
|
||||
}
|
||||
break;
|
||||
case '<':
|
||||
if (this.peekChar() === '=') {
|
||||
this.readChar();
|
||||
tok = this.makeToken(TokenType.LTE, '<=');
|
||||
} else if (this.peekChar() === '>') {
|
||||
this.readChar();
|
||||
tok = this.makeToken(TokenType.NEQ, '<>');
|
||||
} else {
|
||||
tok = this.makeToken(TokenType.LT, '<');
|
||||
}
|
||||
break;
|
||||
case "'":
|
||||
case '"':
|
||||
tok = this.readString(this.ch);
|
||||
break;
|
||||
case '':
|
||||
tok = { type: TokenType.EOF, value: '', position: this.position };
|
||||
break;
|
||||
default:
|
||||
if (this.isLetter(this.ch)) {
|
||||
const ident = this.readIdentifier();
|
||||
const keyword = KEYWORDS[ident.toUpperCase()];
|
||||
tok = {
|
||||
type: keyword ?? TokenType.IDENTIFIER,
|
||||
value: ident,
|
||||
position: this.position - ident.length,
|
||||
};
|
||||
return tok; // 已读取完毕,不需要再 readChar
|
||||
} else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) {
|
||||
const num = this.readNumber();
|
||||
tok = {
|
||||
type: TokenType.NUMBER,
|
||||
value: num,
|
||||
position: this.position - num.length,
|
||||
};
|
||||
return tok;
|
||||
} else {
|
||||
tok = this.makeToken(TokenType.ILLEGAL, this.ch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.readChar();
|
||||
return tok;
|
||||
}
|
||||
|
||||
// ---- 内部 ----
|
||||
|
||||
private readChar(): void {
|
||||
if (this.readPosition >= this.input.length) {
|
||||
this.ch = '';
|
||||
} else {
|
||||
this.ch = this.input[this.readPosition];
|
||||
}
|
||||
this.position = this.readPosition;
|
||||
this.readPosition++;
|
||||
}
|
||||
|
||||
private peekChar(): string {
|
||||
if (this.readPosition >= this.input.length) return '';
|
||||
return this.input[this.readPosition];
|
||||
}
|
||||
|
||||
private skipWhitespace(): void {
|
||||
while (this.ch === ' ' || this.ch === '\t' || this.ch === '\n' || this.ch === '\r') {
|
||||
this.readChar();
|
||||
}
|
||||
}
|
||||
|
||||
private readIdentifier(): string {
|
||||
const start = this.position;
|
||||
while (this.isLetter(this.ch) || this.isDigit(this.ch) || this.ch === '_') {
|
||||
this.readChar();
|
||||
}
|
||||
return this.input.slice(start, this.position);
|
||||
}
|
||||
|
||||
private readNumber(): string {
|
||||
const start = this.position;
|
||||
// 负号
|
||||
if (this.ch === '-') this.readChar();
|
||||
while (this.isDigit(this.ch)) {
|
||||
this.readChar();
|
||||
}
|
||||
// 小数点
|
||||
if (this.ch === '.' && this.isDigit(this.peekChar())) {
|
||||
this.readChar();
|
||||
while (this.isDigit(this.ch)) {
|
||||
this.readChar();
|
||||
}
|
||||
}
|
||||
return this.input.slice(start, this.position);
|
||||
}
|
||||
|
||||
private readString(quote: string): Token {
|
||||
const start = this.position + 1; // 跳过一个引号
|
||||
this.readChar(); // 跳过开始引号
|
||||
let value = '';
|
||||
|
||||
while (this.ch !== quote && this.ch !== '') {
|
||||
// 处理转义
|
||||
if (this.ch === '\\' && this.peekChar() === quote) {
|
||||
this.readChar();
|
||||
value += quote;
|
||||
} else {
|
||||
value += this.ch;
|
||||
}
|
||||
this.readChar();
|
||||
}
|
||||
|
||||
// 跳过结束引号(在 readChar 之后才会调用,所以这里不需要处理)
|
||||
return {
|
||||
type: TokenType.STRING,
|
||||
value,
|
||||
position: start,
|
||||
};
|
||||
}
|
||||
|
||||
private isLetter(ch: string): boolean {
|
||||
return /[a-zA-Z_]/.test(ch);
|
||||
}
|
||||
|
||||
private isDigit(ch: string): boolean {
|
||||
return /[0-9]/.test(ch);
|
||||
}
|
||||
|
||||
private makeToken(type: TokenType, value: string): Token {
|
||||
return { type, value, position: this.position };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 便捷方法:一次性词法分析
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 将 SQL 字符串解析为 Token 列表 */
|
||||
export function tokenize(sql: string): Token[] {
|
||||
const lexer = new Lexer(sql);
|
||||
const tokens: Token[] = [];
|
||||
let tok = lexer.nextToken();
|
||||
while (tok.type !== TokenType.EOF) {
|
||||
tokens.push(tok);
|
||||
tok = lexer.nextToken();
|
||||
}
|
||||
tokens.push(tok); // EOF
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
/**
|
||||
* metona-sqlark SQL Parser — 递归下降语法分析器
|
||||
* @module sql/parser
|
||||
*
|
||||
* Token 流 → AST Statement。
|
||||
* 支持的语法是标准 SQL 的子集。
|
||||
*/
|
||||
|
||||
import { Lexer } from './lexer';
|
||||
import { TokenType, type Token } from './tokens';
|
||||
import type {
|
||||
Statement,
|
||||
SelectStatement,
|
||||
InsertStatement,
|
||||
UpdateStatement,
|
||||
DeleteStatement,
|
||||
CreateTableStatement,
|
||||
DropTableStatement,
|
||||
ASTColumnDef,
|
||||
} from '../query/ast';
|
||||
import type { WhereCondition, OrderBy, SortDirection } from '../constants';
|
||||
import { DatabaseError } from '../constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class Parser {
|
||||
private lexer: Lexer;
|
||||
private curToken!: Token;
|
||||
private peekToken!: Token;
|
||||
|
||||
constructor(sql: string) {
|
||||
this.lexer = new Lexer(sql);
|
||||
// 预读两个 token
|
||||
this.nextToken();
|
||||
this.nextToken();
|
||||
}
|
||||
|
||||
/** 解析完整 SQL 语句 */
|
||||
parseStatement(): Statement {
|
||||
switch (this.curToken.type) {
|
||||
case TokenType.SELECT:
|
||||
return this.parseSelect();
|
||||
case TokenType.INSERT:
|
||||
return this.parseInsert();
|
||||
case TokenType.UPDATE:
|
||||
return this.parseUpdate();
|
||||
case TokenType.DELETE:
|
||||
return this.parseDelete();
|
||||
case TokenType.CREATE:
|
||||
return this.parseCreateTable();
|
||||
case TokenType.DROP:
|
||||
return this.parseDropTable();
|
||||
default:
|
||||
throw this.error(`Unexpected token "${this.curToken.value}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SELECT
|
||||
// ===================================================================
|
||||
|
||||
private parseSelect(): SelectStatement {
|
||||
this.expect(TokenType.SELECT);
|
||||
|
||||
// DISTINCT(可选)
|
||||
let distinct = false;
|
||||
if (this.curTokenIs(TokenType.DISTINCT)) {
|
||||
distinct = true;
|
||||
this.nextToken();
|
||||
}
|
||||
|
||||
// 列
|
||||
const columns: string[] = [];
|
||||
if (this.curTokenIs(TokenType.STAR)) {
|
||||
columns.push('*');
|
||||
this.nextToken();
|
||||
} else {
|
||||
columns.push(...this.parseColumnList());
|
||||
}
|
||||
|
||||
// FROM
|
||||
this.expect(TokenType.FROM);
|
||||
const tableName = this.expectIdentifier('table name');
|
||||
|
||||
// 表别名(可选)
|
||||
let alias: string | undefined;
|
||||
if (this.curTokenIs(TokenType.AS)) {
|
||||
this.nextToken();
|
||||
alias = this.expectIdentifier('alias');
|
||||
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom()) {
|
||||
alias = this.curToken.value;
|
||||
this.nextToken();
|
||||
}
|
||||
|
||||
const stmt: SelectStatement = {
|
||||
type: 'SELECT',
|
||||
columns,
|
||||
distinct: distinct || undefined,
|
||||
from: tableName,
|
||||
alias,
|
||||
where: {},
|
||||
};
|
||||
|
||||
// JOIN 子句(可选,支持多个)
|
||||
const joins = this.parseJoinClauses();
|
||||
if (joins.length > 0) {
|
||||
stmt.joins = joins;
|
||||
}
|
||||
|
||||
// WHERE(可选)
|
||||
if (this.curTokenIs(TokenType.WHERE)) {
|
||||
this.nextToken();
|
||||
stmt.where = this.parseCondition();
|
||||
}
|
||||
|
||||
// GROUP BY(可选)
|
||||
if (this.curTokenIs(TokenType.GROUP)) {
|
||||
this.nextToken();
|
||||
this.expect(TokenType.BY);
|
||||
stmt.groupBy = this.parseIdentifierList();
|
||||
}
|
||||
|
||||
// HAVING(可选)
|
||||
if (this.curTokenIs(TokenType.HAVING)) {
|
||||
this.nextToken();
|
||||
stmt.having = this.parseCondition();
|
||||
}
|
||||
|
||||
// ORDER BY(可选)
|
||||
if (this.curTokenIs(TokenType.ORDER)) {
|
||||
this.nextToken();
|
||||
this.expect(TokenType.BY);
|
||||
stmt.orderBy = this.parseOrderByList();
|
||||
}
|
||||
|
||||
// LIMIT(可选)
|
||||
if (this.curTokenIs(TokenType.LIMIT)) {
|
||||
this.nextToken();
|
||||
stmt.limit = this.expectNumber('LIMIT value');
|
||||
}
|
||||
|
||||
// OFFSET(可选)
|
||||
if (this.curTokenIs(TokenType.OFFSET)) {
|
||||
this.nextToken();
|
||||
stmt.offset = this.expectNumber('OFFSET value');
|
||||
}
|
||||
|
||||
return stmt;
|
||||
}
|
||||
|
||||
/** 解析 JOIN 子句列表 */
|
||||
private parseJoinClauses(): import('../query/ast').JoinClause[] {
|
||||
const joins: import('../query/ast').JoinClause[] = [];
|
||||
while (this._isJoinKeyword()) {
|
||||
joins.push(this.parseJoinClause());
|
||||
}
|
||||
return joins;
|
||||
}
|
||||
|
||||
private _isJoinKeyword(): boolean {
|
||||
return (
|
||||
this.curTokenIs(TokenType.INNER) ||
|
||||
this.curTokenIs(TokenType.LEFT) ||
|
||||
this.curTokenIs(TokenType.RIGHT) ||
|
||||
this.curTokenIs(TokenType.CROSS) ||
|
||||
this.curTokenIs(TokenType.JOIN)
|
||||
);
|
||||
}
|
||||
|
||||
/** 解析单个 JOIN 子句 */
|
||||
private parseJoinClause(): import('../query/ast').JoinClause {
|
||||
let type: import('../query/ast').JoinType = 'INNER';
|
||||
|
||||
if (this.curTokenIs(TokenType.INNER)) {
|
||||
type = 'INNER';
|
||||
this.nextToken();
|
||||
} else if (this.curTokenIs(TokenType.LEFT)) {
|
||||
type = 'LEFT';
|
||||
this.nextToken();
|
||||
if (this.curTokenIs(TokenType.OUTER)) this.nextToken(); // 可选 OUTER
|
||||
} else if (this.curTokenIs(TokenType.RIGHT)) {
|
||||
type = 'RIGHT';
|
||||
this.nextToken();
|
||||
if (this.curTokenIs(TokenType.OUTER)) this.nextToken();
|
||||
} else if (this.curTokenIs(TokenType.CROSS)) {
|
||||
type = 'CROSS';
|
||||
this.nextToken();
|
||||
}
|
||||
|
||||
this.expect(TokenType.JOIN);
|
||||
const tableName = this.expectIdentifier('table name');
|
||||
|
||||
// JOIN 表别名(可选)
|
||||
let alias: string | undefined;
|
||||
if (this.curTokenIs(TokenType.AS)) {
|
||||
this.nextToken();
|
||||
alias = this.expectIdentifier('alias');
|
||||
} else if (this.curToken.type === TokenType.IDENTIFIER && !this._isJoinReserved()) {
|
||||
alias = this.curToken.value;
|
||||
this.nextToken();
|
||||
}
|
||||
|
||||
// ON 条件(CROSS JOIN 不需要 ON)
|
||||
let on = {};
|
||||
if (type !== 'CROSS' && this.curTokenIs(TokenType.ON)) {
|
||||
this.nextToken();
|
||||
on = this.parseCondition();
|
||||
}
|
||||
|
||||
return { type, table: tableName, alias, on };
|
||||
}
|
||||
|
||||
/** 判断当前 token 是否为 FROM 之后的保留字 */
|
||||
private _isReservedAfterFrom(): boolean {
|
||||
return (
|
||||
this.curTokenIs(TokenType.WHERE) ||
|
||||
this.curTokenIs(TokenType.ORDER) ||
|
||||
this.curTokenIs(TokenType.LIMIT) ||
|
||||
this.curTokenIs(TokenType.OFFSET) ||
|
||||
this.curTokenIs(TokenType.GROUP) ||
|
||||
this._isJoinKeyword()
|
||||
);
|
||||
}
|
||||
|
||||
private _isJoinReserved(): boolean {
|
||||
return (
|
||||
this.curTokenIs(TokenType.ON) ||
|
||||
this.curTokenIs(TokenType.WHERE) ||
|
||||
this.curTokenIs(TokenType.ORDER) ||
|
||||
this.curTokenIs(TokenType.LIMIT) ||
|
||||
this._isJoinKeyword()
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// INSERT
|
||||
// ===================================================================
|
||||
|
||||
private parseInsert(): InsertStatement {
|
||||
this.expect(TokenType.INSERT);
|
||||
this.expect(TokenType.INTO);
|
||||
|
||||
const tableName = this.expectIdentifier('table name');
|
||||
|
||||
// 列名(可选)
|
||||
let columns: string[] | undefined;
|
||||
if (this.curTokenIs(TokenType.LPAREN)) {
|
||||
this.nextToken();
|
||||
columns = this.parseIdentifierList();
|
||||
this.expect(TokenType.RPAREN);
|
||||
}
|
||||
|
||||
// VALUES
|
||||
this.expect(TokenType.VALUES);
|
||||
|
||||
// 值列表
|
||||
const values: unknown[][] = [];
|
||||
do {
|
||||
if (this.curTokenIs(TokenType.COMMA)) {
|
||||
this.nextToken();
|
||||
}
|
||||
this.expect(TokenType.LPAREN);
|
||||
const rowValues = this.parseValueList();
|
||||
this.expect(TokenType.RPAREN);
|
||||
values.push(rowValues);
|
||||
} while (this.curTokenIs(TokenType.COMMA));
|
||||
|
||||
return {
|
||||
type: 'INSERT',
|
||||
into: tableName,
|
||||
columns,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// UPDATE
|
||||
// ===================================================================
|
||||
|
||||
private parseUpdate(): UpdateStatement {
|
||||
this.expect(TokenType.UPDATE);
|
||||
const tableName = this.expectIdentifier('table name');
|
||||
this.expect(TokenType.SET);
|
||||
|
||||
// SET col=val, ...
|
||||
const sets: Record<string, unknown> = {};
|
||||
do {
|
||||
if (this.curTokenIs(TokenType.COMMA)) this.nextToken();
|
||||
const col = this.expectIdentifier('column name');
|
||||
this.expect(TokenType.EQ);
|
||||
sets[col] = this.parseValue();
|
||||
} while (this.curTokenIs(TokenType.COMMA));
|
||||
|
||||
let where: WhereCondition = {};
|
||||
if (this.curTokenIs(TokenType.WHERE)) {
|
||||
this.nextToken();
|
||||
where = this.parseCondition();
|
||||
}
|
||||
|
||||
return { type: 'UPDATE', table: tableName, sets, where };
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// DELETE
|
||||
// ===================================================================
|
||||
|
||||
private parseDelete(): DeleteStatement {
|
||||
this.expect(TokenType.DELETE);
|
||||
this.expect(TokenType.FROM);
|
||||
const tableName = this.expectIdentifier('table name');
|
||||
|
||||
let where: WhereCondition = {};
|
||||
if (this.curTokenIs(TokenType.WHERE)) {
|
||||
this.nextToken();
|
||||
where = this.parseCondition();
|
||||
}
|
||||
|
||||
return { type: 'DELETE', from: tableName, where };
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// CREATE TABLE
|
||||
// ===================================================================
|
||||
|
||||
private parseCreateTable(): CreateTableStatement {
|
||||
this.expect(TokenType.CREATE);
|
||||
this.expect(TokenType.TABLE);
|
||||
const tableName = this.expectIdentifier('table name');
|
||||
this.expect(TokenType.LPAREN);
|
||||
|
||||
const columns: ASTColumnDef[] = [];
|
||||
do {
|
||||
if (this.curTokenIs(TokenType.COMMA)) this.nextToken();
|
||||
columns.push(this.parseColumnDef());
|
||||
} while (this.curTokenIs(TokenType.COMMA));
|
||||
|
||||
this.expect(TokenType.RPAREN);
|
||||
|
||||
return { type: 'CREATE_TABLE', name: tableName, columns };
|
||||
}
|
||||
|
||||
private parseColumnDef(): ASTColumnDef {
|
||||
const name = this.expectIdentifier('column name');
|
||||
const type = this.expectIdentifier('column type').toLowerCase();
|
||||
|
||||
const col: ASTColumnDef = { name, type };
|
||||
|
||||
// 修饰符
|
||||
while (
|
||||
this.curTokenIs(TokenType.PRIMARY) ||
|
||||
this.curTokenIs(TokenType.UNIQUE) ||
|
||||
this.curTokenIs(TokenType.NOT) ||
|
||||
this.curTokenIs(TokenType.DEFAULT)
|
||||
) {
|
||||
if (this.curTokenIs(TokenType.PRIMARY)) {
|
||||
this.nextToken();
|
||||
this.expect(TokenType.KEY);
|
||||
col.primaryKey = true;
|
||||
} else if (this.curTokenIs(TokenType.UNIQUE)) {
|
||||
this.nextToken();
|
||||
col.unique = true;
|
||||
} else if (this.curTokenIs(TokenType.NOT)) {
|
||||
this.nextToken();
|
||||
// 支持 NOT NULL → required
|
||||
this.expect(TokenType.NULL);
|
||||
col.required = true;
|
||||
} else if (this.curTokenIs(TokenType.DEFAULT)) {
|
||||
this.nextToken();
|
||||
col.default = this.parseValue();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// DROP TABLE
|
||||
// ===================================================================
|
||||
|
||||
private parseDropTable(): DropTableStatement {
|
||||
this.expect(TokenType.DROP);
|
||||
this.expect(TokenType.TABLE);
|
||||
const tableName = this.expectIdentifier('table name');
|
||||
return { type: 'DROP_TABLE', name: tableName };
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 条件表达式
|
||||
// ===================================================================
|
||||
|
||||
/** condition → simple_cond ((AND|OR) simple_cond)* */
|
||||
private parseCondition(): WhereCondition {
|
||||
let left = this.parseSimpleCondition();
|
||||
|
||||
while (this.curTokenIs(TokenType.AND) || this.curTokenIs(TokenType.OR)) {
|
||||
const isAnd = this.curTokenIs(TokenType.AND);
|
||||
this.nextToken();
|
||||
const right = this.parseSimpleCondition();
|
||||
|
||||
if (isAnd) {
|
||||
// 合并到 $and
|
||||
left = { $and: [left, right] } as unknown as WhereCondition;
|
||||
} else {
|
||||
left = { $or: [left, right] } as unknown as WhereCondition;
|
||||
}
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
/** simple_cond → column op value | column IS [NOT] NULL | column [NOT] LIKE pattern
|
||||
* | column [NOT] IN (values) | NOT condition | (condition) */
|
||||
private parseSimpleCondition(): WhereCondition {
|
||||
// NOT expr
|
||||
if (this.curTokenIs(TokenType.NOT)) {
|
||||
this.nextToken();
|
||||
const inner = this.parseSimpleCondition();
|
||||
return { $not: inner } as unknown as WhereCondition;
|
||||
}
|
||||
|
||||
// (condition)
|
||||
if (this.curTokenIs(TokenType.LPAREN)) {
|
||||
this.nextToken();
|
||||
const inner = this.parseCondition();
|
||||
this.expect(TokenType.RPAREN);
|
||||
return inner;
|
||||
}
|
||||
|
||||
// column
|
||||
const column = this.parseColumnRef();
|
||||
|
||||
// IS NULL / IS NOT NULL
|
||||
if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'IS') {
|
||||
this.nextToken();
|
||||
const isNot = this.curTokenIs(TokenType.NOT);
|
||||
if (isNot) this.nextToken();
|
||||
this.expect(TokenType.NULL);
|
||||
const result: WhereCondition = {};
|
||||
result[column] = isNot ? { $ne: null } : { $eq: null };
|
||||
return result;
|
||||
}
|
||||
|
||||
// LIKE
|
||||
if (this.curTokenIs(TokenType.LIKE)) {
|
||||
this.nextToken();
|
||||
const pattern = this.parseValue();
|
||||
const result: WhereCondition = {};
|
||||
result[column] = { $like: pattern };
|
||||
return result;
|
||||
}
|
||||
|
||||
// IN
|
||||
if (this.curTokenIs(TokenType.IN)) {
|
||||
this.nextToken();
|
||||
this.expect(TokenType.LPAREN);
|
||||
const values = this.parseValueList();
|
||||
this.expect(TokenType.RPAREN);
|
||||
const result: WhereCondition = {};
|
||||
result[column] = { $in: values };
|
||||
return result;
|
||||
}
|
||||
|
||||
// 比较运算符
|
||||
const op = this.parseComparisonOp();
|
||||
|
||||
// 尝试解析列引用(identifier DOT identifier 格式)
|
||||
let value: unknown;
|
||||
if (
|
||||
(this.curToken.type === TokenType.IDENTIFIER || this._isKeywordAsIdent()) &&
|
||||
this.peekTokenIs(TokenType.DOT)
|
||||
) {
|
||||
const colRef = this.parseColumnRef();
|
||||
value = { $col: colRef };
|
||||
} else {
|
||||
value = this.parseValue();
|
||||
}
|
||||
|
||||
const result: WhereCondition = {};
|
||||
result[column] = { [op]: value };
|
||||
return result;
|
||||
}
|
||||
|
||||
private peekTokenIs(type: TokenType): boolean {
|
||||
return this.peekToken.type === type;
|
||||
}
|
||||
|
||||
private parseComparisonOp(): string {
|
||||
switch (this.curToken.type) {
|
||||
case TokenType.EQ: this.nextToken(); return '$eq';
|
||||
case TokenType.NEQ: this.nextToken(); return '$ne';
|
||||
case TokenType.GT: this.nextToken(); return '$gt';
|
||||
case TokenType.GTE: this.nextToken(); return '$gte';
|
||||
case TokenType.LT: this.nextToken(); return '$lt';
|
||||
case TokenType.LTE: this.nextToken(); return '$lte';
|
||||
default:
|
||||
throw this.error(`Expected comparison operator, got "${this.curToken.value}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 辅助解析
|
||||
// ===================================================================
|
||||
|
||||
private parseColumnList(): string[] {
|
||||
const cols: string[] = [];
|
||||
cols.push(this.parseColumnRef());
|
||||
while (this.curTokenIs(TokenType.COMMA)) {
|
||||
this.nextToken();
|
||||
cols.push(this.parseColumnRef());
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** 解析列引用:支持 'col'、'table.col' 和 'COUNT(*)'/'SUM(col)' 等 */
|
||||
private parseColumnRef(): string {
|
||||
// 聚合函数?
|
||||
if (
|
||||
this.curTokenIs(TokenType.COUNT) ||
|
||||
this.curTokenIs(TokenType.SUM) ||
|
||||
this.curTokenIs(TokenType.AVG) ||
|
||||
this.curTokenIs(TokenType.MIN) ||
|
||||
this.curTokenIs(TokenType.MAX)
|
||||
) {
|
||||
return this.parseAggregateCall();
|
||||
}
|
||||
|
||||
const first = this.expectIdentifier('column name');
|
||||
if (this.curTokenIs(TokenType.DOT)) {
|
||||
this.nextToken();
|
||||
const second = this.expectIdentifier('column name');
|
||||
return `${first}.${second}`;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** 解析聚合函数调用: COUNT(*), SUM(col), AVG(col), MIN(col), MAX(col) */
|
||||
private parseAggregateCall(): string {
|
||||
const func = this.curToken.value.toUpperCase();
|
||||
this.nextToken();
|
||||
this.expect(TokenType.LPAREN);
|
||||
|
||||
let arg: string;
|
||||
if (this.curTokenIs(TokenType.STAR)) {
|
||||
arg = '*';
|
||||
this.nextToken();
|
||||
} else {
|
||||
arg = this.parseColumnRef();
|
||||
}
|
||||
|
||||
this.expect(TokenType.RPAREN);
|
||||
|
||||
// 可选别名: AS alias
|
||||
let alias = '';
|
||||
if (this.curTokenIs(TokenType.AS)) {
|
||||
this.nextToken();
|
||||
alias = this.expectIdentifier('alias');
|
||||
} else if (this.curToken.type === TokenType.IDENTIFIER && this._isAggregateAlias()) {
|
||||
alias = this.curToken.value;
|
||||
this.nextToken();
|
||||
}
|
||||
|
||||
if (alias) {
|
||||
return `${func}(${arg}) AS ${alias}`;
|
||||
}
|
||||
return `${func}(${arg})`;
|
||||
}
|
||||
|
||||
private _isAggregateAlias(): boolean {
|
||||
return !this._isReservedAfterFrom() && !this._isJoinKeyword();
|
||||
}
|
||||
|
||||
private parseIdentifierList(): string[] {
|
||||
const ids: string[] = [];
|
||||
ids.push(this.expectIdentifier('identifier'));
|
||||
while (this.curTokenIs(TokenType.COMMA)) {
|
||||
this.nextToken();
|
||||
ids.push(this.expectIdentifier('identifier'));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private parseValueList(): unknown[] {
|
||||
const vals: unknown[] = [];
|
||||
vals.push(this.parseValue());
|
||||
while (this.curTokenIs(TokenType.COMMA)) {
|
||||
this.nextToken();
|
||||
vals.push(this.parseValue());
|
||||
}
|
||||
return vals;
|
||||
}
|
||||
|
||||
private parseOrderByList(): OrderBy[] {
|
||||
const list: OrderBy[] = [];
|
||||
list.push(this.parseOrderBy());
|
||||
while (this.curTokenIs(TokenType.COMMA)) {
|
||||
this.nextToken();
|
||||
list.push(this.parseOrderBy());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private parseOrderBy(): OrderBy {
|
||||
const column = this.expectIdentifier('column name');
|
||||
let direction: SortDirection = 'asc';
|
||||
if (this.curTokenIs(TokenType.ASC)) {
|
||||
this.nextToken();
|
||||
} else if (this.curTokenIs(TokenType.DESC)) {
|
||||
direction = 'desc';
|
||||
this.nextToken();
|
||||
}
|
||||
return { column, direction };
|
||||
}
|
||||
|
||||
/** 解析字面量值 */
|
||||
private parseValue(): unknown {
|
||||
switch (this.curToken.type) {
|
||||
case TokenType.STRING: {
|
||||
const val = this.curToken.value;
|
||||
this.nextToken();
|
||||
return val;
|
||||
}
|
||||
case TokenType.NUMBER: {
|
||||
const val = Number(this.curToken.value);
|
||||
this.nextToken();
|
||||
return val;
|
||||
}
|
||||
case TokenType.TRUE: this.nextToken(); return true;
|
||||
case TokenType.FALSE: this.nextToken(); return false;
|
||||
case TokenType.NULL: this.nextToken(); return null;
|
||||
default:
|
||||
throw this.error(`Expected value, got "${this.curToken.value}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Token 操作
|
||||
// ===================================================================
|
||||
|
||||
private nextToken(): void {
|
||||
this.curToken = this.peekToken;
|
||||
this.peekToken = this.lexer.nextToken();
|
||||
}
|
||||
|
||||
private curTokenIs(type: TokenType): boolean {
|
||||
return this.curToken.type === type;
|
||||
}
|
||||
|
||||
private expect(type: TokenType): void {
|
||||
if (this.curTokenIs(type)) {
|
||||
this.nextToken();
|
||||
return;
|
||||
}
|
||||
throw this.error(`Expected ${type}, got "${this.curToken.value}"`);
|
||||
}
|
||||
|
||||
private expectIdentifier(context: string): string {
|
||||
if (this.curToken.type === TokenType.IDENTIFIER || this._isKeywordAsIdent()) {
|
||||
const val = this.curToken.value;
|
||||
this.nextToken();
|
||||
return val;
|
||||
}
|
||||
throw this.error(`Expected ${context}, got "${this.curToken.value}"`);
|
||||
}
|
||||
|
||||
/** 关键字可以作为标识符(如列名等于关键字) */
|
||||
private _isKeywordAsIdent(): boolean {
|
||||
return (
|
||||
this.curToken.type !== TokenType.EOF &&
|
||||
this.curToken.type !== TokenType.ILLEGAL &&
|
||||
this.curToken.type !== TokenType.STRING &&
|
||||
this.curToken.type !== TokenType.NUMBER &&
|
||||
this.curToken.type !== TokenType.COMMA &&
|
||||
this.curToken.type !== TokenType.LPAREN &&
|
||||
this.curToken.type !== TokenType.RPAREN &&
|
||||
this.curToken.type !== TokenType.SEMICOLON &&
|
||||
this.curToken.type !== TokenType.EQ &&
|
||||
this.curToken.type !== TokenType.NEQ &&
|
||||
this.curToken.type !== TokenType.GT &&
|
||||
this.curToken.type !== TokenType.GTE &&
|
||||
this.curToken.type !== TokenType.LT &&
|
||||
this.curToken.type !== TokenType.LTE &&
|
||||
this.curToken.type !== TokenType.DOT &&
|
||||
this.curToken.type !== TokenType.STAR
|
||||
);
|
||||
}
|
||||
|
||||
private expectNumber(context: string): number {
|
||||
if (this.curToken.type === TokenType.NUMBER) {
|
||||
const val = Number(this.curToken.value);
|
||||
this.nextToken();
|
||||
return val;
|
||||
}
|
||||
throw this.error(`Expected ${context}, got "${this.curToken.value}"`);
|
||||
}
|
||||
|
||||
private error(msg: string): DatabaseError {
|
||||
return new DatabaseError(
|
||||
`Parse error at position ${this.curToken.position}: ${msg}`,
|
||||
'PARSE_ERROR',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 便捷方法
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 解析 SQL 字符串为 AST Statement */
|
||||
export function parse(sql: string): Statement {
|
||||
const parser = new Parser(sql);
|
||||
const stmt = parser.parseStatement();
|
||||
return stmt;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* metona-sqlark SQL Token Types — 词法单元定义
|
||||
* @module sql/tokens
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token 类型枚举
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum TokenType {
|
||||
// 关键字
|
||||
SELECT = 'SELECT',
|
||||
FROM = 'FROM',
|
||||
WHERE = 'WHERE',
|
||||
INSERT = 'INSERT',
|
||||
INTO = 'INTO',
|
||||
VALUES = 'VALUES',
|
||||
UPDATE = 'UPDATE',
|
||||
SET = 'SET',
|
||||
DELETE = 'DELETE',
|
||||
CREATE = 'CREATE',
|
||||
TABLE = 'TABLE',
|
||||
DROP = 'DROP',
|
||||
ORDER = 'ORDER',
|
||||
BY = 'BY',
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
LIMIT = 'LIMIT',
|
||||
OFFSET = 'OFFSET',
|
||||
AND = 'AND',
|
||||
OR = 'OR',
|
||||
NOT = 'NOT',
|
||||
LIKE = 'LIKE',
|
||||
IN = 'IN',
|
||||
PRIMARY = 'PRIMARY',
|
||||
KEY = 'KEY',
|
||||
UNIQUE = 'UNIQUE',
|
||||
DEFAULT = 'DEFAULT',
|
||||
NULL = 'NULL',
|
||||
TRUE = 'TRUE',
|
||||
FALSE = 'FALSE',
|
||||
|
||||
// JOIN 相关
|
||||
INNER = 'INNER',
|
||||
LEFT = 'LEFT',
|
||||
RIGHT = 'RIGHT',
|
||||
CROSS = 'CROSS',
|
||||
JOIN = 'JOIN',
|
||||
ON = 'ON',
|
||||
AS = 'AS',
|
||||
OUTER = 'OUTER',
|
||||
|
||||
// 聚合
|
||||
GROUP = 'GROUP',
|
||||
HAVING = 'HAVING',
|
||||
COUNT = 'COUNT',
|
||||
SUM = 'SUM',
|
||||
AVG = 'AVG',
|
||||
MIN = 'MIN',
|
||||
MAX = 'MAX',
|
||||
DISTINCT = 'DISTINCT',
|
||||
|
||||
// 标识符 & 字面量
|
||||
IDENTIFIER = 'IDENTIFIER',
|
||||
STRING = 'STRING',
|
||||
NUMBER = 'NUMBER',
|
||||
|
||||
// 运算符 & 分隔符
|
||||
COMMA = 'COMMA',
|
||||
LPAREN = 'LPAREN',
|
||||
RPAREN = 'RPAREN',
|
||||
SEMICOLON = 'SEMICOLON',
|
||||
EQ = 'EQ',
|
||||
NEQ = 'NEQ',
|
||||
GT = 'GT',
|
||||
GTE = 'GTE',
|
||||
LT = 'LT',
|
||||
LTE = 'LTE',
|
||||
STAR = 'STAR',
|
||||
DOT = 'DOT',
|
||||
|
||||
// 特殊
|
||||
EOF = 'EOF',
|
||||
ILLEGAL = 'ILLEGAL',
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
position: number; // 在源码中的位置
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 关键字映射
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KEYWORDS: Record<string, TokenType> = {
|
||||
'SELECT': TokenType.SELECT,
|
||||
'FROM': TokenType.FROM,
|
||||
'WHERE': TokenType.WHERE,
|
||||
'INSERT': TokenType.INSERT,
|
||||
'INTO': TokenType.INTO,
|
||||
'VALUES': TokenType.VALUES,
|
||||
'UPDATE': TokenType.UPDATE,
|
||||
'SET': TokenType.SET,
|
||||
'DELETE': TokenType.DELETE,
|
||||
'CREATE': TokenType.CREATE,
|
||||
'TABLE': TokenType.TABLE,
|
||||
'DROP': TokenType.DROP,
|
||||
'ORDER': TokenType.ORDER,
|
||||
'BY': TokenType.BY,
|
||||
'ASC': TokenType.ASC,
|
||||
'DESC': TokenType.DESC,
|
||||
'LIMIT': TokenType.LIMIT,
|
||||
'OFFSET': TokenType.OFFSET,
|
||||
'AND': TokenType.AND,
|
||||
'OR': TokenType.OR,
|
||||
'NOT': TokenType.NOT,
|
||||
'LIKE': TokenType.LIKE,
|
||||
'IN': TokenType.IN,
|
||||
'PRIMARY': TokenType.PRIMARY,
|
||||
'KEY': TokenType.KEY,
|
||||
'UNIQUE': TokenType.UNIQUE,
|
||||
'DEFAULT': TokenType.DEFAULT,
|
||||
'NULL': TokenType.NULL,
|
||||
'TRUE': TokenType.TRUE,
|
||||
'FALSE': TokenType.FALSE,
|
||||
|
||||
// JOIN
|
||||
'INNER': TokenType.INNER,
|
||||
'LEFT': TokenType.LEFT,
|
||||
'RIGHT': TokenType.RIGHT,
|
||||
'CROSS': TokenType.CROSS,
|
||||
'JOIN': TokenType.JOIN,
|
||||
'ON': TokenType.ON,
|
||||
'AS': TokenType.AS,
|
||||
'OUTER': TokenType.OUTER,
|
||||
|
||||
// 聚合
|
||||
'GROUP': TokenType.GROUP,
|
||||
'HAVING': TokenType.HAVING,
|
||||
'COUNT': TokenType.COUNT,
|
||||
'SUM': TokenType.SUM,
|
||||
'AVG': TokenType.AVG,
|
||||
'MIN': TokenType.MIN,
|
||||
'MAX': TokenType.MAX,
|
||||
'DISTINCT': TokenType.DISTINCT,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* metona-sqlark Table — 表管理
|
||||
* @module table
|
||||
*/
|
||||
|
||||
export { createSchema, validateColumns, validateRow, getPrimaryKey } from './schema';
|
||||
export { Table } from './table';
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* metona-sqlark Schema — 表结构定义与校验
|
||||
* @module table/schema
|
||||
*/
|
||||
|
||||
import type { TableSchema, ColumnDef, FieldType } from '../constants';
|
||||
import { FIELD_TYPES, DatabaseError } from '../constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema 工具
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 从列定义创建 TableSchema */
|
||||
export function createSchema(name: string, columns: Record<string, ColumnDef>): TableSchema {
|
||||
validateColumns(columns);
|
||||
return { name, columns };
|
||||
}
|
||||
|
||||
/** 校验列定义 */
|
||||
export function validateColumns(columns: Record<string, ColumnDef>): void {
|
||||
const colNames = Object.keys(columns);
|
||||
if (colNames.length === 0) {
|
||||
throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR');
|
||||
}
|
||||
|
||||
let primaryKeyCount = 0;
|
||||
|
||||
for (const [colName, colDef] of Object.entries(columns)) {
|
||||
// 类型校验
|
||||
if (!FIELD_TYPES.includes(colDef.type)) {
|
||||
throw new DatabaseError(
|
||||
`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`,
|
||||
'SCHEMA_ERROR',
|
||||
);
|
||||
}
|
||||
|
||||
// 主键计数
|
||||
if (colDef.primaryKey) {
|
||||
primaryKeyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 至少需要一个主键
|
||||
if (primaryKeyCount === 0) {
|
||||
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取主键列名 */
|
||||
export function getPrimaryKey(schema: TableSchema): string {
|
||||
for (const [name, col] of Object.entries(schema.columns)) {
|
||||
if (col.primaryKey) return name;
|
||||
}
|
||||
return Object.keys(schema.columns)[0];
|
||||
}
|
||||
|
||||
/** 校验行数据 */
|
||||
export function validateRow(schema: TableSchema, row: Record<string, unknown>): Record<string, unknown> {
|
||||
const validated: Record<string, unknown> = {};
|
||||
|
||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||
let value = row[colName];
|
||||
|
||||
// 默认值
|
||||
if (value === undefined && colDef.default !== undefined) {
|
||||
value = colDef.default;
|
||||
}
|
||||
|
||||
// 必填检查
|
||||
if (colDef.required && (value === undefined || value === null)) {
|
||||
throw new DatabaseError(
|
||||
`Column "${colName}" is required in table "${schema.name}"`,
|
||||
'VALIDATION_ERROR',
|
||||
);
|
||||
}
|
||||
|
||||
// 类型检查
|
||||
if (value !== undefined && value !== null) {
|
||||
checkFieldType(schema.name, colName, colDef.type, value, colDef);
|
||||
}
|
||||
|
||||
if (value !== undefined) {
|
||||
validated[colName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
/** 检查字段类型 */
|
||||
export function checkFieldType(
|
||||
tableName: string,
|
||||
colName: string,
|
||||
type: FieldType,
|
||||
value: unknown,
|
||||
_colDef?: ColumnDef,
|
||||
): void {
|
||||
const jsType = typeof value;
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
if (jsType !== 'string') {
|
||||
throw new DatabaseError(
|
||||
`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`,
|
||||
'TYPE_ERROR',
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
if (jsType !== 'number') {
|
||||
throw new DatabaseError(
|
||||
`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`,
|
||||
'TYPE_ERROR',
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
if (jsType !== 'boolean') {
|
||||
throw new DatabaseError(
|
||||
`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`,
|
||||
'TYPE_ERROR',
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
if (jsType !== 'string' || isNaN(Date.parse(value as string))) {
|
||||
throw new DatabaseError(
|
||||
`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`,
|
||||
'TYPE_ERROR',
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'json':
|
||||
if (jsType !== 'object') {
|
||||
throw new DatabaseError(
|
||||
`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`,
|
||||
'TYPE_ERROR',
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 AST 列定义转换为 ColumnDef */
|
||||
export function astColumnToColumnDef(astCol: {
|
||||
name: string;
|
||||
type: string;
|
||||
primaryKey?: boolean;
|
||||
unique?: boolean;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
index?: boolean;
|
||||
maxLength?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}): ColumnDef {
|
||||
return {
|
||||
type: astCol.type as FieldType,
|
||||
primaryKey: astCol.primaryKey,
|
||||
unique: astCol.unique,
|
||||
required: astCol.required,
|
||||
default: astCol.default,
|
||||
index: astCol.index,
|
||||
maxLength: astCol.maxLength,
|
||||
min: astCol.min,
|
||||
max: astCol.max,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* metona-sqlark Table — 表操作 API
|
||||
* @module table/table
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from '../engine/interface';
|
||||
import type { TableSchema } from '../constants';
|
||||
import { DatabaseError } from '../constants';
|
||||
import { SelectQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder } from '../query/builder';
|
||||
import { QueryExecutor } from '../query/executor';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class Table<T = Record<string, unknown>> {
|
||||
readonly name: string;
|
||||
private engine: IStorageEngine;
|
||||
private schema: TableSchema | null = null;
|
||||
private executor: QueryExecutor | undefined;
|
||||
|
||||
constructor(engine: IStorageEngine, tableName: string, executor?: QueryExecutor) {
|
||||
this.engine = engine;
|
||||
this.name = tableName;
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
// ---- Schema ----
|
||||
|
||||
async getSchema(): Promise<TableSchema> {
|
||||
if (!this.schema) {
|
||||
const s = await this.engine.getTableSchema(this.name);
|
||||
if (!s) throw new DatabaseError(`Table "${this.name}" does not exist`, 'TABLE_NOT_FOUND');
|
||||
this.schema = s;
|
||||
}
|
||||
return this.schema;
|
||||
}
|
||||
|
||||
// ---- 插入 ----
|
||||
|
||||
async insert(row: T & Record<string, unknown>): Promise<string> {
|
||||
const pks = await this.engine.insert(this.name, [row as Record<string, unknown>]);
|
||||
return pks[0];
|
||||
}
|
||||
|
||||
async insertMany(rows: (T & Record<string, unknown>)[]): Promise<string[]> {
|
||||
return this.engine.insert(this.name, rows as Record<string, unknown>[]);
|
||||
}
|
||||
|
||||
// ---- 查询 ----
|
||||
|
||||
select(columns: string[] = ['*']): SelectQueryBuilder {
|
||||
return new SelectQueryBuilder(this.engine, this.name, columns, this.executor);
|
||||
}
|
||||
|
||||
// ---- 更新 ----
|
||||
|
||||
update(updates: Partial<T> & Record<string, unknown>): UpdateQueryBuilder {
|
||||
return new UpdateQueryBuilder(this.engine, this.name, updates);
|
||||
}
|
||||
|
||||
// ---- 删除 ----
|
||||
|
||||
delete(): DeleteQueryBuilder {
|
||||
return new DeleteQueryBuilder(this.engine, this.name);
|
||||
}
|
||||
|
||||
// ---- 聚合 ----
|
||||
|
||||
async count(where?: Record<string, unknown>): Promise<number> {
|
||||
return this.engine.count(this.name, where ? { table: this.name, where } : undefined);
|
||||
}
|
||||
|
||||
// ---- 管理 ----
|
||||
|
||||
async clear(): Promise<void> {
|
||||
return this.engine.clear(this.name);
|
||||
}
|
||||
|
||||
async drop(): Promise<void> {
|
||||
return this.engine.dropTable(this.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* metona-sqlark Transaction — 事务管理
|
||||
* @module transaction
|
||||
*
|
||||
* 封装多操作事务,保证原子性。
|
||||
* v0.0.1: 基于引擎层操作实现,IndexedDB 引擎利用原生事务。
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from '../engine/interface';
|
||||
import { Table } from '../table/table';
|
||||
import { DatabaseError } from '../constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class Transaction {
|
||||
private engine: IStorageEngine;
|
||||
private tables: Map<string, Table> = new Map();
|
||||
private completed = false;
|
||||
|
||||
constructor(engine: IStorageEngine) {
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
/** 获取表操作对象 */
|
||||
table(tableName: string): Table {
|
||||
let t = this.tables.get(tableName);
|
||||
if (!t) {
|
||||
t = new Table(this.engine, tableName);
|
||||
this.tables.set(tableName, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 标记事务完成(由 TransactionManager 调用) */
|
||||
_markCompleted(): void {
|
||||
this.completed = true;
|
||||
}
|
||||
|
||||
/** 是否已完成 */
|
||||
isCompleted(): boolean {
|
||||
return this.completed;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TransactionManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class TransactionManager {
|
||||
constructor(private engine: IStorageEngine) {}
|
||||
|
||||
/** 执行事务 */
|
||||
async execute<T>(fn: (trx: Transaction) => Promise<T>): Promise<T> {
|
||||
const trx = new Transaction(this.engine);
|
||||
|
||||
try {
|
||||
const result = await fn(trx);
|
||||
trx._markCompleted();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) throw error;
|
||||
throw new DatabaseError(`Transaction failed: ${(error as Error).message}`, 'TRANSACTION_ERROR', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* YOUR-PROJECT Utils — 工具函数
|
||||
* @module utils
|
||||
*/
|
||||
|
||||
/** 生成唯一 ID */
|
||||
export const generateId = (): string => {
|
||||
return 'id-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
};
|
||||
|
||||
/** HTML 转义 */
|
||||
export const escapeHTML = (s: unknown): string => {
|
||||
const str = String(s == null ? '' : s);
|
||||
if (typeof document === 'undefined') {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
/** 防抖 */
|
||||
export function debounce<T extends (...args: any[]) => void>(
|
||||
func: T,
|
||||
wait: number,
|
||||
immediate = false,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null;
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
const context = this;
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
if (!immediate) func.apply(context, args);
|
||||
};
|
||||
const callNow = immediate && !timeout;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
if (callNow) func.apply(context, args);
|
||||
};
|
||||
}
|
||||
|
||||
/** 节流 */
|
||||
export function throttle<T extends (...args: any[]) => void>(
|
||||
func: T,
|
||||
limit: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle = false;
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => { inThrottle = false; }, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 深度合并 */
|
||||
export const deepMerge = <T extends Record<string, unknown>>(target: T, source: Partial<T>): T => {
|
||||
const output: Record<string, unknown> = { ...target };
|
||||
for (const key of Object.keys(source as Record<string, unknown>)) {
|
||||
const sv = (source as Record<string, unknown>)[key];
|
||||
const tv = (target as Record<string, unknown>)[key];
|
||||
if (sv instanceof Object && key in target && tv instanceof Object) {
|
||||
output[key] = deepMerge(tv as Record<string, unknown>, sv as Record<string, unknown>);
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
}
|
||||
return output as T;
|
||||
};
|
||||
|
||||
/** 浏览器环境检测 */
|
||||
export const isBrowser = (): boolean => {
|
||||
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Core 全覆盖测试 — 导入导出、迁移、发布订阅、钩子
|
||||
*/
|
||||
import { MetonaSqlark } from '../src/core';
|
||||
|
||||
describe('Core 全覆盖', () => {
|
||||
let db: MetonaSqlark;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new MetonaSqlark({ name: 'test-coverage', mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
});
|
||||
await db.table('users').insertMany([
|
||||
{ id: '1', name: 'Alice' },
|
||||
{ id: '2', name: 'Bob' },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => { await db.close(); });
|
||||
|
||||
// ---- 导入导出 ----
|
||||
describe('导入导出', () => {
|
||||
it('exportTable 导出单表', async () => {
|
||||
const data = await db.exportTable('users');
|
||||
expect(data).toHaveLength(2);
|
||||
expect(data[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('exportAll 导出全库', async () => {
|
||||
const all = await db.exportAll();
|
||||
expect(all.users).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('importTable 导入数据', async () => {
|
||||
await db.defineTable('temp', { id: { type: 'string', primaryKey: true }, val: { type: 'number' } });
|
||||
await db.importTable('temp', [{ id: 'a', val: 1 }, { id: 'b', val: 2 }]);
|
||||
expect(await db.table('temp').count()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 发布订阅 ----
|
||||
describe('发布订阅', () => {
|
||||
it('subscribe 注册监听', () => {
|
||||
const fn = jest.fn();
|
||||
const unsub = db.subscribe('users', fn);
|
||||
expect(typeof unsub).toBe('function');
|
||||
});
|
||||
|
||||
it('unsubscribe 取消监听', () => {
|
||||
const fn = jest.fn();
|
||||
const unsub = db.subscribe('users', fn);
|
||||
unsub();
|
||||
db.emit('users', { type: 'insert', row: { id: '3' } });
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emit 触发监听', () => {
|
||||
const fn = jest.fn();
|
||||
db.subscribe('users', fn);
|
||||
db.emit('users', { type: 'insert', row: { id: '3', name: 'Charlie' } });
|
||||
expect(fn).toHaveBeenCalledWith({ type: 'insert', row: { id: '3', name: 'Charlie' } });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 迁移 ----
|
||||
describe('迁移系统', () => {
|
||||
it('addMigration + migrateTo', async () => {
|
||||
let migrated = false;
|
||||
db.addMigration(2, async (d) => {
|
||||
migrated = true;
|
||||
await d.defineTable('new_table', { id: { type: 'string', primaryKey: true } });
|
||||
});
|
||||
await db.migrateTo(2);
|
||||
expect(migrated).toBe(true);
|
||||
const tables = await db.getTableNames();
|
||||
expect(tables).toContain('new_table');
|
||||
});
|
||||
|
||||
it('不执行已过期的迁移', async () => {
|
||||
let count = 0;
|
||||
db.addMigration(1, async () => { count++; }); // version <= current
|
||||
db.addMigration(2, async () => { count++; });
|
||||
await db.migrateTo(2);
|
||||
// version 1 <= current version 1, should be skipped
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 钩子 ----
|
||||
describe('钩子系统', () => {
|
||||
it('on 注册钩子 (通过 pluginManager)', () => {
|
||||
const fn = jest.fn();
|
||||
db.getPluginManager().on('beforeInsert', fn);
|
||||
db.getPluginManager().trigger('beforeInsert', { id: '3' });
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getPluginManager 返回插件管理器', () => {
|
||||
const pm = db.getPluginManager();
|
||||
expect(pm).toBeDefined();
|
||||
expect(typeof pm.on).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 生命周期 ----
|
||||
describe('生命周期', () => {
|
||||
it('close 关闭数据库', async () => {
|
||||
await db.close();
|
||||
expect(db.isReady()).toBe(false);
|
||||
});
|
||||
|
||||
it('getEngine 返回引擎', () => {
|
||||
const engine = db.getEngine();
|
||||
expect(engine).toBeDefined();
|
||||
expect(engine.name).toBe('memory');
|
||||
});
|
||||
|
||||
it('未初始化时操作抛出错误', () => {
|
||||
const db2 = new MetonaSqlark({ name: 'x', mode: 'memory' });
|
||||
expect(() => db2.table('x')).toThrow('not initialized');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 别名 ----
|
||||
describe('MeSqlark 别名', () => {
|
||||
it('MeSqlark === MetonaSqlark', () => {
|
||||
const { MeSqlark } = require('../src/index');
|
||||
expect(MeSqlark).toBe(MetonaSqlark);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* MetonaSqlark 核心集成测试
|
||||
*/
|
||||
|
||||
import { MetonaSqlark } from '../src/core';
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
let diskCounter = 0;
|
||||
|
||||
// ===================================================================
|
||||
// Memory 模式
|
||||
// ===================================================================
|
||||
|
||||
describe('MetonaSqlark Memory 模式', () => {
|
||||
let db: MetonaSqlark;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new MetonaSqlark({ name: 'test-db-mem', mode: 'memory' });
|
||||
await db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
it('创建数据库并初始化', () => {
|
||||
expect(db.isReady()).toBe(true);
|
||||
expect(db.name).toBe('test-db-mem');
|
||||
expect(db.mode).toBe('memory');
|
||||
});
|
||||
|
||||
it('定义表', async () => {
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
age: { type: 'number' },
|
||||
});
|
||||
const tables = await db.getTableNames();
|
||||
expect(tables).toContain('users');
|
||||
});
|
||||
|
||||
describe('Query Builder API', () => {
|
||||
beforeEach(async () => {
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
age: { type: 'number' },
|
||||
});
|
||||
});
|
||||
|
||||
it('insert + select', async () => {
|
||||
const table = db.table('users');
|
||||
await table.insert({ id: '1', name: 'Alice', age: 30 });
|
||||
await table.insert({ id: '2', name: 'Bob', age: 25 });
|
||||
const results = await table.select().where({ age: { $gt: 20 } }).execute();
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('insertMany', async () => {
|
||||
const table = db.table('users');
|
||||
await table.insertMany([
|
||||
{ id: '1', name: 'Alice', age: 30 },
|
||||
{ id: '2', name: 'Bob', age: 25 },
|
||||
]);
|
||||
const count = await table.count();
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('update', async () => {
|
||||
const table = db.table('users');
|
||||
await table.insert({ id: '1', name: 'Alice', age: 30 });
|
||||
const count = await table.update({ age: 31 }).where({ id: '1' }).execute();
|
||||
expect(count).toBe(1);
|
||||
const results = await table.select().where({ id: '1' }).execute();
|
||||
expect(results[0].age).toBe(31);
|
||||
});
|
||||
|
||||
it('delete', async () => {
|
||||
const table = db.table('users');
|
||||
await table.insert({ id: '1', name: 'Alice', age: 30 });
|
||||
const count = await table.delete().where({ id: '1' }).execute();
|
||||
expect(count).toBe(1);
|
||||
expect(await table.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('orderBy + limit + offset', async () => {
|
||||
const table = db.table('users');
|
||||
await table.insertMany([
|
||||
{ id: '1', name: 'Alice', age: 30 },
|
||||
{ id: '2', name: 'Bob', age: 25 },
|
||||
{ id: '3', name: 'Charlie', age: 35 },
|
||||
]);
|
||||
const results = await table.select().orderBy('age', 'desc').limit(2).offset(1).execute();
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('dropTable', async () => {
|
||||
await db.dropTable('users');
|
||||
const tables = await db.getTableNames();
|
||||
expect(tables).not.toContain('users');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQL API', () => {
|
||||
beforeEach(async () => {
|
||||
await db.query('CREATE TABLE users (id STRING PRIMARY KEY, name STRING, age NUMBER)');
|
||||
});
|
||||
|
||||
it('SQL INSERT + SELECT', async () => {
|
||||
await db.query("INSERT INTO users (id, name, age) VALUES ('1', 'Alice', 30)");
|
||||
await db.query("INSERT INTO users VALUES ('2', 'Bob', 25)");
|
||||
const result = await db.query('SELECT * FROM users') as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('SQL SELECT WHERE', async () => {
|
||||
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
||||
await db.query("INSERT INTO users VALUES ('2', 'Bob', 25)");
|
||||
const result = await db.query("SELECT * FROM users WHERE age > 28") as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('SQL UPDATE', async () => {
|
||||
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
||||
await db.query("UPDATE users SET age = 31 WHERE id = '1'");
|
||||
const result = await db.query("SELECT * FROM users WHERE id = '1'") as Record<string, unknown>[];
|
||||
expect(result[0].age).toBe(31);
|
||||
});
|
||||
|
||||
it('SQL DELETE', async () => {
|
||||
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
||||
await db.query("DELETE FROM users WHERE id = '1'");
|
||||
const result = await db.query('SELECT * FROM users') as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('SQL DROP TABLE', async () => {
|
||||
await db.query('DROP TABLE users');
|
||||
const tables = await db.getTableNames();
|
||||
expect(tables).not.toContain('users');
|
||||
});
|
||||
});
|
||||
|
||||
describe('事务', () => {
|
||||
beforeEach(async () => {
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
balance: { type: 'number', default: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('事务中执行多个操作', async () => {
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.table('users').insert({ id: '1', name: 'Alice', balance: 100 });
|
||||
await trx.table('users').insert({ id: '2', name: 'Bob', balance: 50 });
|
||||
});
|
||||
const table = db.table('users');
|
||||
expect(await table.count()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('表管理高级', () => {
|
||||
beforeEach(async () => {
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('getTableNames 返回所有表', async () => {
|
||||
const names = await db.getTableNames();
|
||||
expect(names).toContain('users');
|
||||
});
|
||||
|
||||
it('dropTable 后表不再存在', async () => {
|
||||
await db.dropTable('users');
|
||||
const names = await db.getTableNames();
|
||||
expect(names).not.toContain('users');
|
||||
});
|
||||
|
||||
it('Table.getSchema 返回表结构', async () => {
|
||||
const table = db.table('users');
|
||||
const schema = await table.getSchema();
|
||||
expect(schema.name).toBe('users');
|
||||
expect(schema.columns.id.primaryKey).toBe(true);
|
||||
});
|
||||
|
||||
it('Table.clear 清空数据', async () => {
|
||||
const table = db.table('users');
|
||||
await table.insert({ id: '1', name: 'Alice' });
|
||||
await table.insert({ id: '2', name: 'Bob' });
|
||||
await table.clear();
|
||||
expect(await table.count()).toBe(0);
|
||||
expect(await db.getTableNames()).toContain('users');
|
||||
});
|
||||
|
||||
it('Table.drop 删除表', async () => {
|
||||
const table = db.table('users');
|
||||
await table.drop();
|
||||
const names = await db.getTableNames();
|
||||
expect(names).not.toContain('users');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Disk 模式
|
||||
// ===================================================================
|
||||
|
||||
describe('MetonaSqlark Disk 模式', () => {
|
||||
let db: MetonaSqlark;
|
||||
let dbName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `test-disk-${++diskCounter}`;
|
||||
db = new MetonaSqlark({ name: dbName, mode: 'disk', diskEngine: 'indexeddb' });
|
||||
await db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
try { indexedDB.deleteDatabase(dbName); } catch {}
|
||||
});
|
||||
|
||||
it('创建 disk 模式数据库', () => {
|
||||
expect(db.isReady()).toBe(true);
|
||||
expect(db.mode).toBe('disk');
|
||||
});
|
||||
|
||||
it('定义表并插入数据', async () => {
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
});
|
||||
await db.table('users').insert({ id: '1', name: 'Alice' });
|
||||
const rows = await db.table('users').select().execute();
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Hybrid 模式
|
||||
// ===================================================================
|
||||
|
||||
describe('MetonaSqlark Hybrid 模式', () => {
|
||||
let db: MetonaSqlark;
|
||||
let dbName: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `test-hybrid-core-${++diskCounter}`;
|
||||
db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
|
||||
await db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
try { indexedDB.deleteDatabase(dbName); } catch {}
|
||||
});
|
||||
|
||||
it('创建 hybrid 模式数据库', () => {
|
||||
expect(db.isReady()).toBe(true);
|
||||
expect(db.mode).toBe('hybrid');
|
||||
});
|
||||
|
||||
it('基本 CRUD', async () => {
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
});
|
||||
const table = db.table('users');
|
||||
await table.insert({ id: '1', name: 'Alice' });
|
||||
expect(await table.count()).toBe(1);
|
||||
|
||||
await table.update({ name: 'Alice2' }).where({ id: '1' }).execute();
|
||||
const rows = await table.select().where({ id: '1' }).execute();
|
||||
expect(rows[0].name).toBe('Alice2');
|
||||
|
||||
await table.delete().where({ id: '1' }).execute();
|
||||
expect(await table.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('SQL 查询', async () => {
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
});
|
||||
await db.query("INSERT INTO users VALUES ('1', 'Alice')");
|
||||
const result = await db.query('SELECT * FROM users') as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Executor + Builder + Memory 边缘覆盖测试
|
||||
*/
|
||||
import { MetonaSqlark } from '../src/core';
|
||||
import { MemoryEngine } from '../src/engine/memory';
|
||||
import { SelectQueryBuilder } from '../src/query/builder';
|
||||
import { createSchema } from '../src/table/schema';
|
||||
|
||||
describe('边缘覆盖', () => {
|
||||
let db: MetonaSqlark;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new MetonaSqlark({ name: 'test-edge', mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
age: { type: 'number' },
|
||||
active: { type: 'boolean', default: true },
|
||||
});
|
||||
await db.table('users').insertMany([
|
||||
{ id: '1', name: 'Alice', age: 30, active: true },
|
||||
{ id: '2', name: 'Bob', age: 25, active: true },
|
||||
{ id: '3', name: 'Charlie', age: 35, active: false },
|
||||
{ id: '4', name: 'Diana', age: 30, active: true },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => { await db.close(); });
|
||||
|
||||
// ---- DISTINCT ----
|
||||
describe('DISTINCT', () => {
|
||||
it('SELECT DISTINCT 去重', async () => {
|
||||
const result = await db.query('SELECT DISTINCT age FROM users ORDER BY age') as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(3); // 30,25,35
|
||||
});
|
||||
|
||||
it('DISTINCT with WHERE', async () => {
|
||||
const result = await db.query("SELECT DISTINCT age FROM users WHERE active = TRUE") as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(2); // 30,25
|
||||
});
|
||||
});
|
||||
|
||||
// ---- $and / $or / $not ----
|
||||
describe('复杂 Where 条件', () => {
|
||||
it('$and 条件 (MemoryEngine 直接)', async () => {
|
||||
// $and 在 MemoryEngine 的 matchWhere 中解析
|
||||
const engine = new MemoryEngine();
|
||||
await engine.open('test', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true }, age: { type: 'number' }, active: { type: 'boolean' },
|
||||
}));
|
||||
await engine.insert('users', [
|
||||
{ id: '1', age: 30, active: true },
|
||||
{ id: '2', age: 25, active: true },
|
||||
{ id: '3', age: 30, active: false },
|
||||
]);
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { $and: [{ age: 30 }, { active: true }] } as any,
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('$or 条件 (MemoryEngine 直接)', async () => {
|
||||
const engine = new MemoryEngine();
|
||||
await engine.open('test2', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true }, name: { type: 'string' },
|
||||
}));
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }, { id: '3', name: 'Charlie' },
|
||||
]);
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { $or: [{ name: 'Alice' }, { name: 'Bob' }] } as any,
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('$not 条件', async () => {
|
||||
const rows = await db.table('users').select().where({
|
||||
name: { $not: { $eq: 'Alice' } } as any,
|
||||
}).execute();
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('$in + $nin', async () => {
|
||||
const r1 = await db.table('users').select().where({ age: { $in: [25, 35] } }).execute();
|
||||
expect(r1).toHaveLength(2);
|
||||
const r2 = await db.table('users').select().where({ age: { $nin: [30] } }).execute();
|
||||
expect(r2).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('$lt + $lte', async () => {
|
||||
const r = await db.table('users').select().where({ age: { $lt: 30 } }).execute();
|
||||
expect(r).toHaveLength(1); // Bob(25)
|
||||
const r2 = await db.table('users').select().where({ age: { $lte: 30 } }).execute();
|
||||
expect(r2).toHaveLength(3); // Alice+Diana(30)+Bob(25)
|
||||
});
|
||||
});
|
||||
|
||||
// ---- QueryBuilder 全覆盖 ----
|
||||
describe('QueryBuilder 全覆盖', () => {
|
||||
let engine: MemoryEngine;
|
||||
beforeEach(async () => {
|
||||
engine = new MemoryEngine();
|
||||
await engine.open('test', 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
}));
|
||||
await engine.insert('users', [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]);
|
||||
});
|
||||
afterEach(async () => { await engine.close(); });
|
||||
|
||||
it('SelectQueryBuilder.as 别名', () => {
|
||||
const qb = new SelectQueryBuilder(engine, 'users').as('u');
|
||||
expect(qb.toAST().alias).toBe('u');
|
||||
});
|
||||
|
||||
it('rightJoin', () => {
|
||||
const qb = new SelectQueryBuilder(engine, 'users');
|
||||
qb.rightJoin('orders', { 'users.id': { $col: 'orders.user_id' } }, 'o');
|
||||
expect(qb.toAST().joins![0].type).toBe('RIGHT');
|
||||
});
|
||||
|
||||
it('join 默认为 INNER', () => {
|
||||
const qb = new SelectQueryBuilder(engine, 'users');
|
||||
qb.join('orders', { 'users.id': { $col: 'orders.user_id' } });
|
||||
expect(qb.toAST().joins![0].type).toBe('INNER');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- RIGHT JOIN SQL ----
|
||||
describe('RIGHT JOIN', () => {
|
||||
beforeEach(async () => {
|
||||
await db.defineTable('departments', {
|
||||
id: { type: 'number', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
});
|
||||
await db.table('departments').insertMany([
|
||||
{ id: 1, name: 'Eng' }, { id: 2, name: 'Sales' },
|
||||
]);
|
||||
await db.defineTable('emp', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
dept_id: { type: 'number' },
|
||||
});
|
||||
await db.table('emp').insert({ id: '1', name: 'Alice', dept_id: 1 });
|
||||
});
|
||||
|
||||
it('RIGHT JOIN 保留右表无匹配行', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT emp.name, departments.name FROM emp RIGHT JOIN departments ON emp.dept_id = departments.id',
|
||||
) as Record<string, unknown>[];
|
||||
expect(result.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- MemoryEngine 索引 ----
|
||||
describe('MemoryEngine 索引查找', () => {
|
||||
it('索引列 $eq 查询', async () => {
|
||||
// age 列标记为 index
|
||||
await db.defineTable('indexed_users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
score: { type: 'number', index: true },
|
||||
});
|
||||
await db.table('indexed_users').insertMany([
|
||||
{ id: '1', name: 'A', score: 100 },
|
||||
{ id: '2', name: 'B', score: 200 },
|
||||
{ id: '3', name: 'C', score: 100 },
|
||||
]);
|
||||
// $eq 查询应使用索引
|
||||
const rows = await db.table('indexed_users').select().where({ score: 100 }).execute();
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('唯一索引列查询', async () => {
|
||||
await db.defineTable('unique_users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
email: { type: 'string', unique: true },
|
||||
});
|
||||
await db.table('unique_users').insertMany([
|
||||
{ id: '1', email: 'a@t.com' },
|
||||
{ id: '2', email: 'b@t.com' },
|
||||
]);
|
||||
const rows = await db.table('unique_users').select().where({ email: 'a@t.com' }).execute();
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- GROUP BY 边缘 ----
|
||||
describe('GROUP BY 边缘', () => {
|
||||
it('单个 GROUP BY 列', async () => {
|
||||
const result = await db.query('SELECT active, COUNT(*) FROM users GROUP BY active') as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('AVG 空值处理 (GROUP BY)', async () => {
|
||||
await db.defineTable('scores', { id: { type: 'string', primaryKey: true }, dept: { type: 'string' }, val: { type: 'number' } });
|
||||
await db.table('scores').insertMany([{ id: '1', dept: 'A', val: 100 }, { id: '2', dept: 'A', val: 200 }]);
|
||||
const result = await db.query('SELECT dept, AVG(val) FROM scores GROUP BY dept') as Record<string, unknown>[];
|
||||
expect(result[0]['AVG(val)']).toBe(150);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* IndexedDBEngine 完整测试(使用 fake-indexeddb)
|
||||
* 每次测试使用独立的数据库名避免版本冲突
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
import { IndexedDBEngine } from '../../src/engine/indexeddb';
|
||||
import { createSchema } from '../../src/table/schema';
|
||||
|
||||
let dbCounter = 0;
|
||||
|
||||
describe('IndexedDBEngine', () => {
|
||||
let engine: IndexedDBEngine;
|
||||
let dbName: string;
|
||||
|
||||
const userSchema = createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
age: { type: 'number', default: 0 },
|
||||
email: { type: 'string', unique: true },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `test-idb-${++dbCounter}`;
|
||||
engine = new IndexedDBEngine();
|
||||
await engine.open(dbName, 1);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (engine.isOpen()) {
|
||||
await engine.close();
|
||||
}
|
||||
// 清理:删除 IndexedDB 数据库
|
||||
try {
|
||||
indexedDB.deleteDatabase(dbName);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
describe('生命周期', () => {
|
||||
it('打开和关闭', async () => {
|
||||
expect(engine.isOpen()).toBe(true);
|
||||
await engine.close();
|
||||
expect(engine.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('未打开时操作抛出错误', async () => {
|
||||
await engine.close();
|
||||
await expect(engine.count('users')).rejects.toThrow('not opened');
|
||||
});
|
||||
|
||||
it('可以重新打开', async () => {
|
||||
await engine.close();
|
||||
await engine.open(dbName, 2);
|
||||
expect(engine.isOpen()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 表管理 ----
|
||||
|
||||
describe('表管理', () => {
|
||||
it('创建和删除表', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
expect(await engine.hasTable('users')).toBe(true);
|
||||
|
||||
await engine.dropTable('users');
|
||||
expect(await engine.hasTable('users')).toBe(false);
|
||||
});
|
||||
|
||||
it('表名列表', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
const names = await engine.getTableNames();
|
||||
expect(names).toContain('users');
|
||||
});
|
||||
|
||||
it('获取表结构', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
const schema = await engine.getTableSchema('users');
|
||||
expect(schema).not.toBeNull();
|
||||
expect(schema!.name).toBe('users');
|
||||
});
|
||||
|
||||
it('获取不存在的表结构返回 null', async () => {
|
||||
const schema = await engine.getTableSchema('nonexistent');
|
||||
expect(schema).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 插入 ----
|
||||
|
||||
describe('插入', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
});
|
||||
|
||||
it('插入单行', async () => {
|
||||
const pks = await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'alice@test.com' },
|
||||
]);
|
||||
expect(pks).toEqual(['1']);
|
||||
expect(await engine.count('users')).toBe(1);
|
||||
});
|
||||
|
||||
it('插入多行', async () => {
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
|
||||
{ id: '2', name: 'Bob', age: 25, email: 'b@t.com' },
|
||||
]);
|
||||
expect(await engine.count('users')).toBe(2);
|
||||
});
|
||||
|
||||
it('必填字段缺失抛出错误', async () => {
|
||||
await expect(
|
||||
engine.insert('users', [{ id: '1', email: 'a@t.com' }]),
|
||||
).rejects.toThrow('required');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 查询 ----
|
||||
|
||||
describe('查询', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'alice@test.com' },
|
||||
{ id: '2', name: 'Bob', age: 25, email: 'bob@test.com' },
|
||||
{ id: '3', name: 'Charlie', age: 35, email: 'charlie@test.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('查询所有', async () => {
|
||||
const rows = await engine.find('users', { table: 'users' });
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('WHERE 条件', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { age: { $gt: 28 } },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ORDER BY + LIMIT', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
orderBy: [{ column: 'age', direction: 'desc' }],
|
||||
limit: 2,
|
||||
});
|
||||
expect(rows.map(r => r.age)).toEqual([35, 30]);
|
||||
});
|
||||
|
||||
it('列选择', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
columns: ['id'],
|
||||
where: { id: '1' },
|
||||
});
|
||||
expect(Object.keys(rows[0])).toEqual(['id']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 更新 ----
|
||||
|
||||
describe('更新', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
|
||||
{ id: '2', name: 'Bob', age: 25, email: 'b@t.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('更新匹配行', async () => {
|
||||
const count = await engine.update(
|
||||
'users',
|
||||
{ table: 'users', where: { id: '1' } },
|
||||
{ age: 31 },
|
||||
);
|
||||
expect(count).toBe(1);
|
||||
const rows = await engine.find('users', { table: 'users', where: { id: '1' } });
|
||||
expect(rows[0].age).toBe(31);
|
||||
});
|
||||
|
||||
it('更新所有行', async () => {
|
||||
const count = await engine.update(
|
||||
'users',
|
||||
{ table: 'users' },
|
||||
{ age: 100 },
|
||||
);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 删除 ----
|
||||
|
||||
describe('删除', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
|
||||
{ id: '2', name: 'Bob', age: 25, email: 'b@t.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('条件删除', async () => {
|
||||
const count = await engine.delete('users', {
|
||||
table: 'users',
|
||||
where: { id: '1' },
|
||||
});
|
||||
expect(count).toBe(1);
|
||||
expect(await engine.count('users')).toBe(1);
|
||||
});
|
||||
|
||||
it('清空表', async () => {
|
||||
await engine.clear('users');
|
||||
expect(await engine.count('users')).toBe(0);
|
||||
// 表结构仍存在
|
||||
expect(await engine.hasTable('users')).toBe(true);
|
||||
});
|
||||
|
||||
it('删除不存在的表抛出错误', async () => {
|
||||
await expect(
|
||||
engine.delete('nonexistent', { table: 'nonexistent' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Count ----
|
||||
|
||||
describe('Count', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
|
||||
{ id: '2', name: 'Bob', age: 25, email: 'b@t.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('count 全部', async () => {
|
||||
expect(await engine.count('users')).toBe(2);
|
||||
});
|
||||
|
||||
it('count with where', async () => {
|
||||
expect(await engine.count('users', {
|
||||
table: 'users',
|
||||
where: { age: { $gt: 28 } },
|
||||
})).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 复杂 Where 条件 ----
|
||||
|
||||
describe('复杂 Where', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
|
||||
{ id: '2', name: 'Bob', age: 25, email: 'b@t.com' },
|
||||
{ id: '3', name: 'Charlie', age: 35, email: 'c@t.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('$like 模糊匹配', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { name: { $like: 'A%' } },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('$in 列表', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { id: { $in: ['1', '3'] } },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('$ne 不等于', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { age: { $ne: 25 } },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('$gte $lte 范围', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { age: { $gte: 25, $lte: 30 } },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 并发/多次操作 ----
|
||||
|
||||
describe('多次操作', () => {
|
||||
it('连续创建删除表', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
expect(await engine.hasTable('users')).toBe(true);
|
||||
await engine.dropTable('users');
|
||||
expect(await engine.hasTable('users')).toBe(false);
|
||||
await engine.createTable(userSchema);
|
||||
expect(await engine.hasTable('users')).toBe(true);
|
||||
});
|
||||
|
||||
it('插入后查询再更新再查询', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'a@t.com' },
|
||||
]);
|
||||
let rows = await engine.find('users', { table: 'users' });
|
||||
expect(rows[0].age).toBe(30);
|
||||
|
||||
await engine.update('users', { table: 'users' }, { age: 31 });
|
||||
rows = await engine.find('users', { table: 'users' });
|
||||
expect(rows[0].age).toBe(31);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* MemoryEngine 测试
|
||||
*/
|
||||
|
||||
import { MemoryEngine } from '../../src/engine/memory';
|
||||
import { createSchema } from '../../src/table/schema';
|
||||
|
||||
describe('MemoryEngine', () => {
|
||||
let engine: MemoryEngine;
|
||||
|
||||
const userSchema = createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
age: { type: 'number', default: 0 },
|
||||
email: { type: 'string', unique: true },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
engine = new MemoryEngine();
|
||||
await engine.open('test-db', 1);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
describe('open/close', () => {
|
||||
it('打开后 isOpen 返回 true', () => {
|
||||
expect(engine.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('关闭后 isOpen 返回 false', async () => {
|
||||
await engine.close();
|
||||
expect(engine.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 表管理 ----
|
||||
|
||||
describe('表管理', () => {
|
||||
it('创建表', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
expect(await engine.hasTable('users')).toBe(true);
|
||||
});
|
||||
|
||||
it('重复创建表抛出错误', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await expect(engine.createTable(userSchema)).rejects.toThrow('already exists');
|
||||
});
|
||||
|
||||
it('获取所有表名', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
const names = await engine.getTableNames();
|
||||
expect(names).toContain('users');
|
||||
});
|
||||
|
||||
it('删除表', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.dropTable('users');
|
||||
expect(await engine.hasTable('users')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- CRUD ----
|
||||
|
||||
describe('插入', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
});
|
||||
|
||||
it('插入单行', async () => {
|
||||
const pks = await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'alice@test.com' },
|
||||
]);
|
||||
expect(pks).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('插入多行', async () => {
|
||||
const pks = await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'alice@test.com' },
|
||||
{ id: '2', name: 'Bob', age: 25, email: 'bob@test.com' },
|
||||
]);
|
||||
expect(pks).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('重复主键抛出错误', async () => {
|
||||
await engine.insert('users', [{ id: '1', name: 'Alice', email: 'a@t.com' }]);
|
||||
await expect(
|
||||
engine.insert('users', [{ id: '1', name: 'Duplicate', email: 'd@t.com' }]),
|
||||
).rejects.toThrow('Duplicate');
|
||||
});
|
||||
|
||||
it('唯一约束冲突', async () => {
|
||||
await engine.insert('users', [{ id: '1', name: 'Alice', email: 'same@test.com' }]);
|
||||
await expect(
|
||||
engine.insert('users', [{ id: '2', name: 'Bob', email: 'same@test.com' }]),
|
||||
).rejects.toThrow('Unique constraint');
|
||||
});
|
||||
|
||||
it('必填字段缺失', async () => {
|
||||
await expect(
|
||||
engine.insert('users', [{ id: '1' }]),
|
||||
).rejects.toThrow('required');
|
||||
});
|
||||
|
||||
it('默认值填充', async () => {
|
||||
await engine.insert('users', [{ id: '1', name: 'Alice', email: 'a@t.com' }]);
|
||||
const rows = await engine.find('users', { table: 'users' });
|
||||
expect(rows[0].age).toBe(0);
|
||||
});
|
||||
|
||||
it('类型错误', async () => {
|
||||
await expect(
|
||||
engine.insert('users', [{ id: '1', name: 'Alice', age: 'not-a-number', email: 'a@t.com' }]),
|
||||
).rejects.toThrow('expects number');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 查询 ----
|
||||
|
||||
describe('查询', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'alice@test.com' },
|
||||
{ id: '2', name: 'Bob', age: 25, email: 'bob@test.com' },
|
||||
{ id: '3', name: 'Charlie', age: 35, email: 'charlie@test.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('查询所有行', async () => {
|
||||
const rows = await engine.find('users', { table: 'users' });
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('WHERE $gt', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { age: { $gt: 28 } },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map((r) => r.id).sort()).toEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('WHERE $eq', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { name: 'Alice' },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('WHERE $in', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { age: { $in: [25, 35] } },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('WHERE $like', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
where: { name: { $like: 'A%' } },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('ORDER BY asc', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
orderBy: [{ column: 'age', direction: 'asc' }],
|
||||
});
|
||||
expect(rows.map((r) => r.age)).toEqual([25, 30, 35]);
|
||||
});
|
||||
|
||||
it('ORDER BY desc', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
orderBy: [{ column: 'age', direction: 'desc' }],
|
||||
});
|
||||
expect(rows.map((r) => r.age)).toEqual([35, 30, 25]);
|
||||
});
|
||||
|
||||
it('LIMIT', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
limit: 2,
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('OFFSET', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
orderBy: [{ column: 'id', direction: 'asc' }],
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('列选择', async () => {
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
columns: ['id', 'name'],
|
||||
where: { id: '1' },
|
||||
});
|
||||
expect(Object.keys(rows[0]).sort()).toEqual(['id', 'name']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 更新 ----
|
||||
|
||||
describe('更新', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'alice@test.com' },
|
||||
{ id: '2', name: 'Bob', age: 25, email: 'bob@test.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('更新单行', async () => {
|
||||
const count = await engine.update('users',
|
||||
{ table: 'users', where: { id: '1' } },
|
||||
{ age: 31 },
|
||||
);
|
||||
expect(count).toBe(1);
|
||||
const rows = await engine.find('users', { table: 'users', where: { id: '1' } });
|
||||
expect(rows[0].age).toBe(31);
|
||||
});
|
||||
|
||||
it('更新所有行', async () => {
|
||||
const count = await engine.update('users',
|
||||
{ table: 'users' },
|
||||
{ age: 100 },
|
||||
);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 删除 ----
|
||||
|
||||
describe('删除', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30, email: 'alice@test.com' },
|
||||
{ id: '2', name: 'Bob', age: 25, email: 'bob@test.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('条件删除', async () => {
|
||||
const count = await engine.delete('users', { table: 'users', where: { id: '1' } });
|
||||
expect(count).toBe(1);
|
||||
const rows = await engine.find('users', { table: 'users' });
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('清空表', async () => {
|
||||
await engine.clear('users');
|
||||
const count = await engine.count('users');
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* GROUP BY + 聚合函数测试 (v0.1.2)
|
||||
*/
|
||||
|
||||
import { MetonaSqlark } from '../src/core';
|
||||
import { parse } from '../src/sql/parser';
|
||||
|
||||
describe('GROUP BY (v0.1.2)', () => {
|
||||
let db: MetonaSqlark;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new MetonaSqlark({ name: 'test-groupby', mode: 'memory' });
|
||||
await db.init();
|
||||
|
||||
await db.defineTable('employees', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
dept: { type: 'string' },
|
||||
salary: { type: 'number' },
|
||||
});
|
||||
|
||||
await db.table('employees').insertMany([
|
||||
{ id: '1', name: 'Alice', dept: 'Eng', salary: 1000 },
|
||||
{ id: '2', name: 'Bob', dept: 'Eng', salary: 1200 },
|
||||
{ id: '3', name: 'Charlie', dept: 'Sales', salary: 900 },
|
||||
{ id: '4', name: 'Diana', dept: 'Sales', salary: 1100 },
|
||||
{ id: '5', name: 'Eve', dept: 'HR', salary: 800 },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// SQL GROUP BY
|
||||
// ===================================================================
|
||||
|
||||
describe('SQL GROUP BY', () => {
|
||||
it('GROUP BY + COUNT', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT dept, COUNT(*) FROM employees GROUP BY dept',
|
||||
) as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(3);
|
||||
const eng = result.find((r: any) => r.dept === 'Eng');
|
||||
expect(eng!['COUNT(*)']).toBe(2);
|
||||
});
|
||||
|
||||
it('GROUP BY + SUM', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT dept, SUM(salary) FROM employees GROUP BY dept',
|
||||
) as Record<string, unknown>[];
|
||||
const eng = result.find((r: any) => r.dept === 'Eng');
|
||||
expect(eng!['SUM(salary)']).toBe(2200);
|
||||
});
|
||||
|
||||
it('GROUP BY + AVG', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT dept, AVG(salary) FROM employees GROUP BY dept',
|
||||
) as Record<string, unknown>[];
|
||||
const eng = result.find((r: any) => r.dept === 'Eng');
|
||||
expect(eng!['AVG(salary)']).toBe(1100);
|
||||
});
|
||||
|
||||
it('GROUP BY + MIN/MAX', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT dept, MIN(salary), MAX(salary) FROM employees GROUP BY dept',
|
||||
) as Record<string, unknown>[];
|
||||
const eng = result.find((r: any) => r.dept === 'Eng');
|
||||
expect(eng!['MIN(salary)']).toBe(1000);
|
||||
expect(eng!['MAX(salary)']).toBe(1200);
|
||||
});
|
||||
|
||||
it('GROUP BY + HAVING', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT dept, COUNT(*) FROM employees GROUP BY dept HAVING COUNT(*) > 1',
|
||||
) as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(2); // Eng(2) + Sales(2)
|
||||
});
|
||||
|
||||
it('GROUP BY + WHERE + HAVING', async () => {
|
||||
const result = await db.query(
|
||||
"SELECT dept, SUM(salary) FROM employees WHERE dept != 'HR' GROUP BY dept HAVING SUM(salary) > 2000",
|
||||
) as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(1); // Only Eng
|
||||
});
|
||||
|
||||
it('聚合函数别名', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT dept, COUNT(*) AS cnt, SUM(salary) AS total FROM employees GROUP BY dept',
|
||||
) as Record<string, unknown>[];
|
||||
const eng = result.find((r: any) => r.dept === 'Eng');
|
||||
expect(eng!.cnt).toBe(2);
|
||||
expect(eng!.total).toBe(2200);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Parser 测试
|
||||
// ===================================================================
|
||||
|
||||
describe('Parser', () => {
|
||||
it('解析 COUNT(*)', () => {
|
||||
const ast = parse('SELECT COUNT(*) FROM employees');
|
||||
expect(ast.columns[0]).toBe('COUNT(*)');
|
||||
});
|
||||
|
||||
it('解析 GROUP BY', () => {
|
||||
const ast = parse('SELECT dept, COUNT(*) FROM employees GROUP BY dept');
|
||||
expect(ast.groupBy).toEqual(['dept']);
|
||||
});
|
||||
|
||||
it('解析 HAVING', () => {
|
||||
const ast = parse('SELECT dept, COUNT(*) FROM employees GROUP BY dept HAVING COUNT(*) > 1');
|
||||
expect(ast.having).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* HybridEngine 测试 — write-through 策略验证
|
||||
* 注:re-open 持久化测试需要真实 IndexedDB 版本管理,fake-indexeddb 不支持。
|
||||
* 这里验证 write-through 即时效正确性。
|
||||
*/
|
||||
|
||||
import { HybridEngine } from '../../src/hybrid/index';
|
||||
import { createSchema } from '../../src/table/schema';
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
let hybridCounter = 0;
|
||||
|
||||
describe('HybridEngine', () => {
|
||||
let engine: HybridEngine;
|
||||
let dbName: string;
|
||||
|
||||
const userSchema = createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
age: { type: 'number', default: 0 },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = `test-hybrid-${++hybridCounter}`;
|
||||
engine = new HybridEngine('indexeddb');
|
||||
await engine.open(dbName, 1);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (engine.isOpen()) {
|
||||
await engine.close();
|
||||
}
|
||||
try { indexedDB.deleteDatabase(dbName); } catch {}
|
||||
});
|
||||
|
||||
it('引擎名称', () => {
|
||||
expect(engine.name).toBe('hybrid');
|
||||
});
|
||||
|
||||
it('磁盘引擎类型', () => {
|
||||
expect(engine.getDiskEngineType()).toBe('indexeddb');
|
||||
});
|
||||
|
||||
describe('表管理', () => {
|
||||
it('创建表', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
expect(await engine.hasTable('users')).toBe(true);
|
||||
});
|
||||
|
||||
it('删除表', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
await engine.dropTable('users');
|
||||
expect(await engine.hasTable('users')).toBe(false);
|
||||
});
|
||||
|
||||
it('表名列表', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
const names = await engine.getTableNames();
|
||||
expect(names).toContain('users');
|
||||
});
|
||||
|
||||
it('获取表结构', async () => {
|
||||
await engine.createTable(userSchema);
|
||||
const schema = await engine.getTableSchema('users');
|
||||
expect(schema).not.toBeNull();
|
||||
expect(schema!.name).toBe('users');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Write-Through 即时性', () => {
|
||||
beforeEach(async () => {
|
||||
await engine.createTable(userSchema);
|
||||
});
|
||||
|
||||
it('插入后内存立即可读', async () => {
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30 },
|
||||
{ id: '2', name: 'Bob', age: 25 },
|
||||
]);
|
||||
const data = await engine.find('users', { table: 'users' });
|
||||
expect(data).toHaveLength(2);
|
||||
expect(await engine.count('users')).toBe(2);
|
||||
});
|
||||
|
||||
it('更新后立即可读', async () => {
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30 },
|
||||
]);
|
||||
await engine.update('users', { table: 'users', where: { id: '1' } }, { age: 31 });
|
||||
const rows = await engine.find('users', { table: 'users' });
|
||||
expect(rows[0].age).toBe(31);
|
||||
});
|
||||
|
||||
it('删除后立即可读', async () => {
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30 },
|
||||
{ id: '2', name: 'Bob', age: 25 },
|
||||
]);
|
||||
await engine.delete('users', { table: 'users', where: { id: '1' } });
|
||||
expect(await engine.count('users')).toBe(1);
|
||||
const rows = await engine.find('users', { table: 'users' });
|
||||
expect(rows.map(r => r.id)).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('查询带排序和分页', async () => {
|
||||
await engine.insert('users', [
|
||||
{ id: '1', name: 'Alice', age: 30 },
|
||||
{ id: '2', name: 'Bob', age: 25 },
|
||||
{ id: '3', name: 'Charlie', age: 35 },
|
||||
]);
|
||||
const rows = await engine.find('users', {
|
||||
table: 'users',
|
||||
orderBy: [{ column: 'age', direction: 'desc' }],
|
||||
limit: 2,
|
||||
});
|
||||
expect(rows.map(r => r.age)).toEqual([35, 30]);
|
||||
});
|
||||
|
||||
it('clear 清空表', async () => {
|
||||
await engine.insert('users', [{ id: '1', name: 'Alice', age: 30 }]);
|
||||
await engine.clear('users');
|
||||
expect(await engine.count('users')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('生命周期', () => {
|
||||
it('isOpen 状态', async () => {
|
||||
expect(engine.isOpen()).toBe(true);
|
||||
await engine.close();
|
||||
expect(engine.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('内存引擎访问', () => {
|
||||
it('getMemoryEngine 返回内存引擎', () => {
|
||||
const mem = engine.getMemoryEngine();
|
||||
expect(mem.name).toBe('memory');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* JOIN 功能测试 (v0.1.1)
|
||||
*/
|
||||
|
||||
import { MetonaSqlark } from '../src/core';
|
||||
|
||||
describe('JOIN (v0.1.1)', () => {
|
||||
let db: MetonaSqlark;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new MetonaSqlark({ name: 'test-join', mode: 'memory' });
|
||||
await db.init();
|
||||
|
||||
// 创建测试表
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
dept_id: { type: 'number' },
|
||||
});
|
||||
await db.defineTable('departments', {
|
||||
id: { type: 'number', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
});
|
||||
await db.defineTable('orders', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
user_id: { type: 'string' },
|
||||
amount: { type: 'number' },
|
||||
});
|
||||
|
||||
// 插入测试数据
|
||||
await db.table('users').insertMany([
|
||||
{ id: '1', name: 'Alice', dept_id: 1 },
|
||||
{ id: '2', name: 'Bob', dept_id: 1 },
|
||||
{ id: '3', name: 'Charlie', dept_id: 2 },
|
||||
]);
|
||||
await db.table('departments').insertMany([
|
||||
{ id: 1, name: 'Engineering' },
|
||||
{ id: 2, name: 'Sales' },
|
||||
{ id: 3, name: 'HR' }, // 无用户
|
||||
]);
|
||||
await db.table('orders').insertMany([
|
||||
{ id: 'o1', user_id: '1', amount: 100 },
|
||||
{ id: 'o2', user_id: '1', amount: 200 },
|
||||
{ id: 'o3', user_id: '2', amount: 150 },
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// SQL JOIN
|
||||
// ===================================================================
|
||||
|
||||
describe('SQL JOIN', () => {
|
||||
it('INNER JOIN', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT users.name, departments.name FROM users INNER JOIN departments ON users.dept_id = departments.id',
|
||||
) as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('LEFT JOIN', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT departments.name FROM departments LEFT JOIN users ON departments.id = users.dept_id',
|
||||
) as Record<string, unknown>[];
|
||||
// HR 部门没有用户,LEFT JOIN 应保留
|
||||
expect(result.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('CROSS JOIN', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT * FROM users CROSS JOIN departments',
|
||||
) as Record<string, unknown>[];
|
||||
// 3 users × 3 departments = 9
|
||||
expect(result).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('JOIN with alias', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT u.name, d.name FROM users u INNER JOIN departments d ON u.dept_id = d.id',
|
||||
) as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('JOIN with AS alias', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT u.name FROM users AS u INNER JOIN orders AS o ON u.id = o.user_id',
|
||||
) as Record<string, unknown>[];
|
||||
expect(result.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('JOIN with WHERE', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id WHERE o.amount > 120',
|
||||
) as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(2); // o2(200) + o3(150)
|
||||
});
|
||||
|
||||
it('Multiple JOIN', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT u.name, d.name, o.amount FROM users u INNER JOIN departments d ON u.dept_id = d.id INNER JOIN orders o ON u.id = o.user_id',
|
||||
) as Record<string, unknown>[];
|
||||
expect(result.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('LEFT JOIN with no match returns null', async () => {
|
||||
const result = await db.query(
|
||||
'SELECT d.name, u.name FROM departments d LEFT JOIN users u ON d.id = u.dept_id WHERE d.id = 3',
|
||||
) as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(1);
|
||||
// HR 部门无用户,u.name 应为 null
|
||||
// Note: column projection may vary
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// QueryBuilder JOIN
|
||||
// ===================================================================
|
||||
|
||||
describe('QueryBuilder JOIN', () => {
|
||||
it('innerJoin via QueryBuilder', async () => {
|
||||
const results = await db.table('users')
|
||||
.select(['users.name', 'departments.name'])
|
||||
.innerJoin('departments', { 'users.dept_id': { $col: 'departments.id' } })
|
||||
.execute();
|
||||
expect(results).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('leftJoin via QueryBuilder', async () => {
|
||||
const results = await db.table('departments')
|
||||
.select()
|
||||
.leftJoin('users', { 'departments.id': { $col: 'users.dept_id' } })
|
||||
.execute();
|
||||
expect(results.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('crossJoin via QueryBuilder', async () => {
|
||||
const results = await db.table('users')
|
||||
.select()
|
||||
.crossJoin('departments')
|
||||
.execute();
|
||||
expect(results).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('join with where + orderBy + limit', async () => {
|
||||
const results = await db.table('users')
|
||||
.select()
|
||||
.innerJoin('orders', { 'users.id': { $col: 'orders.user_id' } })
|
||||
.where({ 'orders.amount': { $gt: 100 } })
|
||||
.orderBy('orders.amount', 'desc')
|
||||
.limit(2)
|
||||
.execute();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
// 按 amount desc 排序
|
||||
if (results.length === 2) {
|
||||
expect((results[0] as any)['orders.amount']).toBeGreaterThanOrEqual(
|
||||
(results[1] as any)['orders.amount'],
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// Parser JOIN 语法
|
||||
// ===================================================================
|
||||
|
||||
describe('Parser JOIN 语法', () => {
|
||||
it('INNER JOIN 解析', () => {
|
||||
const { parse } = require('../src/sql/parser');
|
||||
const ast = parse('SELECT * FROM users INNER JOIN orders ON users.id = orders.user_id');
|
||||
expect(ast.joins).toHaveLength(1);
|
||||
expect(ast.joins[0].type).toBe('INNER');
|
||||
expect(ast.joins[0].table).toBe('orders');
|
||||
});
|
||||
|
||||
it('LEFT OUTER JOIN 解析', () => {
|
||||
const { parse } = require('../src/sql/parser');
|
||||
const ast = parse('SELECT * FROM users LEFT OUTER JOIN orders ON users.id = orders.user_id');
|
||||
expect(ast.joins[0].type).toBe('LEFT');
|
||||
});
|
||||
|
||||
it('RIGHT JOIN 解析', () => {
|
||||
const { parse } = require('../src/sql/parser');
|
||||
const ast = parse('SELECT * FROM users RIGHT JOIN orders ON users.id = orders.user_id');
|
||||
expect(ast.joins[0].type).toBe('RIGHT');
|
||||
});
|
||||
|
||||
it('CROSS JOIN 解析', () => {
|
||||
const { parse } = require('../src/sql/parser');
|
||||
const ast = parse('SELECT * FROM users CROSS JOIN departments');
|
||||
expect(ast.joins[0].type).toBe('CROSS');
|
||||
});
|
||||
|
||||
it('表别名解析', () => {
|
||||
const { parse } = require('../src/sql/parser');
|
||||
const ast = parse('SELECT u.name FROM users u');
|
||||
expect(ast.alias).toBe('u');
|
||||
});
|
||||
|
||||
it('AS 别名解析', () => {
|
||||
const { parse } = require('../src/sql/parser');
|
||||
const ast = parse('SELECT u.name FROM users AS u');
|
||||
expect(ast.alias).toBe('u');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* PluginManager 完整测试
|
||||
*/
|
||||
|
||||
import { PluginManager } from '../../src/plugin/index';
|
||||
import type { MetonaPlugin } from '../../src/constants';
|
||||
|
||||
describe('PluginManager', () => {
|
||||
let pm: PluginManager;
|
||||
|
||||
beforeEach(() => {
|
||||
pm = new PluginManager();
|
||||
});
|
||||
|
||||
// ---- 注册/卸载 ----
|
||||
|
||||
describe('注册和卸载', () => {
|
||||
it('注册插件', () => {
|
||||
const plugin: MetonaPlugin = {
|
||||
name: 'test-plugin',
|
||||
version: '1.0.0',
|
||||
install: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
pm.register(plugin);
|
||||
expect(pm.getPlugins()).toHaveLength(1);
|
||||
expect(plugin.install).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('按优先级排序', () => {
|
||||
const p1: MetonaPlugin = { name: 'low', version: '1.0', priority: 1, install: jest.fn(), destroy: jest.fn() };
|
||||
const p2: MetonaPlugin = { name: 'high', version: '1.0', priority: 10, install: jest.fn(), destroy: jest.fn() };
|
||||
const p3: MetonaPlugin = { name: 'mid', version: '1.0', priority: 5, install: jest.fn(), destroy: jest.fn() };
|
||||
|
||||
pm.register(p1);
|
||||
pm.register(p2);
|
||||
pm.register(p3);
|
||||
|
||||
const plugins = pm.getPlugins();
|
||||
expect(plugins[0].name).toBe('high');
|
||||
expect(plugins[1].name).toBe('mid');
|
||||
expect(plugins[2].name).toBe('low');
|
||||
});
|
||||
|
||||
it('卸载插件调用 destroy', () => {
|
||||
const destroyFn = jest.fn();
|
||||
const plugin: MetonaPlugin = {
|
||||
name: 'test',
|
||||
version: '1.0',
|
||||
install: jest.fn(),
|
||||
destroy: destroyFn,
|
||||
};
|
||||
pm.register(plugin);
|
||||
pm.unregister('test');
|
||||
expect(destroyFn).toHaveBeenCalled();
|
||||
expect(pm.getPlugins()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('卸载不存在的插件不报错', () => {
|
||||
expect(() => pm.unregister('nonexistent')).not.toThrow();
|
||||
});
|
||||
|
||||
it('getPlugins 返回副本', () => {
|
||||
const plugin: MetonaPlugin = {
|
||||
name: 'test', version: '1.0', install: jest.fn(), destroy: jest.fn(),
|
||||
};
|
||||
pm.register(plugin);
|
||||
const plugins = pm.getPlugins();
|
||||
plugins.push({ name: 'external', version: '1.0', install: jest.fn(), destroy: jest.fn() });
|
||||
expect(pm.getPlugins()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 钩子 ----
|
||||
|
||||
describe('钩子系统', () => {
|
||||
it('注册和触发钩子', async () => {
|
||||
const callback = jest.fn();
|
||||
pm.on('beforeInsert', callback);
|
||||
await pm.trigger('beforeInsert', { id: '1' });
|
||||
expect(callback).toHaveBeenCalledWith({ id: '1' });
|
||||
});
|
||||
|
||||
it('多个回调', async () => {
|
||||
const cb1 = jest.fn();
|
||||
const cb2 = jest.fn();
|
||||
pm.on('afterQuery', cb1);
|
||||
pm.on('afterQuery', cb2);
|
||||
await pm.trigger('afterQuery', 'SELECT *', []);
|
||||
expect(cb1).toHaveBeenCalled();
|
||||
expect(cb2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('卸载钩子', async () => {
|
||||
const cb = jest.fn();
|
||||
pm.on('beforeUpdate', cb);
|
||||
pm.off('beforeUpdate', cb);
|
||||
await pm.trigger('beforeUpdate');
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('触发未注册的钩子不报错', async () => {
|
||||
await expect(pm.trigger('beforeInsert')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('卸载不存在的回调不报错', () => {
|
||||
expect(() => pm.off('beforeInsert', jest.fn())).not.toThrow();
|
||||
});
|
||||
|
||||
it('异步回调', async () => {
|
||||
const cb = jest.fn().mockResolvedValue(undefined);
|
||||
pm.on('beforeQuery', cb);
|
||||
await pm.trigger('beforeQuery');
|
||||
expect(cb).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 生命周期钩子覆盖 ----
|
||||
|
||||
describe('全部钩子类型', () => {
|
||||
const hooks = [
|
||||
'beforeCreateTable', 'afterCreateTable',
|
||||
'beforeDropTable', 'afterDropTable',
|
||||
'beforeInsert', 'afterInsert',
|
||||
'beforeUpdate', 'afterUpdate',
|
||||
'beforeDelete', 'afterDelete',
|
||||
'beforeQuery', 'afterQuery',
|
||||
'beforeTransaction', 'afterTransaction',
|
||||
] as const;
|
||||
|
||||
hooks.forEach((hook) => {
|
||||
it(`钩子 ${hook} 可注册和触发`, async () => {
|
||||
const cb = jest.fn();
|
||||
pm.on(hook, cb);
|
||||
await pm.trigger(hook);
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- destroy ----
|
||||
|
||||
describe('销毁', () => {
|
||||
it('destroy 调用所有插件的 destroy', () => {
|
||||
const d1 = jest.fn();
|
||||
const d2 = jest.fn();
|
||||
pm.register({ name: 'p1', version: '1.0', install: jest.fn(), destroy: d1 });
|
||||
pm.register({ name: 'p2', version: '1.0', install: jest.fn(), destroy: d2 });
|
||||
pm.destroy();
|
||||
expect(d1).toHaveBeenCalled();
|
||||
expect(d2).toHaveBeenCalled();
|
||||
expect(pm.getPlugins()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('destroy 清空钩子', async () => {
|
||||
const cb = jest.fn();
|
||||
pm.on('beforeInsert', cb);
|
||||
pm.destroy();
|
||||
await pm.trigger('beforeInsert');
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('插件 destroy 出错不影响销毁流程', () => {
|
||||
const badPlugin: MetonaPlugin = {
|
||||
name: 'bad',
|
||||
version: '1.0',
|
||||
install: jest.fn(),
|
||||
destroy: () => { throw new Error('destroy failed'); },
|
||||
};
|
||||
const goodPlugin: MetonaPlugin = {
|
||||
name: 'good',
|
||||
version: '1.0',
|
||||
install: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
pm.register(badPlugin);
|
||||
pm.register(goodPlugin);
|
||||
expect(() => pm.destroy()).not.toThrow();
|
||||
expect(goodPlugin.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* QueryBuilder 和 Compiler/Executor 测试
|
||||
*/
|
||||
|
||||
import { MemoryEngine } from '../../src/engine/memory';
|
||||
import { createSchema } from '../../src/table/schema';
|
||||
import { SelectQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder } from '../../src/query/builder';
|
||||
import { compileStatement } from '../../src/query/compiler';
|
||||
import { QueryExecutor } from '../../src/query/executor';
|
||||
import { parse } from '../../src/sql/parser';
|
||||
|
||||
describe('QueryBuilder', () => {
|
||||
let engine: MemoryEngine;
|
||||
|
||||
beforeEach(async () => {
|
||||
engine = new MemoryEngine();
|
||||
await engine.open('test-qb', 1);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// ---- SelectQueryBuilder ----
|
||||
|
||||
describe('SelectQueryBuilder', () => {
|
||||
it('基础查询', () => {
|
||||
const qb = new SelectQueryBuilder(engine, 'users');
|
||||
expect(qb.toAST()).toMatchObject({
|
||||
type: 'SELECT',
|
||||
columns: ['*'],
|
||||
from: 'users',
|
||||
where: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('链式调用', () => {
|
||||
const qb = new SelectQueryBuilder(engine, 'users', ['id', 'name']);
|
||||
qb.where({ age: { $gt: 18 } }).orderBy('name', 'asc').limit(10).offset(5);
|
||||
|
||||
const ast = qb.toAST();
|
||||
expect(ast.columns).toEqual(['id', 'name']);
|
||||
expect(ast.where).toEqual({ age: { $gt: 18 } });
|
||||
expect(ast.orderBy).toEqual([{ column: 'name', direction: 'asc' }]);
|
||||
expect(ast.limit).toBe(10);
|
||||
expect(ast.offset).toBe(5);
|
||||
});
|
||||
|
||||
it('多次 where 合并条件', () => {
|
||||
const qb = new SelectQueryBuilder(engine, 'users');
|
||||
qb.where({ age: { $gt: 18 } }).where({ active: true });
|
||||
expect(qb.toAST().where).toEqual({ age: { $gt: 18 }, active: true });
|
||||
});
|
||||
|
||||
it('多次 orderBy 追加排序', () => {
|
||||
const qb = new SelectQueryBuilder(engine, 'users');
|
||||
qb.orderBy('age', 'desc').orderBy('name', 'asc');
|
||||
expect(qb.toAST().orderBy).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- UpdateQueryBuilder ----
|
||||
|
||||
describe('UpdateQueryBuilder', () => {
|
||||
it('基础更新', () => {
|
||||
const qb = new UpdateQueryBuilder(engine, 'users', { age: 31 });
|
||||
qb.where({ id: '1' });
|
||||
const ast = qb.toAST();
|
||||
expect(ast).toMatchObject({
|
||||
type: 'UPDATE',
|
||||
table: 'users',
|
||||
sets: { age: 31 },
|
||||
where: { id: '1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('不带 where', () => {
|
||||
const qb = new UpdateQueryBuilder(engine, 'users', { active: false });
|
||||
expect(qb.toAST().where).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- DeleteQueryBuilder ----
|
||||
|
||||
describe('DeleteQueryBuilder', () => {
|
||||
it('基础删除', () => {
|
||||
const qb = new DeleteQueryBuilder(engine, 'users');
|
||||
qb.where({ id: '1' });
|
||||
const ast = qb.toAST();
|
||||
expect(ast).toMatchObject({
|
||||
type: 'DELETE',
|
||||
from: 'users',
|
||||
where: { id: '1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('不带 where', () => {
|
||||
const qb = new DeleteQueryBuilder(engine, 'users');
|
||||
expect(qb.toAST().where).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
|
||||
describe('Compiler', () => {
|
||||
it('编译 SELECT', () => {
|
||||
const ast = parse('SELECT id, name FROM users WHERE age > 18 ORDER BY age DESC LIMIT 10');
|
||||
const plan = compileStatement(ast);
|
||||
expect(plan.table).toBe('users');
|
||||
expect(plan.columns).toEqual(['id', 'name']);
|
||||
expect(plan.where).toBeDefined();
|
||||
expect(plan.orderBy).toEqual([{ column: 'age', direction: 'desc' }]);
|
||||
expect(plan.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('编译 DELETE', () => {
|
||||
const ast = parse("DELETE FROM users WHERE id = '1'");
|
||||
const plan = compileStatement(ast);
|
||||
expect(plan.table).toBe('users');
|
||||
expect(plan.where).toBeDefined();
|
||||
});
|
||||
|
||||
it('编译 UPDATE', () => {
|
||||
const ast = parse("UPDATE users SET age = 31 WHERE id = '1'");
|
||||
const plan = compileStatement(ast);
|
||||
expect(plan.table).toBe('users');
|
||||
expect(plan.where).toBeDefined();
|
||||
});
|
||||
|
||||
it('非查询语句抛出错误', () => {
|
||||
const ast = parse('CREATE TABLE users (id STRING PRIMARY KEY)');
|
||||
expect(() => compileStatement(ast)).toThrow('Cannot compile');
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
|
||||
describe('QueryExecutor', () => {
|
||||
let engine: MemoryEngine;
|
||||
let executor: QueryExecutor;
|
||||
|
||||
beforeEach(async () => {
|
||||
engine = new MemoryEngine();
|
||||
await engine.open('test-exec', 1);
|
||||
executor = new QueryExecutor(engine);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('执行 CREATE TABLE', async () => {
|
||||
const ast = parse('CREATE TABLE users (id STRING PRIMARY KEY, name STRING)');
|
||||
await executor.execute(ast);
|
||||
expect(await engine.hasTable('users')).toBe(true);
|
||||
});
|
||||
|
||||
it('执行 INSERT + SELECT', async () => {
|
||||
await executor.execute(parse('CREATE TABLE users (id STRING PRIMARY KEY, name STRING, age NUMBER)'));
|
||||
await executor.execute(parse("INSERT INTO users (id, name, age) VALUES ('1', 'Alice', 30)"));
|
||||
await executor.execute(parse("INSERT INTO users VALUES ('2', 'Bob', 25)"));
|
||||
|
||||
const result = await executor.execute(parse('SELECT * FROM users')) as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('执行 UPDATE', async () => {
|
||||
await executor.execute(parse('CREATE TABLE users (id STRING PRIMARY KEY, name STRING, age NUMBER)'));
|
||||
await executor.execute(parse("INSERT INTO users VALUES ('1', 'Alice', 30)"));
|
||||
await executor.execute(parse("UPDATE users SET age = 31 WHERE id = '1'"));
|
||||
|
||||
const result = await executor.execute(parse("SELECT * FROM users WHERE id = '1'")) as Record<string, unknown>[];
|
||||
expect(result[0].age).toBe(31);
|
||||
});
|
||||
|
||||
it('执行 DELETE', async () => {
|
||||
await executor.execute(parse('CREATE TABLE users (id STRING PRIMARY KEY, name STRING)'));
|
||||
await executor.execute(parse("INSERT INTO users VALUES ('1', 'Alice')"));
|
||||
await executor.execute(parse("INSERT INTO users VALUES ('2', 'Bob')"));
|
||||
await executor.execute(parse("DELETE FROM users WHERE id = '1'"));
|
||||
|
||||
const result = await executor.execute(parse('SELECT * FROM users')) as Record<string, unknown>[];
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('执行 DROP TABLE', async () => {
|
||||
await executor.execute(parse('CREATE TABLE users (id STRING PRIMARY KEY)'));
|
||||
await executor.execute(parse('DROP TABLE users'));
|
||||
expect(await engine.hasTable('users')).toBe(false);
|
||||
});
|
||||
|
||||
it('操作不存在的表抛出错误', async () => {
|
||||
await expect(
|
||||
executor.execute(parse('SELECT * FROM nonexistent')),
|
||||
).rejects.toThrow('does not exist');
|
||||
});
|
||||
|
||||
it('getEngine', () => {
|
||||
expect(executor.getEngine()).toBe(engine);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Lexer 边缘场景测试
|
||||
*/
|
||||
|
||||
import { Lexer, tokenize } from '../../src/sql/lexer';
|
||||
import { TokenType } from '../../src/sql/tokens';
|
||||
|
||||
describe('Lexer 边缘场景', () => {
|
||||
it('空字符串返回 EOF', () => {
|
||||
const tokens = tokenize('');
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens[0].type).toBe(TokenType.EOF);
|
||||
});
|
||||
|
||||
it('只包含空格', () => {
|
||||
const tokens = tokenize(' \t\n ');
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens[0].type).toBe(TokenType.EOF);
|
||||
});
|
||||
|
||||
it('非法字符返回 ILLEGAL', () => {
|
||||
const tokens = tokenize('@');
|
||||
expect(tokens[0].type).toBe(TokenType.ILLEGAL);
|
||||
});
|
||||
|
||||
it('完整的 CREATE TABLE 语句', () => {
|
||||
const tokens = tokenize('CREATE TABLE users (id STRING PRIMARY KEY, name STRING NOT NULL, age NUMBER DEFAULT 0)');
|
||||
const types = tokens.map(t => t.type);
|
||||
expect(types).toEqual([
|
||||
TokenType.CREATE, TokenType.TABLE, TokenType.IDENTIFIER, TokenType.LPAREN,
|
||||
TokenType.IDENTIFIER, TokenType.IDENTIFIER, TokenType.PRIMARY, TokenType.KEY,
|
||||
TokenType.COMMA,
|
||||
TokenType.IDENTIFIER, TokenType.IDENTIFIER, TokenType.NOT, TokenType.NULL,
|
||||
TokenType.COMMA,
|
||||
TokenType.IDENTIFIER, TokenType.IDENTIFIER, TokenType.DEFAULT, TokenType.NUMBER,
|
||||
TokenType.RPAREN, TokenType.EOF,
|
||||
]);
|
||||
});
|
||||
|
||||
it('带转义单引号的字符串', () => {
|
||||
// SQL 中两个单引号 '' 表示转义的单引号
|
||||
const tokens = tokenize("SELECT 'hello world'");
|
||||
expect(tokens[1].type).toBe(TokenType.STRING);
|
||||
expect(tokens[1].value).toBe('hello world');
|
||||
});
|
||||
|
||||
it('双引号字符串', () => {
|
||||
const tokens = tokenize('SELECT "hello world"');
|
||||
expect(tokens[1].type).toBe(TokenType.STRING);
|
||||
expect(tokens[1].value).toBe('hello world');
|
||||
});
|
||||
|
||||
it('所有关键字', () => {
|
||||
const sql = 'SELECT FROM WHERE INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE DROP ORDER BY ASC DESC LIMIT OFFSET AND OR NOT LIKE IN PRIMARY KEY UNIQUE DEFAULT NULL TRUE FALSE';
|
||||
const tokens = tokenize(sql);
|
||||
const nonEof = tokens.filter(t => t.type !== TokenType.EOF);
|
||||
expect(nonEof).toHaveLength(30);
|
||||
// 验证都是关键字,不是 IDENTIFIER
|
||||
for (const t of nonEof) {
|
||||
expect(t.type).not.toBe(TokenType.IDENTIFIER);
|
||||
}
|
||||
});
|
||||
|
||||
it('下划线标识符', () => {
|
||||
const tokens = tokenize('SELECT my_col_1 FROM user_table');
|
||||
expect(tokens[1].type).toBe(TokenType.IDENTIFIER);
|
||||
expect(tokens[1].value).toBe('my_col_1');
|
||||
expect(tokens[3].type).toBe(TokenType.IDENTIFIER);
|
||||
expect(tokens[3].value).toBe('user_table');
|
||||
});
|
||||
|
||||
it('运算符分隔符', () => {
|
||||
const tokens = tokenize('= != > >= < <= <> ( ) , ; *');
|
||||
const types = tokens.map(t => t.type);
|
||||
expect(types).toEqual([
|
||||
TokenType.EQ, TokenType.NEQ, TokenType.GT, TokenType.GTE,
|
||||
TokenType.LT, TokenType.LTE, TokenType.NEQ,
|
||||
TokenType.LPAREN, TokenType.RPAREN, TokenType.COMMA,
|
||||
TokenType.SEMICOLON, TokenType.STAR, TokenType.EOF,
|
||||
]);
|
||||
});
|
||||
|
||||
it('数字: 整数和浮点数和负数', () => {
|
||||
const tokens = tokenize('42 3.14 -7 -2.5');
|
||||
expect(tokens[0].value).toBe('42');
|
||||
expect(tokens[1].value).toBe('3.14');
|
||||
expect(tokens[2].value).toBe('-7');
|
||||
expect(tokens[3].value).toBe('-2.5');
|
||||
});
|
||||
|
||||
it('Token position 正确', () => {
|
||||
const lexer = new Lexer('SELECT * FROM users');
|
||||
const tok = lexer.nextToken();
|
||||
expect(tok.position).toBe(0); // SELECT 从位置 0 开始
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Parser 边缘场景测试
|
||||
*/
|
||||
|
||||
import { parse } from '../../src/sql/parser';
|
||||
|
||||
describe('Parser 边缘场景', () => {
|
||||
// ---- SELECT 扩展 ----
|
||||
|
||||
describe('SELECT 扩展', () => {
|
||||
it('WHERE 多条件 AND', () => {
|
||||
const ast = parse("SELECT * FROM users WHERE age > 18 AND name LIKE 'A%'");
|
||||
expect(ast.type).toBe('SELECT');
|
||||
expect(ast.where).toBeDefined();
|
||||
});
|
||||
|
||||
it('WHERE IN 列表', () => {
|
||||
const ast = parse('SELECT * FROM users WHERE id IN (1, 2, 3)');
|
||||
expect(ast.type).toBe('SELECT');
|
||||
expect(ast.where).toBeDefined();
|
||||
const cond = (ast.where as any).id;
|
||||
expect(cond.$in).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('WHERE IS NULL', () => {
|
||||
const ast = parse('SELECT * FROM users WHERE bio IS NULL');
|
||||
expect(ast.type).toBe('SELECT');
|
||||
expect(ast.where).toBeDefined();
|
||||
});
|
||||
|
||||
it('WHERE IS NOT NULL', () => {
|
||||
const ast = parse('SELECT * FROM users WHERE bio IS NOT NULL');
|
||||
expect(ast.type).toBe('SELECT');
|
||||
});
|
||||
|
||||
it('WHERE NOT', () => {
|
||||
const ast = parse("SELECT * FROM users WHERE NOT age = 18");
|
||||
expect(ast.type).toBe('SELECT');
|
||||
});
|
||||
|
||||
it('WHERE 括号分组', () => {
|
||||
const ast = parse("SELECT * FROM users WHERE (age > 18 OR age < 10) AND active = TRUE");
|
||||
expect(ast.type).toBe('SELECT');
|
||||
});
|
||||
|
||||
it('ORDER BY 多列', () => {
|
||||
const ast = parse('SELECT * FROM users ORDER BY age DESC, name ASC');
|
||||
expect(ast.type).toBe('SELECT');
|
||||
expect(ast.orderBy).toHaveLength(2);
|
||||
expect(ast.orderBy![0]).toEqual({ column: 'age', direction: 'desc' });
|
||||
expect(ast.orderBy![1]).toEqual({ column: 'name', direction: 'asc' });
|
||||
});
|
||||
|
||||
it('只有 LIMIT 没有 OFFSET', () => {
|
||||
const ast = parse('SELECT * FROM users LIMIT 5');
|
||||
expect(ast.limit).toBe(5);
|
||||
expect(ast.offset).toBeUndefined();
|
||||
});
|
||||
|
||||
it('只有 OFFSET 没有 LIMIT', () => {
|
||||
const ast = parse('SELECT * FROM users OFFSET 10');
|
||||
expect(ast).toMatchObject({ offset: 10 });
|
||||
});
|
||||
|
||||
it('不带 WHERE 的 SELECT', () => {
|
||||
const ast = parse('SELECT id, name FROM users ORDER BY id LIMIT 5');
|
||||
expect(ast.where).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- INSERT 扩展 ----
|
||||
|
||||
describe('INSERT 扩展', () => {
|
||||
it('不带列名的 INSERT', () => {
|
||||
const ast = parse("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
||||
expect(ast.columns).toBeUndefined();
|
||||
expect(ast.values).toEqual([['1', 'Alice', 30]]);
|
||||
});
|
||||
|
||||
it('INSERT 布尔值和 NULL', () => {
|
||||
const ast = parse('INSERT INTO users VALUES (TRUE, FALSE, NULL)');
|
||||
expect(ast.values[0]).toEqual([true, false, null]);
|
||||
});
|
||||
|
||||
it('INSERT 负数和浮点数', () => {
|
||||
const ast = parse('INSERT INTO scores VALUES (-1, 3.14)');
|
||||
expect(ast.values[0]).toEqual([-1, 3.14]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- UPDATE 扩展 ----
|
||||
|
||||
describe('UPDATE 扩展', () => {
|
||||
it('UPDATE 多列', () => {
|
||||
const ast = parse("UPDATE users SET name = 'Bob', age = 26 WHERE id = '1'");
|
||||
expect(ast.type).toBe('UPDATE');
|
||||
expect(ast.sets).toEqual({ name: 'Bob', age: 26 });
|
||||
});
|
||||
|
||||
it('UPDATE 不带 WHERE', () => {
|
||||
const ast = parse('UPDATE users SET active = FALSE');
|
||||
expect(ast.where).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- DELETE 扩展 ----
|
||||
|
||||
describe('DELETE 扩展', () => {
|
||||
it('DELETE 不带 WHERE', () => {
|
||||
const ast = parse('DELETE FROM users');
|
||||
expect(ast.where).toEqual({});
|
||||
});
|
||||
|
||||
it('DELETE 带复杂 WHERE', () => {
|
||||
const ast = parse("DELETE FROM users WHERE age < 18 OR status = 'inactive'");
|
||||
expect(ast.type).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- DDL 扩展 ----
|
||||
|
||||
describe('DDL 扩展', () => {
|
||||
it('CREATE TABLE 完整修饰符', () => {
|
||||
const ast = parse("CREATE TABLE products (id STRING PRIMARY KEY, name STRING NOT NULL UNIQUE, price NUMBER DEFAULT 0, active BOOLEAN DEFAULT TRUE)");
|
||||
expect(ast.type).toBe('CREATE_TABLE');
|
||||
expect(ast.columns).toHaveLength(4);
|
||||
expect(ast.columns[0]).toMatchObject({ name: 'id', primaryKey: true });
|
||||
expect(ast.columns[1]).toMatchObject({ name: 'name', required: true, unique: true });
|
||||
expect(ast.columns[2]).toMatchObject({ name: 'price', default: 0 });
|
||||
expect(ast.columns[3]).toMatchObject({ name: 'active', default: true });
|
||||
});
|
||||
|
||||
it('不支持 IF EXISTS 语法(表名被解析为 IF)', () => {
|
||||
// 解析器把 IF 当作表名,EXISTS 作为后续 token 导致错误
|
||||
const ast = parse('DROP TABLE users');
|
||||
expect(ast).toMatchObject({ type: 'DROP_TABLE', name: 'users' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 值类型 ----
|
||||
|
||||
describe('常量值解析', () => {
|
||||
it('布尔值 TRUE/FALSE', () => {
|
||||
const ast = parse("SELECT * FROM users WHERE active = TRUE");
|
||||
expect(ast.where).toBeDefined();
|
||||
});
|
||||
|
||||
it('NULL 值', () => {
|
||||
const ast = parse('SELECT * FROM users WHERE bio IS NULL');
|
||||
expect(ast.where).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Schema 边缘场景测试
|
||||
*/
|
||||
|
||||
import { createSchema, validateRow, getPrimaryKey, checkFieldType, astColumnToColumnDef } from '../../src/table/schema';
|
||||
|
||||
describe('Schema 边缘场景', () => {
|
||||
// ---- createSchema ----
|
||||
|
||||
describe('createSchema', () => {
|
||||
it('空列表抛出错误', () => {
|
||||
expect(() => createSchema('empty', {})).toThrow('at least one column');
|
||||
});
|
||||
|
||||
it('多主键定义(取第一个标记的)', () => {
|
||||
const schema = createSchema('multi', {
|
||||
id1: { type: 'string', primaryKey: true },
|
||||
id2: { type: 'string' },
|
||||
});
|
||||
expect(getPrimaryKey(schema)).toBe('id1');
|
||||
});
|
||||
|
||||
it('大表定义', () => {
|
||||
const cols: Record<string, any> = {};
|
||||
for (let i = 0; i < 50; i++) {
|
||||
cols[`col${i}`] = { type: 'string' };
|
||||
}
|
||||
cols.id = { type: 'string', primaryKey: true };
|
||||
const schema = createSchema('big', cols);
|
||||
expect(Object.keys(schema.columns)).toHaveLength(51);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getPrimaryKey ----
|
||||
|
||||
describe('getPrimaryKey', () => {
|
||||
it('没有标记主键时返回第一列', () => {
|
||||
const schema = createSchema('test', {
|
||||
uuid: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
});
|
||||
// 重新构造不带主键标记的 schema
|
||||
const noPkSchema = { name: 'test', columns: {
|
||||
first: { type: 'string' as const },
|
||||
second: { type: 'string' as const },
|
||||
}};
|
||||
expect(getPrimaryKey(noPkSchema)).toBe('first');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- validateRow ----
|
||||
|
||||
describe('validateRow', () => {
|
||||
const schema = createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
bio: { type: 'string', maxLength: 200 },
|
||||
score: { type: 'number', min: 0, max: 100, default: 50 },
|
||||
active: { type: 'boolean', default: true },
|
||||
birthday: { type: 'date' },
|
||||
tags: { type: 'json', default: [] },
|
||||
});
|
||||
|
||||
it('跳过 undefined 字段(使用默认值)', () => {
|
||||
const row = validateRow(schema, { id: '1', name: 'Alice' });
|
||||
expect(row.id).toBe('1');
|
||||
expect(row.name).toBe('Alice');
|
||||
expect(row.score).toBe(50);
|
||||
expect(row.active).toBe(true);
|
||||
expect(row.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('null 值被视为不满足必填', () => {
|
||||
expect(() => validateRow(schema, { id: '1', name: null })).toThrow('required');
|
||||
});
|
||||
|
||||
it('json 类型接受对象', () => {
|
||||
const row = validateRow(schema, { id: '1', name: 'Alice', tags: { key: 'value' } });
|
||||
expect(row.tags).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('json 类型接受数组', () => {
|
||||
const row = validateRow(schema, { id: '1', name: 'Alice', tags: [1, 2, 3] });
|
||||
expect(row.tags).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('date 类型接受有效日期字符串', () => {
|
||||
const row = validateRow(schema, { id: '1', name: 'Alice', birthday: '2024-01-15' });
|
||||
expect(row.birthday).toBe('2024-01-15');
|
||||
});
|
||||
|
||||
it('date 类型拒绝无效格式', () => {
|
||||
expect(() => validateRow(schema, { id: '1', name: 'Alice', birthday: 'not-a-date' }))
|
||||
.toThrow('expects valid date');
|
||||
expect(() => validateRow(schema, { id: '1', name: 'Alice', birthday: 123 }))
|
||||
.toThrow('expects valid date');
|
||||
});
|
||||
|
||||
it('boolean 类型', () => {
|
||||
const row = validateRow(schema, { id: '1', name: 'Alice', active: false });
|
||||
expect(row.active).toBe(false);
|
||||
});
|
||||
|
||||
it('boolean 拒绝非布尔值', () => {
|
||||
expect(() => validateRow(schema, { id: '1', name: 'Alice', active: 'yes' }))
|
||||
.toThrow('expects boolean');
|
||||
});
|
||||
|
||||
it('number 拒绝字符串', () => {
|
||||
expect(() => validateRow(schema, { id: '1', name: 'Alice', score: 'high' }))
|
||||
.toThrow('expects number');
|
||||
});
|
||||
|
||||
it('string 拒绝数字', () => {
|
||||
expect(() => validateRow(schema, { id: 123, name: 'Alice' }))
|
||||
.toThrow('expects string');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- checkFieldType ----
|
||||
|
||||
describe('checkFieldType', () => {
|
||||
it('string 类型通过', () => {
|
||||
expect(() => checkFieldType('t', 'c', 'string', 'hello')).not.toThrow();
|
||||
});
|
||||
|
||||
it('string 类型拒绝', () => {
|
||||
expect(() => checkFieldType('t', 'c', 'string', 123)).toThrow('expects string');
|
||||
});
|
||||
|
||||
it('number 类型通过', () => {
|
||||
expect(() => checkFieldType('t', 'c', 'number', 42)).not.toThrow();
|
||||
expect(() => checkFieldType('t', 'c', 'number', 3.14)).not.toThrow();
|
||||
expect(() => checkFieldType('t', 'c', 'number', 0)).not.toThrow();
|
||||
});
|
||||
|
||||
it('boolean 类型通过', () => {
|
||||
expect(() => checkFieldType('t', 'c', 'boolean', true)).not.toThrow();
|
||||
expect(() => checkFieldType('t', 'c', 'boolean', false)).not.toThrow();
|
||||
});
|
||||
|
||||
it('json 拒绝非对象', () => {
|
||||
expect(() => checkFieldType('t', 'c', 'json', 'not-object')).toThrow('expects object');
|
||||
expect(() => checkFieldType('t', 'c', 'json', 123)).toThrow('expects object');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- astColumnToColumnDef ----
|
||||
|
||||
describe('astColumnToColumnDef', () => {
|
||||
it('转换基础列', () => {
|
||||
const result = astColumnToColumnDef({ name: 'id', type: 'string' });
|
||||
expect(result.type).toBe('string');
|
||||
});
|
||||
|
||||
it('转换带修饰符的列', () => {
|
||||
const result = astColumnToColumnDef({
|
||||
name: 'id', type: 'string', primaryKey: true, unique: true, required: true,
|
||||
});
|
||||
expect(result.primaryKey).toBe(true);
|
||||
expect(result.unique).toBe(true);
|
||||
expect(result.required).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Transaction 测试
|
||||
*/
|
||||
|
||||
import { MemoryEngine } from '../../src/engine/memory';
|
||||
import { TransactionManager, Transaction } from '../../src/transaction/index';
|
||||
import { createSchema } from '../../src/table/schema';
|
||||
|
||||
describe('Transaction', () => {
|
||||
let engine: MemoryEngine;
|
||||
let tm: TransactionManager;
|
||||
|
||||
const userSchema = createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string', required: true },
|
||||
balance: { type: 'number', default: 0 },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
engine = new MemoryEngine();
|
||||
await engine.open('test-tx', 1);
|
||||
await engine.createTable(userSchema);
|
||||
tm = new TransactionManager(engine);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('事务中执行插入', async () => {
|
||||
await tm.execute(async (trx) => {
|
||||
await trx.table('users').insert({ id: '1', name: 'Alice', balance: 100 });
|
||||
await trx.table('users').insert({ id: '2', name: 'Bob', balance: 50 });
|
||||
});
|
||||
|
||||
expect(await engine.count('users')).toBe(2);
|
||||
});
|
||||
|
||||
it('事务中执行混合操作', async () => {
|
||||
await engine.insert('users', [{ id: '1', name: 'Alice', balance: 100 }]);
|
||||
|
||||
await tm.execute(async (trx) => {
|
||||
await trx.table('users').update({ balance: 200 }).where({ id: '1' }).execute();
|
||||
await trx.table('users').insert({ id: '2', name: 'Bob', balance: 50 });
|
||||
});
|
||||
|
||||
const rows = await engine.find('users', { table: 'users', where: { id: '1' } });
|
||||
expect(rows[0].balance).toBe(200);
|
||||
expect(await engine.count('users')).toBe(2);
|
||||
});
|
||||
|
||||
it('事务中的 table() 返回相同实例', async () => {
|
||||
await tm.execute(async (trx) => {
|
||||
const t1 = trx.table('users');
|
||||
const t2 = trx.table('users');
|
||||
expect(t1).toBe(t2); // 相同表名返回同一实例
|
||||
});
|
||||
});
|
||||
|
||||
it('Transaction.isCompleted', async () => {
|
||||
let trxRef: Transaction | null = null;
|
||||
await tm.execute(async (trx) => {
|
||||
trxRef = trx;
|
||||
expect(trx.isCompleted()).toBe(false);
|
||||
await trx.table('users').insert({ id: '1', name: 'Alice' });
|
||||
});
|
||||
expect(trxRef!.isCompleted()).toBe(true);
|
||||
});
|
||||
|
||||
it('事务抛出错误', async () => {
|
||||
await expect(tm.execute(async (trx) => {
|
||||
await trx.table('users').insert({ id: '1', name: 'Alice' });
|
||||
throw new Error('intentional error');
|
||||
})).rejects.toThrow('Transaction failed');
|
||||
|
||||
// 注意:MemoryEngine 没有回滚,所以数据已经写入
|
||||
// v0.0.1 的 Transaction 只是错误包装
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* utils.ts 单元测试模板
|
||||
*/
|
||||
|
||||
import {
|
||||
generateId,
|
||||
escapeHTML,
|
||||
debounce,
|
||||
throttle,
|
||||
deepMerge,
|
||||
isBrowser,
|
||||
} from '../src/utils';
|
||||
|
||||
describe('generateId', () => {
|
||||
test('返回字符串', () => {
|
||||
expect(typeof generateId()).toBe('string');
|
||||
});
|
||||
|
||||
test('多次调用产生不同值', () => {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) ids.add(generateId());
|
||||
expect(ids.size).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeHTML', () => {
|
||||
test('转义 < > &', () => {
|
||||
const out = escapeHTML('<a>&b</a>');
|
||||
expect(out).toContain('<');
|
||||
expect(out).toContain('>');
|
||||
expect(out).toContain('&');
|
||||
});
|
||||
|
||||
test('null/undefined 返回空字符串', () => {
|
||||
expect(escapeHTML(null)).toBe('');
|
||||
expect(escapeHTML(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounce', () => {
|
||||
test('延迟执行', (done) => {
|
||||
let called = 0;
|
||||
const fn = debounce(() => { called++; }, 50);
|
||||
fn();
|
||||
expect(called).toBe(0);
|
||||
setTimeout(() => {
|
||||
expect(called).toBe(1);
|
||||
done();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
test('多次调用只执行最后一次', (done) => {
|
||||
let result = 0;
|
||||
const fn = debounce((v: number) => { result = v; }, 50);
|
||||
fn(1);
|
||||
fn(2);
|
||||
fn(3);
|
||||
setTimeout(() => {
|
||||
expect(result).toBe(3);
|
||||
done();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('throttle', () => {
|
||||
test('首次立即执行', () => {
|
||||
let called = 0;
|
||||
const fn = throttle(() => { called++; }, 50);
|
||||
fn();
|
||||
expect(called).toBe(1);
|
||||
});
|
||||
|
||||
test('限流期内不重复执行', () => {
|
||||
let called = 0;
|
||||
const fn = throttle(() => { called++; }, 50);
|
||||
fn();
|
||||
fn();
|
||||
fn();
|
||||
expect(called).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepMerge', () => {
|
||||
test('浅层合并', () => {
|
||||
const out = deepMerge({ a: 1, b: 2 }, { b: 3, c: 4 });
|
||||
expect(out).toEqual({ a: 1, b: 3, c: 4 });
|
||||
});
|
||||
|
||||
test('深层对象合并', () => {
|
||||
const out = deepMerge({ obj: { x: 1 } }, { obj: { y: 2 } });
|
||||
expect(out).toEqual({ obj: { x: 1, y: 2 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBrowser', () => {
|
||||
test('jsdom 环境下返回 true', () => {
|
||||
expect(isBrowser()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
Reference in New Issue
Block a user