初始化项目

This commit is contained in:
2026-10-19 00:11:38 +08:00
commit 9545e48e4d
150 changed files with 9559 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
/node_modules
/oh_modules
/local.properties
/.idea
**/build
/.hvigor
.cxx
/.clangd
/.clang-format
/.clang-tidy
**/.test
/.appanalyzer
+11
View File
@@ -0,0 +1,11 @@
{
"app": {
"bundleName": "org.lqh.kuge",
"vendor": "lqh",
"versionCode": 100,
"versionName": "0.1.0",
"buildVersion": "1",
"icon": "$media:layered_image",
"label": "$string:app_name"
}
}
@@ -0,0 +1,8 @@
{
"string": [
{
"name": "app_name",
"value": "库阁"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,7 @@
{
"layered-image":
{
"background" : "$media:background",
"foreground" : "$media:foreground"
}
}
+78
View File
@@ -0,0 +1,78 @@
{
"app": {
"signingConfigs": [],
"products": [
{
"name": "default",
"signingConfig": "default",
"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.0(23)",
"runtimeOS": "HarmonyOS",
"buildOption": {
"strictMode": {
"caseSensitiveCheck": true,
"useNormalizedOHMUrl": true
}
}
}
],
"buildModeSet": [
{
"name": "debug",
},
{
"name": "release"
}
]
},
"modules": [
{
"name": "entry",
"srcPath": "./entry",
"targets": [
{
"name": "default",
"applyToProducts": [
"default"
]
}
]
},
{
"name": "core",
"srcPath": "./core",
"targets": [
{
"name": "default"
}
]
},
{
"name": "engine",
"srcPath": "./engine",
"targets": [
{
"name": "default"
}
]
},
{
"name": "ui",
"srcPath": "./ui",
"targets": [
{
"name": "default"
}
]
},
{
"name": "data",
"srcPath": "./data",
"targets": [
{
"name": "default"
}
]
}
]
}
+60
View File
@@ -0,0 +1,60 @@
# CI 构建与签名流水线(1.3.11)
本目录把「命令行构建 → 签名 → 产物归档」固化为可复用脚本与流水线说明。
## 前置环境
| 项 | 要求 | 说明 |
| --- | --- | --- |
| JDK | 17(`JAVA_HOME`) | hvigor 依赖 |
| Node | 22.19+ / 24 | hvigor 与 ohpm |
| 命令行工具 | `command-line-tools`(hvigorw / ohpm / sdk) | 与 DevEco Studio 版本一致 |
| SDK | `DEVECO_SDK_HOME` 指向 SDK 根目录 | 当前 API 24(6.1.1.125) |
| 签名材料 | `.p12`、`.cer`、`.p7b` | 由 AGC 申请,只注入 CI 密钥库 |
## 流水线阶段
```
① 检出代码
② ohpm install --all(锁定 lockfile)
③ 静态检查:ArkTS 编译 + code-linter(hvigorw 内置)
④ 构建:ci/build-hap.ps1 -BuildMode debug|release
⑤ 签名:注入签名配置(见 signing.template.json5),hvigorw 重新打包签名
⑥ 归档:HAP/APP、mapping、符号表,保留最近 N 次
⑦ 分发:上传 AGC(发布通道 / 测试通道)
```
## 本地用法
```powershell
# 只打印命令,不构建
powershell -ExecutionPolicy Bypass -File ci\build-hap.ps1 -DryRun
# 调试构建
powershell -ExecutionPolicy Bypass -File ci\build-hap.ps1 -BuildMode debug
# 发布构建(需先配置 signingConfigs)
powershell -ExecutionPolicy Bypass -File ci\build-hap.ps1 -BuildMode release
```
产物定位:`entry\build\<product>\outputs\<product>\*.hap`。
## 签名配置
1. 复制 `signing.template.json5` 的 `signingConfigs` 到 `build-profile.json5` 的 `app` 节点;
2. 证书与密码通过环境变量/密钥库注入,**不得提交到仓库**;
3. 未配置签名时构建仍可成功,但产物为 `*-unsigned.hap`,无法真机安装。
## 已知问题
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| `EPERM: operation not permitted, symlink ...` | hvigor 需要在工作区外创建工具链接;受限沙箱会拒绝 | CI 运行在完整权限环境;本地不要在受限沙箱执行 |
| `No signingConfigs profile is configured` | 未配置签名 | 按上节注入签名配置 |
| `version ... does not comply with SemVer` | oh-package 版本主版本号为 0 | 模块版本使用 1.x.y |
## 版本与产物保留
- `versionCode` 单调递增,`versionName` 语义化;
- 每个发布版本归档:HAP、mapping、混淆符号表、构建日志;
- 保留策略:最近 10 个版本 + 所有里程碑版本。
+65
View File
@@ -0,0 +1,65 @@
<#
build-hap.ps1 - 命令行构建 HAP(CI 与本地通用)
用法:
powershell -ExecutionPolicy Bypass -File ci\build-hap.ps1 [-BuildMode debug|release] [-Product default] [-SdkHome <SDK 路径>] [-DryRun]
说明:
- 只封装 hvigorw 调用与产物定位,签名由 CI 的独立步骤完成(见 ci/README.md);
- -DryRun 只打印将要执行的命令,不触发构建(用于自检与流水线 dry-run)。
#>
param(
[ValidateSet('debug', 'release')][string]$BuildMode = 'debug',
[string]$Product = 'default',
[string]$SdkHome = 'D:\env\huawei\command-line-tools\sdk',
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
if (-not (Test-Path (Join-Path $projectRoot 'hvigorfile.ts'))) {
throw "未找到工程根目录(缺少 hvigorfile.ts): $projectRoot"
}
if (-not (Test-Path $SdkHome)) {
throw "SDK 路径不存在: $SdkHome"
}
$env:DEVECO_SDK_HOME = $SdkHome
$hvArgs = @(
'assembleHap',
'--mode', 'module',
'-p', "product=$Product",
'-p', "buildMode=$BuildMode",
'--no-daemon'
)
if ($DryRun) {
Write-Output ("PROJECT: " + $projectRoot)
Write-Output ("SDK: " + $SdkHome)
Write-Output ("DRYRUN: hvigorw " + ($hvArgs -join ' '))
return
}
Push-Location $projectRoot
try {
& hvigorw @hvArgs
if ($LASTEXITCODE -ne 0) {
throw "hvigorw 构建失败,退出码 $LASTEXITCODE"
}
}
finally {
Pop-Location
}
$outDir = Join-Path $projectRoot ("entry\build\" + $Product + "\outputs\" + $Product)
$hap = Get-ChildItem $outDir -Filter '*.hap' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($null -eq $hap) {
throw "未找到 HAP 产物: $outDir"
}
Write-Output ("ARTIFACT: " + $hap.FullName)
Write-Output ("SIZE: " + $hap.Length)
+21
View File
@@ -0,0 +1,21 @@
// 签名配置模板(不要直接提交真实证书与密码)
//
// 用法:把 signingConfigs 合并进 build-profile.json5 的 app 节点,并把占位路径换成
// CI 密钥目录或本地证书路径。证书、私钥与密码不得进入版本库。
{
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "<CI_SECRET_DIR>/kuge-release.p12",
"storePassword": "<ENV:KUGE_STORE_PASSWORD>",
"keyAlias": "kuge",
"keyPassword": "<ENV:KUGE_KEY_PASSWORD>",
"certpath": "<CI_SECRET_DIR>/kuge-release.cer",
"profile": "<CI_SECRET_DIR>/kuge-release.p7b",
"signAlg": "SHA256withECDSA"
}
}
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"files": [
"**/*.ets"
],
"ignore": [
"**/src/ohosTest/**/*",
"**/src/test/**/*",
"**/src/mock/**/*",
"**/node_modules/**/*",
"**/oh_modules/**/*",
"**/build/**/*",
"**/.preview/**/*"
],
"ruleSet": [
"plugin:@performance/recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@security/no-unsafe-aes": "error",
"@security/no-unsafe-hash": "error",
"@security/no-unsafe-mac": "warn",
"@security/no-unsafe-dh": "error",
"@security/no-unsafe-dsa": "error",
"@security/no-unsafe-ecdsa": "error",
"@security/no-unsafe-rsa-encrypt": "error",
"@security/no-unsafe-rsa-sign": "error",
"@security/no-unsafe-rsa-key": "error",
"@security/no-unsafe-dsa-key": "error",
"@security/no-unsafe-dh-key": "error",
"@security/no-unsafe-3des": "error"
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Use these variables when you tailor your ArkTS code. They must be of the const type.
*/
export const HAR_VERSION = '1.0.0';
export const BUILD_MODE_NAME = 'debug';
export const DEBUG = true;
export const TARGET_NAME = 'default';
/**
* BuildProfile Class is used only for compatibility purposes.
*/
export default class BuildProfile {
static readonly HAR_VERSION = HAR_VERSION;
static readonly BUILD_MODE_NAME = BUILD_MODE_NAME;
static readonly DEBUG = DEBUG;
static readonly TARGET_NAME = TARGET_NAME;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* @kuge/core 的唯一公开入口。
*
* 约定(见 docs/adr/ADR-001):HAR 模块只通过本文件暴露能力,
* 外部模块禁止直接 import src/main/ets 下的实现文件。
*/
export { Logger, LogLevel, LOG_DOMAIN, LogSink } from './src/main/ets/logger/Logger';
export { FileLogSink } from './src/main/ets/logger/FileLogSink';
export { AppConfig } from './src/main/ets/config/AppConfig';
export { AppInfo } from './src/main/ets/app/AppInfo';
export { TaskScheduler, TaskRunner, TaskCancelledError } from './src/main/ets/task/TaskScheduler';
export { LruCache } from './src/main/ets/storage/LruCache';
export { KeyValueStore, MemoryKeyValueStore } from './src/main/ets/storage/KeyValueStore';
export { CredentialStore, CredentialVault, KeyProvider, InMemoryKeyProvider } from './src/main/ets/security/CredentialVault';
export { I18nUtil } from './src/main/ets/i18n/I18n';
export { DiagnosticBuilder, DiagnosticContext, Redactor } from './src/main/ets/diagnostics/DiagnosticBuilder';
+9
View File
@@ -0,0 +1,9 @@
{
"apiType": "stageMode",
"buildOption": {},
"targets": [
{
"name": "default"
}
]
}
+6
View File
@@ -0,0 +1,6 @@
import { harTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@kuge/core",
"version": "1.0.0",
"description": "Kuge core capabilities: logging, app info, shared types",
"main": "Index.ets",
"author": "Kuge Team",
"license": "Apache-2.0",
"dependencies": {}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 应用级静态信息。
*
* 仅存放不随构建变化的产品标识;构建期注入的信息(版本号、构建号)
* 由 AppScope/app.json5 与 BuildProfile 提供,不在此重复维护。
*/
export class AppInfo {
/** 产品中文名。 */
static readonly NAME: string = '库阁';
/** 产品英文名(拼音音译)。 */
static readonly ENGLISH_NAME: string = 'Kuge';
/** 一句话定位。 */
static readonly TAGLINE: string = '跨库数据库工作台';
/** 目标平台。 */
static readonly PLATFORM: string = 'HarmonyOS NEXT';
/** 目标设备形态。 */
static readonly FORM_FACTORS: string = 'PC / 平板';
/** 当前迭代版本(与 app.json5 的 versionName 保持同步)。 */
static readonly VERSION: string = '0.1.0';
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 应用配置(持久化)。
*
* 使用 Preferences 保存少量启动即读的配置项;业务级配置(连接、外观等)
* 在后续工作包中按领域拆到各自模块,不堆在同一个 key 空间里。
*
* 约定:配置读写同步完成(Preferences 的 Sync 接口),避免启动阶段异步竞态;
* 落盘用 flush() 异步提交,不阻塞 UI 线程。存储不可用时退化为内存默认值,
* 不阻断应用启动。
*/
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { LogLevel } from '../logger/Logger';
/** Preferences 文件名。 */
const STORE_NAME: string = 'kuge_settings';
/** 日志级别键。 */
const KEY_LOG_LEVEL: string = 'log.level';
export class AppConfig {
private static store: preferences.Preferences | undefined = undefined;
private static logLevel: LogLevel = LogLevel.INFO;
/** 在 Ability 启动时调用一次;重复调用无副作用。 */
static init(context: common.Context): void {
if (AppConfig.store !== undefined) {
return;
}
try {
const options: preferences.Options = { name: STORE_NAME };
const store: preferences.Preferences = preferences.getPreferencesSync(context, options);
const raw: preferences.ValueType = store.getSync(KEY_LOG_LEVEL, 'info');
AppConfig.store = store;
AppConfig.logLevel = AppConfig.parseLevel(typeof raw === 'string' ? raw : 'info');
} catch (err) {
// 存储不可用(首次启动异常、配额受限等)时使用默认值,不影响主流程。
AppConfig.store = undefined;
AppConfig.logLevel = LogLevel.INFO;
}
}
static getLogLevel(): LogLevel {
return AppConfig.logLevel;
}
/** 修改并持久化日志级别。 */
static setLogLevel(level: LogLevel): void {
AppConfig.logLevel = level;
const store = AppConfig.store;
if (store === undefined) {
return;
}
try {
store.putSync(KEY_LOG_LEVEL, AppConfig.levelName(level));
store.flush();
} catch (err) {
// 持久化失败时保留内存值,下次启动回落到旧值。
}
}
/** 级别名(用于持久化与界面展示)。 */
static levelName(level: LogLevel): string {
switch (level) {
case LogLevel.DEBUG:
return 'debug';
case LogLevel.INFO:
return 'info';
case LogLevel.WARN:
return 'warn';
case LogLevel.ERROR:
return 'error';
default:
return 'info';
}
}
private static parseLevel(raw: string): LogLevel {
switch (raw) {
case 'debug':
return LogLevel.DEBUG;
case 'warn':
return LogLevel.WARN;
case 'error':
return LogLevel.ERROR;
case 'info':
default:
return LogLevel.INFO;
}
}
}
@@ -0,0 +1,66 @@
/**
* 诊断信息构建与脱敏(1.3.10)。
*
* 职责:
* 1. 汇总应用/平台/版本/时间与日志尾部,生成可读的诊断文本;
* 2. 对敏感字段做脱敏(密码、令牌、密钥、鉴权头),保证诊断包可安全外发;
* 3. 崩溃处理与 faultlogger 接入在平台适配器中完成,本文件保持纯逻辑、可测试。
*/
/** 诊断上下文。 */
export interface DiagnosticContext {
appName: string;
version: string;
platform: string;
formFactors: string;
logLevel: string;
}
/** 脱敏器:按常见敏感键名替换值。 */
export class Redactor {
/** 需要脱敏的键名(小写比较)。 */
private static readonly KEYS: string[] = [
'password', 'passwd', 'pwd', 'secret', 'token', 'apikey', 'api_key', 'authorization', 'credential'
];
/** 把 key=value / key: value 形式的值替换为 ***。 */
static redact(text: string): string {
let out: string = text;
for (let i = 0; i < Redactor.KEYS.length; i++) {
const key: string = Redactor.KEYS[i];
const pattern: RegExp = new RegExp('(' + key + '\\s*[=:]\\s*)([^\\s,;]+)', 'gi');
out = out.replace(pattern, '$1***');
}
// 鉴权头整行脱敏(可能含 "Bearer xxx" 等前缀)
out = out.replace(new RegExp('(authorization\\s*[=:]\\s*)[^\\r\\n]*', 'gi'), '$1***');
// 裸 Bearer 令牌
out = out.replace(new RegExp('(bearer\\s+)[A-Za-z0-9._\\-]+', 'gi'), '$1***');
return out;
}
}
/** 诊断包构建器。 */
export class DiagnosticBuilder {
private readonly ctx: DiagnosticContext;
constructor(ctx: DiagnosticContext) {
this.ctx = ctx;
}
/**
* 生成诊断文本。
* @param logTail 日志尾部(已读取的内容)
* @param timestamp ISO 时间字符串,由调用方提供,避免依赖本地时区
*/
build(logTail: string, timestamp: string): string {
const lines: string[] = [];
lines.push('=== 库阁诊断信息 ===');
lines.push('应用: ' + this.ctx.appName + ' ' + this.ctx.version);
lines.push('平台: ' + this.ctx.platform + ' (' + this.ctx.formFactors + ')');
lines.push('日志级别: ' + this.ctx.logLevel);
lines.push('时间: ' + timestamp);
lines.push('--- 日志尾部 ---');
lines.push(Redactor.redact(logTail));
return lines.join('\n');
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 多语言运行时(1.3.9)。
*
* 纯逻辑实现:语言标签归一化、支持列表匹配、用户偏好与系统语言的协商。
* 资源文件仍由 resources/<语言>/element/string.json 提供,本类只决定「用哪个语言」。
*
* 协商顺序(对应 I18N-001 设计):
* 1) 用户显式选择且在支持列表内 → 用它;
* 2) 否则取系统语言,按「完全匹配 → 主语言匹配」找支持项;
* 3) 都不匹配 → 回退 fallback(中文简体)。
*
* 标签规范:小写化并用 '-' 分隔,如 zh-Hans-CN、en-US;忽略大小写与下划线差异。
*/
export class I18nUtil {
/** 归一化语言标签:小写、下划线转连字符、去空白。 */
static normalize(tag: string): string {
return tag.trim().toLowerCase().replace('_', '-');
}
/** 主语言(第一个子标签),如 zh-Hans-CN → zh。 */
static primaryOf(tag: string): string {
const normalized: string = I18nUtil.normalize(tag);
const idx: number = normalized.indexOf('-');
return idx < 0 ? normalized : normalized.substring(0, idx);
}
/** 是否在支持列表中(完全匹配,忽略大小写)。 */
static isSupported(tag: string, supported: string[]): boolean {
const target: string = I18nUtil.normalize(tag);
for (let i = 0; i < supported.length; i++) {
if (I18nUtil.normalize(supported[i]) === target) {
return true;
}
}
return false;
}
/** 在支持列表中找到与给定标签最匹配的项;找不到返回 undefined。 */
static match(tag: string, supported: string[]): string | undefined {
const target: string = I18nUtil.normalize(tag);
for (let i = 0; i < supported.length; i++) {
if (I18nUtil.normalize(supported[i]) === target) {
return supported[i];
}
}
const primary: string = I18nUtil.primaryOf(target);
for (let i = 0; i < supported.length; i++) {
if (I18nUtil.primaryOf(supported[i]) === primary) {
return supported[i];
}
}
return undefined;
}
/**
* 协商最终语言。
* @param systemTag 系统语言,如 zh-Hans-CN
* @param userTag 用户偏好(空表示跟随系统)
* @param supported 支持列表,顺序即优先级
* @param fallback 回退语言
*/
static negotiate(systemTag: string, userTag: string, supported: string[], fallback: string): string {
if (userTag.length > 0) {
const byUser: string | undefined = I18nUtil.match(userTag, supported);
if (byUser !== undefined) {
return byUser;
}
}
if (systemTag.length > 0) {
const bySystem: string | undefined = I18nUtil.match(systemTag, supported);
if (bySystem !== undefined) {
return bySystem;
}
}
return fallback;
}
}
+139
View File
@@ -0,0 +1,139 @@
/**
* 文件日志落地。
*
* 只把 WARN 及以上写入文件,控制 IO 量与磁盘占用;完整日志仍在 hilog。
* 单文件超过上限时截断重写(保留最近一个窗口),后续接入归档与诊断包导出
* 时再替换为滚动策略(对应 1.3.10 崩溃上报与诊断打包)。
*
* 所有文件操作都在内部吞掉异常:日志系统自身的失败不得影响业务。
*/
import { fileIo as fs } from '@kit.CoreFileKit';
import { LogLevel, LogSink } from './Logger';
/** 单文件上限:512 KB。 */
const MAX_FILE_BYTES: number = 512 * 1024;
export class FileLogSink implements LogSink {
private readonly filePath: string;
private readonly dirPath: string;
private file: fs.File | undefined = undefined;
constructor(dirPath: string) {
this.dirPath = dirPath;
this.filePath = dirPath + '/kuge.log';
try {
if (!fs.accessSync(dirPath)) {
fs.mkdirSync(dirPath);
}
} catch (err) {
// 目录创建失败时仍尝试直接打开文件(目录可能已存在)。
}
this.file = this.openAppendSafe();
}
/** 只落 WARN / ERROR,避免 DEBUG 日志把文件刷满。 */
write(level: LogLevel, tag: string, message: string): void {
if (level < LogLevel.WARN) {
return;
}
try {
this.rotateIfNeeded();
const file = this.file;
if (file === undefined) {
return;
}
const line: string = this.timestamp() + ' [' + this.levelTag(level) + '] [' + tag + '] ' + message + '\n';
fs.writeSync(file.fd, line);
} catch (err) {
// 忽略写入失败,避免日志系统本身造成业务中断。
}
}
/** 读取最近 maxLines 行,用于诊断面板与问题反馈。 */
readTail(maxLines: number): string {
try {
const all: string = fs.readTextSync(this.filePath);
const lines: string[] = all.split('\n');
const start: number = Math.max(0, lines.length - maxLines);
return lines.slice(start).join('\n');
} catch (err) {
return '';
}
}
close(): void {
const file = this.file;
if (file === undefined) {
return;
}
try {
fs.closeSync(file);
} catch (err) {
// 忽略关闭异常
}
this.file = undefined;
}
private openAppendSafe(): fs.File | undefined {
try {
return fs.openSync(this.filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.APPEND);
} catch (err) {
return undefined;
}
}
private rotateIfNeeded(): void {
const file = this.file;
if (file === undefined) {
return;
}
try {
const size: number = fs.statSync(this.filePath).size;
if (size <= MAX_FILE_BYTES) {
return;
}
fs.closeSync(file);
this.file = undefined;
const fresh: fs.File = fs.openSync(this.filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.closeSync(fresh);
this.file = this.openAppendSafe();
} catch (err) {
// 截断失败时保持原文件继续追加,避免丢日志。
}
}
private levelTag(level: LogLevel): string {
switch (level) {
case LogLevel.WARN:
return 'W';
case LogLevel.ERROR:
return 'E';
case LogLevel.INFO:
return 'I';
default:
return 'D';
}
}
private timestamp(): string {
const d: Date = new Date();
return d.getFullYear() + '-' + this.pad(d.getMonth() + 1) + '-' + this.pad(d.getDate()) +
' ' + this.pad(d.getHours()) + ':' + this.pad(d.getMinutes()) + ':' + this.pad(d.getSeconds()) +
'.' + this.pad3(d.getMilliseconds());
}
private pad(n: number): string {
return n < 10 ? '0' + n : '' + n;
}
private pad3(n: number): string {
if (n < 10) {
return '00' + n;
}
if (n < 100) {
return '0' + n;
}
return '' + n;
}
}
+108
View File
@@ -0,0 +1,108 @@
/**
* 统一日志出口。
*
* 业务代码不直接调用 hilog,统一走 Logger,保证:
* 1. 域号(domain)唯一且集中分配;
* 2. tag 由模块名派生,便于过滤;
* 3. 级别可在运行期统一收敛(发布版默认 WARN);
* 4. 可选挂接 LogSink(如文件落地),实现与 hilog 同源的诊断日志。
*/
import { hilog } from '@kit.PerformanceAnalysisKit';
/** hilog 域号,取值范围 0x0000–0xFFFF,按业务域集中分配。 */
export const LOG_DOMAIN: number = 0xDB01;
/** 日志级别,数值越大越严重。 */
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3
}
/** 日志落地面(文件、内存缓冲、远程上报等按此接口扩展)。 */
export interface LogSink {
write(level: LogLevel, tag: string, message: string): void;
}
/** 统一日志器。按模块创建实例后复用,避免重复分配。 */
export class Logger {
private static minLevel: LogLevel = LogLevel.INFO;
private static sink: LogSink | undefined = undefined;
private readonly tag: string;
private constructor(tag: string) {
this.tag = tag;
}
/** 创建指定模块的日志器。 */
static create(tag: string): Logger {
return new Logger(tag);
}
/** 设置全局最低输出级别,发布版应设为 WARN 及以上。 */
static setMinLevel(level: LogLevel): void {
Logger.minLevel = level;
}
static getMinLevel(): LogLevel {
return Logger.minLevel;
}
/** 挂接落地面;传 undefined 表示取消。 */
static setSink(sink: LogSink | undefined): void {
Logger.sink = sink;
}
/** 用 {0}、{1} 占位符拼接消息,避免业务侧自行拼字符串。 */
private format(message: string, args: string[]): string {
let text: string = message;
for (let i = 0; i < args.length; i++) {
text = text.replace('{' + i + '}', args[i]);
}
return text;
}
private emit(level: LogLevel, message: string, args: string[]): void {
if (level < Logger.minLevel) {
return;
}
const text: string = this.format(message, args);
switch (level) {
case LogLevel.DEBUG:
hilog.debug(LOG_DOMAIN, this.tag, '%{public}s', text);
break;
case LogLevel.INFO:
hilog.info(LOG_DOMAIN, this.tag, '%{public}s', text);
break;
case LogLevel.WARN:
hilog.warn(LOG_DOMAIN, this.tag, '%{public}s', text);
break;
case LogLevel.ERROR:
default:
hilog.error(LOG_DOMAIN, this.tag, '%{public}s', text);
break;
}
const sink = Logger.sink;
if (sink !== undefined) {
sink.write(level, this.tag, text);
}
}
debug(message: string, ...args: string[]): void {
this.emit(LogLevel.DEBUG, message, args);
}
info(message: string, ...args: string[]): void {
this.emit(LogLevel.INFO, message, args);
}
warn(message: string, ...args: string[]): void {
this.emit(LogLevel.WARN, message, args);
}
error(message: string, ...args: string[]): void {
this.emit(LogLevel.ERROR, message, args);
}
}
@@ -0,0 +1,123 @@
/**
* 凭据保险箱(1.3.8)。
*
* 结构:
* CredentialVault —— 编排层:确保密钥存在、加密后写入 KeyValueStore、读取时解密;
* KeyProvider —— 密钥与加解密能力抽象,HUKS 实现在平台适配器中提供;
* InMemoryKeyProvider —— 测试/降级实现(仅做可逆标记,不具备保密性,禁止用于生产)。
*
* 约束(对应 SEC-001 设计):
* - 明文只在 put/get 的调用栈中存在,不写入日志、不进入界面状态;
* - 密钥别名按保险箱实例隔离;
* - 加密失败不得降级为明文。
*/
import { KeyValueStore } from '../storage/KeyValueStore';
/** 凭据条目前缀,便于按域清理。 */
const CRED_PREFIX: string = 'cred.';
/** 密钥提供者:生成/存在性/删除/加解密。 */
export interface KeyProvider {
generate(keyAlias: string): Promise<void>;
has(keyAlias: string): Promise<boolean>;
delete(keyAlias: string): Promise<void>;
encrypt(keyAlias: string, plain: string): Promise<string>;
decrypt(keyAlias: string, cipher: string): Promise<string>;
}
/** 凭据存储契约。 */
export interface CredentialStore {
put(ref: string, secret: string): Promise<void>;
get(ref: string): Promise<string | undefined>;
has(ref: string): Promise<boolean>;
remove(ref: string): Promise<void>;
clear(): Promise<void>;
}
/** 测试用密钥提供者:可逆标记,不具备安全性。 */
export class InMemoryKeyProvider implements KeyProvider {
private readonly keys: Set<string> = new Set<string>();
async generate(keyAlias: string): Promise<void> {
this.keys.add(keyAlias);
}
async has(keyAlias: string): Promise<boolean> {
return this.keys.has(keyAlias);
}
async delete(keyAlias: string): Promise<void> {
this.keys.delete(keyAlias);
}
async encrypt(keyAlias: string, plain: string): Promise<string> {
if (!this.keys.has(keyAlias)) {
throw new Error('key not found: ' + keyAlias);
}
return 'enc:' + plain;
}
async decrypt(keyAlias: string, cipher: string): Promise<string> {
if (!this.keys.has(keyAlias)) {
throw new Error('key not found: ' + keyAlias);
}
if (cipher.indexOf('enc:') !== 0) {
throw new Error('invalid cipher text');
}
return cipher.substring(4);
}
}
export class CredentialVault implements CredentialStore {
private readonly store: KeyValueStore;
private readonly provider: KeyProvider;
private readonly keyAlias: string;
constructor(store: KeyValueStore, provider: KeyProvider, keyAlias: string) {
if (keyAlias.length === 0) {
throw new Error('keyAlias must not be empty');
}
this.store = store;
this.provider = provider;
this.keyAlias = keyAlias;
}
async put(ref: string, secret: string): Promise<void> {
if (ref.length === 0) {
throw new Error('credential ref must not be empty');
}
if (secret.length === 0) {
throw new Error('credential secret must not be empty');
}
if (!(await this.provider.has(this.keyAlias))) {
await this.provider.generate(this.keyAlias);
}
const cipher: string = await this.provider.encrypt(this.keyAlias, secret);
await this.store.put(CRED_PREFIX + ref, cipher);
}
async get(ref: string): Promise<string | undefined> {
const cipher: string | undefined = await this.store.get(CRED_PREFIX + ref);
if (cipher === undefined) {
return undefined;
}
return this.provider.decrypt(this.keyAlias, cipher);
}
async has(ref: string): Promise<boolean> {
return (await this.store.get(CRED_PREFIX + ref)) !== undefined;
}
async remove(ref: string): Promise<void> {
await this.store.delete(CRED_PREFIX + ref);
}
async clear(): Promise<void> {
const keys: string[] = await this.store.keys();
for (let i = 0; i < keys.length; i++) {
if (keys[i].indexOf(CRED_PREFIX) === 0) {
await this.store.delete(keys[i]);
}
}
}
}
@@ -0,0 +1,44 @@
/**
* 键值存储抽象(1.3.7)。
*
* 平台实现(Preferences / RDB / 文件)在后续适配器中提供;本文件只定义契约与
* 内存实现,便于在无平台环境测试业务逻辑,也便于替换存储介质。
*
* 约定:键使用命名空间前缀(如 conn./meta./ui.),便于按域清理与迁移。
*/
export interface KeyValueStore {
get(key: string): Promise<string | undefined>;
put(key: string, value: string): Promise<void>;
delete(key: string): Promise<void>;
keys(): Promise<string[]>;
clear(): Promise<void>;
}
/** 内存实现:仅用于测试与降级(不可持久化)。 */
export class MemoryKeyValueStore implements KeyValueStore {
private readonly data: Map<string, string> = new Map<string, string>();
async get(key: string): Promise<string | undefined> {
return this.data.get(key);
}
async put(key: string, value: string): Promise<void> {
this.data.set(key, value);
}
async delete(key: string): Promise<void> {
this.data.delete(key);
}
async keys(): Promise<string[]> {
const out: string[] = [];
this.data.forEach((_value: string, key: string) => {
out.push(key);
});
return out;
}
async clear(): Promise<void> {
this.data.clear();
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* LRU 缓存(1.3.7)。
*
* 用途:对象元数据、查询结果分页、连接信息等热点数据的本地淘汰缓存。
* 纯逻辑实现,不依赖平台 API;上层可用 Preferences / RDB / 文件做持久化适配。
*
* 语义:get 命中会把条目移到最近使用位置;put 超出容量时淘汰最久未使用项。
*/
export class LruCache<K, V> {
private readonly capacity: number;
private readonly map: Map<K, V> = new Map<K, V>();
private order: K[] = [];
constructor(capacity: number) {
if (capacity < 1) {
throw new Error('LruCache capacity must be >= 1');
}
this.capacity = capacity;
}
get(key: K): V | undefined {
if (!this.map.has(key)) {
return undefined;
}
this.touch(key);
return this.map.get(key);
}
put(key: K, value: V): void {
if (this.map.has(key)) {
this.map.set(key, value);
this.touch(key);
return;
}
this.map.set(key, value);
this.order.push(key);
while (this.order.length > this.capacity) {
const oldest: K | undefined = this.order.shift();
if (oldest !== undefined) {
this.map.delete(oldest);
}
}
}
has(key: K): boolean {
return this.map.has(key);
}
delete(key: K): boolean {
if (!this.map.has(key)) {
return false;
}
this.map.delete(key);
const idx: number = this.order.indexOf(key);
if (idx >= 0) {
this.order.splice(idx, 1);
}
return true;
}
size(): number {
return this.order.length;
}
clear(): void {
this.map.clear();
this.order = [];
}
/** 按最近使用顺序返回键(最旧在前),用于诊断。 */
keys(): K[] {
const out: K[] = [];
for (let i = 0; i < this.order.length; i++) {
out.push(this.order[i]);
}
return out;
}
private touch(key: K): void {
const idx: number = this.order.indexOf(key);
if (idx >= 0) {
this.order.splice(idx, 1);
}
this.order.push(key);
}
}
+114
View File
@@ -0,0 +1,114 @@
/**
* 任务调度框架(1.3.5)。
*
* 目标:把「并发上限、排队、取消、失败传播」收敛到一处,业务侧只提交任务。
* 执行体抽象为 TaskRunner,UI 层与 Worker/TaskPool 的差异由实现方注入,
* 便于在无平台环境做单元测试,也便于后续替换为 TaskPool 的真实执行器。
*
* 语义:
* - submit 返回的 Promise 在任务成功时 resolve,失败或取消时 reject;
* - 取消仅在任务尚未开始(排队中)时生效,返回 true;运行中的任务需自行响应取消信号;
* - 并发达到上限时排队,任务完成后自动补位。
*/
/** 可执行任务。 */
export interface TaskRunner {
run(): Promise<void>;
}
/** 取消错误。 */
export class TaskCancelledError extends Error {
constructor(message: string) {
super(message);
this.name = 'TaskCancelledError';
}
}
/** 排队中的任务项。 */
class QueuedTask {
readonly id: string;
readonly runner: TaskRunner;
readonly resolve: (value: void) => void;
readonly reject: (reason: Error) => void;
constructor(id: string, runner: TaskRunner, resolve: (value: void) => void, reject: (reason: Error) => void) {
this.id = id;
this.runner = runner;
this.resolve = resolve;
this.reject = reject;
}
}
export class TaskScheduler {
private readonly maxConcurrency: number;
private readonly queue: QueuedTask[] = [];
private readonly runningIds: Set<string> = new Set<string>();
private runningCount: number = 0;
constructor(maxConcurrency: number) {
if (maxConcurrency < 1) {
throw new Error('maxConcurrency must be >= 1');
}
this.maxConcurrency = maxConcurrency;
}
/** 提交任务;返回完成 Promise。 */
submit(id: string, runner: TaskRunner): Promise<void> {
if (this.runningIds.has(id)) {
return Promise.reject(new Error('duplicate running task id: ' + id));
}
for (let i = 0; i < this.queue.length; i++) {
if (this.queue[i].id === id) {
return Promise.reject(new Error('duplicate queued task id: ' + id));
}
}
return new Promise<void>((resolve, reject) => {
this.queue.push(new QueuedTask(id, runner, resolve, reject));
this.pump();
});
}
/** 取消排队中的任务;返回是否取消成功。 */
cancel(id: string): boolean {
for (let i = 0; i < this.queue.length; i++) {
if (this.queue[i].id === id) {
const task: QueuedTask = this.queue[i];
this.queue.splice(i, 1);
task.reject(new TaskCancelledError('task cancelled: ' + id));
return true;
}
}
return false;
}
pendingCount(): number {
return this.queue.length;
}
activeCount(): number {
return this.runningCount;
}
private pump(): void {
while (this.runningCount < this.maxConcurrency && this.queue.length > 0) {
const task: QueuedTask = this.queue.shift() as QueuedTask;
this.runningCount++;
this.runningIds.add(task.id);
task.runner.run()
.then(() => {
this.finish(task.id);
task.resolve();
})
.catch((err: Error) => {
this.finish(task.id);
task.reject(err);
});
}
}
private finish(id: string): void {
this.runningIds.delete(id);
this.runningCount--;
this.pump();
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"module": {
"name": "core",
"type": "har",
"deviceTypes": [
"tablet",
"2in1"
]
}
}
@@ -0,0 +1,8 @@
{
"string": [
{
"name": "core_module_desc",
"value": "DBMGR 核心能力模块(日志、应用信息、公共类型)"
}
]
}
+103
View File
@@ -0,0 +1,103 @@
/**
* @kuge/data 的唯一公开入口。
*
* 约定(见 ADR-001 / ADR-003):HAR 模块只通过本文件暴露能力。
*/
export { DbObject, ObjectType, objectTypeLabel } from './src/main/ets/metadata/Objects';
export { ObjectTreeModel } from './src/main/ets/metadata/ObjectTreeModel';
export {
CheckConstraint,
ColumnDef,
ColumnType,
ForeignKeyDef,
IndexDef,
TableDef,
TableValidator,
createColumn,
emptyTable
} from './src/main/ets/schema/TableModel';
export { DdlGenerator, SqlDialect } from './src/main/ets/schema/DdlGenerator';
export { IndexModel } from './src/main/ets/schema/IndexModel';
export { ConstraintDdl, ConstraintValidator } from './src/main/ets/schema/ConstraintModel';
export { ViewDdl, ViewDef } from './src/main/ets/schema/ViewDdl';
export { RoutineDdl, RoutineDef, RoutineKind } from './src/main/ets/schema/RoutineDdl';
export { TriggerDdl, TriggerDef, TriggerEvent, TriggerTiming } from './src/main/ets/schema/TriggerDdl';
export { MiscObjectDdl, SequenceDef } from './src/main/ets/schema/MiscObjectDdl';
export { PartitionDef, PartitionMethod, SpaceDdl } from './src/main/ets/schema/SpaceDdl';
export { SearchUtil } from './src/main/ets/metadata/SearchUtil';
export { ChangeKind, TableChange, TableDiff } from './src/main/ets/schema/TableDiff';
export { BatchOp, BatchOpKind, BatchOps } from './src/main/ets/schema/BatchOps';
export { CacheKeys, MetadataCache } from './src/main/ets/metadata/MetadataCache';
export { PermissionCatalog, PermissionItem } from './src/main/ets/security/PermissionCatalog';
export { AuditEntry, AuditLog } from './src/main/ets/audit/AuditLog';
export { DiffLine, DiffLineKind, TextDiff } from './src/main/ets/schema/TextDiff';
export { ObjectComparator, ObjectCompareSummary } from './src/main/ets/schema/ObjectComparator';
export { SqlToken, SqlTokenizer, TokenKind } from './src/main/ets/sql/SqlTokenizer';
export { FoldingRange, HighlightSpan, SqlHighlighter } from './src/main/ets/sql/SqlHighlighter';
export { CompletionCandidate, CompletionEngine, CompletionRequest } from './src/main/ets/sql/CompletionEngine';
export { SqlFormatter } from './src/main/ets/sql/SqlFormatter';
export { HistoryEntry, QueryHistory } from './src/main/ets/sql/QueryHistory';
export { Snippet, SnippetStore, SnippetVariable } from './src/main/ets/sql/SnippetStore';
export { NamedParam, ParameterBinder, ParamSpec, ParamStyle } from './src/main/ets/sql/ParameterBinder';
export { ConsoleCommand, ConsoleCommandKind, ConsoleParser } from './src/main/ets/sql/ConsoleParser';
export { ExplainParser, PlanNode } from './src/main/ets/sql/ExplainParser';
export { Profiler, TimingSample, TimingStats } from './src/main/ets/sql/Profiler';
export { ValueInspector, ValueKind, ValueView } from './src/main/ets/sql/ValueInspector';
export { PagedResult } from './src/main/ets/sql/PagedResult';
export { EditResult, GridColumn, GridModel, VisibleRange } from './src/main/ets/grid/GridModel';
export {
FilterBuilder,
FilterCondition,
FilterGroup,
FilterLogic,
FilterOperator,
SortSpec
} from './src/main/ets/grid/FilterBuilder';
export { AggFunc, AggregationBuilder, AggSpec, GroupSpec, TimePart } from './src/main/ets/grid/AggregationBuilder';
export { CellChange, CommitResult, KeyPart, TableEditor } from './src/main/ets/grid/TableEditor';
export { BatchModifier } from './src/main/ets/grid/BatchModifier';
export { DelimitedCodec } from './src/main/ets/grid/DelimitedCodec';
export { InputDevice, KeyboardPlatform, KeyBinding, KeymapRegistry } from './src/main/ets/grid/KeymapRegistry';
export { RendererRegistry, RendererSpec } from './src/main/ets/grid/RendererRegistry';
export {
ColumnRule,
ConflictDetector,
ConflictInfo,
RowValidator,
ValidationIssue
} from './src/main/ets/grid/Validation';
export { FieldMapper, FieldMapping } from './src/main/ets/grid/FieldMapper';
export { LobBuffer } from './src/main/ets/grid/LobBuffer';
export { FieldSpec, FormViewModel } from './src/main/ets/grid/FormViewModel';
export { WIZARD_STEP_NAMES, WizardModel, WizardStep } from './src/main/ets/io/WizardModel';
export { CsvHandler, CsvImportResult, CsvOptions } from './src/main/ets/io/CsvHandler';
export { SqlExporter } from './src/main/ets/io/SqlExporter';
export { XmlCodec, XmlParseResult } from './src/main/ets/io/XmlCodec';
export { JsonCodec, JsonParseResult } from './src/main/ets/io/JsonCodec';
export { ConvertResult, TargetType, TypeConverter } from './src/main/ets/io/TypeConverter';
export { TextEncoding } from './src/main/ets/io/TextEncoding';
export { FormatRegistry, FormatSpec } from './src/main/ets/io/FormatRegistry';
export { BatchQueue, BatchSummary, BatchTask, TaskState } from './src/main/ets/io/BatchQueue';
export { HtmlExporter, HtmlOptions } from './src/main/ets/io/HtmlExporter';
export { SheetData, SpreadsheetModel } from './src/main/ets/io/SpreadsheetModel';
export {
InteractionAction,
InteractionContext,
MouseButton,
PointerInput,
PointerResolver
} from './src/main/ets/ui/PointerResolver';
export {
Breakpoint,
DeviceForm,
FormAdapter,
FormContext,
LayoutSpec,
Orientation
} from './src/main/ets/ui/FormAdapter';
export {
DispatchAction,
InputDispatcher,
InputGesture,
InputSource
} from './src/main/ets/ui/InputDispatcher';
+9
View File
@@ -0,0 +1,9 @@
{
"apiType": "stageMode",
"buildOption": {},
"targets": [
{
"name": "default"
}
]
}
+6
View File
@@ -0,0 +1,6 @@
import { harTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@kuge/data",
"version": "1.0.0",
"description": "Kuge data layer: object model, metadata, query session, result sets",
"main": "Index.ets",
"author": "Kuge Team",
"license": "Apache-2.0",
"dependencies": {}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 对象变更审计(1.5.16)。
*
* 内存环形审计:记录结构变更/授权/脚本执行等操作,支持按时间倒序读取与导出为 CSV。
* 上层在每次执行 DDL 前调用 record;持久化由 1.3.7 存储层负责(后续接入)。
*/
export interface AuditEntry {
id: number;
at: number;
actor: string;
action: string;
target: string;
detail: string;
}
export class AuditLog {
private readonly capacity: number;
private readonly clock: () => number;
private readonly entries: AuditEntry[] = [];
private nextId: number = 1;
constructor(capacity: number, clock?: () => number) {
if (capacity < 1) {
throw new Error('capacity must be >= 1');
}
this.capacity = capacity;
this.clock = clock === undefined ? () => Date.now() : clock;
}
/** 记录一条审计;返回带 id 与时间的条目。 */
record(actor: string, action: string, target: string, detail: string): AuditEntry {
const entry: AuditEntry = {
id: this.nextId,
at: this.clock(),
actor: actor,
action: action,
target: target,
detail: detail
};
this.nextId++;
this.entries.push(entry);
while (this.entries.length > this.capacity) {
this.entries.shift();
}
return entry;
}
/** 按时间倒序返回最近 limit 条(limit<=0 返回全部)。 */
recent(limit: number): AuditEntry[] {
const start: number = limit > 0 && this.entries.length > limit ? this.entries.length - limit : 0;
const out: AuditEntry[] = [];
for (let i = this.entries.length - 1; i >= start; i--) {
out.push(this.entries[i]);
}
return out;
}
size(): number {
return this.entries.length;
}
clear(): void {
this.entries.length = 0;
}
/** 导出为 CSV(字段内的双引号会转义)。 */
toCsv(): string {
const lines: string[] = ['id,at,actor,action,target,detail'];
for (let i = 0; i < this.entries.length; i++) {
const e: AuditEntry = this.entries[i];
lines.push(e.id + ',' + e.at + ',' + AuditLog.escape(e.actor) + ',' + AuditLog.escape(e.action) +
',' + AuditLog.escape(e.target) + ',' + AuditLog.escape(e.detail));
}
return lines.join('\n');
}
private static escape(text: string): string {
if (text.indexOf(',') >= 0 || text.indexOf('"') >= 0 || text.indexOf('\n') >= 0) {
return '"' + text.replace(new RegExp('"', 'g'), '""') + '"';
}
return text;
}
}
@@ -0,0 +1,152 @@
/**
* 分组与聚合(1.7.4)。
*
* 生成聚合查询的 SELECT/GROUP BY 片段,并提供按方言的时间提取表达式。
* 纯逻辑,供数据浏览界面的「分组统计」面板使用。
*/
import { SqlDialect } from '../schema/DdlGenerator';
export enum AggFunc {
COUNT = 0,
SUM = 1,
AVG = 2,
MIN = 3,
MAX = 4
}
export interface AggSpec {
func: AggFunc;
/** 列名;COUNT(*) 用 '*'。 */
column: string;
/** 别名;为空则不生成 AS。 */
alias: string;
}
export interface GroupSpec {
groupBy: string[];
aggregates: AggSpec[];
}
export enum TimePart {
YEAR = 0,
MONTH = 1,
DAY = 2,
HOUR = 3
}
export class AggregationBuilder {
/** 生成 SELECT ... GROUP BY ... 片段(不含 FROM)。 */
static selectClause(spec: GroupSpec): string {
const items: string[] = [];
for (let i = 0; i < spec.groupBy.length; i++) {
const column: string = spec.groupBy[i].trim();
if (column.length > 0) {
items.push(column);
}
}
for (let i = 0; i < spec.aggregates.length; i++) {
items.push(AggregationBuilder.aggregateSql(spec.aggregates[i]));
}
if (items.length === 0) {
return '';
}
const selectPart: string = 'SELECT ' + items.join(', ');
const groupColumns: string[] = [];
for (let i = 0; i < spec.groupBy.length; i++) {
if (spec.groupBy[i].trim().length > 0) {
groupColumns.push(spec.groupBy[i].trim());
}
}
if (groupColumns.length === 0) {
return selectPart;
}
return selectPart + ' GROUP BY ' + groupColumns.join(', ');
}
static aggregateSql(agg: AggSpec): string {
const column: string = agg.column.trim().length === 0 ? '*' : agg.column.trim();
const name: string = AggregationBuilder.funcName(agg.func);
const alias: string = agg.alias.trim();
return name + '(' + column + ')' + (alias.length === 0 ? '' : ' AS ' + alias);
}
/** 时间提取表达式(按方言)。 */
static timeExtractSql(part: TimePart, column: string, dialect: SqlDialect): string {
const col: string = column.trim();
const unit: string = AggregationBuilder.partName(part);
switch (dialect) {
case SqlDialect.MYSQL:
return 'EXTRACT(' + unit + ' FROM ' + col + ')';
case SqlDialect.POSTGRESQL:
return 'EXTRACT(' + unit + ' FROM ' + col + ')';
case SqlDialect.ORACLE:
return 'EXTRACT(' + unit + ' FROM ' + col + ')';
case SqlDialect.SQLITE:
return 'strftime(\'' + AggregationBuilder.strftimeFormat(part) + '\', ' + col + ')';
case SqlDialect.SQLSERVER:
return 'DATEPART(' + unit.toLowerCase() + ', ' + col + ')';
default:
return 'EXTRACT(' + unit + ' FROM ' + col + ')';
}
}
/** 校验分组规格。 */
static validate(spec: GroupSpec): string[] {
const errors: string[] = [];
for (let i = 0; i < spec.aggregates.length; i++) {
const agg: AggSpec = spec.aggregates[i];
if (agg.func !== AggFunc.COUNT && agg.column.trim().length === 0) {
errors.push('aggregate ' + AggregationBuilder.funcName(agg.func) + ' requires a column');
}
}
if (spec.groupBy.length === 0 && spec.aggregates.length === 0) {
errors.push('group spec is empty');
}
return errors;
}
private static funcName(func: AggFunc): string {
switch (func) {
case AggFunc.COUNT:
return 'COUNT';
case AggFunc.SUM:
return 'SUM';
case AggFunc.AVG:
return 'AVG';
case AggFunc.MIN:
return 'MIN';
default:
return 'MAX';
}
}
private static partName(part: TimePart): string {
switch (part) {
case TimePart.YEAR:
return 'YEAR';
case TimePart.MONTH:
return 'MONTH';
case TimePart.DAY:
return 'DAY';
case TimePart.HOUR:
return 'HOUR';
default:
return 'YEAR';
}
}
private static strftimeFormat(part: TimePart): string {
switch (part) {
case TimePart.YEAR:
return '%Y';
case TimePart.MONTH:
return '%m';
case TimePart.DAY:
return '%d';
case TimePart.HOUR:
return '%H';
default:
return '%Y';
}
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* 多行选择与批量修改(1.7.8)。
*
* 把「对某列统一赋值」展开为逐行变更,交给 TableEditor 记录;
* 展开时读取每行旧值以保留可回滚信息。
*/
import { CellChange } from './TableEditor';
export class BatchModifier {
/**
* 对选定行的某列批量赋值。
* @param rows 行号列表(会去重并升序)
* @param column 列名
* @param newValue 新值
* @param oldValueOf 读取旧值的回调
*/
static applyToRows(rows: number[], column: string, newValue: string,
oldValueOf: (row: number) => string): CellChange[] {
const unique: number[] = BatchModifier.normalizeRows(rows);
const out: CellChange[] = [];
for (let i = 0; i < unique.length; i++) {
out.push({
rowIndex: unique[i],
column: column,
oldValue: oldValueOf(unique[i]),
newValue: newValue
});
}
return out;
}
/**
* 对选定行的多列批量赋值(列到值的映射)。
* @param values 列名 → 新值
*/
static applyMultiple(rows: number[], values: Map<string, string>,
oldValueOf: (row: number, column: string) => string): CellChange[] {
const unique: number[] = BatchModifier.normalizeRows(rows);
const out: CellChange[] = [];
for (let i = 0; i < unique.length; i++) {
values.forEach((value: string, column: string) => {
out.push({
rowIndex: unique[i],
column: column,
oldValue: oldValueOf(unique[i], column),
newValue: value
});
});
}
return out;
}
/** 行号去重并升序(负数忽略)。 */
static normalizeRows(rows: number[]): number[] {
const seen: Set<number> = new Set<number>();
const out: number[] = [];
for (let i = 0; i < rows.length; i++) {
if (rows[i] >= 0 && !seen.has(rows[i])) {
seen.add(rows[i]);
out.push(rows[i]);
}
}
out.sort((a: number, b: number) => a - b);
return out;
}
}
+101
View File
@@ -0,0 +1,101 @@
/**
* 分隔文本编解码(1.7.9,1.8.2 复用)。
*
* TSV 与 CSV 共用:字段含分隔符、引号或换行时用双引号包裹,内部引号双写;
* 解析支持引号包裹与双写转义,兼容 CRLF。
*
* 纯逻辑:不涉及文件与编码(编码 BOM 处理属 1.8.11)。
*/
export class DelimitedCodec {
/** 序列化为分隔文本;行之间用 \n,末尾不带换行。 */
static serialize(rows: string[][], delimiter: string): string {
const lines: string[] = [];
for (let i = 0; i < rows.length; i++) {
const cells: string[] = [];
for (let j = 0; j < rows[i].length; j++) {
cells.push(DelimitedCodec.escape(rows[i][j], delimiter));
}
lines.push(cells.join(delimiter));
}
return lines.join('\n');
}
/** 解析分隔文本为二维数组;空文本返回空数组。 */
static parse(text: string, delimiter: string): string[][] {
const normalized: string = text.replace(new RegExp('\r\n', 'g'), '\n').replace(new RegExp('\r', 'g'), '\n');
const rows: string[][] = [];
let row: string[] = [];
let cell: string = '';
let i: number = 0;
let inQuotes: boolean = false;
while (i < normalized.length) {
const ch: string = normalized.charAt(i);
if (inQuotes) {
if (ch === '"') {
if (normalized.charAt(i + 1) === '"') {
cell += '"';
i += 2;
continue;
}
inQuotes = false;
i++;
continue;
}
cell += ch;
i++;
continue;
}
if (ch === '"' && cell.length === 0) {
inQuotes = true;
i++;
continue;
}
if (ch === delimiter) {
row.push(cell);
cell = '';
i++;
continue;
}
if (ch === '\n') {
row.push(cell);
rows.push(row);
row = [];
cell = '';
i++;
continue;
}
cell += ch;
i++;
}
if (cell.length > 0 || row.length > 0) {
row.push(cell);
rows.push(row);
}
return rows;
}
static toTsv(rows: string[][]): string {
return DelimitedCodec.serialize(rows, '\t');
}
static parseTsv(text: string): string[][] {
return DelimitedCodec.parse(text, '\t');
}
static toCsv(rows: string[][]): string {
return DelimitedCodec.serialize(rows, ',');
}
static parseCsv(text: string): string[][] {
return DelimitedCodec.parse(text, ',');
}
private static escape(value: string, delimiter: string): string {
const needsQuote: boolean = value.indexOf(delimiter) >= 0 || value.indexOf('"') >= 0 ||
value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0;
if (!needsQuote) {
return value;
}
return '"' + value.replace(new RegExp('"', 'g'), '""') + '"';
}
}
+120
View File
@@ -0,0 +1,120 @@
/**
* 字段映射(1.7.5,1.8.9 复用)。
*
* 把源数据列映射到目标列,支持简单转换(trim/upper/lower)。
* 编辑器侧用于「批量字段映射」;导入向导(1.8.1/1.8.9)复用同一模型。
*/
export interface FieldMapping {
source: string;
target: string;
/** none / trim / upper / lower */
transform: string;
}
export class FieldMapper {
/** 校验映射;返回错误列表。 */
static validate(mappings: FieldMapping[]): string[] {
const errors: string[] = [];
const sources: Set<string> = new Set<string>();
const targets: Set<string> = new Set<string>();
for (let i = 0; i < mappings.length; i++) {
const mapping: FieldMapping = mappings[i];
if (mapping.source.trim().length === 0) {
errors.push('source column is required at index ' + i);
continue;
}
if (mapping.target.trim().length === 0) {
errors.push('target column is required at index ' + i);
}
if (sources.has(mapping.source.toLowerCase())) {
errors.push('duplicate source: ' + mapping.source);
}
sources.add(mapping.source.toLowerCase());
if (targets.has(mapping.target.toLowerCase())) {
errors.push('duplicate target: ' + mapping.target);
}
targets.add(mapping.target.toLowerCase());
if (!FieldMapper.isKnownTransform(mapping.transform)) {
errors.push('unknown transform: ' + mapping.transform);
}
}
return errors;
}
/** 映射一行数据;未映射的源列被忽略,未出现的映射目标留空。 */
static mapRow(row: Map<string, string>, mappings: FieldMapping[]): Map<string, string> {
const out: Map<string, string> = new Map<string, string>();
for (let i = 0; i < mappings.length; i++) {
const mapping: FieldMapping = mappings[i];
const sourceKey: string = FieldMapper.lookupKey(row, mapping.source);
const raw: string = sourceKey.length === 0 ? '' : (row.get(sourceKey) as string);
out.set(mapping.target, FieldMapper.transform(raw, mapping.transform));
}
return out;
}
/** 批量映射。 */
static mapRows(rows: Map<string, string>[], mappings: FieldMapping[]): Map<string, string>[] {
const out: Map<string, string>[] = [];
for (let i = 0; i < rows.length; i++) {
out.push(FieldMapper.mapRow(rows[i], mappings));
}
return out;
}
/** 按名称(大小写不敏感)自动建议映射。 */
static suggest(sourceColumns: string[], targetColumns: string[]): FieldMapping[] {
const out: FieldMapping[] = [];
const usedTargets: Set<string> = new Set<string>();
for (let i = 0; i < sourceColumns.length; i++) {
const source: string = sourceColumns[i].trim();
if (source.length === 0) {
continue;
}
for (let j = 0; j < targetColumns.length; j++) {
const target: string = targetColumns[j].trim();
if (target.length === 0 || usedTargets.has(target.toLowerCase())) {
continue;
}
if (source.toLowerCase() === target.toLowerCase()) {
out.push({ source: source, target: target, transform: 'none' });
usedTargets.add(target.toLowerCase());
break;
}
}
}
return out;
}
static transform(value: string, transform: string): string {
switch (transform.trim().toLowerCase()) {
case 'trim':
return value.trim();
case 'upper':
return value.toUpperCase();
case 'lower':
return value.toLowerCase();
default:
return value;
}
}
private static isKnownTransform(transform: string): boolean {
const key: string = transform.trim().toLowerCase();
return key === '' || key === 'none' || key === 'trim' || key === 'upper' || key === 'lower';
}
private static lookupKey(row: Map<string, string>, source: string): string {
if (row.has(source)) {
return source;
}
const lower: string = source.toLowerCase();
let found: string = '';
row.forEach((value: string, key: string) => {
if (found.length === 0 && key.toLowerCase() === lower) {
found = key;
}
});
return found;
}
}
+162
View File
@@ -0,0 +1,162 @@
/**
* 筛选与排序条件构建(1.7.3)。
*
* 把界面上的筛选条件(分组嵌套 AND/OR)与排序规则翻译为 SQL 片段。
* 值一律按字面量转义(单引号双写、拒绝 NUL),避免条件注入。
*
* 说明:不做列名合法性校验(列名来自元数据);如需支持表达式列,由调用方自行保证。
*/
export enum FilterOperator {
EQ = 0,
NE = 1,
GT = 2,
GE = 3,
LT = 4,
LE = 5,
LIKE = 6,
IN = 7,
IS_NULL = 8,
IS_NOT_NULL = 9
}
export enum FilterLogic {
AND = 0,
OR = 1
}
export interface FilterCondition {
column: string;
op: FilterOperator;
/** 值;IN 时用逗号分隔多个值,IS NULL 时忽略。 */
value: string;
}
export interface FilterGroup {
logic: FilterLogic;
conditions: FilterCondition[];
children: FilterGroup[];
}
export interface SortSpec {
column: string;
/** true 为升序。 */
ascending: boolean;
}
export class FilterBuilder {
/** 构建 WHERE 片段;无有效条件时返回空字符串。 */
static toSql(group: FilterGroup): string {
const parts: string[] = [];
for (let i = 0; i < group.conditions.length; i++) {
const condition: FilterCondition = group.conditions[i];
if (!FilterBuilder.isValid(condition)) {
continue;
}
parts.push(FilterBuilder.conditionSql(condition));
}
for (let i = 0; i < group.children.length; i++) {
const child: string = FilterBuilder.toSql(group.children[i]);
if (child.length > 0) {
parts.push('(' + child + ')');
}
}
if (parts.length === 0) {
return '';
}
const joiner: string = group.logic === FilterLogic.AND ? ' AND ' : ' OR ';
return parts.join(joiner);
}
/** 完整 WHERE 子句(含前缀);无有效条件返回空字符串。 */
static whereClause(group: FilterGroup): string {
const body: string = FilterBuilder.toSql(group);
return body.length === 0 ? '' : 'WHERE ' + body;
}
/** 排序子句;无有效排序返回空字符串。 */
static orderByClause(sorts: SortSpec[]): string {
const parts: string[] = [];
for (let i = 0; i < sorts.length; i++) {
const column: string = sorts[i].column.trim();
if (column.length === 0) {
continue;
}
parts.push(column + (sorts[i].ascending ? ' ASC' : ' DESC'));
}
return parts.length === 0 ? '' : 'ORDER BY ' + parts.join(', ');
}
/** 条件校验。 */
static validate(condition: FilterCondition): string[] {
const errors: string[] = [];
if (condition.column.trim().length === 0) {
errors.push('column is required');
}
if (condition.op !== FilterOperator.IS_NULL && condition.op !== FilterOperator.IS_NOT_NULL &&
condition.op !== FilterOperator.IN && condition.value.length === 0) {
errors.push('value is required');
}
if (condition.op === FilterOperator.IN && FilterBuilder.splitValues(condition.value).length === 0) {
errors.push('IN requires at least one value');
}
return errors;
}
private static isValid(condition: FilterCondition): boolean {
return FilterBuilder.validate(condition).length === 0;
}
private static conditionSql(condition: FilterCondition): string {
const column: string = condition.column.trim();
switch (condition.op) {
case FilterOperator.EQ:
return column + ' = ' + FilterBuilder.literal(condition.value);
case FilterOperator.NE:
return column + ' <> ' + FilterBuilder.literal(condition.value);
case FilterOperator.GT:
return column + ' > ' + FilterBuilder.literal(condition.value);
case FilterOperator.GE:
return column + ' >= ' + FilterBuilder.literal(condition.value);
case FilterOperator.LT:
return column + ' < ' + FilterBuilder.literal(condition.value);
case FilterOperator.LE:
return column + ' <= ' + FilterBuilder.literal(condition.value);
case FilterOperator.LIKE:
return column + ' LIKE ' + FilterBuilder.literal(condition.value);
case FilterOperator.IN: {
const values: string[] = FilterBuilder.splitValues(condition.value);
const quoted: string[] = [];
for (let i = 0; i < values.length; i++) {
quoted.push(FilterBuilder.literal(values[i]));
}
return column + ' IN (' + quoted.join(', ') + ')';
}
case FilterOperator.IS_NULL:
return column + ' IS NULL';
default:
return column + ' IS NOT NULL';
}
}
/** IN 列表拆分(支持逗号分隔,去空白,忽略空项)。 */
static splitValues(text: string): string[] {
const raw: string[] = text.split(',');
const out: string[] = [];
for (let i = 0; i < raw.length; i++) {
const value: string = raw[i].trim();
if (value.length > 0) {
out.push(value);
}
}
return out;
}
private static literal(value: string): string {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) === 0) {
throw new Error('filter value contains NUL character');
}
}
return '\'' + value.replace(new RegExp('\'', 'g'), '\'\'') + '\'';
}
}
+128
View File
@@ -0,0 +1,128 @@
/**
* 表单视图模型(1.7.2)。
*
* 单行记录的表单编辑:字段定义(可编辑/只读/必填),值的脏检测与回滚,
* 提交时产出 CellChange 交给 TableEditor 生成 UPDATE。
*/
import { CellChange } from './TableEditor';
export interface FieldSpec {
column: string;
label: string;
editable: boolean;
required: boolean;
}
export class FormViewModel {
private readonly fields: FieldSpec[];
private original: Map<string, string> = new Map<string, string>();
private current: Map<string, string> = new Map<string, string>();
constructor(fields: FieldSpec[]) {
this.fields = fields.slice();
}
fields_(): FieldSpec[] {
return this.fields.slice();
}
/** 载入一行数据(重置脏状态)。 */
load(values: Map<string, string>): void {
this.original = new Map<string, string>();
this.current = new Map<string, string>();
for (let i = 0; i < this.fields.length; i++) {
const column: string = this.fields[i].column;
const value: string | undefined = values.get(column);
this.original.set(column, value === undefined ? '' : value);
this.current.set(column, value === undefined ? '' : value);
}
}
get(column: string): string {
const value: string | undefined = this.current.get(column);
return value === undefined ? '' : value;
}
/** 修改值;只读列或未知列忽略。 */
set(column: string, value: string): boolean {
const field: FieldSpec | undefined = this.fieldOf(column);
if (field === undefined || !field.editable) {
return false;
}
this.current.set(column, value);
return true;
}
isEditable(column: string): boolean {
const field: FieldSpec | undefined = this.fieldOf(column);
return field !== undefined && field.editable;
}
/** 脏字段(值发生过变化)。 */
dirtyFields(): string[] {
const out: string[] = [];
for (let i = 0; i < this.fields.length; i++) {
const column: string = this.fields[i].column;
if (this.get(column) !== this.originalOf(column)) {
out.push(column);
}
}
return out;
}
isDirty(): boolean {
return this.dirtyFields().length > 0;
}
/** 回滚到载入时的值。 */
revert(): void {
this.current = new Map<string, string>();
this.original.forEach((value: string, column: string) => {
this.current.set(column, value);
});
}
/** 必填校验;返回错误列与消息。 */
validate(): string[] {
const errors: string[] = [];
for (let i = 0; i < this.fields.length; i++) {
const field: FieldSpec = this.fields[i];
if (field.required && this.get(field.column).trim().length === 0) {
errors.push(field.column + ' is required');
}
}
return errors;
}
/** 产出变更(供 TableEditor 提交);必填不通过时返回空数组。 */
changes(): CellChange[] {
if (this.validate().length > 0) {
return [];
}
const out: CellChange[] = [];
const dirty: string[] = this.dirtyFields();
for (let i = 0; i < dirty.length; i++) {
out.push({
rowIndex: 0,
column: dirty[i],
oldValue: this.originalOf(dirty[i]),
newValue: this.get(dirty[i])
});
}
return out;
}
private originalOf(column: string): string {
const value: string | undefined = this.original.get(column);
return value === undefined ? '' : value;
}
private fieldOf(column: string): FieldSpec | undefined {
for (let i = 0; i < this.fields.length; i++) {
if (this.fields[i].column === column) {
return this.fields[i];
}
}
return undefined;
}
}
+194
View File
@@ -0,0 +1,194 @@
/**
* 数据网格内核(1.7.1)。
*
* 虚拟化网格的状态与行为模型(不含 ArkUI 渲染):
* - 列定义与行数;
* - 可视行窗口(按滚动位置与行高计算);
* - 选择(单选/范围/多选);
* - 编辑态(当前编辑单元格与草稿值);
* - 选区复制为 TSV。
*
* 与 1.2.10 的数据网格内核设计一致;UI 层只负责把可见行渲染出来。
*/
export interface GridColumn {
key: string;
title: string;
width: number;
}
export interface VisibleRange {
first: number;
last: number;
}
export class GridModel {
private readonly columns: GridColumn[];
private readonly rowCount: number;
private readonly selected: Set<number> = new Set<number>();
private editRow: number = -1;
private editColumn: number = -1;
private editText: string = '';
constructor(columns: GridColumn[], rowCount: number) {
this.columns = columns;
this.rowCount = rowCount < 0 ? 0 : rowCount;
}
columnCount(): number {
return this.columns.length;
}
rows(): number {
return this.rowCount;
}
columnAt(index: number): GridColumn | undefined {
return index >= 0 && index < this.columns.length ? this.columns[index] : undefined;
}
/** 可视行窗口:首行与末行(含),越界裁剪。 */
visibleRange(scrollTop: number, viewportHeight: number, rowHeight: number): VisibleRange {
if (rowHeight <= 0 || this.rowCount === 0) {
return { first: 0, last: -1 };
}
const firstRaw: number = Math.floor(Math.max(0, scrollTop) / rowHeight);
const visibleCount: number = Math.ceil(Math.max(0, viewportHeight) / rowHeight);
const first: number = firstRaw >= this.rowCount ? this.rowCount - 1 : firstRaw;
const last: number = Math.min(this.rowCount - 1, first + visibleCount - 1);
return { first: first, last: last };
}
/** 单选。 */
select(row: number): void {
if (row < 0 || row >= this.rowCount) {
return;
}
this.selected.clear();
this.selected.add(row);
}
/** 追加/取消选择(多选)。 */
toggle(row: number): void {
if (row < 0 || row >= this.rowCount) {
return;
}
if (this.selected.has(row)) {
this.selected.delete(row);
} else {
this.selected.add(row);
}
}
/** 范围选择(含首尾)。 */
selectRange(start: number, end: number): void {
if (this.rowCount === 0) {
return;
}
const lower: number = Math.max(0, Math.min(start, end));
const upper: number = Math.min(this.rowCount - 1, Math.max(start, end));
this.selected.clear();
for (let row = lower; row <= upper; row++) {
this.selected.add(row);
}
}
clearSelection(): void {
this.selected.clear();
}
isSelected(row: number): boolean {
return this.selected.has(row);
}
selectedRows(): number[] {
const out: number[] = [];
this.selected.forEach((row: number) => {
out.push(row);
});
out.sort((a: number, b: number) => a - b);
return out;
}
selectionSize(): number {
return this.selected.size;
}
/** 全部选中。 */
selectAll(): void {
this.selected.clear();
for (let row = 0; row < this.rowCount; row++) {
this.selected.add(row);
}
}
// ---- 编辑态 ----
beginEdit(row: number, column: number, initialText: string): void {
if (row < 0 || row >= this.rowCount || column < 0 || column >= this.columns.length) {
return;
}
this.editRow = row;
this.editColumn = column;
this.editText = initialText;
}
isEditing(): boolean {
return this.editRow >= 0;
}
editingRow(): number {
return this.editRow;
}
editingColumn(): number {
return this.editColumn;
}
editingText(): string {
return this.editText;
}
updateEditText(text: string): void {
if (this.editRow >= 0) {
this.editText = text;
}
}
/** 结束编辑,返回 {row, column, text};未在编辑时返回 undefined。 */
endEdit(): EditResult | undefined {
if (this.editRow < 0) {
return undefined;
}
const result: EditResult = { row: this.editRow, column: this.editColumn, text: this.editText };
this.editRow = -1;
this.editColumn = -1;
this.editText = '';
return result;
}
cancelEdit(): void {
this.editRow = -1;
this.editColumn = -1;
this.editText = '';
}
/**
* 选区复制为 TSV(选中行按行号升序,列按定义顺序)。
* 行内容由调用方提供(getRow 回调),避免内核持有全量数据。
*/
copySelection(getRow: (row: number) => string[]): string {
const rows: number[] = this.selectedRows();
const lines: string[] = [];
for (let i = 0; i < rows.length; i++) {
const cells: string[] = getRow(rows[i]);
lines.push(cells.join('\t'));
}
return lines.join('\n');
}
}
export interface EditResult {
row: number;
column: number;
text: string;
}
+142
View File
@@ -0,0 +1,142 @@
/**
* 快捷键与键位映射(1.7.12)。
*
* 统一键位规范:修饰键按 Ctrl+Alt+Shift 顺序规范化,按键大写;
* 平台(PC/平板外接键盘)可分别注册;同一组合冲突时后注册者覆盖先注册者。
*/
export enum InputDevice {
KEYBOARD = 0,
MOUSE = 1,
TOUCH = 2
}
export enum KeyboardPlatform {
PC = 0,
TABLET = 1,
BOTH = 2
}
export interface KeyBinding {
/** 动作标识(与命令系统一致)。 */
action: string;
/** 规范化组合键,如 Ctrl+Enter。 */
combo: string;
platform: KeyboardPlatform;
/** 界面展示文案。 */
label: string;
}
export class KeymapRegistry {
private readonly bindings: KeyBinding[] = [];
constructor() {
const defaults: KeyBinding[] = KeymapRegistry.defaults();
for (let i = 0; i < defaults.length; i++) {
this.bindings.push(defaults[i]);
}
}
static defaults(): KeyBinding[] {
return [
{ action: 'executeAll', combo: 'Ctrl+Enter', platform: KeyboardPlatform.BOTH, label: '执行全部' },
{ action: 'executeSelection', combo: 'Ctrl+Shift+Enter', platform: KeyboardPlatform.BOTH, label: '执行选中' },
{ action: 'newTab', combo: 'Ctrl+T', platform: KeyboardPlatform.BOTH, label: '新建标签' },
{ action: 'closeTab', combo: 'Ctrl+W', platform: KeyboardPlatform.BOTH, label: '关闭标签' },
{ action: 'formatSql', combo: 'Ctrl+Shift+F', platform: KeyboardPlatform.BOTH, label: '格式化' },
{ action: 'find', combo: 'Ctrl+F', platform: KeyboardPlatform.BOTH, label: '查找' },
{ action: 'cancel', combo: 'Esc', platform: KeyboardPlatform.BOTH, label: '取消执行' },
{ action: 'copySelection', combo: 'Ctrl+C', platform: KeyboardPlatform.PC, label: '复制选区' },
{ action: 'paste', combo: 'Ctrl+V', platform: KeyboardPlatform.PC, label: '粘贴' },
{ action: 'deleteRows', combo: 'Delete', platform: KeyboardPlatform.PC, label: '删除行' },
{ action: 'contextMenu', combo: 'Shift+F10', platform: KeyboardPlatform.PC, label: '上下文菜单' },
{ action: 'selectAll', combo: 'Ctrl+A', platform: KeyboardPlatform.BOTH, label: '全选' }
];
}
/** 规范化组合键:修饰键顺序固定,字母键大写。 */
static normalize(combo: string): string {
const parts: string[] = combo.split('+');
let ctrl: boolean = false;
let alt: boolean = false;
let shift: boolean = false;
const rest: string[] = [];
for (let i = 0; i < parts.length; i++) {
const token: string = parts[i].trim();
const lower: string = token.toLowerCase();
if (lower === 'ctrl' || lower === 'control') {
ctrl = true;
} else if (lower === 'alt') {
alt = true;
} else if (lower === 'shift') {
shift = true;
} else if (lower.length > 0) {
rest.push(token.length === 1 ? token.toUpperCase() : KeymapRegistry.capitalize(lower));
}
}
const out: string[] = [];
if (ctrl) {
out.push('Ctrl');
}
if (alt) {
out.push('Alt');
}
if (shift) {
out.push('Shift');
}
for (let i = 0; i < rest.length; i++) {
out.push(rest[i]);
}
return out.join('+');
}
/** 注册(覆盖同平台同组合)。 */
register(binding: KeyBinding): void {
const combo: string = KeymapRegistry.normalize(binding.combo);
for (let i = 0; i < this.bindings.length; i++) {
if (this.bindings[i].platform === binding.platform && this.bindings[i].combo === combo) {
this.bindings.splice(i, 1);
break;
}
}
this.bindings.push({
action: binding.action,
combo: combo,
platform: binding.platform,
label: binding.label
});
}
/** 按组合键解析动作;未命中返回 undefined。 */
resolve(combo: string): string | undefined {
const normalized: string = KeymapRegistry.normalize(combo);
for (let i = 0; i < this.bindings.length; i++) {
if (this.bindings[i].combo === normalized) {
return this.bindings[i].action;
}
}
return undefined;
}
/** 某平台的可用绑定(BOTH 始终包含)。 */
forPlatform(platform: KeyboardPlatform): KeyBinding[] {
const out: KeyBinding[] = [];
for (let i = 0; i < this.bindings.length; i++) {
if (this.bindings[i].platform === platform || this.bindings[i].platform === KeyboardPlatform.BOTH) {
out.push(this.bindings[i]);
}
}
return out;
}
all(): KeyBinding[] {
return this.bindings.slice();
}
private static capitalize(text: string): string {
if (text.length === 0) {
return text;
}
const first: string = text.charAt(0).toUpperCase();
return first + text.substring(1);
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* 大字段(LOB)分块缓冲(1.7.7)。
*
* 大文本/二进制按固定大小分块加载与编辑:
* - 只请求可视范围内的块,避免一次性读取整列;
* - 记录已加载块,read 拼接时若跨未加载块则抛错(提示先加载)。
*/
export class LobBuffer {
private readonly totalSize: number;
private readonly chunkSize: number;
private readonly loaded: Map<number, string> = new Map<number, string>();
constructor(totalSize: number, chunkSize: number) {
if (chunkSize < 1) {
throw new Error('chunkSize must be >= 1');
}
this.totalSize = totalSize < 0 ? 0 : totalSize;
this.chunkSize = chunkSize;
}
size(): number {
return this.totalSize;
}
chunkCount(): number {
return Math.ceil(this.totalSize / this.chunkSize);
}
chunkLength(index: number): number {
if (index < 0 || index >= this.chunkCount()) {
return 0;
}
const start: number = index * this.chunkSize;
const remaining: number = this.totalSize - start;
return remaining < this.chunkSize ? remaining : this.chunkSize;
}
/** 覆盖 [offset, offset+length) 需要的块号。 */
chunksFor(offset: number, length: number): number[] {
if (this.totalSize === 0 || length <= 0) {
return [];
}
const start: number = offset < 0 ? 0 : offset;
const end: number = Math.min(this.totalSize, start + length);
if (end <= start) {
return [];
}
const first: number = Math.floor(start / this.chunkSize);
const last: number = Math.floor((end - 1) / this.chunkSize);
const out: number[] = [];
for (let index = first; index <= last; index++) {
out.push(index);
}
return out;
}
setChunk(index: number, data: string): void {
if (index < 0 || index >= this.chunkCount()) {
return;
}
this.loaded.set(index, data);
}
isLoaded(index: number): boolean {
return this.loaded.has(index);
}
loadedChunks(): number {
return this.loaded.size;
}
loadedBytes(): number {
let bytes: number = 0;
this.loaded.forEach((data: string, index: number) => {
bytes += data.length;
});
return bytes;
}
/** 是否需要加载(覆盖范围内有未加载块)。 */
needsLoad(offset: number, length: number): boolean {
const chunks: number[] = this.chunksFor(offset, length);
for (let i = 0; i < chunks.length; i++) {
if (!this.loaded.has(chunks[i])) {
return true;
}
}
return false;
}
/** 读取区间;跨未加载块时抛错。 */
read(offset: number, length: number): string {
const start: number = offset < 0 ? 0 : offset;
const end: number = Math.min(this.totalSize, start + length);
if (end <= start) {
return '';
}
const chunks: number[] = this.chunksFor(start, end - start);
for (let i = 0; i < chunks.length; i++) {
if (!this.loaded.has(chunks[i])) {
throw new Error('chunk ' + chunks[i] + ' not loaded');
}
}
let out: string = '';
for (let i = 0; i < chunks.length; i++) {
out += this.loaded.get(chunks[i]) as string;
}
const sliceStart: number = start - chunks[0] * this.chunkSize;
const sliceEnd: number = sliceStart + (end - start);
return out.substring(sliceStart, sliceEnd);
}
/** 全部块已加载。 */
isComplete(): boolean {
return this.chunkCount() === 0 || this.loaded.size === this.chunkCount();
}
}
@@ -0,0 +1,79 @@
/**
* 单元格渲染器注册表(1.7.10)。
*
* 列类型 → 渲染器;支持自定义渲染器按优先级覆盖默认映射。
* 渲染器只描述「用哪种视图」,实际绘制由 UI 层按 kind 分发。
*/
export interface RendererSpec {
/** 渲染器标识,如 number/boolean/json/binary/date/text/link/color。 */
kind: string;
/** 显示名。 */
label: string;
/** 优先级:数值大者优先(自定义高于默认)。 */
priority: number;
/** 适用的列类型(小写),如 int、varchar、json、blob、date。 */
columnTypes: string[];
}
export class RendererRegistry {
private readonly specs: RendererSpec[] = [];
constructor() {
const defaults: RendererSpec[] = [
{ kind: 'number', label: '数字', priority: 0, columnTypes: ['int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real'] },
{ kind: 'boolean', label: '布尔', priority: 0, columnTypes: ['boolean', 'bool', 'tinyint(1)'] },
{ kind: 'json', label: 'JSON', priority: 0, columnTypes: ['json', 'jsonb'] },
{ kind: 'binary', label: '二进制', priority: 0, columnTypes: ['blob', 'bytea', 'varbinary', 'binary'] },
{ kind: 'date', label: '日期', priority: 0, columnTypes: ['date', 'datetime', 'timestamp', 'time'] },
{ kind: 'link', label: '链接', priority: 0, columnTypes: ['url', 'link'] },
{ kind: 'text', label: '文本', priority: -1, columnTypes: ['char', 'varchar', 'text', 'clob', 'nvarchar'] }
];
for (let i = 0; i < defaults.length; i++) {
this.specs.push(defaults[i]);
}
}
/** 注册自定义渲染器;同 kind 覆盖。 */
register(spec: RendererSpec): void {
for (let i = 0; i < this.specs.length; i++) {
if (this.specs[i].kind === spec.kind) {
this.specs.splice(i, 1);
break;
}
}
this.specs.push(spec);
}
/** 按列类型解析渲染器;未命中返回 text。 */
resolve(columnType: string): string {
const type: string = columnType.trim().toLowerCase();
let best: RendererSpec | undefined = undefined;
for (let i = 0; i < this.specs.length; i++) {
const spec: RendererSpec = this.specs[i];
if (spec.columnTypes.indexOf(type) < 0) {
continue;
}
if (best === undefined || spec.priority > best.priority) {
best = spec;
}
}
return best === undefined ? 'text' : best.kind;
}
/** 全部候选(按优先级降序)。 */
candidates(columnType: string): RendererSpec[] {
const type: string = columnType.trim().toLowerCase();
const out: RendererSpec[] = [];
for (let i = 0; i < this.specs.length; i++) {
if (this.specs[i].columnTypes.indexOf(type) >= 0) {
out.push(this.specs[i]);
}
}
out.sort((a: RendererSpec, b: RendererSpec) => b.priority - a.priority);
return out;
}
list(): RendererSpec[] {
return this.specs.slice();
}
}
+184
View File
@@ -0,0 +1,184 @@
/**
* 行内编辑与事务化提交(1.7.6)。
*
* 编辑模型:
* - 单元格修改先进入「待提交变更集」,按行聚合;
* - 提交时按主键生成 UPDATE 语句;主键缺失或变更集为空时拒绝提交(避免全表更新);
* - 支持按行回滚与全部回滚。
*
* 安全约束(显式):
* - 没有主键值的行不允许提交,validate() 给出错误;
* - 值按字面量转义并拒绝 NUL;
* - 只更新发生变更的列。
*/
import { TableDef } from '../schema/TableModel';
import { DdlGenerator, SqlDialect } from '../schema/DdlGenerator';
export interface CellChange {
rowIndex: number;
column: string;
oldValue: string;
newValue: string;
}
export interface KeyPart {
column: string;
value: string;
}
export interface CommitResult {
statements: string[];
errors: string[];
}
export class TableEditor {
private readonly changes: Map<number, Map<string, CellChange>> = new Map<number, Map<string, CellChange>>();
private readonly rowKeys: Map<number, KeyPart[]> = new Map<number, KeyPart[]>();
/** 记录单元格修改;同一行同一列重复修改保留最初的 oldValue。 */
setCell(rowIndex: number, column: string, oldValue: string, newValue: string): void {
let rowChanges: Map<string, CellChange> | undefined = this.changes.get(rowIndex);
if (rowChanges === undefined) {
rowChanges = new Map<string, CellChange>();
this.changes.set(rowIndex, rowChanges);
}
const existing: CellChange | undefined = rowChanges.get(column);
const originalOld: string = existing === undefined ? oldValue : existing.oldValue;
if (originalOld === newValue) {
rowChanges.delete(column);
} else {
rowChanges.set(column, {
rowIndex: rowIndex,
column: column,
oldValue: originalOld,
newValue: newValue
});
}
if (rowChanges.size === 0) {
this.changes.delete(rowIndex);
}
}
/** 记录行的主键值(提交 UPDATE 的条件)。 */
setRowKey(rowIndex: number, keys: KeyPart[]): void {
this.rowKeys.set(rowIndex, keys.slice());
}
/** 某行的变更(列名升序,保证输出稳定)。 */
changesFor(rowIndex: number): CellChange[] {
const rowChanges: Map<string, CellChange> | undefined = this.changes.get(rowIndex);
if (rowChanges === undefined) {
return [];
}
const out: CellChange[] = [];
rowChanges.forEach((change: CellChange) => {
out.push(change);
});
out.sort((a: CellChange, b: CellChange) => a.column.localeCompare(b.column));
return out;
}
/** 全部变更(按行号升序)。 */
allChanges(): CellChange[] {
const rows: number[] = this.dirtyRows();
const out: CellChange[] = [];
for (let i = 0; i < rows.length; i++) {
const changes: CellChange[] = this.changesFor(rows[i]);
for (let j = 0; j < changes.length; j++) {
out.push(changes[j]);
}
}
return out;
}
dirtyRows(): number[] {
const out: number[] = [];
this.changes.forEach((value: Map<string, CellChange>, row: number) => {
out.push(row);
});
out.sort((a: number, b: number) => a - b);
return out;
}
isDirty(rowIndex: number): boolean {
return this.changes.has(rowIndex);
}
changeCount(): number {
let count: number = 0;
this.changes.forEach((rowChanges: Map<string, CellChange>) => {
count += rowChanges.size;
});
return count;
}
/** 回滚某行的全部修改。 */
revertRow(rowIndex: number): void {
this.changes.delete(rowIndex);
}
/** 回滚全部修改(主键信息保留,便于重新编辑)。 */
revertAll(): void {
this.changes.clear();
}
/** 校验是否可以提交;返回错误列表。 */
validate(): string[] {
const errors: string[] = [];
const rows: number[] = this.dirtyRows();
for (let i = 0; i < rows.length; i++) {
const keys: KeyPart[] | undefined = this.rowKeys.get(rows[i]);
if (keys === undefined || keys.length === 0) {
errors.push('row ' + rows[i] + ' has no primary key; refusing to generate UPDATE');
}
}
return errors;
}
/** 生成 UPDATE 语句(仅变更列,主键作为条件);有错误时 statements 为空。 */
commit(table: TableDef, dialect: SqlDialect): CommitResult {
const errors: string[] = this.validate();
if (errors.length > 0) {
return { statements: [], errors: errors };
}
if (this.changeCount() === 0) {
return { statements: [], errors: [] };
}
const tableName: string = TableEditor.tableName(table, dialect);
const statements: string[] = [];
const rows: number[] = this.dirtyRows();
for (let i = 0; i < rows.length; i++) {
const changes: CellChange[] = this.changesFor(rows[i]);
if (changes.length === 0) {
continue;
}
const assignments: string[] = [];
for (let j = 0; j < changes.length; j++) {
assignments.push(DdlGenerator.quote(changes[j].column, dialect) + ' = ' +
TableEditor.literal(changes[j].newValue));
}
const keys: KeyPart[] = this.rowKeys.get(rows[i]) as KeyPart[];
const conditions: string[] = [];
for (let j = 0; j < keys.length; j++) {
conditions.push(DdlGenerator.quote(keys[j].column, dialect) + ' = ' +
TableEditor.literal(keys[j].value));
}
statements.push('UPDATE ' + tableName + ' SET ' + assignments.join(', ') +
' WHERE ' + conditions.join(' AND ') + ';');
}
return { statements: statements, errors: [] };
}
private static tableName(table: TableDef, dialect: SqlDialect): string {
return DdlGenerator.qualifiedName(table.schema, table.name, dialect);
}
private static literal(value: string): string {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) === 0) {
throw new Error('value contains NUL character');
}
}
return '\'' + value.replace(new RegExp('\'', 'g'), '\'\'') + '\'';
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* 数据校验与冲突提示(1.7.11)。
*
* 两层:
* - RowValidator:按列规则(必填/长度/正则)校验单元格与整行;
* - ConflictDetector:乐观并发——提交前比对「读取时版本」与「当前库中版本」,
* 不一致即判定冲突,UI 提示用户重新加载或覆盖。
*/
export interface ColumnRule {
column: string;
required: boolean;
minLength: number;
maxLength: number;
/** 正则;为空表示不校验。 */
pattern: string;
}
export interface ValidationIssue {
column: string;
message: string;
}
export class RowValidator {
/** 校验单元格,返回错误消息(空数组通过)。 */
static validateCell(rule: ColumnRule, value: string): string[] {
const errors: string[] = [];
const text: string = value === undefined || value === null ? '' : value;
if (rule.required && text.trim().length === 0) {
errors.push(rule.column + ' is required');
return errors;
}
if (text.length > 0 && rule.maxLength > 0 && text.length > rule.maxLength) {
errors.push(rule.column + ' exceeds max length ' + rule.maxLength);
}
if (text.length > 0 && rule.minLength > 0 && text.length < rule.minLength) {
errors.push(rule.column + ' is shorter than min length ' + rule.minLength);
}
if (text.length > 0 && rule.pattern.length > 0 && !new RegExp(rule.pattern).test(text)) {
errors.push(rule.column + ' does not match pattern ' + rule.pattern);
}
return errors;
}
/** 校验整行(values: 列 → 值)。 */
static validateRow(values: Map<string, string>, rules: ColumnRule[]): ValidationIssue[] {
const issues: ValidationIssue[] = [];
for (let i = 0; i < rules.length; i++) {
const rule: ColumnRule = rules[i];
const value: string | undefined = values.get(rule.column);
const errors: string[] = RowValidator.validateCell(rule, value === undefined ? '' : value);
for (let j = 0; j < errors.length; j++) {
issues.push({ column: rule.column, message: errors[j] });
}
}
return issues;
}
/** 便捷:无错误为 true。 */
static isValid(values: Map<string, string>, rules: ColumnRule[]): boolean {
return RowValidator.validateRow(values, rules).length === 0;
}
}
export interface ConflictInfo {
conflict: boolean;
reason: string;
}
export class ConflictDetector {
/**
* 乐观并发检测:读取时版本与提交时版本不同即冲突。
* 版本可以是时间戳、行版本号或 updated_at 字符串。
*/
static detect(fetchedVersion: string, currentVersion: string): ConflictInfo {
if (fetchedVersion === currentVersion) {
return { conflict: false, reason: '' };
}
return {
conflict: true,
reason: 'row changed since load: fetched=' + fetchedVersion + ', current=' + currentVersion
};
}
/** 批量检测(行号 → {fetched, current})。 */
static detectBatch(pairs: Map<number, string[]>): Map<number, ConflictInfo> {
const out: Map<number, ConflictInfo> = new Map<number, ConflictInfo>();
pairs.forEach((versions: string[], row: number) => {
if (versions.length >= 2) {
out.set(row, ConflictDetector.detect(versions[0], versions[1]));
}
});
return out;
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* 批量导出任务队列(1.8.10)。
*
* 多表/多格式导出编排:任务按加入顺序执行,记录进度与错误;
* 支持取消与汇总统计。实际 I/O 由调用方执行后回写状态。
*/
export enum TaskState {
PENDING = 0,
RUNNING = 1,
SUCCEEDED = 2,
FAILED = 3,
CANCELLED = 4
}
export interface BatchTask {
id: string;
label: string;
state: TaskState;
/** 0–100。 */
progress: number;
error: string;
}
export interface BatchSummary {
total: number;
pending: number;
running: number;
succeeded: number;
failed: number;
cancelled: number;
}
export class BatchQueue {
private readonly tasks: BatchTask[] = [];
private nextId: number = 1;
add(label: string): BatchTask {
const task: BatchTask = {
id: 'task-' + this.nextId,
label: label.trim(),
state: TaskState.PENDING,
progress: 0,
error: ''
};
this.nextId++;
this.tasks.push(task);
return task;
}
list(): BatchTask[] {
return this.tasks.slice();
}
find(id: string): BatchTask | undefined {
for (let i = 0; i < this.tasks.length; i++) {
if (this.tasks[i].id === id) {
return this.tasks[i];
}
}
return undefined;
}
start(id: string): boolean {
const task: BatchTask | undefined = this.find(id);
if (task === undefined || task.state !== TaskState.PENDING) {
return false;
}
task.state = TaskState.RUNNING;
task.progress = 0;
return true;
}
updateProgress(id: string, progress: number): void {
const task: BatchTask | undefined = this.find(id);
if (task === undefined || task.state !== TaskState.RUNNING) {
return;
}
task.progress = progress < 0 ? 0 : (progress > 100 ? 100 : progress);
}
succeed(id: string): boolean {
const task: BatchTask | undefined = this.find(id);
if (task === undefined) {
return false;
}
task.state = TaskState.SUCCEEDED;
task.progress = 100;
task.error = '';
return true;
}
fail(id: string, error: string): boolean {
const task: BatchTask | undefined = this.find(id);
if (task === undefined) {
return false;
}
task.state = TaskState.FAILED;
task.error = error;
return true;
}
cancel(id: string): boolean {
const task: BatchTask | undefined = this.find(id);
if (task === undefined || task.state === TaskState.SUCCEEDED || task.state === TaskState.FAILED) {
return false;
}
task.state = TaskState.CANCELLED;
return true;
}
/** 下一个待执行任务。 */
next(): BatchTask | undefined {
for (let i = 0; i < this.tasks.length; i++) {
if (this.tasks[i].state === TaskState.PENDING) {
return this.tasks[i];
}
}
return undefined;
}
/** 全部任务进入终态。 */
allFinished(): boolean {
for (let i = 0; i < this.tasks.length; i++) {
const state: TaskState = this.tasks[i].state;
if (state === TaskState.PENDING || state === TaskState.RUNNING) {
return false;
}
}
return true;
}
summary(): BatchSummary {
const out: BatchSummary = { total: this.tasks.length, pending: 0, running: 0, succeeded: 0, failed: 0, cancelled: 0 };
for (let i = 0; i < this.tasks.length; i++) {
switch (this.tasks[i].state) {
case TaskState.PENDING:
out.pending++;
break;
case TaskState.RUNNING:
out.running++;
break;
case TaskState.SUCCEEDED:
out.succeeded++;
break;
case TaskState.FAILED:
out.failed++;
break;
default:
out.cancelled++;
break;
}
}
return out;
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* CSV / Text 导入导出(1.8.2)。
*
* 在 DelimitedCodec 之上补齐导入导出语义:
* - 导出:可选表头、可强制全字段加引号;
* - 导入:可选首行表头、列数一致性校验(带行号),返回表头与数据行。
*/
import { DelimitedCodec } from '../grid/DelimitedCodec';
export interface CsvOptions {
/** 分隔符,默认逗号;文本格式可用 \t。 */
delimiter: string;
/** 首行是否为表头。 */
hasHeader: boolean;
/** 导出时所有字段加引号。 */
quoteAll: boolean;
}
export interface CsvImportResult {
header: string[];
rows: string[][];
errors: string[];
}
export class CsvHandler {
static defaultOptions(): CsvOptions {
return { delimiter: ',', hasHeader: true, quoteAll: false };
}
/** 导出为 CSV/文本。 */
static exportRows(rows: string[][], options: CsvOptions): string {
const delimiter: string = options.delimiter;
if (!options.quoteAll) {
return DelimitedCodec.serialize(rows, delimiter);
}
const lines: string[] = [];
for (let i = 0; i < rows.length; i++) {
const cells: string[] = [];
for (let j = 0; j < rows[i].length; j++) {
cells.push('"' + rows[i][j].replace(new RegExp('"', 'g'), '""') + '"');
}
lines.push(cells.join(delimiter));
}
return lines.join('\n');
}
/** 导入并校验列数。 */
static importText(text: string, options: CsvOptions): CsvImportResult {
const parsed: string[][] = DelimitedCodec.parse(text, options.delimiter);
const header: string[] = [];
const rows: string[][] = [];
const errors: string[] = [];
let expected: number = -1;
for (let i = 0; i < parsed.length; i++) {
const line: string[] = parsed[i];
if (i === 0 && options.hasHeader) {
for (let j = 0; j < line.length; j++) {
header.push(line[j].trim());
}
expected = header.length;
continue;
}
if (expected < 0) {
expected = line.length;
}
if (line.length !== expected) {
errors.push('line ' + (i + 1) + ': expected ' + expected + ' columns, got ' + line.length);
}
rows.push(line);
}
if (options.hasHeader && header.length === 0) {
errors.push('header row is empty');
}
return { header: header, rows: rows, errors: errors };
}
/** 转成「列名 → 值」的行映射(需有表头)。 */
static toMaps(result: CsvImportResult): Map<string, string>[] {
const out: Map<string, string>[] = [];
for (let i = 0; i < result.rows.length; i++) {
const map: Map<string, string> = new Map<string, string>();
for (let j = 0; j < result.header.length; j++) {
const value: string | undefined = result.rows[i][j];
map.set(result.header[j], value === undefined ? '' : value);
}
out.push(map);
}
return out;
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 格式插件注册表(1.8.7)。
*
* 内置格式(csv/tsv/json/xml/sql/xlsx/html/pdf)与自定义格式统一注册;
* 按 id 或扩展名查找,供向导第 1 步与导出菜单使用。
*
* 说明:注册表只描述能力(能否导入/导出、扩展名、MIME),
* 实际编解码由对应 Handler 实现,注册表不做 I/O。
*/
export interface FormatSpec {
id: string;
label: string;
extensions: string[];
mimeType: string;
supportsImport: boolean;
supportsExport: boolean;
}
export class FormatRegistry {
private readonly specs: FormatSpec[] = [];
constructor() {
const builtins: FormatSpec[] = [
{ id: 'csv', label: 'CSV', extensions: ['csv'], mimeType: 'text/csv', supportsImport: true, supportsExport: true },
{ id: 'tsv', label: 'TSV / 文本', extensions: ['tsv', 'txt'], mimeType: 'text/tab-separated-values', supportsImport: true, supportsExport: true },
{ id: 'json', label: 'JSON', extensions: ['json'], mimeType: 'application/json', supportsImport: true, supportsExport: true },
{ id: 'xml', label: 'XML', extensions: ['xml'], mimeType: 'application/xml', supportsImport: true, supportsExport: true },
{ id: 'sql', label: 'SQL 脚本', extensions: ['sql'], mimeType: 'text/x-sql', supportsImport: false, supportsExport: true },
{ id: 'xlsx', label: 'Excel', extensions: ['xlsx'], mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', supportsImport: true, supportsExport: true },
{ id: 'html', label: 'HTML', extensions: ['html', 'htm'], mimeType: 'text/html', supportsImport: false, supportsExport: true },
{ id: 'pdf', label: 'PDF', extensions: ['pdf'], mimeType: 'application/pdf', supportsImport: false, supportsExport: true }
];
for (let i = 0; i < builtins.length; i++) {
this.specs.push(builtins[i]);
}
}
register(spec: FormatSpec): void {
for (let i = 0; i < this.specs.length; i++) {
if (this.specs[i].id === spec.id) {
this.specs.splice(i, 1);
break;
}
}
this.specs.push(spec);
}
find(id: string): FormatSpec | undefined {
const key: string = id.trim().toLowerCase();
for (let i = 0; i < this.specs.length; i++) {
if (this.specs[i].id === key) {
return this.specs[i];
}
}
return undefined;
}
/** 按扩展名查找(默认带点,可省略)。 */
forExtension(ext: string): FormatSpec | undefined {
let key: string = ext.trim().toLowerCase();
if (key.length > 0 && key.charAt(0) === '.') {
key = key.substring(1);
}
for (let i = 0; i < this.specs.length; i++) {
if (this.specs[i].extensions.indexOf(key) >= 0) {
return this.specs[i];
}
}
return undefined;
}
list(): FormatSpec[] {
return this.specs.slice();
}
importable(): FormatSpec[] {
return this.specs.filter((spec: FormatSpec) => spec.supportsImport);
}
exportable(): FormatSpec[] {
return this.specs.filter((spec: FormatSpec) => spec.supportsExport);
}
}
+78
View File
@@ -0,0 +1,78 @@
/**
* HTML 导出(1.8.8)。
*
* 把结果集渲染为可独立打开的 HTML 文档(含基础样式与表格)。
* 所有单元格内容做 HTML 转义,避免导出文件里的脚本被浏览器执行。
*/
export interface HtmlOptions {
title: string;
/** 是否输出表头行。 */
withHeader: boolean;
/** 附加说明(如导出时间、连接名),可空。 */
footer: string;
}
export class HtmlExporter {
static defaultOptions(): HtmlOptions {
return { title: '查询结果', withHeader: true, footer: '' };
}
/** 仅表格片段。 */
static toTable(header: string[], rows: string[][]): string {
const parts: string[] = [];
parts.push('<table>');
if (header.length > 0) {
parts.push(' <thead><tr>');
for (let i = 0; i < header.length; i++) {
parts.push(' <th>' + HtmlExporter.escape(header[i]) + '</th>');
}
parts.push(' </tr></thead>');
}
parts.push(' <tbody>');
for (let i = 0; i < rows.length; i++) {
parts.push(' <tr>');
for (let j = 0; j < rows[i].length; j++) {
parts.push(' <td>' + HtmlExporter.escape(rows[i][j]) + '</td>');
}
parts.push(' </tr>');
}
parts.push(' </tbody>');
parts.push('</table>');
return parts.join('\n');
}
/** 完整 HTML 文档。 */
static toDocument(header: string[], rows: string[][], options: HtmlOptions): string {
const lines: string[] = [];
lines.push('<!DOCTYPE html>');
lines.push('<html lang="zh-CN">');
lines.push('<head>');
lines.push(' <meta charset="UTF-8">');
lines.push(' <title>' + HtmlExporter.escape(options.title) + '</title>');
lines.push(' <style>');
lines.push(' body{font-family:-apple-system,"HarmonyOS Sans SC",sans-serif;margin:24px;color:#1f2329}');
lines.push(' h1{font-size:18px;margin:0 0 16px}');
lines.push(' table{border-collapse:collapse;font-size:13px}');
lines.push(' th,td{border:1px solid #c9cdd4;padding:4px 8px;text-align:left}');
lines.push(' th{background:#f2f3f5;font-weight:600}');
lines.push(' footer{color:#86909c;font-size:12px;margin-top:12px}');
lines.push(' </style>');
lines.push('</head>');
lines.push('<body>');
lines.push(' <h1>' + HtmlExporter.escape(options.title) + '</h1>');
lines.push(HtmlExporter.toTable(options.withHeader ? header : [], rows));
if (options.footer.length > 0) {
lines.push(' <footer>' + HtmlExporter.escape(options.footer) + '</footer>');
}
lines.push('</body>');
lines.push('</html>');
return lines.join('\n');
}
static escape(text: string): string {
return text.replace(new RegExp('&', 'g'), '&amp;')
.replace(new RegExp('<', 'g'), '&lt;')
.replace(new RegExp('>', 'g'), '&gt;')
.replace(new RegExp('"', 'g'), '&quot;');
}
}
+103
View File
@@ -0,0 +1,103 @@
/**
* JSON 导入导出(1.8.5)。
*
* 导出行对象数组:[{"id":"1","name":"alice"}, …]。
* 导入要求为对象数组;缺列按空值补齐,并检查列集合一致性(不一致记为警告)。
* 解析失败返回错误而非抛异常。
*/
export interface JsonParseResult {
header: string[];
rows: string[][];
errors: string[];
warnings: string[];
}
export class JsonCodec {
static serialize(header: string[], rows: string[][]): string {
const objects: Map<string, string>[] = [];
for (let i = 0; i < rows.length; i++) {
const obj: Map<string, string> = new Map<string, string>();
for (let j = 0; j < header.length; j++) {
const value: string | undefined = rows[i][j];
obj.set(header[j], value === undefined ? '' : value);
}
objects.push(obj);
}
const plain: Record<string, string>[] = [];
for (let i = 0; i < objects.length; i++) {
const record: Record<string, string> = {};
objects[i].forEach((value: string, key: string) => {
record[key] = value;
});
plain.push(record);
}
return JSON.stringify(plain, null, 2);
}
static parse(text: string): JsonParseResult {
const errors: string[] = [];
const warnings: string[] = [];
let parsed: Object | null = null;
try {
parsed = JSON.parse(text) as Object;
} catch (e) {
const message: string = e instanceof Error ? e.message : String(e);
return { header: [], rows: [], errors: ['invalid JSON: ' + message], warnings: [] };
}
if (!Array.isArray(parsed)) {
return { header: [], rows: [], errors: ['top-level JSON value must be an array'], warnings: [] };
}
const array: Object[] = parsed as Object[];
if (array.length === 0) {
return { header: [], rows: [], errors: [], warnings: ['empty array'] };
}
const header: string[] = [];
const first: Object = array[0];
if (first === null || Array.isArray(first) || typeof first !== 'object') {
return { header: [], rows: [], errors: ['array items must be objects'], warnings: [] };
}
const firstRecord: Record<string, Object> = first as Record<string, Object>;
for (const key in firstRecord) {
header.push(key);
}
const rows: string[][] = [];
for (let i = 0; i < array.length; i++) {
const item: Object = array[i];
if (item === null || Array.isArray(item) || typeof item !== 'object') {
errors.push('item ' + i + ' is not an object');
continue;
}
const record: Record<string, Object> = item as Record<string, Object>;
const row: string[] = [];
for (let j = 0; j < header.length; j++) {
const value: Object = record[header[j]];
if (value === undefined) {
warnings.push('item ' + i + ' missing key ' + header[j]);
row.push('');
} else {
row.push(JsonCodec.stringifyValue(value));
}
}
for (const key in record) {
if (header.indexOf(key) < 0) {
warnings.push('item ' + i + ' has extra key ' + key + ' not in header');
}
}
rows.push(row);
}
return { header: header, rows: rows, errors: errors, warnings: warnings };
}
private static stringifyValue(value: Object): string {
if (value === null) {
return '';
}
if (typeof value === 'string') {
return value as string;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 工作表模型(1.8.3)。
*
* Excel/多工作表导出的中间表示:工作表集合、行数据、列宽与名称校验。
* 字节级 xlsx 编解码由外部库承担(见 DBM-IO-001 评估),本模型只做数据组织与校验,
* 避免把第三方库耦合进 data 层。
*/
export interface SheetData {
name: string;
header: string[];
rows: string[][];
}
export class SpreadsheetModel {
private readonly sheets: SheetData[] = [];
addSheet(name: string, header: string[], rows: string[][]): SheetData {
const sheet: SheetData = { name: name.trim(), header: header.slice(), rows: rows };
this.sheets.push(sheet);
return sheet;
}
sheets_(): SheetData[] {
return this.sheets.slice();
}
sheet(name: string): SheetData | undefined {
for (let i = 0; i < this.sheets.length; i++) {
if (this.sheets[i].name === name) {
return this.sheets[i];
}
}
return undefined;
}
totalRows(): number {
let count: number = 0;
for (let i = 0; i < this.sheets.length; i++) {
count += this.sheets[i].rows.length;
}
return count;
}
/** 校验:名称非空、唯一、不超过 31 字符(Excel 限制)、列宽一致。 */
validate(): string[] {
const errors: string[] = [];
const names: Set<string> = new Set<string>();
for (let i = 0; i < this.sheets.length; i++) {
const sheet: SheetData = this.sheets[i];
const key: string = sheet.name.toLowerCase();
if (sheet.name.length === 0) {
errors.push('sheet ' + i + ' has empty name');
}
if (sheet.name.length > 31) {
errors.push('sheet ' + sheet.name + ' exceeds 31 characters');
}
if (names.has(key)) {
errors.push('duplicate sheet: ' + sheet.name);
}
names.add(key);
for (let r = 0; r < sheet.rows.length; r++) {
if (sheet.header.length > 0 && sheet.rows[r].length !== sheet.header.length) {
errors.push('sheet ' + sheet.name + ' row ' + r + ' column count mismatch');
break;
}
}
}
return errors;
}
}
+65
View File
@@ -0,0 +1,65 @@
/**
* SQL 脚本导出(1.8.6)。
*
* 生成建表脚本与 INSERT 数据脚本:
* - 建表复用 DdlGenerator(含索引);
* - INSERT 支持按行数批量合并(多行 VALUES),值转义、拒绝 NUL。
*/
import { TableDef } from '../schema/TableModel';
import { DdlGenerator, SqlDialect } from '../schema/DdlGenerator';
export class SqlExporter {
/** 生成 INSERT 语句;rows 按列顺序给出。 */
static insertStatements(table: TableDef, rows: string[][], dialect: SqlDialect, batchSize: number): string[] {
const tableName: string = DdlGenerator.qualifiedName(table.schema, table.name, dialect);
const columns: string[] = [];
for (let i = 0; i < table.columns.length; i++) {
columns.push(DdlGenerator.quote(table.columns[i].name, dialect));
}
const columnList: string = columns.join(', ');
const size: number = batchSize > 0 ? batchSize : 1;
const out: string[] = [];
for (let start = 0; start < rows.length; start += size) {
const tuples: string[] = [];
for (let i = start; i < rows.length && i < start + size; i++) {
const row: string[] = rows[i];
if (row.length !== table.columns.length) {
throw new Error('row ' + i + ' has ' + row.length + ' values, expected ' + table.columns.length);
}
const values: string[] = [];
for (let j = 0; j < row.length; j++) {
values.push(SqlExporter.literal(row[j]));
}
tuples.push('(' + values.join(', ') + ')');
}
out.push('INSERT INTO ' + tableName + ' (' + columnList + ') VALUES ' + tuples.join(', ') + ';');
}
return out;
}
/** 完整脚本:建表 + 可选数据。 */
static script(table: TableDef, rows: string[][], dialect: SqlDialect, includeStructure: boolean,
includeData: boolean, batchSize: number): string {
const parts: string[] = [];
if (includeStructure) {
parts.push('-- ' + table.name);
parts.push(DdlGenerator.generate(table, dialect));
}
if (includeData && rows.length > 0) {
const inserts: string[] = SqlExporter.insertStatements(table, rows, dialect, batchSize);
for (let i = 0; i < inserts.length; i++) {
parts.push(inserts[i]);
}
}
return parts.join('\n');
}
private static literal(value: string): string {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) === 0) {
throw new Error('value contains NUL character');
}
}
return '\'' + value.replace(new RegExp('\'', 'g'), '\'\'') + '\'';
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 编码、换行与 BOM 处理(1.8.11)。
*
* - detectBom:按字节前缀识别 UTF-8 / UTF-16LE / UTF-16BE;
* - stripBomText:去除字符串开头的 BOM 字符;
* - detectEol:识别 \r\n / \n / \r;
* - normalizeEol:统一换行;
* - 分隔符建议:按首行出现次数推断逗号/制表符/分号。
*
* 编码解码本身依赖平台 API(TextDecoder 等),本模块只做判定与文本处理。
*/
export class TextEncoding {
/** 字节级 BOM 识别。 */
static detectBom(bytes: number[]): string | undefined {
if (bytes.length >= 3 && (bytes[0] & 0xFF) === 0xEF && (bytes[1] & 0xFF) === 0xBB && (bytes[2] & 0xFF) === 0xBF) {
return 'utf-8';
}
if (bytes.length >= 2 && (bytes[0] & 0xFF) === 0xFF && (bytes[1] & 0xFF) === 0xFE) {
return 'utf-16le';
}
if (bytes.length >= 2 && (bytes[0] & 0xFF) === 0xFE && (bytes[1] & 0xFF) === 0xFF) {
return 'utf-16be';
}
return undefined;
}
/** 去除文本开头的 BOM 字符(U+FEFF)。 */
static stripBomText(text: string): string {
if (text.length > 0 && text.charCodeAt(0) === 0xFEFF) {
return text.substring(1);
}
return text;
}
/** 识别换行风格:CRLF / LF / CR;无换行返回 \n。 */
static detectEol(text: string): string {
if (text.indexOf('\r\n') >= 0) {
return '\r\n';
}
if (text.indexOf('\n') >= 0) {
return '\n';
}
if (text.indexOf('\r') >= 0) {
return '\r';
}
return '\n';
}
/** 统一换行风格。 */
static normalizeEol(text: string, eol: string): string {
const unified: string = text.replace(new RegExp('\r\n', 'g'), '\n').replace(new RegExp('\r', 'g'), '\n');
return unified.replace(new RegExp('\n', 'g'), eol);
}
/**
* 推断分隔符:统计首行中逗号、制表符、分号的出现次数,取最多者;全为 0 返回逗号。
*/
static suggestDelimiter(text: string): string {
const firstLine: string = text.split('\n')[0];
const candidates: string[] = [',', '\t', ';'];
let best: string = ',';
let bestCount: number = 0;
for (let i = 0; i < candidates.length; i++) {
let count: number = 0;
for (let j = 0; j < firstLine.length; j++) {
if (firstLine.charAt(j) === candidates[i]) {
count++;
}
}
if (count > bestCount) {
best = candidates[i];
bestCount = count;
}
}
return best;
}
}
+189
View File
@@ -0,0 +1,189 @@
/**
* 类型转换(1.8.9)。
*
* 导入时把文本转换为目标列类型的规范文本:
* - INTEGER:可选符号 + 数字;
* - DECIMAL:可选符号 + 数字 + 可选小数;
* - BOOLEAN:true/false/1/0/yes/no/是/否 → true/false;
* - DATE:YYYY-MM-DD(接受 / 与 . 分隔);
* - TIMESTAMP:YYYY-MM-DD HH:MM:SS(接受 T 分隔)。
*
* 空值统一返回空字符串(是否允许空由列约束另行校验)。
*/
export enum TargetType {
STRING = 0,
INTEGER = 1,
DECIMAL = 2,
BOOLEAN = 3,
DATE = 4,
TIMESTAMP = 5
}
export interface ConvertResult {
ok: boolean;
value: string;
error: string;
}
export class TypeConverter {
static convert(value: string, target: TargetType): ConvertResult {
const text: string = value === undefined || value === null ? '' : value.trim();
if (text.length === 0) {
return { ok: true, value: '', error: '' };
}
switch (target) {
case TargetType.INTEGER:
return TypeConverter.asInteger(text);
case TargetType.DECIMAL:
return TypeConverter.asDecimal(text);
case TargetType.BOOLEAN:
return TypeConverter.asBoolean(text);
case TargetType.DATE:
return TypeConverter.asDate(text);
case TargetType.TIMESTAMP:
return TypeConverter.asTimestamp(text);
default:
return { ok: true, value: text, error: '' };
}
}
/** 按列类型批量转换,返回错误列表(带列序号)。 */
static convertRow(values: string[], types: TargetType[]): { values: string[]; errors: string[] } {
const out: string[] = [];
const errors: string[] = [];
for (let i = 0; i < values.length; i++) {
const type: TargetType = i < types.length ? types[i] : TargetType.STRING;
const result: ConvertResult = TypeConverter.convert(values[i], type);
out.push(result.value);
if (!result.ok) {
errors.push('column ' + i + ': ' + result.error);
}
}
return { values: out, errors: errors };
}
private static asInteger(text: string): ConvertResult {
let start: number = 0;
if (text.charAt(0) === '-' || text.charAt(0) === '+') {
start = 1;
}
if (start >= text.length) {
return { ok: false, value: text, error: 'not an integer' };
}
for (let i = start; i < text.length; i++) {
const ch: string = text.charAt(i);
if (ch < '0' || ch > '9') {
return { ok: false, value: text, error: 'not an integer' };
}
}
return { ok: true, value: text, error: '' };
}
private static asDecimal(text: string): ConvertResult {
let start: number = 0;
if (text.charAt(0) === '-' || text.charAt(0) === '+') {
start = 1;
}
let dots: number = 0;
let digits: number = 0;
for (let i = start; i < text.length; i++) {
const ch: string = text.charAt(i);
if (ch === '.') {
dots++;
if (dots > 1) {
return { ok: false, value: text, error: 'not a decimal' };
}
continue;
}
if (ch < '0' || ch > '9') {
return { ok: false, value: text, error: 'not a decimal' };
}
digits++;
}
if (digits === 0) {
return { ok: false, value: text, error: 'not a decimal' };
}
return { ok: true, value: text, error: '' };
}
private static asBoolean(text: string): ConvertResult {
const lower: string = text.toLowerCase();
if (lower === 'true' || lower === '1' || lower === 'yes' || lower === 'y' || lower === '是') {
return { ok: true, value: 'true', error: '' };
}
if (lower === 'false' || lower === '0' || lower === 'no' || lower === 'n' || lower === '否') {
return { ok: true, value: 'false', error: '' };
}
return { ok: false, value: text, error: 'not a boolean' };
}
private static asDate(text: string): ConvertResult {
const normalized: string = text.replace(new RegExp('/', 'g'), '-').replace(new RegExp('\\.', 'g'), '-');
const parts: string[] = normalized.split('-');
if (parts.length !== 3) {
return { ok: false, value: text, error: 'expected YYYY-MM-DD' };
}
if (parts[0].length !== 4 || parts[1].length !== 2 || parts[2].length !== 2) {
return { ok: false, value: text, error: 'expected YYYY-MM-DD' };
}
if (!TypeConverter.allDigits(parts[0]) || !TypeConverter.allDigits(parts[1]) || !TypeConverter.allDigits(parts[2])) {
return { ok: false, value: text, error: 'expected YYYY-MM-DD' };
}
const month: number = parseInt(parts[1], 10);
const day: number = parseInt(parts[2], 10);
if (month < 1 || month > 12 || day < 1 || day > 31) {
return { ok: false, value: text, error: 'date out of range' };
}
return { ok: true, value: parts[0] + '-' + parts[1] + '-' + parts[2], error: '' };
}
private static asTimestamp(text: string): ConvertResult {
const normalized: string = text.replace(new RegExp('T', 'g'), ' ');
const spaceIndex: number = normalized.indexOf(' ');
if (spaceIndex < 0) {
const dateOnly: ConvertResult = TypeConverter.asDate(normalized);
return dateOnly.ok
? { ok: true, value: dateOnly.value + ' 00:00:00', error: '' }
: { ok: false, value: text, error: 'expected YYYY-MM-DD HH:MM:SS' };
}
const datePart: ConvertResult = TypeConverter.asDate(normalized.substring(0, spaceIndex));
if (!datePart.ok) {
return { ok: false, value: text, error: 'expected YYYY-MM-DD HH:MM:SS' };
}
const timePart: string = normalized.substring(spaceIndex + 1).trim();
const timeParts: string[] = timePart.split(':');
if (timeParts.length < 2 || timeParts.length > 3) {
return { ok: false, value: text, error: 'expected HH:MM[:SS]' };
}
const hour: string = timeParts[0];
const minute: string = timeParts[1];
const second: string = timeParts.length === 3 ? timeParts[2] : '00';
if (!TypeConverter.allDigits(hour) || !TypeConverter.allDigits(minute) || !TypeConverter.allDigits(second)) {
return { ok: false, value: text, error: 'expected HH:MM[:SS]' };
}
const h: number = parseInt(hour, 10);
const m: number = parseInt(minute, 10);
const s: number = parseInt(second, 10);
if (h > 23 || m > 59 || s > 59) {
return { ok: false, value: text, error: 'time out of range' };
}
return { ok: true, value: datePart.value + ' ' + TypeConverter.pad(hour) + ':' + TypeConverter.pad(minute) + ':' + TypeConverter.pad(second), error: '' };
}
private static allDigits(text: string): boolean {
if (text.length === 0) {
return false;
}
for (let i = 0; i < text.length; i++) {
const ch: string = text.charAt(i);
if (ch < '0' || ch > '9') {
return false;
}
}
return true;
}
private static pad(text: string): string {
return text.length < 2 ? '0' + text : text;
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* 导入导出向导(1.8.1)。
*
* 步骤:数据源 → 目标 → 字段映射 → 选项 → 预览 → 执行 → 完成。
* 每一步有校验门槛,未通过不允许进入下一步。
*
* 说明:向导只管理状态与校验,实际读写与执行由各格式模块(1.8.2–1.8.11)承担。
*/
export enum WizardStep {
SOURCE = 0,
TARGET = 1,
MAPPING = 2,
OPTIONS = 3,
PREVIEW = 4,
EXECUTE = 5,
DONE = 6
}
export const WIZARD_STEP_NAMES: string[] = [
'数据源', '目标', '字段映射', '选项', '预览', '执行', '完成'
];
export class WizardModel {
private step: WizardStep = WizardStep.SOURCE;
private readonly data: Map<string, string> = new Map<string, string>();
/** 当前步骤。 */
current(): WizardStep {
return this.step;
}
currentName(): string {
return WIZARD_STEP_NAMES[this.step];
}
stepCount(): number {
return WIZARD_STEP_NAMES.length;
}
/** 进度百分比(0–100)。 */
progress(): number {
return Math.round((this.step / (WIZARD_STEP_NAMES.length - 1)) * 100);
}
set(key: string, value: string): void {
this.data.set(key, value);
}
get(key: string): string {
const value: string | undefined = this.data.get(key);
return value === undefined ? '' : value;
}
has(key: string): boolean {
return this.data.has(key);
}
/** 当前步骤的校验错误;空数组表示可通过。 */
validateCurrent(): string[] {
const errors: string[] = [];
switch (this.step) {
case WizardStep.SOURCE:
if (this.get('sourceType').length === 0) {
errors.push('请选择数据源类型(文件或表)');
}
if (this.get('sourcePath').length === 0) {
errors.push('请指定源文件或源表');
}
break;
case WizardStep.TARGET:
if (this.get('targetTable').length === 0) {
errors.push('请指定目标表');
}
if (this.get('targetMode').length === 0) {
errors.push('请选择写入模式(追加/覆盖)');
}
break;
case WizardStep.MAPPING:
if (this.get('mappingCount') === '' || this.get('mappingCount') === '0') {
errors.push('请至少配置一个字段映射');
}
break;
case WizardStep.OPTIONS:
if (this.get('delimiter').length === 0 && this.get('format').length === 0) {
errors.push('请确认格式与分隔符');
}
break;
default:
break;
}
return errors;
}
/** 进入下一步;校验失败返回 false 且错误可从 lastErrors 读取。 */
next(): boolean {
this.lastErrors = this.validateCurrent();
if (this.lastErrors.length > 0) {
return false;
}
if (this.step < WizardStep.DONE) {
this.step = (this.step + 1) as WizardStep;
}
return true;
}
/** 返回上一步。 */
back(): void {
if (this.step > WizardStep.SOURCE) {
this.step = (this.step - 1) as WizardStep;
}
}
/** 跳转到指定步骤(仅允许回跳到已完成步骤,或前进一步)。 */
goTo(step: WizardStep): boolean {
if (step < WizardStep.SOURCE || step > WizardStep.DONE) {
return false;
}
if (step > this.step) {
return false;
}
this.step = step;
return true;
}
isDone(): boolean {
return this.step === WizardStep.DONE;
}
reset(): void {
this.step = WizardStep.SOURCE;
this.data.clear();
this.lastErrors = [];
}
private lastErrors: string[] = [];
lastValidationErrors(): string[] {
return this.lastErrors.slice();
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* XML 导入导出(1.8.4)。
*
* 格式(固定、可往返):
* <?xml version="1.0" encoding="UTF-8"?>
* <rows>
* <row><col name="id">1</col><col name="name">alice</col></row>
* </rows>
*
* 只解析本模块产出的结构,不实现通用 XML 解析器(避免实体/命名空间/DTD 攻击面)。
* 解析失败返回错误而非抛异常。
*/
export interface XmlParseResult {
header: string[];
rows: string[][];
errors: string[];
}
export class XmlCodec {
/** 序列化;值做 XML 实体转义。 */
static serialize(header: string[], rows: string[][], rootName: string): string {
const lines: string[] = [];
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
lines.push('<' + rootName + '>');
for (let i = 0; i < rows.length; i++) {
let line: string = ' <row>';
for (let j = 0; j < header.length; j++) {
const value: string | undefined = rows[i][j];
line += '<col name="' + XmlCodec.escape(header[j]) + '">' +
XmlCodec.escape(value === undefined ? '' : value) + '</col>';
}
line += '</row>';
lines.push(line);
}
lines.push('</' + rootName + '>');
return lines.join('\n');
}
/** 解析本模块格式;错误以文本列表返回。 */
static parse(text: string): XmlParseResult {
const errors: string[] = [];
const header: string[] = [];
const rows: string[][] = [];
const rowPattern: RegExp = new RegExp('<row>([\\s\\S]*?)</row>', 'g');
const colPattern: RegExp = new RegExp('<col name="([^"]*)">([\\s\\S]*?)</col>', 'g');
let rowMatch: RegExpExecArray | null = rowPattern.exec(text);
if (rowMatch === null) {
if (text.indexOf('<rows') < 0) {
errors.push('missing <rows> root element');
}
}
while (rowMatch !== null) {
const body: string = rowMatch[1];
const row: string[] = [];
let colMatch: RegExpExecArray | null = colPattern.exec(body);
const names: string[] = [];
while (colMatch !== null) {
names.push(XmlCodec.unescape(colMatch[1]));
row.push(XmlCodec.unescape(colMatch[2]));
colMatch = colPattern.exec(body);
}
if (header.length === 0) {
for (let k = 0; k < names.length; k++) {
header.push(names[k]);
}
}
rows.push(row);
rowMatch = rowPattern.exec(text);
}
if (rows.length === 0 && errors.length === 0) {
errors.push('no <row> elements found');
}
return { header: header, rows: rows, errors: errors };
}
static escape(text: string): string {
return text.replace(new RegExp('&', 'g'), '&amp;')
.replace(new RegExp('<', 'g'), '&lt;')
.replace(new RegExp('>', 'g'), '&gt;')
.replace(new RegExp('"', 'g'), '&quot;')
.replace(new RegExp('\'', 'g'), '&apos;');
}
static unescape(text: string): string {
return text.replace(new RegExp('&lt;', 'g'), '<')
.replace(new RegExp('&gt;', 'g'), '>')
.replace(new RegExp('&quot;', 'g'), '"')
.replace(new RegExp('&apos;', 'g'), '\'')
.replace(new RegExp('&amp;', 'g'), '&');
}
}
@@ -0,0 +1,110 @@
/**
* 元数据缓存(1.5.14)。
*
* 按「连接 + 模式 + 对象类型」分键缓存元数据,带 TTL 与容量上限;
* 时钟可注入,便于离线测试过期与刷新。
*
* 失效策略:
* - invalidate(key):精确失效(结构变更后单对象);
* - invalidatePrefix(prefix):按连接或模式批量失效(断开连接/刷新模式)。
*/
import { ObjectType } from '../metadata/Objects';
interface CacheEntry {
value: string;
expiresAt: number;
}
/** 缓存键构造。 */
export class CacheKeys {
static schema(connId: string, schema: string): string {
return 'conn:' + connId + ':schema:' + schema;
}
static objects(connId: string, schema: string, type: ObjectType): string {
return 'conn:' + connId + ':objects:' + schema + ':' + type;
}
static connectionPrefix(connId: string): string {
return 'conn:' + connId + ':';
}
}
export class MetadataCache {
private readonly ttlMs: number;
private readonly capacity: number;
private readonly clock: () => number;
private readonly entries: Map<string, CacheEntry> = new Map<string, CacheEntry>();
private order: string[] = [];
constructor(ttlMs: number, capacity: number, clock?: () => number) {
if (ttlMs <= 0) {
throw new Error('ttlMs must be > 0');
}
if (capacity < 1) {
throw new Error('capacity must be >= 1');
}
this.ttlMs = ttlMs;
this.capacity = capacity;
this.clock = clock === undefined ? () => Date.now() : clock;
}
get(key: string): string | undefined {
const entry: CacheEntry | undefined = this.entries.get(key);
if (entry === undefined) {
return undefined;
}
if (entry.expiresAt <= this.clock()) {
this.entries.delete(key);
this.removeFromOrder(key);
return undefined;
}
return entry.value;
}
put(key: string, value: string): void {
this.entries.set(key, { value: value, expiresAt: this.clock() + this.ttlMs });
this.removeFromOrder(key);
this.order.push(key);
while (this.order.length > this.capacity) {
const oldest: string | undefined = this.order.shift();
if (oldest !== undefined) {
this.entries.delete(oldest);
}
}
}
invalidate(key: string): void {
this.entries.delete(key);
this.removeFromOrder(key);
}
invalidatePrefix(prefix: string): number {
let removed: number = 0;
const keys: string[] = this.order.slice();
for (let i = 0; i < keys.length; i++) {
if (keys[i].indexOf(prefix) === 0) {
this.entries.delete(keys[i]);
removed++;
}
}
this.order = this.order.filter((key: string) => key.indexOf(prefix) !== 0);
return removed;
}
size(): number {
return this.order.length;
}
clear(): void {
this.entries.clear();
this.order = [];
}
private removeFromOrder(key: string): void {
const idx: number = this.order.indexOf(key);
if (idx >= 0) {
this.order.splice(idx, 1);
}
}
}
@@ -0,0 +1,120 @@
/**
* 对象树模型(1.5.1)。
*
* 把扁平的 DbObject 列表构建为可导航的树,并提供:
* - 根节点与子节点枚举;
* - 按名称/模式搜索(大小写不敏感);
* - 按类型过滤;
* - 从对象回溯祖先路径;
* - 类型计数(供对象树分组显示)。
*
* 纯逻辑,不依赖 UI 与平台 API。
*/
import { DbObject, ObjectType } from './Objects';
export class ObjectTreeModel {
private readonly byId: Map<string, DbObject> = new Map<string, DbObject>();
private readonly childrenIndex: Map<string, string[]> = new Map<string, string[]>();
private readonly rootIds: string[] = [];
constructor(objects: DbObject[]) {
for (let i = 0; i < objects.length; i++) {
this.byId.set(objects[i].id, objects[i]);
}
for (let i = 0; i < objects.length; i++) {
const obj: DbObject = objects[i];
if (obj.parentId.length === 0) {
this.rootIds.push(obj.id);
} else {
const list: string[] | undefined = this.childrenIndex.get(obj.parentId);
if (list === undefined) {
this.childrenIndex.set(obj.parentId, [obj.id]);
} else {
list.push(obj.id);
}
}
}
}
/** 根对象(通常是模式)。 */
roots(): DbObject[] {
const out: DbObject[] = [];
for (let i = 0; i < this.rootIds.length; i++) {
const obj = this.byId.get(this.rootIds[i]);
if (obj !== undefined) {
out.push(obj);
}
}
return out;
}
/** 直接子对象。 */
children(parentId: string): DbObject[] {
const ids: string[] | undefined = this.childrenIndex.get(parentId);
if (ids === undefined) {
return [];
}
const out: DbObject[] = [];
for (let i = 0; i < ids.length; i++) {
const obj = this.byId.get(ids[i]);
if (obj !== undefined) {
out.push(obj);
}
}
return out;
}
find(id: string): DbObject | undefined {
return this.byId.get(id);
}
size(): number {
return this.byId.size;
}
/** 从对象回溯到根(含自身,根在前)。 */
pathOf(id: string): DbObject[] {
const out: DbObject[] = [];
let current: DbObject | undefined = this.byId.get(id);
while (current !== undefined) {
out.unshift(current);
if (current.parentId.length === 0) {
break;
}
current = this.byId.get(current.parentId);
}
return out;
}
/** 按关键字搜索名称或模式(大小写不敏感),可选类型过滤。 */
search(keyword: string, types?: ObjectType[]): DbObject[] {
const needle: string = keyword.trim().toLowerCase();
const out: DbObject[] = [];
if (needle.length === 0) {
return out;
}
this.byId.forEach((obj: DbObject) => {
const hit: boolean = obj.name.toLowerCase().indexOf(needle) >= 0 ||
obj.schema.toLowerCase().indexOf(needle) >= 0;
if (!hit) {
return;
}
if (types !== undefined && types.length > 0 && types.indexOf(obj.type) < 0) {
return;
}
out.push(obj);
});
return out;
}
/** 按类型统计。 */
countOf(type: ObjectType): number {
let count: number = 0;
this.byId.forEach((obj: DbObject) => {
if (obj.type === type) {
count++;
}
});
return count;
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* 数据库对象模型(1.5.1)。
*
* 与引擎无关的内部表示:引擎驱动把各自的元数据转换为 DbObject 列表,
* 上层(对象树、搜索、DDL 生成)只依赖本模型。
*/
export enum ObjectType {
SCHEMA = 0,
TABLE = 1,
VIEW = 2,
INDEX = 3,
PROCEDURE = 4,
FUNCTION = 5,
TRIGGER = 6,
SEQUENCE = 7,
TYPE = 8,
SYNONYM = 9
}
export interface DbObject {
/** 全库唯一 id,建议用 schema + type + name 组合。 */
id: string;
name: string;
type: ObjectType;
schema: string;
/** 父对象 id;根对象为空字符串。 */
parentId: string;
}
/** 对象类型显示名。 */
export function objectTypeLabel(type: ObjectType): string {
switch (type) {
case ObjectType.SCHEMA:
return '模式';
case ObjectType.TABLE:
return '表';
case ObjectType.VIEW:
return '视图';
case ObjectType.INDEX:
return '索引';
case ObjectType.PROCEDURE:
return '存储过程';
case ObjectType.FUNCTION:
return '函数';
case ObjectType.TRIGGER:
return '触发器';
case ObjectType.SEQUENCE:
return '序列';
case ObjectType.TYPE:
return '类型';
case ObjectType.SYNONYM:
return '同义词';
default:
return '未知';
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 对象搜索辅助(1.5.12)。
*
* 在 ObjectTreeModel.search 之外补充:关键词高亮区间、按类型过滤、按名称排序。
* 纯逻辑,便于 UI 直接消费(高亮时按区间渲染)。
*/
import { DbObject, ObjectType } from './Objects';
export class SearchUtil {
/**
* 关键词在文本中出现的所有区间(左闭右开,大小写不敏感)。
* keyword 为空或无命中时返回空数组。
*/
static highlightRanges(text: string, keyword: string): number[][] {
const needle: string = keyword.trim().toLowerCase();
const out: number[][] = [];
if (needle.length === 0) {
return out;
}
const haystack: string = text.toLowerCase();
let from: number = 0;
while (from <= haystack.length - needle.length) {
const idx: number = haystack.indexOf(needle, from);
if (idx < 0) {
break;
}
out.push([idx, idx + needle.length]);
from = idx + needle.length;
}
return out;
}
/** 按类型过滤;types 为空表示不过滤。 */
static filterByTypes(objects: DbObject[], types: ObjectType[]): DbObject[] {
if (types.length === 0) {
return objects.slice();
}
const out: DbObject[] = [];
for (let i = 0; i < objects.length; i++) {
if (types.indexOf(objects[i].type) >= 0) {
out.push(objects[i]);
}
}
return out;
}
/** 按名称排序(大小写不敏感)。 */
static sortByName(objects: DbObject[], ascending: boolean): DbObject[] {
const copy: DbObject[] = objects.slice();
copy.sort((a: DbObject, b: DbObject) => {
const left: string = a.name.toLowerCase();
const right: string = b.name.toLowerCase();
if (left < right) {
return ascending ? -1 : 1;
}
if (left > right) {
return ascending ? 1 : -1;
}
return 0;
});
return copy;
}
}
+128
View File
@@ -0,0 +1,128 @@
/**
* 对象批量操作(1.5.13):重命名、复制、删除的 DDL 生成与执行排序。
*
* 只覆盖表与视图;索引删除由 IndexModel.dropSql 处理(需要所属表);
* 存储过程等对象的改名在各引擎差异过大,返回空字符串由 UI 提示手工处理。
*
* 执行顺序建议:先复制(可能依赖源对象)→ 再重命名 → 最后删除,
* 通过 orderForExecution 显式给出,避免「先删后复制」这类自毁顺序。
*/
import { ObjectType } from '../metadata/Objects';
import { DdlGenerator, SqlDialect } from './DdlGenerator';
export enum BatchOpKind {
COPY = 0,
RENAME = 1,
DROP = 2
}
export interface BatchOp {
kind: BatchOpKind;
type: ObjectType;
schema: string;
name: string;
/** 重命名/复制时的目标名。 */
newName: string;
}
export class BatchOps {
/** 重命名表/视图。 */
static renameSql(op: BatchOp, dialect: SqlDialect): string {
if (op.newName.trim().length === 0) {
return '';
}
if (op.type !== ObjectType.TABLE && op.type !== ObjectType.VIEW) {
return '';
}
if (dialect === SqlDialect.SQLSERVER) {
// sp_rename 需完整对象路径与引号处理,交由驱动层拼装
return '';
}
if (dialect === SqlDialect.MYSQL) {
if (op.type === ObjectType.TABLE) {
return 'RENAME TABLE ' + BatchOps.qualified(op.schema, op.name, dialect) +
' TO ' + BatchOps.qualified(op.schema, op.newName, dialect) + ';';
}
return '';
}
const keyword: string = op.type === ObjectType.TABLE ? 'TABLE' : 'VIEW';
return 'ALTER ' + keyword + ' ' + BatchOps.qualified(op.schema, op.name, dialect) +
' RENAME TO ' + DdlGenerator.quote(op.newName, dialect) + ';';
}
/** 删除表/视图。 */
static dropSql(op: BatchOp, dialect: SqlDialect): string {
if (op.type === ObjectType.TABLE) {
return 'DROP TABLE ' + BatchOps.qualified(op.schema, op.name, dialect) + ';';
}
if (op.type === ObjectType.VIEW) {
return 'DROP VIEW ' + BatchOps.qualified(op.schema, op.name, dialect) + ';';
}
return '';
}
/** 复制表:CTAS(SQL Server 用 SELECT INTO)。 */
static copySql(op: BatchOp, dialect: SqlDialect): string {
if (op.newName.trim().length === 0 || op.type !== ObjectType.TABLE) {
return '';
}
const source: string = BatchOps.qualified(op.schema, op.name, dialect);
const target: string = BatchOps.qualified(op.schema, op.newName, dialect);
if (dialect === SqlDialect.SQLSERVER) {
return 'SELECT * INTO ' + target + ' FROM ' + source + ';';
}
return 'CREATE TABLE ' + target + ' AS SELECT * FROM ' + source + ';';
}
/** 生成单个操作对应的语句。 */
static statementOf(op: BatchOp, dialect: SqlDialect): string {
switch (op.kind) {
case BatchOpKind.COPY:
return BatchOps.copySql(op, dialect);
case BatchOpKind.RENAME:
return BatchOps.renameSql(op, dialect);
case BatchOpKind.DROP:
return BatchOps.dropSql(op, dialect);
default:
return '';
}
}
/** 按「复制 → 重命名 → 删除」排序,保持组内原顺序。 */
static orderForExecution(ops: BatchOp[]): BatchOp[] {
const copies: BatchOp[] = [];
const renames: BatchOp[] = [];
const drops: BatchOp[] = [];
for (let i = 0; i < ops.length; i++) {
switch (ops[i].kind) {
case BatchOpKind.COPY:
copies.push(ops[i]);
break;
case BatchOpKind.RENAME:
renames.push(ops[i]);
break;
default:
drops.push(ops[i]);
break;
}
}
return copies.concat(renames).concat(drops);
}
/** 批量生成语句(自动排序并过滤空语句)。 */
static plan(ops: BatchOp[], dialect: SqlDialect): string[] {
const ordered: BatchOp[] = BatchOps.orderForExecution(ops);
const out: string[] = [];
for (let i = 0; i < ordered.length; i++) {
const sql: string = BatchOps.statementOf(ordered[i], dialect);
if (sql.length > 0) {
out.push(sql);
}
}
return out;
}
private static qualified(schema: string, name: string, dialect: SqlDialect): string {
return DdlGenerator.qualifiedName(schema, name, dialect);
}
}
@@ -0,0 +1,124 @@
/**
* 约束设计辅助(1.5.4 / 1.5.5)。
*
* 覆盖外键与检查约束的校验、命名冲突检测,以及外键的增删语句。
* 建表时的约束内联由 DdlGenerator 负责;本类只处理单独的增删与校验。
*/
import {
CheckConstraint,
ColumnDef,
ForeignKeyDef,
TableDef
} from './TableModel';
import { DdlGenerator, SqlDialect } from './DdlGenerator';
export class ConstraintValidator {
/** 约束名冲突(索引/外键/检查约束统一比较,大小写不敏感)。 */
static duplicateNames(table: TableDef): string[] {
const seen: Set<string> = new Set<string>();
const dup: Set<string> = new Set<string>();
const out: string[] = [];
for (let i = 0; i < table.indexes.length; i++) {
ConstraintValidator.account(table.indexes[i].name, seen, dup, out);
}
for (let i = 0; i < table.foreignKeys.length; i++) {
ConstraintValidator.account(table.foreignKeys[i].name, seen, dup, out);
}
const checks: CheckConstraint[] = table.checks === undefined ? [] : table.checks;
for (let i = 0; i < checks.length; i++) {
ConstraintValidator.account(checks[i].name, seen, dup, out);
}
return out;
}
/** 外键校验(空名、空目标表、列数不匹配、引用未知列)。 */
static validateForeignKeys(table: TableDef): string[] {
const errors: string[] = [];
const columns: Set<string> = new Set<string>();
for (let i = 0; i < table.columns.length; i++) {
columns.add(table.columns[i].name.toLowerCase());
}
for (let i = 0; i < table.foreignKeys.length; i++) {
const fk: ForeignKeyDef = table.foreignKeys[i];
if (fk.name.trim().length === 0) {
errors.push('foreign key name is required at index ' + i);
}
if (fk.refTable.trim().length === 0) {
errors.push('foreign key ' + fk.name + ' has no referenced table');
}
if (fk.columns.length === 0) {
errors.push('foreign key ' + fk.name + ' has no columns');
}
if (fk.columns.length !== fk.refColumns.length) {
errors.push('foreign key ' + fk.name + ' column count mismatch');
}
for (let j = 0; j < fk.columns.length; j++) {
if (!columns.has(fk.columns[j].toLowerCase())) {
errors.push('foreign key ' + fk.name + ' references unknown column: ' + fk.columns[j]);
}
}
}
return errors;
}
/** 检查约束校验(空名、空表达式)。 */
static validateChecks(checks: CheckConstraint[]): string[] {
const errors: string[] = [];
for (let i = 0; i < checks.length; i++) {
if (checks[i].name.trim().length === 0) {
errors.push('check constraint name is required at index ' + i);
}
if (checks[i].expression.trim().length === 0) {
errors.push('check constraint ' + checks[i].name + ' has empty expression');
}
}
return errors;
}
private static account(name: string, seen: Set<string>, dup: Set<string>, out: string[]): void {
const key: string = name.trim().toLowerCase();
if (key.length === 0) {
return;
}
if (seen.has(key)) {
if (!dup.has(key)) {
out.push(name);
dup.add(key);
}
} else {
seen.add(key);
}
}
}
/** 外键增删语句。 */
export class ConstraintDdl {
/** 添加外键;SQLite 不支持独立 ADD CONSTRAINT,返回空字符串。 */
static addForeignKeySql(table: TableDef, fk: ForeignKeyDef, dialect: SqlDialect): string {
if (dialect === SqlDialect.SQLITE) {
return '';
}
const tableName: string = ConstraintDdl.tableName(table, dialect);
const body: string = DdlGenerator.foreignKeySql(fk, dialect);
return 'ALTER TABLE ' + tableName + ' ADD ' + body + ';';
}
/** 删除外键;SQLite 返回空字符串。 */
static dropForeignKeySql(table: TableDef, name: string, dialect: SqlDialect): string {
if (dialect === SqlDialect.SQLITE) {
return '';
}
const tableName: string = ConstraintDdl.tableName(table, dialect);
if (dialect === SqlDialect.MYSQL) {
return 'ALTER TABLE ' + tableName + ' DROP FOREIGN KEY ' + DdlGenerator.quote(name, dialect) + ';';
}
return 'ALTER TABLE ' + tableName + ' DROP CONSTRAINT ' + DdlGenerator.quote(name, dialect) + ';';
}
private static tableName(table: TableDef, dialect: SqlDialect): string {
if (table.schema.length === 0) {
return DdlGenerator.quote(table.name, dialect);
}
return DdlGenerator.quote(table.schema, dialect) + '.' + DdlGenerator.quote(table.name, dialect);
}
}
+198
View File
@@ -0,0 +1,198 @@
/**
* DDL 生成器(1.5.2)。
*
* 按方言输出 CREATE TABLE、索引与约束语句。纯逻辑,可离线测试。
* 支持 MySQL / PostgreSQL / SQLite / SQL Server / Oracle 的标识符引用与常用类型映射;
* 未覆盖的高级类型按基础映射降级,后续在 1.5.11(DDL 生成与预览)中扩展。
*/
import { CheckConstraint, ColumnDef, ColumnType, ForeignKeyDef, IndexDef, TableDef } from './TableModel';
export enum SqlDialect {
MYSQL = 0,
POSTGRESQL = 1,
SQLITE = 2,
SQLSERVER = 3,
ORACLE = 4
}
export class DdlGenerator {
/** 标识符引用。 */
static quote(name: string, dialect: SqlDialect): string {
switch (dialect) {
case SqlDialect.MYSQL:
return '`' + name.replace(new RegExp('`', 'g'), '``') + '`';
case SqlDialect.SQLSERVER:
return '[' + name.replace(new RegExp('\\]', 'g'), ']]') + ']';
default:
return '"' + name.replace(new RegExp('"', 'g'), '""') + '"';
}
}
/** 列类型映射。 */
static typeSql(column: ColumnDef, dialect: SqlDialect): string {
switch (column.type) {
case ColumnType.INT:
return dialect === SqlDialect.ORACLE ? 'NUMBER(10)' : 'INT';
case ColumnType.BIGINT:
return dialect === SqlDialect.ORACLE ? 'NUMBER(19)' : 'BIGINT';
case ColumnType.VARCHAR: {
const len: number = column.length > 0 ? column.length : 255;
if (dialect === SqlDialect.ORACLE) {
return 'VARCHAR2(' + len + ')';
}
return 'VARCHAR(' + len + ')';
}
case ColumnType.TEXT:
if (dialect === SqlDialect.SQLSERVER) {
return 'NVARCHAR(MAX)';
}
if (dialect === SqlDialect.ORACLE) {
return 'CLOB';
}
return 'TEXT';
case ColumnType.DECIMAL: {
const precision: number = column.precision > 0 ? column.precision : 18;
const scale: number = column.scale >= 0 ? column.scale : 0;
if (dialect === SqlDialect.ORACLE) {
return 'NUMBER(' + precision + ',' + scale + ')';
}
return 'DECIMAL(' + precision + ',' + scale + ')';
}
case ColumnType.BOOLEAN:
if (dialect === SqlDialect.MYSQL) {
return 'TINYINT(1)';
}
if (dialect === SqlDialect.SQLITE) {
return 'INTEGER';
}
if (dialect === SqlDialect.SQLSERVER) {
return 'BIT';
}
if (dialect === SqlDialect.ORACLE) {
return 'NUMBER(1)';
}
return 'BOOLEAN';
case ColumnType.DATE:
return 'DATE';
case ColumnType.TIMESTAMP:
if (dialect === SqlDialect.MYSQL) {
return 'DATETIME';
}
if (dialect === SqlDialect.SQLITE) {
return 'TEXT';
}
if (dialect === SqlDialect.SQLSERVER) {
return 'DATETIME2';
}
return 'TIMESTAMP';
case ColumnType.BLOB:
if (dialect === SqlDialect.POSTGRESQL) {
return 'BYTEA';
}
if (dialect === SqlDialect.SQLSERVER) {
return 'VARBINARY(MAX)';
}
return 'BLOB';
case ColumnType.JSON:
if (dialect === SqlDialect.MYSQL) {
return 'JSON';
}
if (dialect === SqlDialect.POSTGRESQL) {
return 'JSONB';
}
if (dialect === SqlDialect.SQLITE) {
return 'TEXT';
}
if (dialect === SqlDialect.SQLSERVER) {
return 'NVARCHAR(MAX)';
}
return 'CLOB';
default:
return 'TEXT';
}
}
/** 默认值表达式。 */
static defaultSql(column: ColumnDef): string {
if (column.defaultValue.length === 0) {
return '';
}
const numeric: boolean = column.type === ColumnType.INT || column.type === ColumnType.BIGINT ||
column.type === ColumnType.DECIMAL || column.type === ColumnType.BOOLEAN;
if (numeric && column.defaultValue.toLowerCase() !== 'null') {
return ' DEFAULT ' + column.defaultValue;
}
return ' DEFAULT \'' + column.defaultValue.replace(new RegExp('\'', 'g'), '\'\'') + '\'';
}
/** 列定义。 */
static columnSql(column: ColumnDef, dialect: SqlDialect): string {
let sql: string = DdlGenerator.quote(column.name, dialect) + ' ' + DdlGenerator.typeSql(column, dialect);
sql += column.nullable ? ' NULL' : ' NOT NULL';
sql += DdlGenerator.defaultSql(column);
return sql;
}
/** 完整 DDL:建表 + 索引。 */
static generate(table: TableDef, dialect: SqlDialect): string {
const tableName: string = DdlGenerator.qualifiedName(table.schema, table.name, dialect);
const parts: string[] = [];
const pkColumns: string[] = [];
for (let i = 0; i < table.columns.length; i++) {
if (table.columns[i].primaryKey) {
pkColumns.push(table.columns[i].name);
}
}
for (let i = 0; i < table.columns.length; i++) {
parts.push(DdlGenerator.columnSql(table.columns[i], dialect));
}
if (pkColumns.length > 0) {
parts.push('PRIMARY KEY (' + DdlGenerator.columnList(pkColumns, dialect) + ')');
}
for (let i = 0; i < table.foreignKeys.length; i++) {
parts.push(DdlGenerator.foreignKeySql(table.foreignKeys[i], dialect));
}
const checks: CheckConstraint[] = table.checks === undefined ? [] : table.checks;
for (let i = 0; i < checks.length; i++) {
parts.push('CONSTRAINT ' + DdlGenerator.quote(checks[i].name, dialect) +
' CHECK (' + checks[i].expression + ')');
}
const statements: string[] = [];
statements.push('CREATE TABLE ' + tableName + ' (\n ' + parts.join(',\n ') + '\n);');
for (let i = 0; i < table.indexes.length; i++) {
statements.push(DdlGenerator.indexSql(table, table.indexes[i], dialect));
}
return statements.join('\n');
}
/** 索引语句。 */
static indexSql(table: TableDef, index: IndexDef, dialect: SqlDialect): string {
const tableName: string = DdlGenerator.qualifiedName(table.schema, table.name, dialect);
const unique: string = index.unique ? 'UNIQUE ' : '';
return 'CREATE ' + unique + 'INDEX ' + DdlGenerator.quote(index.name, dialect) + ' ON ' + tableName +
' (' + DdlGenerator.columnList(index.columns, dialect) + ');';
}
/** 外键约束片段。 */
static foreignKeySql(fk: ForeignKeyDef, dialect: SqlDialect): string {
return 'CONSTRAINT ' + DdlGenerator.quote(fk.name, dialect) + ' FOREIGN KEY (' +
DdlGenerator.columnList(fk.columns, dialect) + ') REFERENCES ' +
DdlGenerator.quote(fk.refTable, dialect) + ' (' + DdlGenerator.columnList(fk.refColumns, dialect) + ')';
}
private static columnList(columns: string[], dialect: SqlDialect): string {
const quoted: string[] = [];
for (let i = 0; i < columns.length; i++) {
quoted.push(DdlGenerator.quote(columns[i], dialect));
}
return quoted.join(', ');
}
/** 限定名:有 schema 时输出 schema.name。 */
static qualifiedName(schema: string, name: string, dialect: SqlDialect): string {
if (schema.length === 0) {
return DdlGenerator.quote(name, dialect);
}
return DdlGenerator.quote(schema, dialect) + '.' + DdlGenerator.quote(name, dialect);
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* 索引设计辅助(1.5.3)。
*
* 提供索引签名、重复索引检测、命名建议与建/删索引语句。
* 建索引语句复用 DdlGenerator;删索引按方言差异单独处理。
*/
import { IndexDef, TableDef } from './TableModel';
import { DdlGenerator, SqlDialect } from './DdlGenerator';
export class IndexModel {
/** 索引签名:列顺序敏感,唯一性参与签名。 */
static signatureOf(index: IndexDef): string {
const cols: string[] = [];
for (let i = 0; i < index.columns.length; i++) {
cols.push(index.columns[i].trim().toLowerCase());
}
return cols.join(',') + '|' + (index.unique ? 'u' : 'n');
}
/** 找出重复的索引签名(同一组列 + 同一唯一性)。 */
static duplicates(indexes: IndexDef[]): string[] {
const seen: Set<string> = new Set<string>();
const reported: Set<string> = new Set<string>();
const out: string[] = [];
for (let i = 0; i < indexes.length; i++) {
const sig: string = IndexModel.signatureOf(indexes[i]);
if (seen.has(sig)) {
if (!reported.has(sig)) {
out.push(sig);
reported.add(sig);
}
} else {
seen.add(sig);
}
}
return out;
}
/** 命名建议:idx_<table>_<columns>[_u],非字母数字转下划线。 */
static suggestName(table: string, columns: string[], unique: boolean): string {
const parts: string[] = [];
parts.push('idx');
parts.push(IndexModel.slug(table));
for (let i = 0; i < columns.length; i++) {
parts.push(IndexModel.slug(columns[i]));
}
let name: string = parts.join('_');
if (unique) {
name += '_u';
}
return name;
}
/** 建索引语句(委托 DdlGenerator)。 */
static createSql(table: TableDef, index: IndexDef, dialect: SqlDialect): string {
return DdlGenerator.indexSql(table, index, dialect);
}
/** 删索引语句:MySQL/SQL Server 需要表名,其余只需索引名。 */
static dropSql(table: TableDef, indexName: string, dialect: SqlDialect): string {
const quotedIndex: string = DdlGenerator.quote(indexName, dialect);
if (dialect === SqlDialect.MYSQL) {
const tableName: string = table.schema.length > 0
? DdlGenerator.quote(table.schema, dialect) + '.' + DdlGenerator.quote(table.name, dialect)
: DdlGenerator.quote(table.name, dialect);
return 'DROP INDEX ' + quotedIndex + ' ON ' + tableName + ';';
}
if (dialect === SqlDialect.SQLSERVER) {
const tableName: string = table.schema.length > 0
? DdlGenerator.quote(table.schema, dialect) + '.' + DdlGenerator.quote(table.name, dialect)
: DdlGenerator.quote(table.name, dialect);
return 'DROP INDEX ' + quotedIndex + ' ON ' + tableName + ';';
}
return 'DROP INDEX ' + quotedIndex + ';';
}
private static slug(text: string): string {
const lower: string = text.trim().toLowerCase();
let out: string = '';
for (let i = 0; i < lower.length; i++) {
const ch: string = lower.charAt(i);
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
out += ch;
} else if (out.length > 0 && out.charAt(out.length - 1) !== '_') {
out += '_';
}
}
while (out.length > 0 && out.charAt(out.length - 1) === '_') {
out = out.substring(0, out.length - 1);
}
return out;
}
}
@@ -0,0 +1,50 @@
/**
* 其余对象 DDL(1.5.9):序列、同义词、枚举类型。
*
* 各引擎支持情况不同:序列在 MySQL/SQLite 无原生支持;同义词仅 Oracle;
* 枚举类型此处只生成 PostgreSQL 的 CREATE TYPE ... AS ENUM。
* 不支持时返回空字符串,由 UI 按能力矩阵隐藏入口。
*/
import { DdlGenerator, SqlDialect } from './DdlGenerator';
export interface SequenceDef {
schema: string;
name: string;
start: number;
increment: number;
cycle: boolean;
}
export class MiscObjectDdl {
/** 创建序列;MySQL/SQLite 返回空字符串。 */
static createSequenceSql(def: SequenceDef, dialect: SqlDialect): string {
if (dialect === SqlDialect.MYSQL || dialect === SqlDialect.SQLITE) {
return '';
}
const name: string = DdlGenerator.qualifiedName(def.schema, def.name, dialect);
const cycle: string = def.cycle ? ' CYCLE' : ' NOCYCLE';
return 'CREATE SEQUENCE ' + name + ' START WITH ' + def.start +
' INCREMENT BY ' + def.increment + cycle + ';';
}
/** 创建同义词;仅 Oracle 支持。 */
static createSynonymSql(synonym: string, target: string, schema: string, dialect: SqlDialect): string {
if (dialect !== SqlDialect.ORACLE) {
return '';
}
const name: string = DdlGenerator.qualifiedName(schema, synonym, dialect);
return 'CREATE SYNONYM ' + name + ' FOR ' + target.trim() + ';';
}
/** 创建枚举类型;仅 PostgreSQL。 */
static createEnumTypeSql(name: string, values: string[], dialect: SqlDialect): string {
if (dialect !== SqlDialect.POSTGRESQL || values.length === 0) {
return '';
}
const quoted: string[] = [];
for (let i = 0; i < values.length; i++) {
quoted.push('\'' + values[i].replace(new RegExp('\'', 'g'), '\'\'') + '\'');
}
return 'CREATE TYPE ' + DdlGenerator.quote(name, dialect) + ' AS ENUM (' + quoted.join(', ') + ');';
}
}
@@ -0,0 +1,65 @@
/**
* 对象比较(1.5.17)。
*
* 两类对象:
* - 表:复用 TableDiff 的结构差异,输出可读摘要;
* - 视图/过程/函数/触发器:比较定义文本(TextDiff)。
*/
import { TableDef } from './TableModel';
import { ChangeKind, TableChange, TableDiff } from './TableDiff';
import { DiffLine, TextDiff } from './TextDiff';
export interface ObjectCompareSummary {
identical: boolean;
/** 结构差异摘要(表对象)。 */
structureChanges: string[];
/** 文本差异(定义类对象)。 */
textChanges: DiffLine[];
}
export class ObjectComparator {
/** 表结构比较。 */
static compareTables(current: TableDef, target: TableDef): ObjectCompareSummary {
const changes: TableChange[] = TableDiff.compare(current, target);
const summary: string[] = [];
for (let i = 0; i < changes.length; i++) {
summary.push(ObjectComparator.describe(changes[i]));
}
return { identical: summary.length === 0, structureChanges: summary, textChanges: [] };
}
/** 定义文本比较(视图/过程/函数/触发器)。 */
static compareDefinitions(currentText: string, targetText: string): ObjectCompareSummary {
const identical: boolean = TextDiff.identical(currentText, targetText);
return {
identical: identical,
structureChanges: [],
textChanges: identical ? [] : TextDiff.compare(currentText, targetText)
};
}
private static describe(change: TableChange): string {
switch (change.kind) {
case ChangeKind.ADD_COLUMN:
return '新增列: ' + change.name;
case ChangeKind.DROP_COLUMN:
return '删除列: ' + change.name;
case ChangeKind.ALTER_COLUMN:
return '修改列: ' + change.name;
case ChangeKind.ADD_INDEX:
return '新增索引: ' + change.name;
case ChangeKind.DROP_INDEX:
return '删除索引: ' + change.name;
case ChangeKind.ADD_FOREIGN_KEY:
return '新增外键: ' + change.name;
case ChangeKind.DROP_FOREIGN_KEY:
return '删除外键: ' + change.name;
case ChangeKind.ADD_CHECK:
return '新增检查约束: ' + change.name;
case ChangeKind.DROP_CHECK:
return '删除检查约束: ' + change.name;
default:
return change.detail + ': ' + change.name;
}
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* 存储过程与函数 DDL(1.5.7)。
*
* 仅生成对象定义骨架:参数列表按用户输入的 SQL 片段原样拼接,函数体按方言包裹。
* MySQL 使用 BEGIN ... END,其余引擎使用 AS <body>;调试与执行在驱动层完成。
*/
import { DdlGenerator, SqlDialect } from './DdlGenerator';
export enum RoutineKind {
PROCEDURE = 0,
FUNCTION = 1
}
export interface RoutineDef {
schema: string;
name: string;
kind: RoutineKind;
/** 原始参数片段,如 "IN a INT, IN b VARCHAR(20)"。 */
params: string;
/** 函数返回类型;过程忽略。 */
returns: string;
body: string;
}
export class RoutineDdl {
static validate(def: RoutineDef): string[] {
const errors: string[] = [];
if (def.name.trim().length === 0) {
errors.push('routine name is required');
}
if (def.body.trim().length === 0) {
errors.push('routine body is required');
}
if (def.kind === RoutineKind.FUNCTION && def.returns.trim().length === 0) {
errors.push('function return type is required');
}
return errors;
}
static createSql(def: RoutineDef, dialect: SqlDialect): string {
const name: string = DdlGenerator.qualifiedName(def.schema, def.name, dialect);
const params: string = '(' + def.params.trim() + ')';
const rawBody: string = def.body.trim();
const isFunction: boolean = def.kind === RoutineKind.FUNCTION;
const keyword: string = isFunction ? 'FUNCTION' : 'PROCEDURE';
const returns: string = isFunction ? ' RETURNS ' + def.returns.trim() : '';
if (dialect === SqlDialect.MYSQL) {
return 'CREATE ' + keyword + ' ' + name + ' ' + params + returns +
'\nBEGIN\n' + rawBody + '\nEND;';
}
// 非 MySQL 引擎用 AS <body>,去掉用户输入末尾的重复分号,避免生成 ";;"
const body: string = rawBody.replace(new RegExp(';+$'), '');
return 'CREATE OR REPLACE ' + keyword + ' ' + name + ' ' + params + returns +
' AS\n' + body + ';';
}
}
+84
View File
@@ -0,0 +1,84 @@
/**
* 对象容器 DDL(1.5.10):模式/库、表空间、分区。
*
* 各引擎概念不同:
* - MySQL:schema 即 database;
* - PostgreSQL / SQL Server:schema 是库内命名空间;
* - Oracle:schema 即用户,无独立 CREATE SCHEMA;表空间有原生支持;
* - SQLite:无 schema(用附加库),无表空间与分区。
* 不支持时返回空字符串,由 UI 按能力矩阵隐藏入口。
*/
import { DdlGenerator, SqlDialect } from './DdlGenerator';
export enum PartitionMethod {
RANGE = 0,
LIST = 1,
HASH = 2
}
export interface PartitionDef {
column: string;
method: PartitionMethod;
/** HASH 分区数量;其它方式忽略。 */
count: number;
}
export class SpaceDdl {
/** 创建模式(MySQL 为 CREATE DATABASE)。 */
static createSchemaSql(name: string, dialect: SqlDialect): string {
if (dialect === SqlDialect.MYSQL) {
return 'CREATE DATABASE ' + DdlGenerator.quote(name, dialect) + ';';
}
if (dialect === SqlDialect.SQLITE || dialect === SqlDialect.ORACLE) {
return '';
}
return 'CREATE SCHEMA ' + DdlGenerator.quote(name, dialect) + ';';
}
/** 删除模式。 */
static dropSchemaSql(name: string, dialect: SqlDialect): string {
if (dialect === SqlDialect.MYSQL) {
return 'DROP DATABASE ' + DdlGenerator.quote(name, dialect) + ';';
}
if (dialect === SqlDialect.SQLITE || dialect === SqlDialect.ORACLE) {
return '';
}
return 'DROP SCHEMA ' + DdlGenerator.quote(name, dialect) + ';';
}
/** 创建表空间;MySQL/SQL Server 返回空字符串(用文件组/NDB 表空间另行处理)。 */
static createTablespaceSql(name: string, path: string, dialect: SqlDialect): string {
const quotedName: string = DdlGenerator.quote(name, dialect);
if (dialect === SqlDialect.POSTGRESQL) {
return 'CREATE TABLESPACE ' + quotedName + ' LOCATION \'' + path.trim() + '\';';
}
if (dialect === SqlDialect.ORACLE) {
return 'CREATE TABLESPACE ' + quotedName + ' DATAFILE \'' + path.trim() + '\' SIZE 100M;';
}
return '';
}
/** 分区子句(拼在 CREATE TABLE 末尾)。 */
static partitionClause(def: PartitionDef, dialect: SqlDialect): string {
if (dialect === SqlDialect.SQLITE || dialect === SqlDialect.SQLSERVER) {
return '';
}
const column: string = DdlGenerator.quote(def.column, dialect);
const method: string = SpaceDdl.methodName(def.method);
if (def.method === PartitionMethod.HASH && dialect === SqlDialect.MYSQL && def.count > 0) {
return 'PARTITION BY HASH (' + column + ') PARTITIONS ' + def.count;
}
return 'PARTITION BY ' + method + ' (' + column + ')';
}
private static methodName(method: PartitionMethod): string {
switch (method) {
case PartitionMethod.LIST:
return 'LIST';
case PartitionMethod.HASH:
return 'HASH';
default:
return 'RANGE';
}
}
}
+291
View File
@@ -0,0 +1,291 @@
/**
* 表结构差异与 ALTER 生成(1.5.11)。
*
* compare(current, target):按列、索引、外键、检查约束四类比对,产出变更列表;
* toAlterSql(current, target, dialect):把变更翻译为 ALTER 语句(按方言差异处理)。
*
* 匹配规则:
* - 列:按名称(大小写不敏感)匹配;类型/长度/精度/可空/默认值任一不同视为修改;
* - 索引:按「列组合 + 唯一性」匹配(索引名变化不产生变更);
* - 外键:按「列 + 目标表 + 目标列」匹配;
* - 检查约束:按「名称 + 表达式」匹配。
*/
import {
CheckConstraint,
ColumnDef,
ForeignKeyDef,
IndexDef,
TableDef
} from './TableModel';
import { DdlGenerator, SqlDialect } from './DdlGenerator';
import { IndexModel } from './IndexModel';
import { ConstraintDdl } from './ConstraintModel';
export enum ChangeKind {
ADD_COLUMN = 0,
DROP_COLUMN = 1,
ALTER_COLUMN = 2,
ADD_INDEX = 3,
DROP_INDEX = 4,
ADD_FOREIGN_KEY = 5,
DROP_FOREIGN_KEY = 6,
ADD_CHECK = 7,
DROP_CHECK = 8
}
export interface TableChange {
kind: ChangeKind;
/** 展示名称(列名/索引名/约束名)。 */
name: string;
/** 变更说明,用于预览列表。 */
detail: string;
column?: ColumnDef;
index?: IndexDef;
foreignKey?: ForeignKeyDef;
check?: CheckConstraint;
}
export class TableDiff {
/** 计算差异(确定性顺序:列 → 索引 → 外键 → 检查)。 */
static compare(current: TableDef, target: TableDef): TableChange[] {
const out: TableChange[] = [];
TableDiff.diffColumns(current, target, out);
TableDiff.diffIndexes(current, target, out);
TableDiff.diffForeignKeys(current, target, out);
TableDiff.diffChecks(current, target, out);
return out;
}
static hasChanges(changes: TableChange[]): boolean {
return changes.length > 0;
}
/** 生成 ALTER 语句数组。 */
static toAlterSql(current: TableDef, target: TableDef, dialect: SqlDialect): string[] {
const changes: TableChange[] = TableDiff.compare(current, target);
const tableName: string = DdlGenerator.qualifiedName(target.schema.length > 0 ? target.schema : current.schema,
target.name, dialect);
const statements: string[] = [];
for (let i = 0; i < changes.length; i++) {
const change: TableChange = changes[i];
switch (change.kind) {
case ChangeKind.ADD_COLUMN: {
const column: ColumnDef = change.column as ColumnDef;
if (dialect === SqlDialect.ORACLE) {
statements.push('ALTER TABLE ' + tableName + ' ADD (' + DdlGenerator.columnSql(column, dialect) + ');');
} else {
statements.push('ALTER TABLE ' + tableName + ' ADD COLUMN ' + DdlGenerator.columnSql(column, dialect) + ';');
}
break;
}
case ChangeKind.DROP_COLUMN:
statements.push('ALTER TABLE ' + tableName + ' DROP COLUMN ' +
DdlGenerator.quote(change.name, dialect) + ';');
break;
case ChangeKind.ALTER_COLUMN: {
const targetColumn: ColumnDef = change.column as ColumnDef;
const oldColumn: ColumnDef | undefined = TableDiff.findColumn(current, targetColumn.name);
TableDiff.alterColumnSql(tableName, oldColumn, targetColumn, dialect, statements);
break;
}
case ChangeKind.ADD_INDEX:
statements.push(IndexModel.createSql(target, change.index as IndexDef, dialect));
break;
case ChangeKind.DROP_INDEX:
statements.push(IndexModel.dropSql(target, (change.index as IndexDef).name, dialect));
break;
case ChangeKind.ADD_FOREIGN_KEY:
statements.push(ConstraintDdl.addForeignKeySql(target, change.foreignKey as ForeignKeyDef, dialect));
break;
case ChangeKind.DROP_FOREIGN_KEY:
statements.push(ConstraintDdl.dropForeignKeySql(target, (change.foreignKey as ForeignKeyDef).name, dialect));
break;
case ChangeKind.ADD_CHECK:
if (dialect === SqlDialect.SQLITE) {
// SQLite 不支持 ADD CONSTRAINT,需要重建表
statements.push('');
break;
}
statements.push('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' +
DdlGenerator.quote((change.check as CheckConstraint).name, dialect) +
' CHECK (' + (change.check as CheckConstraint).expression + ');');
break;
case ChangeKind.DROP_CHECK:
if (dialect === SqlDialect.SQLITE) {
statements.push('');
} else if (dialect === SqlDialect.MYSQL) {
statements.push('ALTER TABLE ' + tableName + ' DROP CHECK ' +
DdlGenerator.quote(change.name, dialect) + ';');
} else {
statements.push('ALTER TABLE ' + tableName + ' DROP CONSTRAINT ' +
DdlGenerator.quote(change.name, dialect) + ';');
}
break;
default:
break;
}
}
const filtered: string[] = [];
for (let i = 0; i < statements.length; i++) {
if (statements[i].length > 0) {
filtered.push(statements[i]);
}
}
return filtered;
}
private static diffColumns(current: TableDef, target: TableDef, out: TableChange[]): void {
const currentByName: Map<string, ColumnDef> = new Map<string, ColumnDef>();
for (let i = 0; i < current.columns.length; i++) {
currentByName.set(current.columns[i].name.toLowerCase(), current.columns[i]);
}
for (let i = 0; i < target.columns.length; i++) {
const column: ColumnDef = target.columns[i];
const old: ColumnDef | undefined = currentByName.get(column.name.toLowerCase());
if (old === undefined) {
out.push({ kind: ChangeKind.ADD_COLUMN, name: column.name, detail: '新增列', column: column });
} else if (!TableDiff.sameColumn(old, column)) {
out.push({ kind: ChangeKind.ALTER_COLUMN, name: column.name, detail: '修改列定义', column: column });
}
currentByName.delete(column.name.toLowerCase());
}
currentByName.forEach((oldColumn: ColumnDef) => {
out.push({ kind: ChangeKind.DROP_COLUMN, name: oldColumn.name, detail: '删除列', column: oldColumn });
});
}
private static diffIndexes(current: TableDef, target: TableDef, out: TableChange[]): void {
const currentSigs: Set<string> = new Set<string>();
for (let i = 0; i < current.indexes.length; i++) {
currentSigs.add(TableDiff.indexSignature(current.indexes[i]));
}
const targetSigs: Set<string> = new Set<string>();
for (let i = 0; i < target.indexes.length; i++) {
targetSigs.add(TableDiff.indexSignature(target.indexes[i]));
if (!currentSigs.has(TableDiff.indexSignature(target.indexes[i]))) {
out.push({ kind: ChangeKind.ADD_INDEX, name: target.indexes[i].name, detail: '新增索引', index: target.indexes[i] });
}
}
for (let i = 0; i < current.indexes.length; i++) {
if (!targetSigs.has(TableDiff.indexSignature(current.indexes[i]))) {
out.push({ kind: ChangeKind.DROP_INDEX, name: current.indexes[i].name, detail: '删除索引', index: current.indexes[i] });
}
}
}
private static diffForeignKeys(current: TableDef, target: TableDef, out: TableChange[]): void {
const currentSigs: Set<string> = new Set<string>();
for (let i = 0; i < current.foreignKeys.length; i++) {
currentSigs.add(TableDiff.fkSignature(current.foreignKeys[i]));
}
const targetSigs: Set<string> = new Set<string>();
for (let i = 0; i < target.foreignKeys.length; i++) {
targetSigs.add(TableDiff.fkSignature(target.foreignKeys[i]));
if (!currentSigs.has(TableDiff.fkSignature(target.foreignKeys[i]))) {
out.push({ kind: ChangeKind.ADD_FOREIGN_KEY, name: target.foreignKeys[i].name, detail: '新增外键', foreignKey: target.foreignKeys[i] });
}
}
for (let i = 0; i < current.foreignKeys.length; i++) {
if (!targetSigs.has(TableDiff.fkSignature(current.foreignKeys[i]))) {
out.push({ kind: ChangeKind.DROP_FOREIGN_KEY, name: current.foreignKeys[i].name, detail: '删除外键', foreignKey: current.foreignKeys[i] });
}
}
}
private static diffChecks(current: TableDef, target: TableDef, out: TableChange[]): void {
const currentChecks: CheckConstraint[] = current.checks === undefined ? [] : current.checks;
const targetChecks: CheckConstraint[] = target.checks === undefined ? [] : target.checks;
const currentSigs: Set<string> = new Set<string>();
for (let i = 0; i < currentChecks.length; i++) {
currentSigs.add(TableDiff.checkSignature(currentChecks[i]));
}
const targetSigs: Set<string> = new Set<string>();
for (let i = 0; i < targetChecks.length; i++) {
targetSigs.add(TableDiff.checkSignature(targetChecks[i]));
if (!currentSigs.has(TableDiff.checkSignature(targetChecks[i]))) {
out.push({ kind: ChangeKind.ADD_CHECK, name: targetChecks[i].name, detail: '新增检查约束', check: targetChecks[i] });
}
}
for (let i = 0; i < currentChecks.length; i++) {
if (!targetSigs.has(TableDiff.checkSignature(currentChecks[i]))) {
out.push({ kind: ChangeKind.DROP_CHECK, name: currentChecks[i].name, detail: '删除检查约束', check: currentChecks[i] });
}
}
}
private static alterColumnSql(tableName: string, oldColumn: ColumnDef | undefined,
targetColumn: ColumnDef, dialect: SqlDialect, out: string[]): void {
if (dialect === SqlDialect.MYSQL) {
out.push('ALTER TABLE ' + tableName + ' MODIFY COLUMN ' + DdlGenerator.columnSql(targetColumn, dialect) + ';');
return;
}
if (dialect === SqlDialect.ORACLE) {
out.push('ALTER TABLE ' + tableName + ' MODIFY (' + DdlGenerator.columnSql(targetColumn, dialect) + ');');
return;
}
if (dialect === SqlDialect.SQLSERVER) {
out.push('ALTER TABLE ' + tableName + ' ALTER COLUMN ' + DdlGenerator.columnSql(targetColumn, dialect) + ';');
return;
}
if (dialect === SqlDialect.SQLITE) {
// SQLite 不支持 ALTER COLUMN,需重建表;此处返回提示性空语句由 UI 处理
out.push('');
return;
}
// PostgreSQL:按差异逐条处理
const quoted: string = DdlGenerator.quote(targetColumn.name, dialect);
if (oldColumn === undefined || DdlGenerator.typeSql(oldColumn, dialect) !== DdlGenerator.typeSql(targetColumn, dialect)) {
out.push('ALTER TABLE ' + tableName + ' ALTER COLUMN ' + quoted + ' TYPE ' +
DdlGenerator.typeSql(targetColumn, dialect) + ';');
}
if (oldColumn === undefined || oldColumn.nullable !== targetColumn.nullable) {
out.push('ALTER TABLE ' + tableName + ' ALTER COLUMN ' + quoted +
(targetColumn.nullable ? ' DROP NOT NULL;' : ' SET NOT NULL;'));
}
if (oldColumn === undefined || oldColumn.defaultValue !== targetColumn.defaultValue) {
if (targetColumn.defaultValue.length === 0) {
out.push('ALTER TABLE ' + tableName + ' ALTER COLUMN ' + quoted + ' DROP DEFAULT;');
} else {
out.push('ALTER TABLE ' + tableName + ' ALTER COLUMN ' + quoted + DdlGenerator.defaultSql(targetColumn) + ';');
}
}
}
private static findColumn(table: TableDef, name: string): ColumnDef | undefined {
for (let i = 0; i < table.columns.length; i++) {
if (table.columns[i].name.toLowerCase() === name.toLowerCase()) {
return table.columns[i];
}
}
return undefined;
}
private static sameColumn(a: ColumnDef, b: ColumnDef): boolean {
return a.type === b.type && a.length === b.length && a.precision === b.precision &&
a.scale === b.scale && a.nullable === b.nullable && a.defaultValue === b.defaultValue;
}
private static indexSignature(index: IndexDef): string {
const cols: string[] = [];
for (let i = 0; i < index.columns.length; i++) {
cols.push(index.columns[i].toLowerCase());
}
return cols.join(',') + '|' + (index.unique ? 'u' : 'n');
}
private static fkSignature(fk: ForeignKeyDef): string {
const cols: string[] = [];
for (let i = 0; i < fk.columns.length; i++) {
cols.push(fk.columns[i].toLowerCase());
}
const refs: string[] = [];
for (let i = 0; i < fk.refColumns.length; i++) {
refs.push(fk.refColumns[i].toLowerCase());
}
return cols.join(',') + '>' + fk.refTable.toLowerCase() + '(' + refs.join(',') + ')';
}
private static checkSignature(check: CheckConstraint): string {
return check.name.toLowerCase() + ':' + check.expression.toLowerCase();
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* 表结构模型与校验(1.5.2)。
*
* 表设计器的内部表示:列、索引、外键。与引擎无关,DDL 生成由 DdlGenerator 按方言输出。
* 纯逻辑,可离线测试。
*/
export enum ColumnType {
INT = 0,
BIGINT = 1,
VARCHAR = 2,
TEXT = 3,
DECIMAL = 4,
BOOLEAN = 5,
DATE = 6,
TIMESTAMP = 7,
BLOB = 8,
JSON = 9
}
export interface ColumnDef {
name: string;
type: ColumnType;
/** VARCHAR 长度;0 表示使用默认 255。 */
length: number;
/** DECIMAL 精度。 */
precision: number;
/** DECIMAL 小数位。 */
scale: number;
nullable: boolean;
primaryKey: boolean;
/** 默认值文本;空表示无默认值。字符串默认值由生成器负责加引号。 */
defaultValue: string;
comment: string;
}
export interface IndexDef {
name: string;
columns: string[];
unique: boolean;
}
export interface ForeignKeyDef {
name: string;
columns: string[];
refTable: string;
refColumns: string[];
}
/** 检查约束(1.5.5)。 */
export interface CheckConstraint {
name: string;
expression: string;
}
export interface TableDef {
schema: string;
name: string;
columns: ColumnDef[];
indexes: IndexDef[];
foreignKeys: ForeignKeyDef[];
/** 检查约束;可选,未设置视为无。 */
checks?: CheckConstraint[];
}
/** 新列默认值。 */
export function createColumn(name: string, type: ColumnType): ColumnDef {
return {
name: name,
type: type,
length: 0,
precision: 0,
scale: 0,
nullable: true,
primaryKey: false,
defaultValue: '',
comment: ''
};
}
/** 新表默认值。 */
export function emptyTable(schema: string, name: string): TableDef {
return { schema: schema, name: name, columns: [], indexes: [], foreignKeys: [] };
}
/** 表结构校验,返回错误消息列表;空数组表示通过。 */
export class TableValidator {
static validate(table: TableDef): string[] {
const errors: string[] = [];
if (table.name.trim().length === 0) {
errors.push('table name is required');
}
if (table.columns.length === 0) {
errors.push('at least one column is required');
}
const seen: Set<string> = new Set<string>();
for (let i = 0; i < table.columns.length; i++) {
const column: ColumnDef = table.columns[i];
const key: string = column.name.toLowerCase();
if (column.name.trim().length === 0) {
errors.push('column name is required at index ' + i);
continue;
}
if (seen.has(key)) {
errors.push('duplicate column: ' + column.name);
}
seen.add(key);
}
for (let i = 0; i < table.indexes.length; i++) {
const index: IndexDef = table.indexes[i];
if (index.name.trim().length === 0) {
errors.push('index name is required at index ' + i);
}
if (index.columns.length === 0) {
errors.push('index has no columns: ' + index.name);
}
for (let j = 0; j < index.columns.length; j++) {
if (!seen.has(index.columns[j].toLowerCase())) {
errors.push('index ' + index.name + ' references unknown column: ' + index.columns[j]);
}
}
}
for (let i = 0; i < table.foreignKeys.length; i++) {
const fk: ForeignKeyDef = table.foreignKeys[i];
if (fk.refTable.trim().length === 0) {
errors.push('foreign key ' + fk.name + ' has no referenced table');
}
for (let j = 0; j < fk.columns.length; j++) {
if (!seen.has(fk.columns[j].toLowerCase())) {
errors.push('foreign key ' + fk.name + ' references unknown column: ' + fk.columns[j]);
}
}
if (fk.columns.length !== fk.refColumns.length) {
errors.push('foreign key ' + fk.name + ' column count mismatch');
}
}
return errors;
}
}
+108
View File
@@ -0,0 +1,108 @@
/**
* 文本差异(1.5.17)。
*
* 用于视图/存储过程等对象的定义比较:先做规范化(统一换行、去行尾空白、忽略首尾空行),
* 再用 LCS 计算逐行差异。定义文本通常只有几十到几百行,O(n*m) 足够。
*/
export enum DiffLineKind {
SAME = 0,
ADDED = 1,
REMOVED = 2
}
export interface DiffLine {
kind: DiffLineKind;
text: string;
}
export class TextDiff {
/** 规范化:\r\n → \n,去行尾空白,去首尾空行。 */
static normalize(text: string): string[] {
const raw: string[] = text.replace(new RegExp('\r\n', 'g'), '\n').replace(new RegExp('\r', 'g'), '\n').split('\n');
const lines: string[] = [];
for (let i = 0; i < raw.length; i++) {
lines.push(raw[i].replace(new RegExp('[ \t]+$'), ''));
}
while (lines.length > 0 && lines[0].length === 0) {
lines.shift();
}
while (lines.length > 0 && lines[lines.length - 1].length === 0) {
lines.pop();
}
return lines;
}
/** 是否等价(规范化后逐行相同)。 */
static identical(oldText: string, newText: string): boolean {
const a: string[] = TextDiff.normalize(oldText);
const b: string[] = TextDiff.normalize(newText);
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
/** 逐行差异(LCS 回溯)。 */
static compare(oldText: string, newText: string): DiffLine[] {
const a: string[] = TextDiff.normalize(oldText);
const b: string[] = TextDiff.normalize(newText);
const n: number = a.length;
const m: number = b.length;
const lcs: number[][] = [];
for (let i = 0; i <= n; i++) {
const row: number[] = [];
for (let j = 0; j <= m; j++) {
row.push(0);
}
lcs.push(row);
}
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
if (a[i] === b[j]) {
lcs[i][j] = lcs[i + 1][j + 1] + 1;
} else {
lcs[i][j] = lcs[i + 1][j] >= lcs[i][j + 1] ? lcs[i + 1][j] : lcs[i][j + 1];
}
}
}
const out: DiffLine[] = [];
let i: number = 0;
let j: number = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ kind: DiffLineKind.SAME, text: a[i] });
i++;
j++;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
out.push({ kind: DiffLineKind.REMOVED, text: a[i] });
i++;
} else {
out.push({ kind: DiffLineKind.ADDED, text: b[j] });
j++;
}
}
while (i < n) {
out.push({ kind: DiffLineKind.REMOVED, text: a[i] });
i++;
}
while (j < m) {
out.push({ kind: DiffLineKind.ADDED, text: b[j] });
j++;
}
return out;
}
static hasChanges(lines: DiffLine[]): boolean {
for (let i = 0; i < lines.length; i++) {
if (lines[i].kind !== DiffLineKind.SAME) {
return true;
}
}
return false;
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* 触发器 DDL(1.5.8)。
*
* MySQL / SQLite / Oracle 使用 BEGIN ... END 包裹体;PostgreSQL 触发器绑定函数,
* 因此 body 字段在 PG 下表示「执行函数名」。
*/
import { DdlGenerator, SqlDialect } from './DdlGenerator';
export enum TriggerTiming {
BEFORE = 0,
AFTER = 1
}
export enum TriggerEvent {
INSERT = 0,
UPDATE = 1,
DELETE = 2
}
export interface TriggerDef {
schema: string;
name: string;
/** 触发表名(可含 schema)。 */
table: string;
timing: TriggerTiming;
event: TriggerEvent;
/** 触发器体;PG 下为函数名。 */
body: string;
}
export class TriggerDdl {
static validate(def: TriggerDef): string[] {
const errors: string[] = [];
if (def.name.trim().length === 0) {
errors.push('trigger name is required');
}
if (def.table.trim().length === 0) {
errors.push('trigger table is required');
}
if (def.body.trim().length === 0) {
errors.push('trigger body is required');
}
return errors;
}
static createSql(def: TriggerDef, dialect: SqlDialect): string {
const name: string = DdlGenerator.qualifiedName(def.schema, def.name, dialect);
const timing: string = def.timing === TriggerTiming.BEFORE ? 'BEFORE' : 'AFTER';
const event: string = TriggerDdl.eventName(def.event);
const table: string = def.table.trim();
const body: string = def.body.trim();
if (dialect === SqlDialect.POSTGRESQL) {
return 'CREATE TRIGGER ' + name + ' ' + timing + ' ' + event + ' ON ' + table +
' FOR EACH ROW EXECUTE FUNCTION ' + body + ';';
}
if (dialect === SqlDialect.ORACLE) {
return 'CREATE OR REPLACE TRIGGER ' + name + ' ' + timing + ' ' + event + ' ON ' + table +
' FOR EACH ROW\nBEGIN\n' + body + '\nEND;';
}
return 'CREATE TRIGGER ' + name + ' ' + timing + ' ' + event + ' ON ' + table +
'\nFOR EACH ROW\nBEGIN\n' + body + '\nEND;';
}
private static eventName(event: TriggerEvent): string {
switch (event) {
case TriggerEvent.INSERT:
return 'INSERT';
case TriggerEvent.UPDATE:
return 'UPDATE';
default:
return 'DELETE';
}
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* 视图 DDL(1.5.6)。
*
* 生成普通视图与物化视图的创建语句。物化视图在 MySQL / SQLite 无原生支持,返回空字符串,
* 由 UI 提示「该引擎不支持物化视图」。
*/
import { DdlGenerator, SqlDialect } from './DdlGenerator';
export interface ViewDef {
schema: string;
name: string;
/** 视图查询语句。 */
query: string;
orReplace: boolean;
materialized: boolean;
}
export class ViewDdl {
static validate(view: ViewDef): string[] {
const errors: string[] = [];
if (view.name.trim().length === 0) {
errors.push('view name is required');
}
if (view.query.trim().length === 0) {
errors.push('view query is required');
}
if (view.materialized && view.orReplace) {
errors.push('OR REPLACE is not supported for materialized view');
}
return errors;
}
/** 创建视图;不支持时返回空字符串。 */
static createSql(view: ViewDef, dialect: SqlDialect): string {
const name: string = DdlGenerator.qualifiedName(view.schema, view.name, dialect);
// 去掉用户输入末尾的分号,避免生成 ";;"
const query: string = view.query.trim().replace(new RegExp(';+$'), '');
if (view.materialized) {
if (dialect === SqlDialect.MYSQL || dialect === SqlDialect.SQLITE) {
return '';
}
return 'CREATE MATERIALIZED VIEW ' + name + ' AS\n' + query + ';';
}
const prefix: string = view.orReplace ? 'CREATE OR REPLACE VIEW ' : 'CREATE VIEW ';
return prefix + name + ' AS\n' + query + ';';
}
}
@@ -0,0 +1,137 @@
/**
* 引擎权限项枚举(1.5.15)。
*
* 以字符串引擎代号为键(避免 data 层依赖 engine 模块的 EngineKind),
* 提供权限项查询与按作用域分组,供权限授予界面(1.16.4)与权限脚本生成(1.16.8)使用。
*
* 数据为初版整理,标 TODO 的条目需在对应驱动联调时按目标版本核对。
*/
export interface PermissionItem {
/** 引擎内的权限关键字。 */
code: string;
/** 界面显示名。 */
label: string;
/** 作用域:system/database/schema/table/column/routine。 */
scope: string;
}
export class PermissionCatalog {
private static readonly MYSQL: PermissionItem[] = [
{ code: 'SELECT', label: '查询', scope: 'table' },
{ code: 'INSERT', label: '插入', scope: 'table' },
{ code: 'UPDATE', label: '更新', scope: 'table' },
{ code: 'DELETE', label: '删除', scope: 'table' },
{ code: 'CREATE', label: '建对象', scope: 'database' },
{ code: 'DROP', label: '删对象', scope: 'database' },
{ code: 'ALTER', label: '改结构', scope: 'table' },
{ code: 'INDEX', label: '建索引', scope: 'table' },
{ code: 'GRANT OPTION', label: '转授权', scope: 'system' }
];
private static readonly POSTGRESQL: PermissionItem[] = [
{ code: 'SELECT', label: '查询', scope: 'table' },
{ code: 'INSERT', label: '插入', scope: 'table' },
{ code: 'UPDATE', label: '更新', scope: 'table' },
{ code: 'DELETE', label: '删除', scope: 'table' },
{ code: 'TRUNCATE', label: '清空', scope: 'table' },
{ code: 'REFERENCES', label: '外键引用', scope: 'table' },
{ code: 'TRIGGER', label: '触发器', scope: 'table' },
{ code: 'CREATE', label: '建对象', scope: 'schema' },
{ code: 'CONNECT', label: '连接', scope: 'database' },
{ code: 'USAGE', label: '使用', scope: 'schema' }
];
private static readonly SQLSERVER: PermissionItem[] = [
{ code: 'SELECT', label: '查询', scope: 'table' },
{ code: 'INSERT', label: '插入', scope: 'table' },
{ code: 'UPDATE', label: '更新', scope: 'table' },
{ code: 'DELETE', label: '删除', scope: 'table' },
{ code: 'EXECUTE', label: '执行', scope: 'routine' },
{ code: 'ALTER', label: '改结构', scope: 'schema' },
{ code: 'CONTROL', label: '完全控制', scope: 'system' },
{ code: 'VIEW DEFINITION', label: '查看定义', scope: 'schema' }
];
private static readonly ORACLE: PermissionItem[] = [
{ code: 'SELECT', label: '查询', scope: 'table' },
{ code: 'INSERT', label: '插入', scope: 'table' },
{ code: 'UPDATE', label: '更新', scope: 'table' },
{ code: 'DELETE', label: '删除', scope: 'table' },
{ code: 'ALTER', label: '改结构', scope: 'table' },
{ code: 'INDEX', label: '建索引', scope: 'table' },
{ code: 'EXECUTE', label: '执行', scope: 'routine' },
{ code: 'REFERENCES', label: '外键引用', scope: 'table' },
{ code: 'GRANT ANY PRIVILEGE', label: '任意授权', scope: 'system' }
];
private static readonly MONGODB: PermissionItem[] = [
{ code: 'read', label: '只读', scope: 'database' },
{ code: 'readWrite', label: '读写', scope: 'database' },
{ code: 'dbAdmin', label: '库管理', scope: 'database' },
{ code: 'userAdmin', label: '用户管理', scope: 'database' },
{ code: 'clusterAdmin', label: '集群管理', scope: 'system' },
{ code: 'root', label: '超级权限', scope: 'system' }
];
private static readonly REDIS: PermissionItem[] = [
{ code: '+@read', label: '读命令', scope: 'database' },
{ code: '+@write', label: '写命令', scope: 'database' },
{ code: '+@admin', label: '管理命令', scope: 'system' },
{ code: '+@dangerous', label: '危险命令', scope: 'system' }
];
private static readonly SQLITE: PermissionItem[] = [];
/** 引擎代号到权限项;未知引擎返回空数组。 */
static items(engineCode: string): PermissionItem[] {
const key: string = engineCode.trim().toLowerCase();
switch (key) {
case 'mysql':
case 'mariadb':
case 'tidb':
return PermissionCatalog.MYSQL.slice();
case 'oceanbase':
// mysqli 兼容模式,权限项按 MySQL 子集处理
return PermissionCatalog.MYSQL.slice();
case 'postgresql':
return PermissionCatalog.POSTGRESQL.slice();
case 'sqlserver':
return PermissionCatalog.SQLSERVER.slice();
case 'oracle':
return PermissionCatalog.ORACLE.slice();
case 'mongodb':
return PermissionCatalog.MONGODB.slice();
case 'redis':
return PermissionCatalog.REDIS.slice();
case 'sqlite':
return PermissionCatalog.SQLITE.slice();
default:
return [];
}
}
/** 按权限码查找(大小写不敏感)。 */
static find(engineCode: string, code: string): PermissionItem | undefined {
const items: PermissionItem[] = PermissionCatalog.items(engineCode);
const needle: string = code.trim().toLowerCase();
for (let i = 0; i < items.length; i++) {
if (items[i].code.toLowerCase() === needle) {
return items[i];
}
}
return undefined;
}
/** 某引擎出现的作用域集合(去重,按首次出现顺序)。 */
static scopes(engineCode: string): string[] {
const items: PermissionItem[] = PermissionCatalog.items(engineCode);
const out: string[] = [];
for (let i = 0; i < items.length; i++) {
if (out.indexOf(items[i].scope) < 0) {
out.push(items[i].scope);
}
}
return out;
}
}
@@ -0,0 +1,53 @@
/**
* 智能补全(1.6.3)。
*
* 输入:光标前缀、当前库对象、函数名、权限项、关键字。
* 输出:按「对象 → 函数 → 关键字 → 权限项」优先级排序的候选,前缀大小写不敏感,去重。
*
* 说明:当前为前缀匹配(与多数编辑器默认行为一致);模糊匹配与语义上下文
* (如 FROM 后只出表名)依赖 AST,属 1.6.x 后续迭代。
*/
export interface CompletionCandidate {
label: string;
kind: string;
detail: string;
}
export interface CompletionRequest {
prefix: string;
objects: string[];
functions: string[];
keywords: string[];
permissions: string[];
limit: number;
}
export class CompletionEngine {
static complete(request: CompletionRequest): CompletionCandidate[] {
const prefix: string = request.prefix.trim().toLowerCase();
const limit: number = request.limit > 0 ? request.limit : 20;
const seen: Set<string> = new Set<string>();
const out: CompletionCandidate[] = [];
CompletionEngine.collect(request.objects, prefix, 'object', '对象', seen, out, limit);
CompletionEngine.collect(request.functions, prefix, 'function', '函数', seen, out, limit);
CompletionEngine.collect(request.keywords, prefix, 'keyword', '关键字', seen, out, limit);
CompletionEngine.collect(request.permissions, prefix, 'permission', '权限项', seen, out, limit);
return out;
}
private static collect(source: string[], prefix: string, kind: string, detail: string,
seen: Set<string>, out: CompletionCandidate[], limit: number): void {
for (let i = 0; i < source.length && out.length < limit; i++) {
const label: string = source[i].trim();
const key: string = label.toLowerCase();
if (key.length === 0 || seen.has(key)) {
continue;
}
if (prefix.length > 0 && key.indexOf(prefix) !== 0) {
continue;
}
seen.add(key);
out.push({ label: label, kind: kind, detail: detail });
}
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* 控制台命令解析(1.6.9)。
*
* 两种输入:
* - SQL:普通语句,交给查询执行;
* - 元命令:以 \ 或 . 开头(如 \connect、\dt、\d table、.exit),由控制台插件处理。
*
* 解析只做拆分与规范化,不执行;未知命令也原样返回,由 UI 提示"不支持"。
*/
export enum ConsoleCommandKind {
SQL = 0,
META = 1
}
export interface ConsoleCommand {
kind: ConsoleCommandKind;
/** 元命令名(不含前缀,小写);SQL 为空。 */
name: string;
/** 元命令参数(已去引号);SQL 为空。 */
args: string[];
/** 原始输入(已 trim)。 */
raw: string;
}
export class ConsoleParser {
static isMeta(line: string): boolean {
const trimmed: string = line.trim();
return trimmed.length > 0 && (trimmed.charAt(0) === '\\' || trimmed.charAt(0) === '.');
}
static parse(line: string): ConsoleCommand {
const trimmed: string = line.trim();
if (trimmed.length === 0) {
return { kind: ConsoleCommandKind.SQL, name: '', args: [], raw: '' };
}
if (!ConsoleParser.isMeta(trimmed)) {
return { kind: ConsoleCommandKind.SQL, name: '', args: [], raw: trimmed };
}
const body: string = trimmed.substring(1).trim();
if (body.length === 0) {
return { kind: ConsoleCommandKind.META, name: '', args: [], raw: trimmed };
}
const parts: string[] = ConsoleParser.splitArgs(body);
const name: string = parts.length > 0 ? parts[0].toLowerCase() : '';
const args: string[] = parts.length > 1 ? parts.slice(1) : [];
return { kind: ConsoleCommandKind.META, name: name, args: args, raw: trimmed };
}
/** 按空白拆分,支持单/双引号包裹的参数。 */
private static splitArgs(text: string): string[] {
const out: string[] = [];
let current: string = '';
let quote: string = '';
for (let i = 0; i < text.length; i++) {
const ch: string = text.charAt(i);
if (quote.length > 0) {
if (ch === quote) {
quote = '';
} else {
current += ch;
}
continue;
}
if (ch === '"' || ch === '\'') {
quote = ch;
continue;
}
if (ch === ' ' || ch === '\t') {
if (current.length > 0) {
out.push(current);
current = '';
}
continue;
}
current += ch;
}
if (current.length > 0) {
out.push(current);
}
return out;
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* 执行计划解析(1.6.7)。
*
* 把引擎返回的文本计划解析为树:按缩进层级(每级 2 空格)与 `->` 标记建树。
* 兼容 PostgreSQL 的 `-> Seq Scan on t (cost=...)` 与 MySQL 8 的树形输出
* (`-> Nested loop inner join`)。
*
* 纯逻辑:不执行 EXPLAIN,也不解释 cost;UI 负责渲染树与高亮关键字。
*/
export interface PlanNode {
/** 节点标题(去掉箭头与多余空白)。 */
label: string;
/** 原始行(保留 cost 等信息)。 */
raw: string;
/** 层级,根为 0。 */
depth: number;
children: PlanNode[];
}
export class ExplainParser {
/** 解析缩进式文本计划。 */
static parseIndented(text: string): PlanNode[] {
const lines: string[] = text.replace(new RegExp('\r\n', 'g'), '\n').split('\n');
const roots: PlanNode[] = [];
const stack: PlanNode[] = [];
for (let i = 0; i < lines.length; i++) {
const raw: string = lines[i];
if (raw.trim().length === 0) {
continue;
}
const depth: number = ExplainParser.depthOf(raw);
const label: string = ExplainParser.labelOf(raw);
const node: PlanNode = { label: label, raw: raw.trim(), depth: depth, children: [] };
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
stack.pop();
}
if (stack.length === 0) {
roots.push(node);
} else {
stack[stack.length - 1].children.push(node);
}
stack.push(node);
}
return roots;
}
/** 统计节点总数(含子节点)。 */
static countNodes(nodes: PlanNode[]): number {
let count: number = 0;
for (let i = 0; i < nodes.length; i++) {
count += 1 + ExplainParser.countNodes(nodes[i].children);
}
return count;
}
/** 扁平化为「缩进文本」,便于复制与日志。 */
static flatten(nodes: PlanNode[], indent: number): string[] {
const out: string[] = [];
for (let i = 0; i < nodes.length; i++) {
out.push(ExplainParser.spaces(indent) + nodes[i].label);
const childLines: string[] = ExplainParser.flatten(nodes[i].children, indent + 2);
for (let j = 0; j < childLines.length; j++) {
out.push(childLines[j]);
}
}
return out;
}
private static depthOf(line: string): number {
let spaces: number = 0;
while (spaces < line.length && line.charAt(spaces) === ' ') {
spaces++;
}
return Math.floor(spaces / 2);
}
private static labelOf(line: string): string {
let text: string = line.trim();
if (text.indexOf('->') === 0) {
text = text.substring(2).trim();
}
return text;
}
private static spaces(count: number): string {
let out: string = '';
for (let i = 0; i < count; i++) {
out += ' ';
}
return out;
}
}
+126
View File
@@ -0,0 +1,126 @@
/**
* 大结果集分页与窗口(1.6.11)。
*
* 结果集虚拟滚动的核心:按页记录已加载区间,滚动到可视区时计算需要请求的页。
* 与 1.2.10 数据网格内核的分页设计对应,纯逻辑、可测试。
*
* 约定:
* - 页大小固定(构造时给定);总行数未知时传 -1,pageCount 返回 -1;
* - 页号从 0 开始;
* - pagesFor 对输入做裁剪,末页不足一页按实际行数处理。
*/
export class PagedResult {
private totalRows: number;
private readonly pageSize: number;
private readonly loaded: Set<number> = new Set<number>();
constructor(totalRows: number, pageSize: number) {
if (pageSize < 1) {
throw new Error('pageSize must be >= 1');
}
this.totalRows = totalRows;
this.pageSize = pageSize;
}
/** 总行数(-1 表示未知)。 */
rows(): number {
return this.totalRows;
}
/** 页数;总行数未知时返回 -1。 */
pageCount(): number {
if (this.totalRows < 0) {
return -1;
}
return Math.ceil(this.totalRows / this.pageSize);
}
/** 末尾页的实际行数(用于渲染末页空白判断)。 */
lastPageRowCount(): number {
if (this.totalRows <= 0) {
return 0;
}
const remainder: number = this.totalRows % this.pageSize;
return remainder === 0 ? this.pageSize : remainder;
}
/** 覆盖 [firstRow, lastRow] 的页号列表(已裁剪到有效范围)。 */
pagesFor(firstRow: number, lastRow: number): number[] {
if (this.totalRows === 0) {
return [];
}
const maxRow: number = this.totalRows > 0 ? this.totalRows - 1 : lastRow;
const start: number = firstRow < 0 ? 0 : firstRow;
const end: number = lastRow > maxRow ? maxRow : lastRow;
if (end < start) {
return [];
}
const firstPage: number = Math.floor(start / this.pageSize);
const lastPage: number = Math.floor(end / this.pageSize);
const out: number[] = [];
for (let page = firstPage; page <= lastPage; page++) {
out.push(page);
}
return out;
}
markLoaded(page: number): void {
if (page >= 0) {
this.loaded.add(page);
}
}
isLoaded(page: number): boolean {
return this.loaded.has(page);
}
/** 已加载页数。 */
loadedPages(): number {
return this.loaded.size;
}
/** 已加载行数(末页按实际行数计算)。 */
loadedRows(): number {
let rows: number = 0;
this.loaded.forEach((page: number) => {
rows += this.rowsInPage(page);
});
return rows;
}
/** 某页实际行数。 */
rowsInPage(page: number): number {
if (page < 0) {
return 0;
}
const start: number = page * this.pageSize;
if (this.totalRows < 0) {
return this.pageSize;
}
if (start >= this.totalRows) {
return 0;
}
const remaining: number = this.totalRows - start;
return remaining < this.pageSize ? remaining : this.pageSize;
}
/** 从 fromPage 开始的下一个未加载页;没有则返回 -1。 */
nextUnloadedPage(fromPage: number): number {
const count: number = this.pageCount();
const start: number = fromPage < 0 ? 0 : fromPage;
for (let page = start; ; page++) {
if (count >= 0 && page >= count) {
return -1;
}
if (!this.loaded.has(page)) {
return page;
}
}
}
/** 重设总行数(重新执行查询时),已加载标记清零。 */
reset(totalRows: number): void {
this.totalRows = totalRows;
this.loaded.clear();
}
}
+208
View File
@@ -0,0 +1,208 @@
/**
* 参数化查询(1.6.13)。
*
* 支持三种占位风格:
* - 问号:?(MySQL/SQLite/Oracle 通用)
* - 命名::name(Oracle/PG 常用)
* - 位置:$1(PostgreSQL)
*
* 职责:统计占位符、校验数量、把值安全地绑定为字面量(单引号双写,拒绝含 NUL 的值)。
* 说明:真实驱动应优先使用引擎原生预编译绑定;本模块用于不支持预编译的场景与预览。
*/
export enum ParamStyle {
QUESTION = 0,
NAMED = 1,
POSITIONAL = 2
}
export interface ParamSpec {
style: ParamStyle;
/** 命名风格的参数名(不含冒号);问号/位置风格为空。 */
names: string[];
/** 占位符总数。 */
count: number;
}
/** 命名参数。 */
export interface NamedParam {
name: string;
value: string;
}
export class ParameterBinder {
/** 统计占位符;字符串与注释中的占位符不计入。 */
static inspect(sql: string): ParamSpec {
const names: string[] = [];
let questionCount: number = 0;
let positionalCount: number = 0;
let i: number = 0;
while (i < sql.length) {
const ch: string = sql.charAt(i);
if (ch === '\'' || ch === '"' || ch === '`') {
i = ParameterBinder.skipQuoted(sql, i, ch);
continue;
}
if (ch === '-' && sql.charAt(i + 1) === '-') {
while (i < sql.length && sql.charAt(i) !== '\n') {
i++;
}
continue;
}
if (ch === '/' && sql.charAt(i + 1) === '*') {
i += 2;
while (i < sql.length && !(sql.charAt(i) === '*' && sql.charAt(i + 1) === '/')) {
i++;
}
i += 2;
continue;
}
if (ch === '?') {
questionCount++;
i++;
continue;
}
if (ch === ':' && ParameterBinder.isNameStart(sql.charAt(i + 1))) {
let j: number = i + 1;
while (j < sql.length && ParameterBinder.isNamePart(sql.charAt(j))) {
j++;
}
names.push(sql.substring(i + 1, j));
i = j;
continue;
}
if (ch === '$' && sql.charAt(i + 1) >= '0' && sql.charAt(i + 1) <= '9') {
positionalCount++;
i++;
while (i < sql.length && sql.charAt(i) >= '0' && sql.charAt(i) <= '9') {
i++;
}
continue;
}
i++;
}
if (names.length > 0) {
return { style: ParamStyle.NAMED, names: names, count: names.length };
}
if (positionalCount > 0) {
return { style: ParamStyle.POSITIONAL, names: [], count: positionalCount };
}
return { style: ParamStyle.QUESTION, names: [], count: questionCount };
}
/** 校验参数数量;返回错误列表。 */
static validate(sql: string, paramCount: number): string[] {
const errors: string[] = [];
const spec: ParamSpec = ParameterBinder.inspect(sql);
if (spec.style === ParamStyle.QUESTION && spec.count !== paramCount) {
errors.push('expected ' + spec.count + ' parameters, got ' + paramCount);
}
if (spec.style === ParamStyle.POSITIONAL && spec.count !== paramCount) {
errors.push('expected ' + spec.count + ' parameters, got ' + paramCount);
}
if (spec.style === ParamStyle.NAMED && spec.names.length !== paramCount) {
errors.push('expected ' + spec.names.length + ' parameters, got ' + paramCount);
}
return errors;
}
/** 字面量安全化:单引号双写;含 NUL 时抛错(避免截断/注入)。 */
static quoteLiteral(value: string): string {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) === 0) {
throw new Error('parameter contains NUL character');
}
}
return '\'' + value.replace(new RegExp('\'', 'g'), '\'\'') + '\'';
}
/** 绑定问号或位置占位符(按出现顺序替换),返回可执行 SQL。 */
static bind(sql: string, values: string[]): string {
const spec: ParamSpec = ParameterBinder.inspect(sql);
if (spec.style === ParamStyle.NAMED) {
throw new Error('named parameters require bindNamed');
}
if (spec.count !== values.length) {
throw new Error('parameter count mismatch: expected ' + spec.count + ', got ' + values.length);
}
let out: string = '';
let valueIndex: number = 0;
let i: number = 0;
while (i < sql.length) {
const ch: string = sql.charAt(i);
if (ch === '\'' || ch === '"' || ch === '`') {
const end: number = ParameterBinder.skipQuoted(sql, i, ch);
out += sql.substring(i, end);
i = end;
continue;
}
if (ch === '-' && sql.charAt(i + 1) === '-') {
const start: number = i;
while (i < sql.length && sql.charAt(i) !== '\n') {
i++;
}
out += sql.substring(start, i);
continue;
}
if (ch === '/' && sql.charAt(i + 1) === '*') {
const start: number = i;
i += 2;
while (i < sql.length && !(sql.charAt(i) === '*' && sql.charAt(i + 1) === '/')) {
i++;
}
i += 2;
out += sql.substring(start, i);
continue;
}
if (ch === '?') {
out += ParameterBinder.quoteLiteral(values[valueIndex]);
valueIndex++;
i++;
continue;
}
if (ch === '$' && sql.charAt(i + 1) >= '0' && sql.charAt(i + 1) <= '9') {
while (i < sql.length && ((sql.charAt(i) >= '0' && sql.charAt(i) <= '9') || sql.charAt(i) === '$')) {
i++;
}
out += ParameterBinder.quoteLiteral(values[valueIndex]);
valueIndex++;
continue;
}
out += ch;
i++;
}
return out;
}
/** 绑定命名占位符(:name),未提供的参数保持原样。 */
static bindNamed(sql: string, variables: NamedParam[]): string {
let out: string = sql;
for (let i = 0; i < variables.length; i++) {
const token: string = ':' + variables[i].name;
out = out.split(token).join(ParameterBinder.quoteLiteral(variables[i].value));
}
return out;
}
private static skipQuoted(sql: string, start: number, quote: string): number {
let i: number = start + 1;
while (i < sql.length) {
if (sql.charAt(i) === quote) {
if (sql.charAt(i + 1) === quote) {
i += 2;
continue;
}
return i + 1;
}
i++;
}
return i;
}
private static isNameStart(ch: string): boolean {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_';
}
private static isNamePart(ch: string): boolean {
return ParameterBinder.isNameStart(ch) || (ch >= '0' && ch <= '9');
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 性能剖析(1.6.8)。
*
* 记录语句耗时并给出统计(均值/最小/最大/P50/P95)与慢查询列表。
* 纯逻辑:不采集引擎侧指标,只消费执行层上报的 durationMs。
*/
export interface TimingSample {
sql: string;
durationMs: number;
at: number;
}
export interface TimingStats {
count: number;
totalMs: number;
avgMs: number;
minMs: number;
maxMs: number;
p50Ms: number;
p95Ms: number;
}
export class Profiler {
private readonly capacity: number;
private readonly clock: () => number;
private readonly samples: TimingSample[] = [];
constructor(capacity: number, clock?: () => number) {
if (capacity < 1) {
throw new Error('capacity must be >= 1');
}
this.capacity = capacity;
this.clock = clock === undefined ? () => Date.now() : clock;
}
record(sql: string, durationMs: number): TimingSample {
const sample: TimingSample = { sql: sql.trim(), durationMs: durationMs, at: this.clock() };
this.samples.push(sample);
while (this.samples.length > this.capacity) {
this.samples.shift();
}
return sample;
}
size(): number {
return this.samples.length;
}
clear(): void {
this.samples.length = 0;
}
/** 统计;无样本时全部为 0。 */
stats(): TimingStats {
if (this.samples.length === 0) {
return { count: 0, totalMs: 0, avgMs: 0, minMs: 0, maxMs: 0, p50Ms: 0, p95Ms: 0 };
}
const values: number[] = [];
let total: number = 0;
for (let i = 0; i < this.samples.length; i++) {
values.push(this.samples[i].durationMs);
total += this.samples[i].durationMs;
}
values.sort((a: number, b: number) => a - b);
return {
count: values.length,
totalMs: total,
avgMs: total / values.length,
minMs: values[0],
maxMs: values[values.length - 1],
p50Ms: Profiler.percentile(values, 50),
p95Ms: Profiler.percentile(values, 95)
};
}
/** 最慢的 limit 条(耗时降序)。 */
slowest(limit: number): TimingSample[] {
const copy: TimingSample[] = this.samples.slice();
copy.sort((a: TimingSample, b: TimingSample) => b.durationMs - a.durationMs);
return limit > 0 && copy.length > limit ? copy.slice(0, limit) : copy;
}
/** 超过阈值的样本数。 */
slowCount(thresholdMs: number): number {
let count: number = 0;
for (let i = 0; i < this.samples.length; i++) {
if (this.samples[i].durationMs > thresholdMs) {
count++;
}
}
return count;
}
private static percentile(sorted: number[], p: number): number {
if (sorted.length === 0) {
return 0;
}
const index: number = Math.ceil((p / 100) * sorted.length) - 1;
const safe: number = index < 0 ? 0 : (index >= sorted.length ? sorted.length - 1 : index);
return sorted[safe];
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* 查询历史(1.6.12)。
*
* 环形容量内存历史:记录 SQL、耗时、行数、成功与否,支持最近读取与关键字搜索。
* 持久化由 1.3.7 存储层负责(后续接入),本类保持纯逻辑。
*/
export interface HistoryEntry {
id: number;
at: number;
sql: string;
durationMs: number;
rowCount: number;
success: boolean;
}
export class QueryHistory {
private readonly capacity: number;
private readonly clock: () => number;
private readonly entries: HistoryEntry[] = [];
private nextId: number = 1;
constructor(capacity: number, clock?: () => number) {
if (capacity < 1) {
throw new Error('capacity must be >= 1');
}
this.capacity = capacity;
this.clock = clock === undefined ? () => Date.now() : clock;
}
record(sql: string, durationMs: number, rowCount: number, success: boolean): HistoryEntry {
const entry: HistoryEntry = {
id: this.nextId,
at: this.clock(),
sql: sql.trim(),
durationMs: durationMs,
rowCount: rowCount,
success: success
};
this.nextId++;
this.entries.push(entry);
while (this.entries.length > this.capacity) {
this.entries.shift();
}
return entry;
}
/** 最近 limit 条,时间倒序;limit<=0 返回全部。 */
recent(limit: number): HistoryEntry[] {
const start: number = limit > 0 && this.entries.length > limit ? this.entries.length - limit : 0;
const out: HistoryEntry[] = [];
for (let i = this.entries.length - 1; i >= start; i--) {
out.push(this.entries[i]);
}
return out;
}
/** SQL 关键字搜索(大小写不敏感),时间倒序。 */
search(keyword: string): HistoryEntry[] {
const needle: string = keyword.trim().toLowerCase();
if (needle.length === 0) {
return this.recent(0);
}
const out: HistoryEntry[] = [];
for (let i = this.entries.length - 1; i >= 0; i--) {
if (this.entries[i].sql.toLowerCase().indexOf(needle) >= 0) {
out.push(this.entries[i]);
}
}
return out;
}
size(): number {
return this.entries.length;
}
clear(): void {
this.entries.length = 0;
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 代码片段库(1.6.4)。
*
* 片段:名称 + SQL 模板 + 标签;模板支持 {{变量}} 占位,插入时按传入变量替换。
* 纯内存实现,持久化由 1.3.7 存储层负责(后续接入)。
*/
export interface Snippet {
id: string;
name: string;
body: string;
tags: string[];
}
export interface SnippetVariable {
name: string;
value: string;
}
export class SnippetStore {
private readonly snippets: Snippet[] = [];
private nextId: number = 1;
add(name: string, body: string, tags: string[]): Snippet {
const snippet: Snippet = {
id: 'snip-' + this.nextId,
name: name.trim(),
body: body,
tags: tags.slice()
};
this.nextId++;
this.snippets.push(snippet);
return snippet;
}
list(): Snippet[] {
return this.snippets.slice();
}
find(id: string): Snippet | undefined {
for (let i = 0; i < this.snippets.length; i++) {
if (this.snippets[i].id === id) {
return this.snippets[i];
}
}
return undefined;
}
/** 按名称或标签搜索(大小写不敏感)。 */
search(keyword: string): Snippet[] {
const needle: string = keyword.trim().toLowerCase();
if (needle.length === 0) {
return this.list();
}
const out: Snippet[] = [];
for (let i = 0; i < this.snippets.length; i++) {
const snippet: Snippet = this.snippets[i];
let hit: boolean = snippet.name.toLowerCase().indexOf(needle) >= 0;
for (let j = 0; j < snippet.tags.length && !hit; j++) {
hit = snippet.tags[j].toLowerCase().indexOf(needle) >= 0;
}
if (hit) {
out.push(snippet);
}
}
return out;
}
remove(id: string): boolean {
for (let i = 0; i < this.snippets.length; i++) {
if (this.snippets[i].id === id) {
this.snippets.splice(i, 1);
return true;
}
}
return false;
}
size(): number {
return this.snippets.length;
}
/** 将 {{变量}} 替换为值;未提供的变量保持原样。 */
static expand(body: string, variables: SnippetVariable[]): string {
let out: string = body;
for (let i = 0; i < variables.length; i++) {
const token: string = '{{' + variables[i].name.trim() + '}}';
out = out.split(token).join(variables[i].value);
}
return out;
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* SQL 格式化(1.6.6)。
*
* 规则(基础版):
* - 关键字大写;
* - 顶层子句前换行:SELECT/FROM/WHERE/GROUP BY/ORDER BY/HAVING/LIMIT/OFFSET/UNION 与 JOIN 族;
* - 逗号后空格、运算符两侧空格、点号紧邻、函数名与左括号之间不留空格;
* - 字符串、引号标识符、注释原样保留;
* - 括号内不换行(仅顶层子句换行),嵌套过深时保持单行以便阅读。
*
* 纯逻辑,便于测试;复杂的缩进风格(每列一行等)属后续增强。
*/
import { SqlToken, SqlTokenizer, TokenKind } from './SqlTokenizer';
export class SqlFormatter {
private static readonly NEWLINE_KEYWORDS: string[] = [
'SELECT', 'FROM', 'WHERE', 'GROUP', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'UNION',
'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER'
];
/** 作为函数名使用的关键字:与左括号之间不留空格。 */
private static readonly FUNCTION_KEYWORDS: string[] = ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'];
static format(sql: string): string {
const tokens: SqlToken[] = SqlTokenizer.tokenize(sql);
let out: string = '';
let depth: number = 0;
let prev: SqlToken | undefined = undefined;
for (let i = 0; i < tokens.length; i++) {
const token: SqlToken = tokens[i];
if (token.kind === TokenKind.WHITESPACE) {
continue;
}
const text: string = token.kind === TokenKind.KEYWORD ? token.text.toUpperCase() : token.text;
const isJoinGroup: boolean = token.kind === TokenKind.KEYWORD &&
SqlFormatter.NEWLINE_KEYWORDS.indexOf(token.text.toUpperCase()) >= 0;
if (token.text === '(') {
out += SqlFormatter.parenSpace(prev);
out += text;
depth++;
prev = token;
continue;
}
if (token.text === ')') {
out += text;
depth = depth > 0 ? depth - 1 : 0;
prev = token;
continue;
}
if (isJoinGroup && depth === 0 && out.length > 0) {
out += '\n' + text;
prev = token;
continue;
}
out += SqlFormatter.separator(prev, token) + text;
prev = token;
}
return out.trim();
}
/** 左括号前是否留空格:函数调用紧贴,其余留空格。 */
private static parenSpace(prev: SqlToken | undefined): string {
if (prev === undefined) {
return '';
}
if (prev.kind === TokenKind.IDENTIFIER || prev.kind === TokenKind.QUOTED_IDENTIFIER) {
return '';
}
if (prev.kind === TokenKind.KEYWORD && SqlFormatter.FUNCTION_KEYWORDS.indexOf(prev.text.toUpperCase()) >= 0) {
return '';
}
return ' ';
}
private static separator(prev: SqlToken | undefined, current: SqlToken): string {
if (prev === undefined) {
return '';
}
if (current.text === ',' || current.text === ';' || current.text === '.') {
return '';
}
if (prev.text === '.' || prev.text === '(') {
return '';
}
if (current.kind === TokenKind.OPERATOR || prev.kind === TokenKind.OPERATOR) {
return ' ';
}
return ' ';
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* 语法高亮与折叠(1.6.2)。
*
* 在 1.6.1 的词法层之上产出渲染所需的区间:
* - spans:跳过空白,按 token 类型给出高亮区间(原串偏移);
* - foldingRanges:括号跨行时给出可折叠的行区间。
*
* 纯逻辑,UI 按区间着色与折叠。
*/
import { SqlToken, SqlTokenizer, TokenKind } from './SqlTokenizer';
export interface HighlightSpan {
start: number;
end: number;
kind: TokenKind;
}
export interface FoldingRange {
/** 起始行(0 起)。 */
startLine: number;
/** 结束行(0 起)。 */
endLine: number;
}
export class SqlHighlighter {
/** 高亮区间(不含空白与普通标点,减少无意义 span)。 */
static spans(sql: string): HighlightSpan[] {
const tokens: SqlToken[] = SqlTokenizer.tokenize(sql);
const out: HighlightSpan[] = [];
for (let i = 0; i < tokens.length; i++) {
const token: SqlToken = tokens[i];
if (token.kind === TokenKind.WHITESPACE) {
continue;
}
if (token.kind === TokenKind.PUNCTUATION && token.text !== '(' && token.text !== ')') {
continue;
}
out.push({ start: token.start, end: token.end, kind: token.kind });
}
return out;
}
/** 跨行括号的折叠区间(行号 0 起)。 */
static foldingRanges(sql: string): FoldingRange[] {
const tokens: SqlToken[] = SqlTokenizer.tokenize(sql);
const lineStarts: number[] = [0];
for (let i = 0; i < sql.length; i++) {
if (sql.charAt(i) === '\n') {
lineStarts.push(i + 1);
}
}
const stack: number[] = [];
const out: FoldingRange[] = [];
for (let i = 0; i < tokens.length; i++) {
const token: SqlToken = tokens[i];
if (token.kind !== TokenKind.PUNCTUATION) {
continue;
}
if (token.text === '(') {
stack.push(token.start);
} else if (token.text === ')') {
if (stack.length === 0) {
continue;
}
const openPos: number = stack.pop() as number;
const startLine: number = SqlHighlighter.lineOf(lineStarts, openPos);
const endLine: number = SqlHighlighter.lineOf(lineStarts, token.start);
if (endLine > startLine) {
out.push({ startLine: startLine, endLine: endLine });
}
}
}
return out;
}
private static lineOf(lineStarts: number[], offset: number): number {
let line: number = 0;
for (let i = 0; i < lineStarts.length; i++) {
if (lineStarts[i] <= offset) {
line = i;
} else {
break;
}
}
return line;
}
}
+213
View File
@@ -0,0 +1,213 @@
/**
* SQL 分词与语句切分(1.6.1)。
*
* 目标:为高亮、补全、语句切分提供稳定的词法层,不依赖任何引擎。
* 覆盖:行注释(--)、块注释(/* *\/)、单引号字符串('' 转义)、双引号标识符("" 转义)、
* 反引号标识符(`` 转义)、数字、标识符/关键字、运算符与标点。
*
* 说明:这不是完整的 SQL 语法分析器;语法树与方言差异由后续 1.6.x 在词法层之上构建。
*/
export enum TokenKind {
WHITESPACE = 0,
COMMENT = 1,
STRING = 2,
QUOTED_IDENTIFIER = 3,
NUMBER = 4,
IDENTIFIER = 5,
KEYWORD = 6,
PUNCTUATION = 7,
OPERATOR = 8
}
export interface SqlToken {
kind: TokenKind;
text: string;
start: number;
end: number;
}
const KEYWORDS: string[] = [
'SELECT', 'FROM', 'WHERE', 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE',
'CREATE', 'ALTER', 'DROP', 'TABLE', 'INDEX', 'VIEW', 'AS', 'JOIN', 'LEFT', 'RIGHT',
'INNER', 'OUTER', 'ON', 'GROUP', 'BY', 'ORDER', 'LIMIT', 'OFFSET', 'AND', 'OR', 'NOT',
'NULL', 'IS', 'IN', 'EXISTS', 'DISTINCT', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'HAVING',
'UNION', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'BEGIN', 'COMMIT', 'ROLLBACK'
];
export class SqlTokenizer {
static isKeyword(word: string): boolean {
return KEYWORDS.indexOf(word.toUpperCase()) >= 0;
}
/** 全量分词(含空白与注释),token 的 start/end 为原串偏移。 */
static tokenize(sql: string): SqlToken[] {
const out: SqlToken[] = [];
let i: number = 0;
while (i < sql.length) {
const ch: string = sql.charAt(i);
// 空白
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
const start: number = i;
while (i < sql.length && ' \t\n\r'.indexOf(sql.charAt(i)) >= 0) {
i++;
}
out.push({ kind: TokenKind.WHITESPACE, text: sql.substring(start, i), start: start, end: i });
continue;
}
// 行注释
if (ch === '-' && sql.charAt(i + 1) === '-') {
const start: number = i;
while (i < sql.length && sql.charAt(i) !== '\n') {
i++;
}
out.push({ kind: TokenKind.COMMENT, text: sql.substring(start, i), start: start, end: i });
continue;
}
// 块注释
if (ch === '/' && sql.charAt(i + 1) === '*') {
const start: number = i;
i += 2;
while (i < sql.length && !(sql.charAt(i) === '*' && sql.charAt(i + 1) === '/')) {
i++;
}
i = i < sql.length ? i + 2 : i;
out.push({ kind: TokenKind.COMMENT, text: sql.substring(start, i), start: start, end: i });
continue;
}
// 单引号字符串
if (ch === '\'') {
const start: number = i;
i++;
while (i < sql.length) {
if (sql.charAt(i) === '\'') {
if (sql.charAt(i + 1) === '\'') {
i += 2;
continue;
}
i++;
break;
}
i++;
}
out.push({ kind: TokenKind.STRING, text: sql.substring(start, i), start: start, end: i });
continue;
}
// 双引号标识符
if (ch === '"') {
const start: number = i;
i++;
while (i < sql.length) {
if (sql.charAt(i) === '"') {
if (sql.charAt(i + 1) === '"') {
i += 2;
continue;
}
i++;
break;
}
i++;
}
out.push({ kind: TokenKind.QUOTED_IDENTIFIER, text: sql.substring(start, i), start: start, end: i });
continue;
}
// 反引号标识符
if (ch === '`') {
const start: number = i;
i++;
while (i < sql.length) {
if (sql.charAt(i) === '`') {
if (sql.charAt(i + 1) === '`') {
i += 2;
continue;
}
i++;
break;
}
i++;
}
out.push({ kind: TokenKind.QUOTED_IDENTIFIER, text: sql.substring(start, i), start: start, end: i });
continue;
}
// 数字
if (ch >= '0' && ch <= '9') {
const start: number = i;
while (i < sql.length && sql.charAt(i) >= '0' && sql.charAt(i) <= '9') {
i++;
}
if (sql.charAt(i) === '.') {
i++;
while (i < sql.length && sql.charAt(i) >= '0' && sql.charAt(i) <= '9') {
i++;
}
}
out.push({ kind: TokenKind.NUMBER, text: sql.substring(start, i), start: start, end: i });
continue;
}
// 标识符/关键字
if (SqlTokenizer.isIdentifierStart(ch)) {
const start: number = i;
while (i < sql.length && SqlTokenizer.isIdentifierPart(sql.charAt(i))) {
i++;
}
const text: string = sql.substring(start, i);
out.push({
kind: SqlTokenizer.isKeyword(text) ? TokenKind.KEYWORD : TokenKind.IDENTIFIER,
text: text,
start: start,
end: i
});
continue;
}
// 标点与运算符
const start: number = i;
i++;
const kind: TokenKind = '(),;'.indexOf(ch) >= 0 ? TokenKind.PUNCTUATION : TokenKind.OPERATOR;
out.push({ kind: kind, text: ch, start: start, end: i });
}
return out;
}
/** 按顶层分号切分语句(字符串与注释内的分号不切分),去掉空语句。 */
static statements(sql: string): string[] {
const tokens: SqlToken[] = SqlTokenizer.tokenize(sql);
const out: string[] = [];
let current: string = '';
for (let i = 0; i < tokens.length; i++) {
const token: SqlToken = tokens[i];
if (token.kind === TokenKind.PUNCTUATION && token.text === ';') {
const trimmed: string = current.trim();
if (trimmed.length > 0) {
out.push(trimmed);
}
current = '';
continue;
}
current += token.text;
}
const tail: string = current.trim();
if (tail.length > 0) {
out.push(tail);
}
return out;
}
/** 去掉注释(保留空白与其它 token 原文)。 */
static stripComments(sql: string): string {
const tokens: SqlToken[] = SqlTokenizer.tokenize(sql);
let out: string = '';
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].kind !== TokenKind.COMMENT) {
out += tokens[i].text;
}
}
return out;
}
private static isIdentifierStart(ch: string): boolean {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_' || ch === '$';
}
private static isIdentifierPart(ch: string): boolean {
return SqlTokenizer.isIdentifierStart(ch) || (ch >= '0' && ch <= '9');
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* 结果值查看器(1.6.10)。
*
* 对单元格值做内容嗅探,决定用哪种视图展示:文本 / JSON / 数字 / 布尔 / 二进制(HEX) / 图片。
* 图片识别基于文件头魔数(PNG/JPEG/GIF),用于 BLOB 字段预览。
*/
export enum ValueKind {
NULL_VALUE = 0,
TEXT = 1,
JSON = 2,
NUMBER = 3,
BOOLEAN = 4,
BINARY = 5,
IMAGE = 6
}
export interface ValueView {
kind: ValueKind;
/** 文本视图内容(二进制时为占位文本)。 */
text: string;
/** 二进制值的十六进制预览(最多 64 字节)。 */
hexPreview: string;
}
export class ValueInspector {
/** 文本值分类。 */
static classify(value: string | undefined): ValueKind {
if (value === undefined || value === null) {
return ValueKind.NULL_VALUE;
}
const trimmed: string = value.trim();
if (trimmed.length === 0) {
return ValueKind.TEXT;
}
const lower: string = trimmed.toLowerCase();
if (lower === 'true' || lower === 'false') {
return ValueKind.BOOLEAN;
}
if (ValueInspector.isNumeric(trimmed)) {
return ValueKind.NUMBER;
}
if ((trimmed.charAt(0) === '{' && trimmed.charAt(trimmed.length - 1) === '}') ||
(trimmed.charAt(0) === '[' && trimmed.charAt(trimmed.length - 1) === ']')) {
return ValueKind.JSON;
}
if (ValueInspector.hasControlChars(value)) {
return ValueKind.BINARY;
}
return ValueKind.TEXT;
}
/** 由原始字节判断图片类型;非图片返回 undefined。 */
static sniffImage(bytes: number[]): string | undefined {
if (ValueInspector.startsWith(bytes, [0x89, 0x50, 0x4E, 0x47])) {
return 'png';
}
if (ValueInspector.startsWith(bytes, [0xFF, 0xD8, 0xFF])) {
return 'jpeg';
}
if (ValueInspector.startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) {
return 'gif';
}
return undefined;
}
/** 构造展示模型:图片优先,其次按文本分类。 */
static view(value: string | undefined, bytes?: number[]): ValueView {
if (bytes !== undefined && bytes.length > 0) {
const image: string | undefined = ValueInspector.sniffImage(bytes);
if (image !== undefined) {
return {
kind: ValueKind.IMAGE,
text: '<image:' + image + '>',
hexPreview: ValueInspector.hexPreview(bytes, 16)
};
}
}
const kind: ValueKind = ValueInspector.classify(value);
const text: string = value === undefined ? '' : value;
return {
kind: kind,
text: text,
hexPreview: (kind === ValueKind.BINARY && bytes !== undefined)
? ValueInspector.hexPreview(bytes, 64)
: ''
};
}
/** 十六进制预览(最多 maxBytes 字节,空格分隔)。 */
static hexPreview(bytes: number[], maxBytes: number): string {
const limit: number = maxBytes > 0 && bytes.length > maxBytes ? maxBytes : bytes.length;
const parts: string[] = [];
for (let i = 0; i < limit; i++) {
const hex: string = (bytes[i] & 0xFF).toString(16).toUpperCase();
parts.push(hex.length < 2 ? '0' + hex : hex);
}
return parts.join(' ');
}
private static isNumeric(text: string): boolean {
let dots: number = 0;
let start: number = 0;
if (text.charAt(0) === '-' || text.charAt(0) === '+') {
start = 1;
}
if (start >= text.length) {
return false;
}
for (let i = start; i < text.length; i++) {
const ch: string = text.charAt(i);
if (ch === '.') {
dots++;
if (dots > 1) {
return false;
}
continue;
}
if (ch < '0' || ch > '9') {
return false;
}
}
return true;
}
private static hasControlChars(text: string): boolean {
for (let i = 0; i < text.length; i++) {
const code: number = text.charCodeAt(i);
if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
return true;
}
}
return false;
}
private static startsWith(bytes: number[], prefix: number[]): boolean {
if (bytes.length < prefix.length) {
return false;
}
for (let i = 0; i < prefix.length; i++) {
if ((bytes[i] & 0xFF) !== prefix[i]) {
return false;
}
}
return true;
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* 形态与断点适配(1.23.6)。
*
* 统一描述 PC / 平板 / 折叠屏(折叠与展开)下的布局参数:
* 断点阈值与 1.23.1 规范一致(sm < 600vp,md < 840vp,lg ≥ 840vp),
* 列数 1/2/3,对象树宽度 0/240/280,侧栏 0/64/72。
*
* 纯逻辑,不依赖 ArkUI;UI 层据此选择布局组件。
*/
export enum Orientation {
PORTRAIT = 0,
LANDSCAPE = 1
}
export enum DeviceForm {
PC = 0,
TABLET = 1,
FOLDABLE_FOLDED = 2,
FOLDABLE_UNFOLDED = 3
}
export enum Breakpoint {
SM = 0,
MD = 1,
LG = 2
}
export interface FormContext {
width: number;
height: number;
orientation: Orientation;
form: DeviceForm;
breakpoint: Breakpoint;
}
export interface LayoutSpec {
columns: number;
treeWidth: number;
railWidth: number;
/** 结果区是否可并排显示。 */
sidePanel: boolean;
}
export class FormAdapter {
static readonly SM_MAX: number = 599;
static readonly MD_MAX: number = 839;
/** 由宽高与设备形态推断上下文。 */
static classify(width: number, height: number, form: DeviceForm): FormContext {
const orientation: Orientation = width > height ? Orientation.LANDSCAPE : Orientation.PORTRAIT;
return {
width: width,
height: height,
orientation: orientation,
form: form,
breakpoint: FormAdapter.breakpointOf(width)
};
}
static breakpointOf(width: number): Breakpoint {
if (width <= FormAdapter.SM_MAX) {
return Breakpoint.SM;
}
if (width <= FormAdapter.MD_MAX) {
return Breakpoint.MD;
}
return Breakpoint.LG;
}
/** 布局参数。 */
static layoutOf(context: FormContext): LayoutSpec {
switch (context.breakpoint) {
case Breakpoint.SM:
return { columns: 1, treeWidth: 0, railWidth: 0, sidePanel: false };
case Breakpoint.MD:
return { columns: 2, treeWidth: 240, railWidth: 64, sidePanel: false };
default:
return { columns: 3, treeWidth: 280, railWidth: 72, sidePanel: true };
}
}
/** 折叠屏:展开视为大屏平板,折叠视为手机宽度。 */
static resolveFoldable(width: number, height: number, unfolded: boolean): FormContext {
const form: DeviceForm = unfolded ? DeviceForm.FOLDABLE_UNFOLDED : DeviceForm.FOLDABLE_FOLDED;
return FormAdapter.classify(width, height, form);
}
/** 尺寸变化是否引起断点或形态变化(用于决定重建布局还是仅重排)。 */
static requiresRelayout(previous: FormContext, next: FormContext): boolean {
return previous.breakpoint !== next.breakpoint || previous.form !== next.form ||
previous.orientation !== next.orientation;
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* 输入形态差异化分派(1.23.8)。
*
* 同一意图在不同输入源上的表达不同:
* - 上下文菜单:鼠标右键 / 键盘 Shift+F10 / 触控长按;
* - 打开:鼠标双击 / 触控双击;
* - 范围选择:Shift+点击 / 触控拖选;
* - 悬停提示:仅鼠标(触控无 hover)。
*
* 本模块把「输入源 + 手势」映射为统一动作(复用 PointerResolver 的动作枚举语义)。
*/
export enum InputSource {
KEYBOARD = 0,
MOUSE = 1,
TOUCH = 2,
PEN = 3
}
export enum InputGesture {
TAP = 0,
DOUBLE_TAP = 1,
LONG_PRESS = 2,
RIGHT_CLICK = 3,
HOVER = 4,
DRAG = 5,
SHIFT_TAP = 6,
CTRL_TAP = 7,
KEY_MENU = 8
}
export enum DispatchAction {
NONE = 0,
SELECT = 1,
TOGGLE_SELECT = 2,
RANGE_SELECT = 3,
OPEN = 4,
CONTEXT_MENU = 5,
DRAG_START = 6,
TOOLTIP = 7
}
export class InputDispatcher {
static dispatch(source: InputSource, gesture: InputGesture): DispatchAction {
switch (gesture) {
case InputGesture.TAP:
if (source === InputSource.TOUCH || source === InputSource.PEN) {
return DispatchAction.SELECT;
}
return DispatchAction.SELECT;
case InputGesture.DOUBLE_TAP:
return DispatchAction.OPEN;
case InputGesture.LONG_PRESS:
// 触控长按等价于右键菜单;鼠标长按不触发菜单
return source === InputSource.TOUCH || source === InputSource.PEN
? DispatchAction.CONTEXT_MENU
: DispatchAction.NONE;
case InputGesture.RIGHT_CLICK:
return DispatchAction.CONTEXT_MENU;
case InputGesture.HOVER:
return source === InputSource.MOUSE ? DispatchAction.TOOLTIP : DispatchAction.NONE;
case InputGesture.DRAG:
return DispatchAction.DRAG_START;
case InputGesture.SHIFT_TAP:
return DispatchAction.RANGE_SELECT;
case InputGesture.CTRL_TAP:
return DispatchAction.TOGGLE_SELECT;
case InputGesture.KEY_MENU:
return DispatchAction.CONTEXT_MENU;
default:
return DispatchAction.NONE;
}
}
/** 该输入源是否支持悬停。 */
static supportsHover(source: InputSource): boolean {
return source === InputSource.MOUSE || source === InputSource.PEN;
}
/** 该输入源是否使用长按作为上下文菜单入口。 */
static usesLongPressForMenu(source: InputSource): boolean {
return source === InputSource.TOUCH || source === InputSource.PEN;
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* PC 键鼠交互解析(1.23.4)。
*
* 把鼠标事件(按钮 + 单击次数 + 修饰键)与所在区域映射为统一交互动作,
* 由 UI 层按动作分发。右键菜单、范围选择、Ctrl 多选等规则集中在此,避免散落在组件里。
*
* 键盘快捷键见 1.7.12 的 KeymapRegistry;本模块只处理指针。
*/
export enum MouseButton {
LEFT = 0,
MIDDLE = 1,
RIGHT = 2
}
export enum InteractionContext {
GRID = 0,
TREE = 1,
EDITOR = 2
}
export enum InteractionAction {
NONE = 0,
SELECT = 1,
TOGGLE_SELECT = 2,
RANGE_SELECT = 3,
OPEN = 4,
CONTEXT_MENU = 5,
RENAME = 6
}
export interface PointerInput {
button: MouseButton;
/** 1 单击,2 双击(连续点击合并由 UI 层完成)。 */
clickCount: number;
ctrl: boolean;
shift: boolean;
alt: boolean;
}
export class PointerResolver {
static resolve(context: InteractionContext, input: PointerInput): InteractionAction {
if (input.button === MouseButton.RIGHT) {
return InteractionAction.CONTEXT_MENU;
}
if (input.button === MouseButton.MIDDLE) {
return InteractionAction.NONE;
}
if (input.clickCount >= 2) {
return PointerResolver.doubleClick(context, input);
}
if (input.shift) {
return InteractionAction.RANGE_SELECT;
}
if (input.ctrl) {
return InteractionAction.TOGGLE_SELECT;
}
return InteractionAction.SELECT;
}
/** 双击语义:网格打开详情,树展开/打开,编辑器选词。 */
private static doubleClick(context: InteractionContext, input: PointerInput): InteractionAction {
if (context === InteractionContext.EDITOR) {
return InteractionAction.SELECT;
}
if (context === InteractionContext.TREE && input.alt) {
return InteractionAction.RENAME;
}
return InteractionAction.OPEN;
}
/** 动作的中文名(供菜单/提示使用)。 */
static actionLabel(action: InteractionAction): string {
switch (action) {
case InteractionAction.SELECT:
return '选择';
case InteractionAction.TOGGLE_SELECT:
return '切换选择';
case InteractionAction.RANGE_SELECT:
return '范围选择';
case InteractionAction.OPEN:
return '打开';
case InteractionAction.CONTEXT_MENU:
return '上下文菜单';
case InteractionAction.RENAME:
return '重命名';
default:
return '无';
}
}
}
+107
View File
@@ -0,0 +1,107 @@
/**
* PC 窗口状态模型(1.23.3)。
*
* 管理窗口尺寸/位置约束与恢复策略:
* - 最小/最大尺寸限制;
* - 从持久化状态恢复时裁剪到可用屏幕;
* - 分屏/最大化/浮动模式判定;
* - 与 FormAdapter 联动得到断点布局。
*
* 实际窗口 API(window.setWindowLayout 等)由平台层调用,本模型只做状态计算。
*/
import { Breakpoint, DeviceForm, FormAdapter, FormContext } from './FormAdapter';
export enum WindowMode {
FULLSCREEN = 0,
SPLIT = 1,
FLOATING = 2,
MAXIMIZED = 3
}
export interface WindowState {
width: number;
height: number;
x: number;
y: number;
mode: WindowMode;
}
export interface WindowLimits {
minWidth: number;
minHeight: number;
maxWidth: number;
maxHeight: number;
}
export class WindowLayout {
/** 桌面办公工具的默认约束。 */
static defaultLimits(): WindowLimits {
return { minWidth: 480, minHeight: 360, maxWidth: 4096, maxHeight: 4096 };
}
/** 尺寸裁剪到 [min, max]。 */
static clampSize(width: number, height: number, limits: WindowLimits): { width: number; height: number } {
return {
width: Math.min(limits.maxWidth, Math.max(limits.minWidth, width)),
height: Math.min(limits.maxHeight, Math.max(limits.minHeight, height))
};
}
/**
* 恢复已保存的窗口状态:
* - 尺寸裁剪到约束;
* - 位置裁剪到可用屏幕(保证标题栏可见);
* - 屏幕过小时降级为最大化。
*/
static restore(saved: WindowState, screenWidth: number, screenHeight: number, limits: WindowLimits): WindowState {
const size = WindowLayout.clampSize(saved.width, saved.height, limits);
if (size.width >= screenWidth || size.height >= screenHeight) {
return {
width: Math.max(limits.minWidth, screenWidth),
height: Math.max(limits.minHeight, screenHeight),
x: 0,
y: 0,
mode: WindowMode.MAXIMIZED
};
}
const maxX: number = Math.max(0, screenWidth - size.width);
const maxY: number = Math.max(0, screenHeight - size.height);
return {
width: size.width,
height: size.height,
x: Math.min(maxX, Math.max(0, saved.x)),
y: Math.min(maxY, Math.max(0, saved.y)),
mode: saved.mode
};
}
/** 根据窗口宽度推断模式:占满屏幕为最大化,否则浮动。 */
static modeOf(width: number, screenWidth: number): WindowMode {
return width >= screenWidth - 1 ? WindowMode.MAXIMIZED : WindowMode.FLOATING;
}
/** 窗口状态 → 断点上下文。 */
static toFormContext(state: WindowState): FormContext {
return FormAdapter.classify(state.width, state.height, DeviceForm.PC);
}
/** 是否需要重建布局(模式或断点变化)。 */
static requiresRelayout(previous: WindowState, next: WindowState): boolean {
if (previous.mode !== next.mode) {
return true;
}
return FormAdapter.breakpointOf(previous.width) !== FormAdapter.breakpointOf(next.width);
}
/** 断点对应的最小可用宽度建议(避免布局挤压)。 */
static recommendedMinWidth(breakpoint: Breakpoint): number {
switch (breakpoint) {
case Breakpoint.SM:
return 480;
case Breakpoint.MD:
return 600;
default:
return 840;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"module": {
"name": "data",
"type": "har",
"deviceTypes": [
"tablet",
"2in1"
]
}
}
@@ -0,0 +1,8 @@
{
"string": [
{
"name": "data_module_desc",
"value": "库阁数据层:对象模型、元数据、查询会话与结果集"
}
]
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Use these variables when you tailor your ArkTS code. They must be of the const type.
*/
export const HAR_VERSION = '1.0.0';
export const BUILD_MODE_NAME = 'debug';
export const DEBUG = true;
export const TARGET_NAME = 'default';
/**
* BuildProfile Class is used only for compatibility purposes.
*/
export default class BuildProfile {
static readonly HAR_VERSION = HAR_VERSION;
static readonly BUILD_MODE_NAME = BUILD_MODE_NAME;
static readonly DEBUG = DEBUG;
static readonly TARGET_NAME = TARGET_NAME;
}
+26
View File
@@ -0,0 +1,26 @@
/**
* @kuge/engine 的唯一公开入口。
*
* 约定(见 docs/adr/ADR-001):HAR 模块只通过本文件暴露能力,
* 外部模块禁止直接 import src/main/ets 下的实现文件。
*/
export {
ConnectionConfig,
ConnectionError,
ConnectionState,
Driver,
DriverFactory,
EngineKind,
engineLabel,
stateLabel
} from './src/main/ets/connection/types';
export { Connection, ConnectionManager } from './src/main/ets/connection/ConnectionManager';
export { Frame, FrameCodec } from './src/main/ets/protocol/FrameCodec';
export { MockDriver, MockDriverFactory } from './src/main/ets/driver/MockDriver';
export { EnginePlugin, PluginMeta } from './src/main/ets/plugin/EnginePlugin';
export { EngineRegistry, ENGINE_API_VERSION } from './src/main/ets/plugin/EngineRegistry';
export { SemVerUtil } from './src/main/ets/plugin/SemVerUtil';
export { ConnectionTester, TestResult, TestStage, TestErrorCode } from './src/main/ets/connection/ConnectionTester';
export { AppError, ErrorCategory, ErrorSeverity } from './src/main/ets/error/AppError';
export { ErrorMapper } from './src/main/ets/error/ErrorMapper';
export { ReconnectPolicy, ReconnectOptions } from './src/main/ets/connection/ReconnectPolicy';
+9
View File
@@ -0,0 +1,9 @@
{
"apiType": "stageMode",
"buildOption": {},
"targets": [
{
"name": "default"
}
]
}
+6
View File
@@ -0,0 +1,6 @@
import { harTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@kuge/engine",
"version": "1.0.0",
"description": "Kuge engine layer: connection abstraction, protocol framing, drivers",
"main": "Index.ets",
"author": "Kuge Team",
"license": "Apache-2.0",
"dependencies": {}
}
@@ -0,0 +1,122 @@
/**
* 连接管理内核。
*
* 职责:按引擎选择驱动、维护连接状态机、提供超时与关闭语义。
* 连接池策略(按引擎配额、复用、空闲回收)在 1.4.19 会话恢复与 1.4.1 后续迭代中补,
* 这里只保留"可注册、可创建、可关闭"的最小闭环。
*/
import { ConnectionConfig, ConnectionError, ConnectionState, Driver, DriverFactory, EngineKind } from './types';
/** 单条连接句柄。 */
export class Connection {
readonly config: ConnectionConfig;
private readonly driver: Driver;
private state: ConnectionState = ConnectionState.IDLE;
constructor(config: ConnectionConfig, driver: Driver) {
this.config = config;
this.driver = driver;
}
getState(): ConnectionState {
return this.state;
}
async connect(): Promise<void> {
if (this.state === ConnectionState.CONNECTED) {
return;
}
this.state = ConnectionState.CONNECTING;
try {
await this.withTimeout(this.driver.connect(this.config));
this.state = ConnectionState.CONNECTED;
} catch (err) {
this.state = ConnectionState.FAILED;
const message: string = err instanceof Error ? err.message : String(err);
throw new ConnectionError(message);
}
}
async ping(): Promise<void> {
if (this.state !== ConnectionState.CONNECTED) {
throw new ConnectionError('connection is not ready, state=' + this.state);
}
await this.driver.ping();
}
async close(): Promise<void> {
if (this.state === ConnectionState.CLOSED || this.state === ConnectionState.IDLE) {
this.state = ConnectionState.CLOSED;
return;
}
this.state = ConnectionState.DISCONNECTING;
try {
await this.driver.disconnect();
} catch (err) {
// 关闭失败不阻断清理,状态仍置为 CLOSED。
}
this.state = ConnectionState.CLOSED;
}
private withTimeout(task: Promise<void>): Promise<void> {
const ms: number = this.config.connectTimeoutMs;
const timeout = new Promise<void>((_resolve, reject) => {
setTimeout(() => {
reject(new ConnectionError('connect timeout after ' + ms + 'ms'));
}, ms);
});
return Promise.race([task, timeout]);
}
}
/** 连接管理器。 */
export class ConnectionManager {
private factories: Map<EngineKind, DriverFactory> = new Map<EngineKind, DriverFactory>();
private connections: Map<string, Connection> = new Map<string, Connection>();
private order: string[] = [];
/** 注册某引擎的驱动工厂,后注册覆盖先注册。 */
register(kind: EngineKind, factory: DriverFactory): void {
this.factories.set(kind, factory);
}
isSupported(kind: EngineKind): boolean {
return this.factories.has(kind);
}
/** 创建连接;id 重复或引擎未注册时抛出 ConnectionError。 */
create(config: ConnectionConfig): Connection {
if (this.connections.has(config.id)) {
throw new ConnectionError('duplicate connection id: ' + config.id);
}
const factory = this.factories.get(config.engine);
if (factory === undefined) {
throw new ConnectionError('no driver registered for engine: ' + config.engine);
}
const conn = new Connection(config, factory.create());
this.connections.set(config.id, conn);
this.order.push(config.id);
return conn;
}
get(id: string): Connection | undefined {
return this.connections.get(id);
}
size(): number {
return this.order.length;
}
/** 关闭全部连接并清空注册表。 */
async closeAll(): Promise<void> {
for (let i = 0; i < this.order.length; i++) {
const conn = this.connections.get(this.order[i]);
if (conn !== undefined) {
await conn.close();
}
}
this.connections.clear();
this.order = [];
}
}
@@ -0,0 +1,89 @@
/**
* 连接测试服务(1.4.2)。
*
* 目标:把「解析凭据 → 建连 → ping」拆成可诊断的阶段,返回稳定的 TestResult,
* 供连接对话框与诊断面板展示(原因 + 建议)。
*
* 设计取舍:凭据由调用方解析后传入(secret),引擎层不直接依赖 core 的凭据实现,
* 避免 ArkTS 名义类型带来的跨模块适配样板。
*/
import { ConnectionConfig, ConnectionError, Driver, DriverFactory, EngineKind } from './types';
import { Connection, ConnectionManager } from './ConnectionManager';
/** 失败阶段。 */
export enum TestStage {
RESOLVE = 0,
CONNECT = 1,
PING = 2,
DONE = 3
}
/** 测试结果。 */
export interface TestResult {
ok: boolean;
stage: TestStage;
errorCode: string;
message: string;
elapsedMs: number;
}
/** 分阶段诊断的 code 约定(对上层稳定,可映射多语言)。 */
export class TestErrorCode {
static readonly OK: string = 'KUGE-CONN-0000';
static readonly CREDENTIAL_MISSING: string = 'KUGE-CONN-0001';
static readonly CONNECT_FAILED: string = 'KUGE-CONN-0002';
static readonly PING_FAILED: string = 'KUGE-CONN-0003';
}
export class ConnectionTester {
private readonly manager: ConnectionManager;
constructor(manager: ConnectionManager) {
this.manager = manager;
}
/**
* 执行连接测试。
* @param config 连接配置
* @param secret 已解析的凭据;config.credentialRef 非空时必须提供
*/
async test(config: ConnectionConfig, secret: string | undefined): Promise<TestResult> {
const started: number = Date.now();
if (config.credentialRef.length > 0 && (secret === undefined || secret.length === 0)) {
return this.fail(TestStage.RESOLVE, TestErrorCode.CREDENTIAL_MISSING, '凭据未找到,请重新输入密码', started);
}
let conn: Connection;
try {
conn = this.manager.create(config);
await conn.connect();
} catch (err) {
const msg: string = err instanceof Error ? err.message : String(err);
return this.fail(TestStage.CONNECT, TestErrorCode.CONNECT_FAILED, msg, started);
}
try {
await conn.ping();
} catch (err) {
const msg: string = err instanceof Error ? err.message : String(err);
await conn.close();
return this.fail(TestStage.PING, TestErrorCode.PING_FAILED, msg, started);
}
await conn.close();
return {
ok: true,
stage: TestStage.DONE,
errorCode: TestErrorCode.OK,
message: '连接成功',
elapsedMs: Date.now() - started
};
}
private fail(stage: TestStage, code: string, message: string, started: number): TestResult {
return {
ok: false,
stage: stage,
errorCode: code,
message: message,
elapsedMs: Date.now() - started
};
}
}
@@ -0,0 +1,55 @@
/**
* 断线重连策略(1.4.19)。
*
* 纯策略逻辑:决定「是否重试」与「等多久」。真实重连动作由 Connection 的调用方执行,
* 定时器的接入在平台层完成,便于单元测试与后续加入抖动(jitter)。
*
* 规则:
* - 指数退避:delay = min(base * multiplier^attempt, maxDelay);
* - 认证/协议/资源限制类错误不重试(重试也不会成功);
* - 达到最大次数后不再重试。
*/
import { ErrorCategory } from '../error/AppError';
export interface ReconnectOptions {
/** 最大尝试次数(不含首次连接)。 */
maxAttempts: number;
/** 首次退避,毫秒。 */
baseDelayMs: number;
/** 退避倍数。 */
multiplier: number;
/** 退避上限,毫秒。 */
maxDelayMs: number;
}
export class ReconnectPolicy {
static defaults(): ReconnectOptions {
return { maxAttempts: 3, baseDelayMs: 500, multiplier: 2, maxDelayMs: 5000 };
}
/** 第 attempt 次重试(0 起)的等待时间。 */
static delayFor(attempt: number, options: ReconnectOptions): number {
if (attempt < 0) {
return 0;
}
let delay: number = options.baseDelayMs;
for (let i = 0; i < attempt; i++) {
delay = delay * options.multiplier;
if (delay >= options.maxDelayMs) {
return options.maxDelayMs;
}
}
return delay > options.maxDelayMs ? options.maxDelayMs : delay;
}
/** 是否应继续重试。 */
static shouldRetry(attempt: number, options: ReconnectOptions, category: ErrorCategory): boolean {
if (attempt >= options.maxAttempts) {
return false;
}
if (category === ErrorCategory.AUTH || category === ErrorCategory.PROTOCOL || category === ErrorCategory.LIMIT) {
return false;
}
return true;
}
}

Some files were not shown because too many files have changed in this diff Show More