commit 9545e48e4dfdfa707c589cdeb2f55d60149abc1f Author: TommyLee <844718628@qq.com> Date: Mon Oct 19 00:11:38 2026 +0800 初始化项目 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2ff201 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/node_modules +/oh_modules +/local.properties +/.idea +**/build +/.hvigor +.cxx +/.clangd +/.clang-format +/.clang-tidy +**/.test +/.appanalyzer \ No newline at end of file diff --git a/AppScope/app.json5 b/AppScope/app.json5 new file mode 100644 index 0000000..a5442d8 --- /dev/null +++ b/AppScope/app.json5 @@ -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" + } +} diff --git a/AppScope/resources/base/element/string.json b/AppScope/resources/base/element/string.json new file mode 100644 index 0000000..6e2bc5a --- /dev/null +++ b/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "库阁" + } + ] +} diff --git a/AppScope/resources/base/media/background.png b/AppScope/resources/base/media/background.png new file mode 100644 index 0000000..923f2b3 Binary files /dev/null and b/AppScope/resources/base/media/background.png differ diff --git a/AppScope/resources/base/media/foreground.png b/AppScope/resources/base/media/foreground.png new file mode 100644 index 0000000..eb94275 Binary files /dev/null and b/AppScope/resources/base/media/foreground.png differ diff --git a/AppScope/resources/base/media/layered_image.json b/AppScope/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/AppScope/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/build-profile.json5 b/build-profile.json5 new file mode 100644 index 0000000..18af774 --- /dev/null +++ b/build-profile.json5 @@ -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" + } + ] + } + ] +} \ No newline at end of file diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000..78a9243 --- /dev/null +++ b/ci/README.md @@ -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\\outputs\\*.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 个版本 + 所有里程碑版本。 diff --git a/ci/build-hap.ps1 b/ci/build-hap.ps1 new file mode 100644 index 0000000..e187fb9 --- /dev/null +++ b/ci/build-hap.ps1 @@ -0,0 +1,65 @@ +<# + build-hap.ps1 - 命令行构建 HAP(CI 与本地通用) + + 用法: + powershell -ExecutionPolicy Bypass -File ci\build-hap.ps1 [-BuildMode debug|release] [-Product default] [-SdkHome ] [-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) diff --git a/ci/signing.template.json5 b/ci/signing.template.json5 new file mode 100644 index 0000000..9a43680 --- /dev/null +++ b/ci/signing.template.json5 @@ -0,0 +1,21 @@ +// 签名配置模板(不要直接提交真实证书与密码) +// +// 用法:把 signingConfigs 合并进 build-profile.json5 的 app 节点,并把占位路径换成 +// CI 密钥目录或本地证书路径。证书、私钥与密码不得进入版本库。 +{ + "signingConfigs": [ + { + "name": "default", + "type": "HarmonyOS", + "material": { + "storeFile": "/kuge-release.p12", + "storePassword": "", + "keyAlias": "kuge", + "keyPassword": "", + "certpath": "/kuge-release.cer", + "profile": "/kuge-release.p7b", + "signAlg": "SHA256withECDSA" + } + } + ] +} diff --git a/code-linter.json5 b/code-linter.json5 new file mode 100644 index 0000000..073990f --- /dev/null +++ b/code-linter.json5 @@ -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" + } +} \ No newline at end of file diff --git a/core/BuildProfile.ets b/core/BuildProfile.ets new file mode 100644 index 0000000..3a501e5 --- /dev/null +++ b/core/BuildProfile.ets @@ -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; +} \ No newline at end of file diff --git a/core/Index.ets b/core/Index.ets new file mode 100644 index 0000000..b497ff0 --- /dev/null +++ b/core/Index.ets @@ -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'; diff --git a/core/build-profile.json5 b/core/build-profile.json5 new file mode 100644 index 0000000..37fa510 --- /dev/null +++ b/core/build-profile.json5 @@ -0,0 +1,9 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default" + } + ] +} diff --git a/core/hvigorfile.ts b/core/hvigorfile.ts new file mode 100644 index 0000000..08f99a1 --- /dev/null +++ b/core/hvigorfile.ts @@ -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. */ +} diff --git a/core/oh-package.json5 b/core/oh-package.json5 new file mode 100644 index 0000000..ceae781 --- /dev/null +++ b/core/oh-package.json5 @@ -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": {} +} diff --git a/core/src/main/ets/app/AppInfo.ets b/core/src/main/ets/app/AppInfo.ets new file mode 100644 index 0000000..c180955 --- /dev/null +++ b/core/src/main/ets/app/AppInfo.ets @@ -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'; +} diff --git a/core/src/main/ets/config/AppConfig.ets b/core/src/main/ets/config/AppConfig.ets new file mode 100644 index 0000000..7783947 --- /dev/null +++ b/core/src/main/ets/config/AppConfig.ets @@ -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; + } + } +} diff --git a/core/src/main/ets/diagnostics/DiagnosticBuilder.ets b/core/src/main/ets/diagnostics/DiagnosticBuilder.ets new file mode 100644 index 0000000..8ff880d --- /dev/null +++ b/core/src/main/ets/diagnostics/DiagnosticBuilder.ets @@ -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'); + } +} diff --git a/core/src/main/ets/i18n/I18n.ets b/core/src/main/ets/i18n/I18n.ets new file mode 100644 index 0000000..d87cdcc --- /dev/null +++ b/core/src/main/ets/i18n/I18n.ets @@ -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; + } +} diff --git a/core/src/main/ets/logger/FileLogSink.ets b/core/src/main/ets/logger/FileLogSink.ets new file mode 100644 index 0000000..703bdcf --- /dev/null +++ b/core/src/main/ets/logger/FileLogSink.ets @@ -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; + } +} diff --git a/core/src/main/ets/logger/Logger.ets b/core/src/main/ets/logger/Logger.ets new file mode 100644 index 0000000..a8bfd4e --- /dev/null +++ b/core/src/main/ets/logger/Logger.ets @@ -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); + } +} diff --git a/core/src/main/ets/security/CredentialVault.ets b/core/src/main/ets/security/CredentialVault.ets new file mode 100644 index 0000000..8581e94 --- /dev/null +++ b/core/src/main/ets/security/CredentialVault.ets @@ -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; + has(keyAlias: string): Promise; + delete(keyAlias: string): Promise; + encrypt(keyAlias: string, plain: string): Promise; + decrypt(keyAlias: string, cipher: string): Promise; +} + +/** 凭据存储契约。 */ +export interface CredentialStore { + put(ref: string, secret: string): Promise; + get(ref: string): Promise; + has(ref: string): Promise; + remove(ref: string): Promise; + clear(): Promise; +} + +/** 测试用密钥提供者:可逆标记,不具备安全性。 */ +export class InMemoryKeyProvider implements KeyProvider { + private readonly keys: Set = new Set(); + + async generate(keyAlias: string): Promise { + this.keys.add(keyAlias); + } + + async has(keyAlias: string): Promise { + return this.keys.has(keyAlias); + } + + async delete(keyAlias: string): Promise { + this.keys.delete(keyAlias); + } + + async encrypt(keyAlias: string, plain: string): Promise { + if (!this.keys.has(keyAlias)) { + throw new Error('key not found: ' + keyAlias); + } + return 'enc:' + plain; + } + + async decrypt(keyAlias: string, cipher: string): Promise { + 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 { + 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 { + 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 { + return (await this.store.get(CRED_PREFIX + ref)) !== undefined; + } + + async remove(ref: string): Promise { + await this.store.delete(CRED_PREFIX + ref); + } + + async clear(): Promise { + 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]); + } + } + } +} diff --git a/core/src/main/ets/storage/KeyValueStore.ets b/core/src/main/ets/storage/KeyValueStore.ets new file mode 100644 index 0000000..dbfaa24 --- /dev/null +++ b/core/src/main/ets/storage/KeyValueStore.ets @@ -0,0 +1,44 @@ +/** + * 键值存储抽象(1.3.7)。 + * + * 平台实现(Preferences / RDB / 文件)在后续适配器中提供;本文件只定义契约与 + * 内存实现,便于在无平台环境测试业务逻辑,也便于替换存储介质。 + * + * 约定:键使用命名空间前缀(如 conn./meta./ui.),便于按域清理与迁移。 + */ +export interface KeyValueStore { + get(key: string): Promise; + put(key: string, value: string): Promise; + delete(key: string): Promise; + keys(): Promise; + clear(): Promise; +} + +/** 内存实现:仅用于测试与降级(不可持久化)。 */ +export class MemoryKeyValueStore implements KeyValueStore { + private readonly data: Map = new Map(); + + async get(key: string): Promise { + return this.data.get(key); + } + + async put(key: string, value: string): Promise { + this.data.set(key, value); + } + + async delete(key: string): Promise { + this.data.delete(key); + } + + async keys(): Promise { + const out: string[] = []; + this.data.forEach((_value: string, key: string) => { + out.push(key); + }); + return out; + } + + async clear(): Promise { + this.data.clear(); + } +} diff --git a/core/src/main/ets/storage/LruCache.ets b/core/src/main/ets/storage/LruCache.ets new file mode 100644 index 0000000..b7122d5 --- /dev/null +++ b/core/src/main/ets/storage/LruCache.ets @@ -0,0 +1,86 @@ +/** + * LRU 缓存(1.3.7)。 + * + * 用途:对象元数据、查询结果分页、连接信息等热点数据的本地淘汰缓存。 + * 纯逻辑实现,不依赖平台 API;上层可用 Preferences / RDB / 文件做持久化适配。 + * + * 语义:get 命中会把条目移到最近使用位置;put 超出容量时淘汰最久未使用项。 + */ +export class LruCache { + private readonly capacity: number; + private readonly map: Map = new Map(); + 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); + } +} diff --git a/core/src/main/ets/task/TaskScheduler.ets b/core/src/main/ets/task/TaskScheduler.ets new file mode 100644 index 0000000..d591dbd --- /dev/null +++ b/core/src/main/ets/task/TaskScheduler.ets @@ -0,0 +1,114 @@ +/** + * 任务调度框架(1.3.5)。 + * + * 目标:把「并发上限、排队、取消、失败传播」收敛到一处,业务侧只提交任务。 + * 执行体抽象为 TaskRunner,UI 层与 Worker/TaskPool 的差异由实现方注入, + * 便于在无平台环境做单元测试,也便于后续替换为 TaskPool 的真实执行器。 + * + * 语义: + * - submit 返回的 Promise 在任务成功时 resolve,失败或取消时 reject; + * - 取消仅在任务尚未开始(排队中)时生效,返回 true;运行中的任务需自行响应取消信号; + * - 并发达到上限时排队,任务完成后自动补位。 + */ + +/** 可执行任务。 */ +export interface TaskRunner { + run(): Promise; +} + +/** 取消错误。 */ +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 = new Set(); + 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 { + 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((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(); + } +} diff --git a/core/src/main/module.json5 b/core/src/main/module.json5 new file mode 100644 index 0000000..4151667 --- /dev/null +++ b/core/src/main/module.json5 @@ -0,0 +1,10 @@ +{ + "module": { + "name": "core", + "type": "har", + "deviceTypes": [ + "tablet", + "2in1" + ] + } +} diff --git a/core/src/main/resources/base/element/string.json b/core/src/main/resources/base/element/string.json new file mode 100644 index 0000000..88103b2 --- /dev/null +++ b/core/src/main/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "core_module_desc", + "value": "DBMGR 核心能力模块(日志、应用信息、公共类型)" + } + ] +} diff --git a/data/Index.ets b/data/Index.ets new file mode 100644 index 0000000..aca8a7a --- /dev/null +++ b/data/Index.ets @@ -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'; diff --git a/data/build-profile.json5 b/data/build-profile.json5 new file mode 100644 index 0000000..37fa510 --- /dev/null +++ b/data/build-profile.json5 @@ -0,0 +1,9 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default" + } + ] +} diff --git a/data/hvigorfile.ts b/data/hvigorfile.ts new file mode 100644 index 0000000..08f99a1 --- /dev/null +++ b/data/hvigorfile.ts @@ -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. */ +} diff --git a/data/oh-package.json5 b/data/oh-package.json5 new file mode 100644 index 0000000..5f31738 --- /dev/null +++ b/data/oh-package.json5 @@ -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": {} +} diff --git a/data/src/main/ets/audit/AuditLog.ets b/data/src/main/ets/audit/AuditLog.ets new file mode 100644 index 0000000..82303eb --- /dev/null +++ b/data/src/main/ets/audit/AuditLog.ets @@ -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; + } +} diff --git a/data/src/main/ets/grid/AggregationBuilder.ets b/data/src/main/ets/grid/AggregationBuilder.ets new file mode 100644 index 0000000..d836acc --- /dev/null +++ b/data/src/main/ets/grid/AggregationBuilder.ets @@ -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'; + } + } +} diff --git a/data/src/main/ets/grid/BatchModifier.ets b/data/src/main/ets/grid/BatchModifier.ets new file mode 100644 index 0000000..9eedaab --- /dev/null +++ b/data/src/main/ets/grid/BatchModifier.ets @@ -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, + 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 = new Set(); + 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; + } +} diff --git a/data/src/main/ets/grid/DelimitedCodec.ets b/data/src/main/ets/grid/DelimitedCodec.ets new file mode 100644 index 0000000..46555c9 --- /dev/null +++ b/data/src/main/ets/grid/DelimitedCodec.ets @@ -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'), '""') + '"'; + } +} diff --git a/data/src/main/ets/grid/FieldMapper.ets b/data/src/main/ets/grid/FieldMapper.ets new file mode 100644 index 0000000..3d724fa --- /dev/null +++ b/data/src/main/ets/grid/FieldMapper.ets @@ -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 = new Set(); + const targets: Set = new Set(); + 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, mappings: FieldMapping[]): Map { + const out: Map = new Map(); + 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[], mappings: FieldMapping[]): Map[] { + const out: Map[] = []; + 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 = new Set(); + 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, 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; + } +} diff --git a/data/src/main/ets/grid/FilterBuilder.ets b/data/src/main/ets/grid/FilterBuilder.ets new file mode 100644 index 0000000..982fcc8 --- /dev/null +++ b/data/src/main/ets/grid/FilterBuilder.ets @@ -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'), '\'\'') + '\''; + } +} diff --git a/data/src/main/ets/grid/FormViewModel.ets b/data/src/main/ets/grid/FormViewModel.ets new file mode 100644 index 0000000..d884013 --- /dev/null +++ b/data/src/main/ets/grid/FormViewModel.ets @@ -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 = new Map(); + private current: Map = new Map(); + + constructor(fields: FieldSpec[]) { + this.fields = fields.slice(); + } + + fields_(): FieldSpec[] { + return this.fields.slice(); + } + + /** 载入一行数据(重置脏状态)。 */ + load(values: Map): void { + this.original = new Map(); + this.current = new Map(); + 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(); + 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; + } +} diff --git a/data/src/main/ets/grid/GridModel.ets b/data/src/main/ets/grid/GridModel.ets new file mode 100644 index 0000000..f73b591 --- /dev/null +++ b/data/src/main/ets/grid/GridModel.ets @@ -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 = new Set(); + 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; +} diff --git a/data/src/main/ets/grid/KeymapRegistry.ets b/data/src/main/ets/grid/KeymapRegistry.ets new file mode 100644 index 0000000..d43280e --- /dev/null +++ b/data/src/main/ets/grid/KeymapRegistry.ets @@ -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); + } +} diff --git a/data/src/main/ets/grid/LobBuffer.ets b/data/src/main/ets/grid/LobBuffer.ets new file mode 100644 index 0000000..85f44da --- /dev/null +++ b/data/src/main/ets/grid/LobBuffer.ets @@ -0,0 +1,117 @@ +/** + * 大字段(LOB)分块缓冲(1.7.7)。 + * + * 大文本/二进制按固定大小分块加载与编辑: + * - 只请求可视范围内的块,避免一次性读取整列; + * - 记录已加载块,read 拼接时若跨未加载块则抛错(提示先加载)。 + */ +export class LobBuffer { + private readonly totalSize: number; + private readonly chunkSize: number; + private readonly loaded: Map = new Map(); + + 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(); + } +} diff --git a/data/src/main/ets/grid/RendererRegistry.ets b/data/src/main/ets/grid/RendererRegistry.ets new file mode 100644 index 0000000..68cfe91 --- /dev/null +++ b/data/src/main/ets/grid/RendererRegistry.ets @@ -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(); + } +} diff --git a/data/src/main/ets/grid/TableEditor.ets b/data/src/main/ets/grid/TableEditor.ets new file mode 100644 index 0000000..6023d62 --- /dev/null +++ b/data/src/main/ets/grid/TableEditor.ets @@ -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> = new Map>(); + private readonly rowKeys: Map = new Map(); + + /** 记录单元格修改;同一行同一列重复修改保留最初的 oldValue。 */ + setCell(rowIndex: number, column: string, oldValue: string, newValue: string): void { + let rowChanges: Map | undefined = this.changes.get(rowIndex); + if (rowChanges === undefined) { + rowChanges = new Map(); + 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 | 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, 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) => { + 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'), '\'\'') + '\''; + } +} diff --git a/data/src/main/ets/grid/Validation.ets b/data/src/main/ets/grid/Validation.ets new file mode 100644 index 0000000..a998d6d --- /dev/null +++ b/data/src/main/ets/grid/Validation.ets @@ -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, 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, 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): Map { + const out: Map = new Map(); + pairs.forEach((versions: string[], row: number) => { + if (versions.length >= 2) { + out.set(row, ConflictDetector.detect(versions[0], versions[1])); + } + }); + return out; + } +} diff --git a/data/src/main/ets/io/BatchQueue.ets b/data/src/main/ets/io/BatchQueue.ets new file mode 100644 index 0000000..f3646bf --- /dev/null +++ b/data/src/main/ets/io/BatchQueue.ets @@ -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; + } +} diff --git a/data/src/main/ets/io/CsvHandler.ets b/data/src/main/ets/io/CsvHandler.ets new file mode 100644 index 0000000..2a8c4f0 --- /dev/null +++ b/data/src/main/ets/io/CsvHandler.ets @@ -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[] { + const out: Map[] = []; + for (let i = 0; i < result.rows.length; i++) { + const map: Map = new Map(); + 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; + } +} diff --git a/data/src/main/ets/io/FormatRegistry.ets b/data/src/main/ets/io/FormatRegistry.ets new file mode 100644 index 0000000..115b875 --- /dev/null +++ b/data/src/main/ets/io/FormatRegistry.ets @@ -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); + } +} diff --git a/data/src/main/ets/io/HtmlExporter.ets b/data/src/main/ets/io/HtmlExporter.ets new file mode 100644 index 0000000..dcfbf9b --- /dev/null +++ b/data/src/main/ets/io/HtmlExporter.ets @@ -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(''); + if (header.length > 0) { + parts.push(' '); + for (let i = 0; i < header.length; i++) { + parts.push(' '); + } + parts.push(' '); + } + parts.push(' '); + for (let i = 0; i < rows.length; i++) { + parts.push(' '); + for (let j = 0; j < rows[i].length; j++) { + parts.push(' '); + } + parts.push(' '); + } + parts.push(' '); + parts.push('
' + HtmlExporter.escape(header[i]) + '
' + HtmlExporter.escape(rows[i][j]) + '
'); + return parts.join('\n'); + } + + /** 完整 HTML 文档。 */ + static toDocument(header: string[], rows: string[][], options: HtmlOptions): string { + const lines: string[] = []; + lines.push(''); + lines.push(''); + lines.push(''); + lines.push(' '); + lines.push(' ' + HtmlExporter.escape(options.title) + ''); + lines.push(' '); + lines.push(''); + lines.push(''); + lines.push('

' + HtmlExporter.escape(options.title) + '

'); + lines.push(HtmlExporter.toTable(options.withHeader ? header : [], rows)); + if (options.footer.length > 0) { + lines.push('
' + HtmlExporter.escape(options.footer) + '
'); + } + lines.push(''); + lines.push(''); + return lines.join('\n'); + } + + static escape(text: string): string { + return text.replace(new RegExp('&', 'g'), '&') + .replace(new RegExp('<', 'g'), '<') + .replace(new RegExp('>', 'g'), '>') + .replace(new RegExp('"', 'g'), '"'); + } +} diff --git a/data/src/main/ets/io/JsonCodec.ets b/data/src/main/ets/io/JsonCodec.ets new file mode 100644 index 0000000..cedb2c5 --- /dev/null +++ b/data/src/main/ets/io/JsonCodec.ets @@ -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[] = []; + for (let i = 0; i < rows.length; i++) { + const obj: Map = new Map(); + 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[] = []; + for (let i = 0; i < objects.length; i++) { + const record: Record = {}; + 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 = first as Record; + 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 = item as Record; + 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); + } +} diff --git a/data/src/main/ets/io/SpreadsheetModel.ets b/data/src/main/ets/io/SpreadsheetModel.ets new file mode 100644 index 0000000..3902a7c --- /dev/null +++ b/data/src/main/ets/io/SpreadsheetModel.ets @@ -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 = new Set(); + 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; + } +} diff --git a/data/src/main/ets/io/SqlExporter.ets b/data/src/main/ets/io/SqlExporter.ets new file mode 100644 index 0000000..6c11d1d --- /dev/null +++ b/data/src/main/ets/io/SqlExporter.ets @@ -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'), '\'\'') + '\''; + } +} diff --git a/data/src/main/ets/io/TextEncoding.ets b/data/src/main/ets/io/TextEncoding.ets new file mode 100644 index 0000000..2b646ab --- /dev/null +++ b/data/src/main/ets/io/TextEncoding.ets @@ -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; + } +} diff --git a/data/src/main/ets/io/TypeConverter.ets b/data/src/main/ets/io/TypeConverter.ets new file mode 100644 index 0000000..d0c91b6 --- /dev/null +++ b/data/src/main/ets/io/TypeConverter.ets @@ -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; + } +} diff --git a/data/src/main/ets/io/WizardModel.ets b/data/src/main/ets/io/WizardModel.ets new file mode 100644 index 0000000..e6e0784 --- /dev/null +++ b/data/src/main/ets/io/WizardModel.ets @@ -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 = new Map(); + + /** 当前步骤。 */ + 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(); + } +} diff --git a/data/src/main/ets/io/XmlCodec.ets b/data/src/main/ets/io/XmlCodec.ets new file mode 100644 index 0000000..c79698c --- /dev/null +++ b/data/src/main/ets/io/XmlCodec.ets @@ -0,0 +1,91 @@ +/** + * XML 导入导出(1.8.4)。 + * + * 格式(固定、可往返): + * + * + * 1alice + * + * + * 只解析本模块产出的结构,不实现通用 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(''); + lines.push('<' + rootName + '>'); + for (let i = 0; i < rows.length; i++) { + let line: string = ' '; + for (let j = 0; j < header.length; j++) { + const value: string | undefined = rows[i][j]; + line += '' + + XmlCodec.escape(value === undefined ? '' : value) + ''; + } + line += ''; + lines.push(line); + } + lines.push(''); + return lines.join('\n'); + } + + /** 解析本模块格式;错误以文本列表返回。 */ + static parse(text: string): XmlParseResult { + const errors: string[] = []; + const header: string[] = []; + const rows: string[][] = []; + const rowPattern: RegExp = new RegExp('([\\s\\S]*?)', 'g'); + const colPattern: RegExp = new RegExp('([\\s\\S]*?)', 'g'); + let rowMatch: RegExpExecArray | null = rowPattern.exec(text); + if (rowMatch === null) { + if (text.indexOf(' 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 elements found'); + } + return { header: header, rows: rows, errors: errors }; + } + + static escape(text: string): string { + return text.replace(new RegExp('&', 'g'), '&') + .replace(new RegExp('<', 'g'), '<') + .replace(new RegExp('>', 'g'), '>') + .replace(new RegExp('"', 'g'), '"') + .replace(new RegExp('\'', 'g'), '''); + } + + static unescape(text: string): string { + return text.replace(new RegExp('<', 'g'), '<') + .replace(new RegExp('>', 'g'), '>') + .replace(new RegExp('"', 'g'), '"') + .replace(new RegExp(''', 'g'), '\'') + .replace(new RegExp('&', 'g'), '&'); + } +} diff --git a/data/src/main/ets/metadata/MetadataCache.ets b/data/src/main/ets/metadata/MetadataCache.ets new file mode 100644 index 0000000..cd4df87 --- /dev/null +++ b/data/src/main/ets/metadata/MetadataCache.ets @@ -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 = new Map(); + 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); + } + } +} diff --git a/data/src/main/ets/metadata/ObjectTreeModel.ets b/data/src/main/ets/metadata/ObjectTreeModel.ets new file mode 100644 index 0000000..2efeccd --- /dev/null +++ b/data/src/main/ets/metadata/ObjectTreeModel.ets @@ -0,0 +1,120 @@ +/** + * 对象树模型(1.5.1)。 + * + * 把扁平的 DbObject 列表构建为可导航的树,并提供: + * - 根节点与子节点枚举; + * - 按名称/模式搜索(大小写不敏感); + * - 按类型过滤; + * - 从对象回溯祖先路径; + * - 类型计数(供对象树分组显示)。 + * + * 纯逻辑,不依赖 UI 与平台 API。 + */ +import { DbObject, ObjectType } from './Objects'; + +export class ObjectTreeModel { + private readonly byId: Map = new Map(); + private readonly childrenIndex: Map = new Map(); + 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; + } +} diff --git a/data/src/main/ets/metadata/Objects.ets b/data/src/main/ets/metadata/Objects.ets new file mode 100644 index 0000000..95e67c1 --- /dev/null +++ b/data/src/main/ets/metadata/Objects.ets @@ -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 '未知'; + } +} diff --git a/data/src/main/ets/metadata/SearchUtil.ets b/data/src/main/ets/metadata/SearchUtil.ets new file mode 100644 index 0000000..e15783f --- /dev/null +++ b/data/src/main/ets/metadata/SearchUtil.ets @@ -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; + } +} diff --git a/data/src/main/ets/schema/BatchOps.ets b/data/src/main/ets/schema/BatchOps.ets new file mode 100644 index 0000000..12eb80e --- /dev/null +++ b/data/src/main/ets/schema/BatchOps.ets @@ -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); + } +} diff --git a/data/src/main/ets/schema/ConstraintModel.ets b/data/src/main/ets/schema/ConstraintModel.ets new file mode 100644 index 0000000..4b9049a --- /dev/null +++ b/data/src/main/ets/schema/ConstraintModel.ets @@ -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 = new Set(); + const dup: Set = new Set(); + 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 = new Set(); + 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, dup: Set, 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); + } +} diff --git a/data/src/main/ets/schema/DdlGenerator.ets b/data/src/main/ets/schema/DdlGenerator.ets new file mode 100644 index 0000000..3731957 --- /dev/null +++ b/data/src/main/ets/schema/DdlGenerator.ets @@ -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); + } +} diff --git a/data/src/main/ets/schema/IndexModel.ets b/data/src/main/ets/schema/IndexModel.ets new file mode 100644 index 0000000..94e4aa1 --- /dev/null +++ b/data/src/main/ets/schema/IndexModel.ets @@ -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 = new Set(); + const reported: Set = new Set(); + 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__[_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; + } +} diff --git a/data/src/main/ets/schema/MiscObjectDdl.ets b/data/src/main/ets/schema/MiscObjectDdl.ets new file mode 100644 index 0000000..a87fd48 --- /dev/null +++ b/data/src/main/ets/schema/MiscObjectDdl.ets @@ -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(', ') + ');'; + } +} diff --git a/data/src/main/ets/schema/ObjectComparator.ets b/data/src/main/ets/schema/ObjectComparator.ets new file mode 100644 index 0000000..53fa68b --- /dev/null +++ b/data/src/main/ets/schema/ObjectComparator.ets @@ -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; + } + } +} diff --git a/data/src/main/ets/schema/RoutineDdl.ets b/data/src/main/ets/schema/RoutineDdl.ets new file mode 100644 index 0000000..6ac2e20 --- /dev/null +++ b/data/src/main/ets/schema/RoutineDdl.ets @@ -0,0 +1,56 @@ +/** + * 存储过程与函数 DDL(1.5.7)。 + * + * 仅生成对象定义骨架:参数列表按用户输入的 SQL 片段原样拼接,函数体按方言包裹。 + * MySQL 使用 BEGIN ... END,其余引擎使用 AS ;调试与执行在驱动层完成。 + */ +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 ,去掉用户输入末尾的重复分号,避免生成 ";;" + const body: string = rawBody.replace(new RegExp(';+$'), ''); + return 'CREATE OR REPLACE ' + keyword + ' ' + name + ' ' + params + returns + + ' AS\n' + body + ';'; + } +} diff --git a/data/src/main/ets/schema/SpaceDdl.ets b/data/src/main/ets/schema/SpaceDdl.ets new file mode 100644 index 0000000..cc266b8 --- /dev/null +++ b/data/src/main/ets/schema/SpaceDdl.ets @@ -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'; + } + } +} diff --git a/data/src/main/ets/schema/TableDiff.ets b/data/src/main/ets/schema/TableDiff.ets new file mode 100644 index 0000000..1332fb1 --- /dev/null +++ b/data/src/main/ets/schema/TableDiff.ets @@ -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 = new Map(); + 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 = new Set(); + for (let i = 0; i < current.indexes.length; i++) { + currentSigs.add(TableDiff.indexSignature(current.indexes[i])); + } + const targetSigs: Set = new Set(); + 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 = new Set(); + for (let i = 0; i < current.foreignKeys.length; i++) { + currentSigs.add(TableDiff.fkSignature(current.foreignKeys[i])); + } + const targetSigs: Set = new Set(); + 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 = new Set(); + for (let i = 0; i < currentChecks.length; i++) { + currentSigs.add(TableDiff.checkSignature(currentChecks[i])); + } + const targetSigs: Set = new Set(); + 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(); + } +} diff --git a/data/src/main/ets/schema/TableModel.ets b/data/src/main/ets/schema/TableModel.ets new file mode 100644 index 0000000..a221e9a --- /dev/null +++ b/data/src/main/ets/schema/TableModel.ets @@ -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 = new Set(); + 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; + } +} diff --git a/data/src/main/ets/schema/TextDiff.ets b/data/src/main/ets/schema/TextDiff.ets new file mode 100644 index 0000000..66f171a --- /dev/null +++ b/data/src/main/ets/schema/TextDiff.ets @@ -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; + } +} diff --git a/data/src/main/ets/schema/TriggerDdl.ets b/data/src/main/ets/schema/TriggerDdl.ets new file mode 100644 index 0000000..df9ef99 --- /dev/null +++ b/data/src/main/ets/schema/TriggerDdl.ets @@ -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'; + } + } +} diff --git a/data/src/main/ets/schema/ViewDdl.ets b/data/src/main/ets/schema/ViewDdl.ets new file mode 100644 index 0000000..2033acd --- /dev/null +++ b/data/src/main/ets/schema/ViewDdl.ets @@ -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 + ';'; + } +} diff --git a/data/src/main/ets/security/PermissionCatalog.ets b/data/src/main/ets/security/PermissionCatalog.ets new file mode 100644 index 0000000..efaf60f --- /dev/null +++ b/data/src/main/ets/security/PermissionCatalog.ets @@ -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; + } +} diff --git a/data/src/main/ets/sql/CompletionEngine.ets b/data/src/main/ets/sql/CompletionEngine.ets new file mode 100644 index 0000000..f4f0b03 --- /dev/null +++ b/data/src/main/ets/sql/CompletionEngine.ets @@ -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 = new Set(); + 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, 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 }); + } + } +} diff --git a/data/src/main/ets/sql/ConsoleParser.ets b/data/src/main/ets/sql/ConsoleParser.ets new file mode 100644 index 0000000..2a950df --- /dev/null +++ b/data/src/main/ets/sql/ConsoleParser.ets @@ -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; + } +} diff --git a/data/src/main/ets/sql/ExplainParser.ets b/data/src/main/ets/sql/ExplainParser.ets new file mode 100644 index 0000000..3532709 --- /dev/null +++ b/data/src/main/ets/sql/ExplainParser.ets @@ -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; + } +} diff --git a/data/src/main/ets/sql/PagedResult.ets b/data/src/main/ets/sql/PagedResult.ets new file mode 100644 index 0000000..e5477ce --- /dev/null +++ b/data/src/main/ets/sql/PagedResult.ets @@ -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 = new Set(); + + 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(); + } +} diff --git a/data/src/main/ets/sql/ParameterBinder.ets b/data/src/main/ets/sql/ParameterBinder.ets new file mode 100644 index 0000000..e72867e --- /dev/null +++ b/data/src/main/ets/sql/ParameterBinder.ets @@ -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'); + } +} diff --git a/data/src/main/ets/sql/Profiler.ets b/data/src/main/ets/sql/Profiler.ets new file mode 100644 index 0000000..d5bfdc7 --- /dev/null +++ b/data/src/main/ets/sql/Profiler.ets @@ -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]; + } +} diff --git a/data/src/main/ets/sql/QueryHistory.ets b/data/src/main/ets/sql/QueryHistory.ets new file mode 100644 index 0000000..685135d --- /dev/null +++ b/data/src/main/ets/sql/QueryHistory.ets @@ -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; + } +} diff --git a/data/src/main/ets/sql/SnippetStore.ets b/data/src/main/ets/sql/SnippetStore.ets new file mode 100644 index 0000000..84f93cf --- /dev/null +++ b/data/src/main/ets/sql/SnippetStore.ets @@ -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; + } +} diff --git a/data/src/main/ets/sql/SqlFormatter.ets b/data/src/main/ets/sql/SqlFormatter.ets new file mode 100644 index 0000000..24d7cb1 --- /dev/null +++ b/data/src/main/ets/sql/SqlFormatter.ets @@ -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 ' '; + } +} diff --git a/data/src/main/ets/sql/SqlHighlighter.ets b/data/src/main/ets/sql/SqlHighlighter.ets new file mode 100644 index 0000000..988b65a --- /dev/null +++ b/data/src/main/ets/sql/SqlHighlighter.ets @@ -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; + } +} diff --git a/data/src/main/ets/sql/SqlTokenizer.ets b/data/src/main/ets/sql/SqlTokenizer.ets new file mode 100644 index 0000000..71ae47a --- /dev/null +++ b/data/src/main/ets/sql/SqlTokenizer.ets @@ -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'); + } +} diff --git a/data/src/main/ets/sql/ValueInspector.ets b/data/src/main/ets/sql/ValueInspector.ets new file mode 100644 index 0000000..4cba1cd --- /dev/null +++ b/data/src/main/ets/sql/ValueInspector.ets @@ -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: '', + 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; + } +} diff --git a/data/src/main/ets/ui/FormAdapter.ets b/data/src/main/ets/ui/FormAdapter.ets new file mode 100644 index 0000000..51b25a6 --- /dev/null +++ b/data/src/main/ets/ui/FormAdapter.ets @@ -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; + } +} diff --git a/data/src/main/ets/ui/InputDispatcher.ets b/data/src/main/ets/ui/InputDispatcher.ets new file mode 100644 index 0000000..1a60ed8 --- /dev/null +++ b/data/src/main/ets/ui/InputDispatcher.ets @@ -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; + } +} diff --git a/data/src/main/ets/ui/PointerResolver.ets b/data/src/main/ets/ui/PointerResolver.ets new file mode 100644 index 0000000..657f0bd --- /dev/null +++ b/data/src/main/ets/ui/PointerResolver.ets @@ -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 '无'; + } + } +} diff --git a/data/src/main/ets/ui/WindowLayout.ets b/data/src/main/ets/ui/WindowLayout.ets new file mode 100644 index 0000000..0db41dc --- /dev/null +++ b/data/src/main/ets/ui/WindowLayout.ets @@ -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; + } + } +} diff --git a/data/src/main/module.json5 b/data/src/main/module.json5 new file mode 100644 index 0000000..bb29f99 --- /dev/null +++ b/data/src/main/module.json5 @@ -0,0 +1,10 @@ +{ + "module": { + "name": "data", + "type": "har", + "deviceTypes": [ + "tablet", + "2in1" + ] + } +} diff --git a/data/src/main/resources/base/element/string.json b/data/src/main/resources/base/element/string.json new file mode 100644 index 0000000..e1bbb04 --- /dev/null +++ b/data/src/main/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "data_module_desc", + "value": "库阁数据层:对象模型、元数据、查询会话与结果集" + } + ] +} diff --git a/engine/BuildProfile.ets b/engine/BuildProfile.ets new file mode 100644 index 0000000..3a501e5 --- /dev/null +++ b/engine/BuildProfile.ets @@ -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; +} \ No newline at end of file diff --git a/engine/Index.ets b/engine/Index.ets new file mode 100644 index 0000000..15e3f4e --- /dev/null +++ b/engine/Index.ets @@ -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'; diff --git a/engine/build-profile.json5 b/engine/build-profile.json5 new file mode 100644 index 0000000..37fa510 --- /dev/null +++ b/engine/build-profile.json5 @@ -0,0 +1,9 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default" + } + ] +} diff --git a/engine/hvigorfile.ts b/engine/hvigorfile.ts new file mode 100644 index 0000000..08f99a1 --- /dev/null +++ b/engine/hvigorfile.ts @@ -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. */ +} diff --git a/engine/oh-package.json5 b/engine/oh-package.json5 new file mode 100644 index 0000000..38f5d9f --- /dev/null +++ b/engine/oh-package.json5 @@ -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": {} +} diff --git a/engine/src/main/ets/connection/ConnectionManager.ets b/engine/src/main/ets/connection/ConnectionManager.ets new file mode 100644 index 0000000..9cd45a4 --- /dev/null +++ b/engine/src/main/ets/connection/ConnectionManager.ets @@ -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 { + 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 { + if (this.state !== ConnectionState.CONNECTED) { + throw new ConnectionError('connection is not ready, state=' + this.state); + } + await this.driver.ping(); + } + + async close(): Promise { + 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): Promise { + const ms: number = this.config.connectTimeoutMs; + const timeout = new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new ConnectionError('connect timeout after ' + ms + 'ms')); + }, ms); + }); + return Promise.race([task, timeout]); + } +} + +/** 连接管理器。 */ +export class ConnectionManager { + private factories: Map = new Map(); + private connections: Map = new Map(); + 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 { + 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 = []; + } +} diff --git a/engine/src/main/ets/connection/ConnectionTester.ets b/engine/src/main/ets/connection/ConnectionTester.ets new file mode 100644 index 0000000..d17faa7 --- /dev/null +++ b/engine/src/main/ets/connection/ConnectionTester.ets @@ -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 { + 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 + }; + } +} diff --git a/engine/src/main/ets/connection/ReconnectPolicy.ets b/engine/src/main/ets/connection/ReconnectPolicy.ets new file mode 100644 index 0000000..7608be8 --- /dev/null +++ b/engine/src/main/ets/connection/ReconnectPolicy.ets @@ -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; + } +} diff --git a/engine/src/main/ets/connection/types.ets b/engine/src/main/ets/connection/types.ets new file mode 100644 index 0000000..f4c7cb3 --- /dev/null +++ b/engine/src/main/ets/connection/types.ets @@ -0,0 +1,119 @@ +/** + * 连接层公共类型。 + * + * 设计约束(对应 WBS 1.4.1 / ADR-001): + * 1. 凭据不进入配置结构,只保存 credentialRef,实际解密由 1.3.8 保险箱负责; + * 2. 引擎以枚举收敛,新增引擎只在末尾追加,保持数值稳定; + * 3. 驱动只暴露 connect / disconnect / ping 三个生命周期动作,查询与元数据 + * 能力在 1.4.x 后续工作包中按接口扩展,避免一开始就把契约定死。 + */ + +/** 支持的数据库引擎(10 种,不含 ODBC / Access / DBase)。 */ +export enum EngineKind { + SQLITE = 0, + MYSQL = 1, + MARIADB = 2, + POSTGRESQL = 3, + SQLSERVER = 4, + ORACLE = 5, + MONGODB = 6, + REDIS = 7, + OCEANBASE = 8, + TIDB = 9 +} + +/** 连接生命周期状态。 */ +export enum ConnectionState { + IDLE = 0, + CONNECTING = 1, + CONNECTED = 2, + DISCONNECTING = 3, + CLOSED = 4, + FAILED = 5 +} + +/** 连接配置。 */ +export interface ConnectionConfig { + /** 连接唯一标识,由连接管理器保证不重复。 */ + id: string; + /** 用户可见名称。 */ + name: string; + engine: EngineKind; + host: string; + port: number; + database: string; + user: string; + /** 凭据引用,不存明文。 */ + credentialRef: string; + /** 建连超时,毫秒。 */ + connectTimeoutMs: number; +} + +/** 引擎驱动契约。 */ +export interface Driver { + readonly kind: EngineKind; + connect(config: ConnectionConfig): Promise; + disconnect(): Promise; + ping(): Promise; +} + +/** 驱动工厂。 */ +export interface DriverFactory { + create(): Driver; +} + +/** 连接层统一错误。 */ +export class ConnectionError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConnectionError'; + } +} + +/** 引擎显示名。 */ +export function engineLabel(kind: EngineKind): string { + switch (kind) { + case EngineKind.SQLITE: + return 'SQLite'; + case EngineKind.MYSQL: + return 'MySQL'; + case EngineKind.MARIADB: + return 'MariaDB'; + case EngineKind.POSTGRESQL: + return 'PostgreSQL'; + case EngineKind.SQLSERVER: + return 'SQL Server'; + case EngineKind.ORACLE: + return 'Oracle'; + case EngineKind.MONGODB: + return 'MongoDB'; + case EngineKind.REDIS: + return 'Redis'; + case EngineKind.OCEANBASE: + return 'OceanBase'; + case EngineKind.TIDB: + return 'TiDB'; + default: + return 'Unknown'; + } +} + +/** 状态显示名。 */ +export function stateLabel(state: ConnectionState): string { + switch (state) { + case ConnectionState.IDLE: + return '空闲'; + case ConnectionState.CONNECTING: + return '连接中'; + case ConnectionState.CONNECTED: + return '已连接'; + case ConnectionState.DISCONNECTING: + return '断开中'; + case ConnectionState.CLOSED: + return '已关闭'; + case ConnectionState.FAILED: + return '连接失败'; + default: + return '未知'; + } +} diff --git a/engine/src/main/ets/driver/MockDriver.ets b/engine/src/main/ets/driver/MockDriver.ets new file mode 100644 index 0000000..9780f8f --- /dev/null +++ b/engine/src/main/ets/driver/MockDriver.ets @@ -0,0 +1,48 @@ +/** + * Mock 驱动(仅供骨架自检与后续单元测试使用)。 + * + * 行为:所有动作带极小延迟后成功返回,用于验证连接状态机、超时与注册表, + * 不产生任何网络 IO。真实驱动见 1.4.3(MySQL/MariaDB)、1.4.7(SQLite)等。 + */ + +import { ConnectionConfig, Driver, DriverFactory, EngineKind } from '../connection/types'; + +export class MockDriver implements Driver { + readonly kind: EngineKind; + + constructor(kind: EngineKind) { + this.kind = kind; + } + + async connect(config: ConnectionConfig): Promise { + await MockDriver.delay(30); + } + + async disconnect(): Promise { + await MockDriver.delay(10); + } + + async ping(): Promise { + await MockDriver.delay(5); + } + + private static delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); + } +} + +export class MockDriverFactory implements DriverFactory { + private readonly kind: EngineKind; + + constructor(kind: EngineKind) { + this.kind = kind; + } + + create(): Driver { + return new MockDriver(this.kind); + } +} diff --git a/engine/src/main/ets/error/AppError.ets b/engine/src/main/ets/error/AppError.ets new file mode 100644 index 0000000..e5f041c --- /dev/null +++ b/engine/src/main/ets/error/AppError.ets @@ -0,0 +1,39 @@ +/** + * 统一错误模型(1.4.18)。 + * + * 上层 UI 与日志只认 AppError:稳定错误码、类别、严重度、可翻译 key 与建议。 + * 各引擎原始错误先经 ErrorMapper 归类,再上抛,避免把厂商错误原文泄露到界面。 + */ +export enum ErrorCategory { + CONNECT = 'connect', + AUTH = 'auth', + PROTOCOL = 'protocol', + IO = 'io', + LIMIT = 'limit', + UNKNOWN = 'unknown' +} + +export enum ErrorSeverity { + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal' +} + +export class AppError extends Error { + readonly code: string; + readonly category: ErrorCategory; + readonly severity: ErrorSeverity; + readonly messageKey: string; + readonly suggestion: string; + + constructor(code: string, category: ErrorCategory, severity: ErrorSeverity, + messageKey: string, suggestion: string, cause?: string) { + super(cause === undefined ? messageKey : cause); + this.name = 'AppError'; + this.code = code; + this.category = category; + this.severity = severity; + this.messageKey = messageKey; + this.suggestion = suggestion; + } +} diff --git a/engine/src/main/ets/error/ErrorMapper.ets b/engine/src/main/ets/error/ErrorMapper.ets new file mode 100644 index 0000000..5d82bf7 --- /dev/null +++ b/engine/src/main/ets/error/ErrorMapper.ets @@ -0,0 +1,94 @@ +/** + * 错误映射表(1.4.18)。 + * + * 把各引擎的原始错误码/文案归类为 AppError。规则按「先精确码、后关键字」排列, + * 命中即返回;未命中归为 UNKNOWN 并保留原始文案供诊断日志使用。 + * + * 覆盖范围:网络层(ECONNREFUSED/ETIMEDOUT/ENOTFOUND)、 + * MySQL(1045/2003/1213)、PostgreSQL(28P01/42501)、Oracle(ORA-01017/ORA-12170)、 + * 以及通用关键字(access denied / timeout / deadlock)。 + */ +import { AppError, ErrorCategory, ErrorSeverity } from './AppError'; + +interface ErrorRule { + pattern: string; + code: string; + category: ErrorCategory; + severity: ErrorSeverity; + messageKey: string; + suggestion: string; +} + +export class ErrorMapper { + private static readonly RULES: ErrorRule[] = [ + { + pattern: '1045', code: 'KUGE-DB-1045', category: ErrorCategory.AUTH, severity: ErrorSeverity.ERROR, + messageKey: 'error.auth.denied', suggestion: '请检查用户名、密码与主机白名单' + }, + { + pattern: '28P01', code: 'KUGE-DB-28P01', category: ErrorCategory.AUTH, severity: ErrorSeverity.ERROR, + messageKey: 'error.auth.denied', suggestion: '请检查用户名与密码' + }, + { + pattern: 'ORA-01017', code: 'KUGE-DB-ORA01017', category: ErrorCategory.AUTH, severity: ErrorSeverity.ERROR, + messageKey: 'error.auth.denied', suggestion: '请检查用户名、密码与服务名' + }, + { + pattern: '42501', code: 'KUGE-DB-42501', category: ErrorCategory.AUTH, severity: ErrorSeverity.ERROR, + messageKey: 'error.auth.insufficient', suggestion: '当前账号权限不足,请联系 DBA 授权' + }, + { + pattern: 'ECONNREFUSED', code: 'KUGE-NET-0001', category: ErrorCategory.CONNECT, severity: ErrorSeverity.ERROR, + messageKey: 'error.connect.refused', suggestion: '目标端口未监听,请检查主机与端口' + }, + { + pattern: 'ENOTFOUND', code: 'KUGE-NET-0002', category: ErrorCategory.CONNECT, severity: ErrorSeverity.ERROR, + messageKey: 'error.connect.dns', suggestion: '主机名无法解析,请检查地址或 DNS' + }, + { + pattern: 'ORA-12170', code: 'KUGE-DB-ORA12170', category: ErrorCategory.CONNECT, severity: ErrorSeverity.ERROR, + messageKey: 'error.connect.timeout', suggestion: '连接超时,请检查网络与防火墙' + }, + { + pattern: 'ETIMEDOUT', code: 'KUGE-NET-0003', category: ErrorCategory.IO, severity: ErrorSeverity.WARN, + messageKey: 'error.io.timeout', suggestion: '网络较慢或服务繁忙,可稍后重试' + }, + { + pattern: '2003', code: 'KUGE-DB-2003', category: ErrorCategory.CONNECT, severity: ErrorSeverity.ERROR, + messageKey: 'error.connect.unreachable', suggestion: '无法连接服务器,请检查主机、端口与网络' + }, + { + pattern: '1213', code: 'KUGE-DB-1213', category: ErrorCategory.IO, severity: ErrorSeverity.WARN, + messageKey: 'error.io.deadlock', suggestion: '发生死锁,事务已回滚,可重试' + }, + { + pattern: 'ACCESS DENIED', code: 'KUGE-DB-0001', category: ErrorCategory.AUTH, severity: ErrorSeverity.ERROR, + messageKey: 'error.auth.denied', suggestion: '请检查凭据与账号状态' + }, + { + pattern: 'TIMEOUT', code: 'KUGE-NET-0004', category: ErrorCategory.IO, severity: ErrorSeverity.WARN, + messageKey: 'error.io.timeout', suggestion: '操作超时,请检查网络后重试' + }, + { + pattern: 'DEADLOCK', code: 'KUGE-DB-0002', category: ErrorCategory.IO, severity: ErrorSeverity.WARN, + messageKey: 'error.io.deadlock', suggestion: '发生死锁,可重试' + } + ]; + + /** + * 映射原始错误。 + * @param rawCode 引擎错误码或网络错误码(可为空) + * @param rawMessage 原始错误文案 + */ + static map(rawCode: string, rawMessage: string): AppError { + const haystack: string = (rawCode + ' ' + rawMessage).toUpperCase(); + for (let i = 0; i < ErrorMapper.RULES.length; i++) { + const rule: ErrorRule = ErrorMapper.RULES[i]; + if (haystack.indexOf(rule.pattern) >= 0) { + return new AppError(rule.code, rule.category, rule.severity, rule.messageKey, rule.suggestion, rawMessage); + } + } + return new AppError('KUGE-UNKNOWN-0000', ErrorCategory.UNKNOWN, ErrorSeverity.ERROR, + 'error.unknown', '请查看诊断日志或联系支持', rawMessage); + } +} diff --git a/engine/src/main/ets/plugin/EnginePlugin.ets b/engine/src/main/ets/plugin/EnginePlugin.ets new file mode 100644 index 0000000..d51cad7 --- /dev/null +++ b/engine/src/main/ets/plugin/EnginePlugin.ets @@ -0,0 +1,24 @@ +/** + * 引擎插件契约(1.2.9 / 1.3.4)。 + * + * 每个 engine- HAR 导出一个 EnginePlugin;装配层只认这个契约。 + * meta.apiVersion 表示插件依赖的 engine-api 版本,由注册表校验。 + */ +import { Driver, EngineKind } from '../connection/types'; + +/** 插件元信息。 */ +export interface PluginMeta { + /** 模块名,如 engine-sqlite。 */ + readonly name: string; + /** 插件自身版本(SemVer)。 */ + readonly version: string; + /** 依赖的 engine-api 版本(SemVer)。 */ + readonly apiVersion: string; +} + +/** 引擎插件。 */ +export interface EnginePlugin { + readonly kind: EngineKind; + readonly meta: PluginMeta; + createDriver(): Driver; +} diff --git a/engine/src/main/ets/plugin/EngineRegistry.ets b/engine/src/main/ets/plugin/EngineRegistry.ets new file mode 100644 index 0000000..24f089b --- /dev/null +++ b/engine/src/main/ets/plugin/EngineRegistry.ets @@ -0,0 +1,66 @@ +/** + * 引擎插件注册表(1.3.4)。 + * + * 职责: + * 1. 注册引擎插件,拒绝重复引擎与不兼容的 engine-api 版本; + * 2. 提供查询与列举,供 ConnectionManager / 装配层使用; + * 3. 记录注册顺序,便于诊断输出。 + * + * 加载方式:装配层(entry/app)显式 import 各引擎插件并调用 register; + * 按需分发的引擎在模块安装完成后注册(ADR-004,拟议)。 + */ +import { EngineKind } from '../connection/types'; +import { EnginePlugin } from './EnginePlugin'; +import { SemVerUtil } from './SemVerUtil'; + +/** 当前 engine-api 版本;契约变化时同步升级(见 ADR-003)。 */ +export const ENGINE_API_VERSION: string = '1.0.0'; + +export class EngineRegistry { + private plugins: Map = new Map(); + private order: EngineKind[] = []; + + /** + * 注册插件。重复引擎或版本不兼容时抛错,避免装配期静默覆盖。 + */ + register(plugin: EnginePlugin): void { + if (this.plugins.has(plugin.kind)) { + throw new Error('duplicate engine plugin: ' + plugin.kind + ' (' + plugin.meta.name + ')'); + } + if (!SemVerUtil.isCompatible(plugin.meta.apiVersion, ENGINE_API_VERSION)) { + throw new Error('incompatible engine-api version: ' + plugin.meta.name + ' requires ' + + plugin.meta.apiVersion + ', current ' + ENGINE_API_VERSION); + } + this.plugins.set(plugin.kind, plugin); + this.order.push(plugin.kind); + } + + get(kind: EngineKind): EnginePlugin | undefined { + return this.plugins.get(kind); + } + + has(kind: EngineKind): boolean { + return this.plugins.has(kind); + } + + /** 按注册顺序返回插件名,用于诊断日志。 */ + names(): string[] { + const out: string[] = []; + for (let i = 0; i < this.order.length; i++) { + const plugin = this.plugins.get(this.order[i]); + if (plugin !== undefined) { + out.push(plugin.meta.name + '@' + plugin.meta.version); + } + } + return out; + } + + size(): number { + return this.order.length; + } + + clear(): void { + this.plugins.clear(); + this.order = []; + } +} diff --git a/engine/src/main/ets/plugin/SemVerUtil.ets b/engine/src/main/ets/plugin/SemVerUtil.ets new file mode 100644 index 0000000..c707267 --- /dev/null +++ b/engine/src/main/ets/plugin/SemVerUtil.ets @@ -0,0 +1,55 @@ +/** + * 语义化版本工具(1.3.4)。 + * + * 只实现插件兼容性判断所需的最小子集:解析 major.minor.patch 与兼容性比较。 + * 纯函数,不依赖平台 API,可独立测试。 + */ +export class SemVerUtil { + /** 解析版本号;非法返回 undefined。 */ + static parse(version: string): number[] | undefined { + if (version.length === 0) { + return undefined; + } + const parts: string[] = version.split('.'); + if (parts.length !== 3) { + return undefined; + } + const out: number[] = []; + for (let i = 0; i < parts.length; i++) { + const text: string = parts[i]; + if (text.length === 0) { + return undefined; + } + for (let j = 0; j < text.length; j++) { + const code: number = text.charCodeAt(j); + if (code < 48 || code > 57) { + return undefined; + } + } + out.push(Number(text)); + } + return out; + } + + /** + * 插件要求的 API 版本是否可被当前版本满足。 + * 规则:主版本必须相同(不兼容变更需升主版本);要求的 minor/patch 不得高于当前。 + */ + static isCompatible(required: string, current: string): boolean { + const req: number[] | undefined = SemVerUtil.parse(required); + const cur: number[] | undefined = SemVerUtil.parse(current); + if (req === undefined || cur === undefined) { + return false; + } + if (req[0] !== cur[0]) { + return false; + } + if (req[1] > cur[1]) { + return false; + } + if (req[1] === cur[1] && req[2] > cur[2]) { + return false; + } + return true; + } +} diff --git a/engine/src/main/ets/protocol/FrameCodec.ets b/engine/src/main/ets/protocol/FrameCodec.ets new file mode 100644 index 0000000..be5fd8a --- /dev/null +++ b/engine/src/main/ets/protocol/FrameCodec.ets @@ -0,0 +1,73 @@ +/** + * 协议帧编解码基座(1.4.11)。 + * + * 帧格式(小端): + * [0..3] uint32 整帧长度(含头,6 + payload) + * [4..5] uint16 序号 + * [6..] bytes 载荷 + * + * 该格式与 MySQL / MariaDB 的 3 字节长度头不同,是引擎无关的内部封装层: + * 各引擎协议实现(1.4.3–1.4.10)在载荷里携带自己的报文,本层只负责 + * 传输分帧、粘包拆分与错误长度防御。 + */ + +import { ConnectionError } from '../connection/types'; + +/** 一帧数据。 */ +export class Frame { + readonly seq: number; + readonly payload: Uint8Array; + + constructor(seq: number, payload: Uint8Array) { + this.seq = seq; + this.payload = payload; + } +} + +export class FrameCodec { + /** 头长度:4 字节长度 + 2 字节序号。 */ + static readonly HEADER_BYTES: number = 6; + /** 单帧载荷上限:16 MB。 */ + static readonly MAX_PAYLOAD_BYTES: number = 16 * 1024 * 1024; + + /** 组帧。载荷超限时抛 ConnectionError。 */ + static encode(seq: number, payload: Uint8Array): Uint8Array { + if (payload.length > FrameCodec.MAX_PAYLOAD_BYTES) { + throw new ConnectionError('payload too large: ' + payload.length); + } + const total: number = FrameCodec.HEADER_BYTES + payload.length; + const out: Uint8Array = new Uint8Array(total); + out[0] = total & 0xFF; + out[1] = (total >>> 8) & 0xFF; + out[2] = (total >>> 16) & 0xFF; + out[3] = (total >>> 24) & 0xFF; + out[4] = seq & 0xFF; + out[5] = (seq >>> 8) & 0xFF; + out.set(payload, FrameCodec.HEADER_BYTES); + return out; + } + + /** 读取头部声明的整帧长度;不足 4 字节返回 -1。 */ + static frameLength(header: Uint8Array): number { + if (header.length < 4) { + return -1; + } + return header[0] | (header[1] << 8) | (header[2] << 16) | (header[3] << 24); + } + + /** 解出第一帧;数据不足返回 undefined(由调用方继续收)。 */ + static decode(buffer: Uint8Array): Frame | undefined { + const total: number = FrameCodec.frameLength(buffer); + if (total < 0) { + return undefined; + } + if (total < FrameCodec.HEADER_BYTES || total > FrameCodec.HEADER_BYTES + FrameCodec.MAX_PAYLOAD_BYTES) { + throw new ConnectionError('invalid frame length: ' + total); + } + if (buffer.length < total) { + return undefined; + } + const seq: number = buffer[4] | (buffer[5] << 8); + return new Frame(seq, buffer.slice(FrameCodec.HEADER_BYTES, total)); + } +} diff --git a/engine/src/main/module.json5 b/engine/src/main/module.json5 new file mode 100644 index 0000000..d667054 --- /dev/null +++ b/engine/src/main/module.json5 @@ -0,0 +1,10 @@ +{ + "module": { + "name": "engine", + "type": "har", + "deviceTypes": [ + "tablet", + "2in1" + ] + } +} diff --git a/engine/src/main/resources/base/element/string.json b/engine/src/main/resources/base/element/string.json new file mode 100644 index 0000000..caaa3a2 --- /dev/null +++ b/engine/src/main/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "engine_module_desc", + "value": "库阁连接层:连接抽象、协议帧编解码与引擎驱动" + } + ] +} diff --git a/entry/.gitignore b/entry/.gitignore new file mode 100644 index 0000000..e2713a2 --- /dev/null +++ b/entry/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 new file mode 100644 index 0000000..6bd6457 --- /dev/null +++ b/entry/build-profile.json5 @@ -0,0 +1,33 @@ +{ + "apiType": "stageMode", + "buildOption": { + "resOptions": { + "copyCodeResource": { + "enable": false + } + } + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": false, + "files": [ + "./obfuscation-rules.txt" + ] + } + } + } + }, + ], + "targets": [ + { + "name": "default" + }, + { + "name": "ohosTest", + } + ] +} \ No newline at end of file diff --git a/entry/hvigorfile.ts b/entry/hvigorfile.ts new file mode 100644 index 0000000..b0e3a1a --- /dev/null +++ b/entry/hvigorfile.ts @@ -0,0 +1,6 @@ +import { hapTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/entry/obfuscation-rules.txt b/entry/obfuscation-rules.txt new file mode 100644 index 0000000..1e7e54e --- /dev/null +++ b/entry/obfuscation-rules.txt @@ -0,0 +1,23 @@ +# Define project specific obfuscation rules here. +# You can include the obfuscation configuration files in the current module's build-profile.json5. +# +# For more details, see +# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/source-obfuscation + +# Obfuscation options: +# -disable-obfuscation: disable all obfuscations +# -enable-property-obfuscation: obfuscate the property names +# -enable-toplevel-obfuscation: obfuscate the names in the global scope +# -compact: remove unnecessary blank spaces and all line feeds +# -remove-log: remove all console.* statements +# -print-namecache: print the name cache that contains the mapping from the old names to new names +# -apply-namecache: reuse the given cache file + +# Keep options: +# -keep-property-name: specifies property names that you want to keep +# -keep-global-name: specifies names that you want to keep in the global scope + +-enable-property-obfuscation +-enable-toplevel-obfuscation +-enable-filename-obfuscation +-enable-export-obfuscation \ No newline at end of file diff --git a/entry/oh-package-lock.json5 b/entry/oh-package-lock.json5 new file mode 100644 index 0000000..48e8524 --- /dev/null +++ b/entry/oh-package-lock.json5 @@ -0,0 +1,33 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@kuge/core@../core": "@kuge/core@../core", + "@kuge/engine@../engine": "@kuge/engine@../engine", + "@kuge/ui@../ui": "@kuge/ui@../ui" + }, + "packages": { + "@kuge/core@../core": { + "name": "@kuge/core", + "version": "1.0.0", + "resolved": "../core", + "registryType": "local" + }, + "@kuge/engine@../engine": { + "name": "@kuge/engine", + "version": "1.0.0", + "resolved": "../engine", + "registryType": "local" + }, + "@kuge/ui@../ui": { + "name": "@kuge/ui", + "version": "1.0.0", + "resolved": "../ui", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 new file mode 100644 index 0000000..9c03bd5 --- /dev/null +++ b/entry/oh-package.json5 @@ -0,0 +1,14 @@ +{ + "name": "entry", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "", + "author": "", + "license": "", + "dependencies": { + "@kuge/core": "file:../core", + "@kuge/engine": "file:../engine", + "@kuge/ui": "file:../ui" + } +} + diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000..1db38b7 --- /dev/null +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,63 @@ +import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; +import { window } from '@kit.ArkUI'; +import { AppConfig, AppInfo, FileLogSink, Logger } from '@kuge/core'; + +const logger: Logger = Logger.create('EntryAbility'); + +export default class EntryAbility extends UIAbility { + private static logSink: FileLogSink | undefined = undefined; + + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { + try { + this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + } catch (err) { + logger.error('Failed to set colorMode. Cause: {0}', JSON.stringify(err)); + } + + // 配置与日志框架初始化:先读配置,再决定日志级别,最后挂文件落地。 + AppConfig.init(this.context); + Logger.setMinLevel(AppConfig.getLogLevel()); + EntryAbility.logSink = new FileLogSink(this.context.filesDir + '/logs'); + Logger.setSink(EntryAbility.logSink); + + logger.info('Ability onCreate, app={0}, version={1}, logLevel={2}, formFactors={3}', + AppInfo.NAME, AppInfo.VERSION, AppConfig.levelName(AppConfig.getLogLevel()), AppInfo.FORM_FACTORS); + } + + onDestroy(): void { + logger.info('Ability onDestroy'); + if (EntryAbility.logSink !== undefined) { + EntryAbility.logSink.close(); + EntryAbility.logSink = undefined; + Logger.setSink(undefined); + } + } + + onWindowStageCreate(windowStage: window.WindowStage): void { + // Main window is created, set main page for this ability + logger.info('Ability onWindowStageCreate'); + + windowStage.loadContent('pages/Index', (err) => { + if (err.code) { + logger.error('Failed to load the content. Cause: {0}', JSON.stringify(err)); + return; + } + logger.info('Succeeded in loading the content.'); + }); + } + + onWindowStageDestroy(): void { + // Main window is destroyed, release UI related resources + logger.info('Ability onWindowStageDestroy'); + } + + onForeground(): void { + // Ability has brought to foreground + logger.info('Ability onForeground'); + } + + onBackground(): void { + // Ability has back to background + logger.info('Ability onBackground'); + } +} diff --git a/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets new file mode 100644 index 0000000..8e4de99 --- /dev/null +++ b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets @@ -0,0 +1,16 @@ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit'; + +const DOMAIN = 0x0000; + +export default class EntryBackupAbility extends BackupExtensionAbility { + async onBackup() { + hilog.info(DOMAIN, 'testTag', 'onBackup ok'); + await Promise.resolve(); + } + + async onRestore(bundleVersion: BundleVersion) { + hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion)); + await Promise.resolve(); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets new file mode 100644 index 0000000..6e49700 --- /dev/null +++ b/entry/src/main/ets/pages/Index.ets @@ -0,0 +1,95 @@ +import { AppConfig, AppInfo, Logger, LogLevel } from '@kuge/core'; +import { ConnectionConfig, ConnectionManager, EngineKind, engineLabel, MockDriverFactory, stateLabel } from '@kuge/engine'; +import { AdaptiveScaffold } from '@kuge/ui'; + +const logger: Logger = Logger.create('Index'); +const manager: ConnectionManager = new ConnectionManager(); +manager.register(EngineKind.SQLITE, new MockDriverFactory(EngineKind.SQLITE)); + +@Entry +@Component +struct Index { + @State message: string = AppInfo.NAME + '(' + AppInfo.ENGLISH_NAME + ')'; + @State hint: string = AppInfo.TAGLINE + '(' + AppInfo.FORM_FACTORS + ')'; + @State logLevel: string = AppConfig.levelName(AppConfig.getLogLevel()); + @State connState: string = '未自检'; + + aboutToAppear(): void { + logger.info('page aboutToAppear, version={0}, logLevel={1}', AppInfo.VERSION, this.logLevel); + } + + async runSelfCheck(): Promise { + try { + const cfg: ConnectionConfig = { + id: 'mock-' + Date.now(), + name: 'Mock SQLite', + engine: EngineKind.SQLITE, + host: 'local', + port: 0, + database: ':memory:', + user: '', + credentialRef: '', + connectTimeoutMs: 2000 + }; + const conn = manager.create(cfg); + await conn.connect(); + await conn.ping(); + this.connState = stateLabel(conn.getState()); + logger.info('self-check ok, engine={0}, state={1}', engineLabel(cfg.engine), this.connState); + await conn.close(); + } catch (err) { + const msg: string = err instanceof Error ? err.message : String(err); + this.connState = '失败:' + msg; + logger.error('self-check failed: {0}', msg); + } + } + + @Builder + pageContent() { + Column({ space: 16 }) { + Text(this.message) + .id('AppTitle') + .fontSize(32) + .fontWeight(FontWeight.Bold) + Text(this.hint) + .id('AppHint') + .fontSize(16) + .fontColor($r('app.color.text_secondary')) + Text('日志级别:' + this.logLevel) + .id('LogLevel') + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + Text('连接状态:' + this.connState) + .id('ConnState') + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + Button('写一条诊断日志') + .id('LogButton') + .onClick(() => { + logger.info('user clicked, formFactors={0}', AppInfo.FORM_FACTORS); + this.hint = '已写入 hilog(域 0xDB01)与文件日志(WARN 起)'; + }) + Button('切换日志级别') + .id('LevelButton') + .onClick(() => { + const next: LogLevel = AppConfig.getLogLevel() === LogLevel.DEBUG ? LogLevel.INFO : LogLevel.DEBUG; + AppConfig.setLogLevel(next); + Logger.setMinLevel(next); + this.logLevel = AppConfig.levelName(next); + logger.info('log level switched to {0}', this.logLevel); + }) + Button('连接自检(Mock 引擎)') + .id('SelfCheckButton') + .onClick(() => { + this.runSelfCheck(); + }) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + } + + build() { + AdaptiveScaffold({ content: this.pageContent }) + } +} diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 new file mode 100644 index 0000000..6f3f0c0 --- /dev/null +++ b/entry/src/main/module.json5 @@ -0,0 +1,51 @@ +{ + "module": { + "name": "entry", + "type": "entry", + "description": "$string:module_desc", + "mainElement": "EntryAbility", + "deviceTypes": [ + "tablet", + "2in1" + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:main_pages", + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:EntryAbility_desc", + "icon": "$media:layered_image", + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:startIcon", + "startWindowBackground": "$color:start_window_background", + "exported": true, + "skills": [ + { + "entities": [ + "entity.system.home" + ], + "actions": [ + "ohos.want.action.home" + ] + } + ] + } + ], + "extensionAbilities": [ + { + "name": "EntryBackupAbility", + "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets", + "type": "backup", + "exported": false, + "metadata": [ + { + "name": "ohos.extension.backup", + "resource": "$profile:backup_config" + } + ], + } + ] + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json new file mode 100644 index 0000000..e619c07 --- /dev/null +++ b/entry/src/main/resources/base/element/color.json @@ -0,0 +1,12 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + }, + { + "name": "text_secondary", + "value": "#66000000" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json new file mode 100644 index 0000000..33ea223 --- /dev/null +++ b/entry/src/main/resources/base/element/float.json @@ -0,0 +1,8 @@ +{ + "float": [ + { + "name": "page_text_font_size", + "value": "50fp" + } + ] +} diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json new file mode 100644 index 0000000..03b6192 --- /dev/null +++ b/entry/src/main/resources/base/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "库阁主模块" + }, + { + "name": "EntryAbility_desc", + "value": "库阁 · 跨库数据库工作台" + }, + { + "name": "EntryAbility_label", + "value": "库阁" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/background.png b/entry/src/main/resources/base/media/background.png new file mode 100644 index 0000000..923f2b3 Binary files /dev/null and b/entry/src/main/resources/base/media/background.png differ diff --git a/entry/src/main/resources/base/media/foreground.png b/entry/src/main/resources/base/media/foreground.png new file mode 100644 index 0000000..97014d3 Binary files /dev/null and b/entry/src/main/resources/base/media/foreground.png differ diff --git a/entry/src/main/resources/base/media/layered_image.json b/entry/src/main/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/entry/src/main/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/startIcon.png b/entry/src/main/resources/base/media/startIcon.png new file mode 100644 index 0000000..205ad8b Binary files /dev/null and b/entry/src/main/resources/base/media/startIcon.png differ diff --git a/entry/src/main/resources/base/profile/backup_config.json b/entry/src/main/resources/base/profile/backup_config.json new file mode 100644 index 0000000..78f40ae --- /dev/null +++ b/entry/src/main/resources/base/profile/backup_config.json @@ -0,0 +1,3 @@ +{ + "allowToBackupRestore": true +} \ No newline at end of file diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000..1898d94 --- /dev/null +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "pages/Index" + ] +} diff --git a/entry/src/main/resources/dark/element/color.json b/entry/src/main/resources/dark/element/color.json new file mode 100644 index 0000000..6e4f4a8 --- /dev/null +++ b/entry/src/main/resources/dark/element/color.json @@ -0,0 +1,12 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#000000" + }, + { + "name": "text_secondary", + "value": "#99FFFFFF" + } + ] +} \ No newline at end of file diff --git a/entry/src/mock/mock-config.json5 b/entry/src/mock/mock-config.json5 new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/entry/src/mock/mock-config.json5 @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/entry/src/ohosTest/ets/test/Ability.test.ets b/entry/src/ohosTest/ets/test/Ability.test.ets new file mode 100644 index 0000000..85c78f6 --- /dev/null +++ b/entry/src/ohosTest/ets/test/Ability.test.ets @@ -0,0 +1,35 @@ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; + +export default function abilityTest() { + describe('ActsAbilityTest', () => { + // Defines a test suite. Two parameters are supported: test suite name and test suite function. + beforeAll(() => { + // Presets an action, which is performed only once before all test cases of the test suite start. + // This API supports only one parameter: preset action function. + }) + beforeEach(() => { + // Presets an action, which is performed before each unit test case starts. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: preset action function. + }) + afterEach(() => { + // Presets a clear action, which is performed after each unit test case ends. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: clear action function. + }) + afterAll(() => { + // Presets a clear action, which is performed after all test cases of the test suite end. + // This API supports only one parameter: clear action function. + }) + it('assertContain', 0, () => { + // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. + hilog.info(0x0000, 'testTag', '%{public}s', 'it begin'); + let a = 'abc'; + let b = 'b'; + // Defines a variety of assertion methods, which are used to declare expected boolean conditions. + expect(a).assertContain(b); + expect(a).assertEqual(a); + }) + }) +} \ No newline at end of file diff --git a/entry/src/ohosTest/ets/test/List.test.ets b/entry/src/ohosTest/ets/test/List.test.ets new file mode 100644 index 0000000..794c7dc --- /dev/null +++ b/entry/src/ohosTest/ets/test/List.test.ets @@ -0,0 +1,5 @@ +import abilityTest from './Ability.test'; + +export default function testsuite() { + abilityTest(); +} \ No newline at end of file diff --git a/entry/src/ohosTest/module.json5 b/entry/src/ohosTest/module.json5 new file mode 100644 index 0000000..0c3af0f --- /dev/null +++ b/entry/src/ohosTest/module.json5 @@ -0,0 +1,12 @@ +{ + "module": { + "name": "entry_test", + "type": "feature", + "deviceTypes": [ + "tablet", + "2in1" + ], + "deliveryWithInstall": true, + "installationFree": false + } +} diff --git a/entry/src/test/List.test.ets b/entry/src/test/List.test.ets new file mode 100644 index 0000000..bb5b5c3 --- /dev/null +++ b/entry/src/test/List.test.ets @@ -0,0 +1,5 @@ +import localUnitTest from './LocalUnit.test'; + +export default function testsuite() { + localUnitTest(); +} \ No newline at end of file diff --git a/entry/src/test/LocalUnit.test.ets b/entry/src/test/LocalUnit.test.ets new file mode 100644 index 0000000..165fc16 --- /dev/null +++ b/entry/src/test/LocalUnit.test.ets @@ -0,0 +1,33 @@ +import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; + +export default function localUnitTest() { + describe('localUnitTest', () => { + // Defines a test suite. Two parameters are supported: test suite name and test suite function. + beforeAll(() => { + // Presets an action, which is performed only once before all test cases of the test suite start. + // This API supports only one parameter: preset action function. + }); + beforeEach(() => { + // Presets an action, which is performed before each unit test case starts. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: preset action function. + }); + afterEach(() => { + // Presets a clear action, which is performed after each unit test case ends. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: clear action function. + }); + afterAll(() => { + // Presets a clear action, which is performed after all test cases of the test suite end. + // This API supports only one parameter: clear action function. + }); + it('assertContain', 0, () => { + // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. + let a = 'abc'; + let b = 'b'; + // Defines a variety of assertion methods, which are used to declare expected boolean conditions. + expect(a).assertContain(b); + expect(a).assertEqual(a); + }); + }); +} \ No newline at end of file diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 new file mode 100644 index 0000000..4481d31 --- /dev/null +++ b/hvigor/hvigor-config.json5 @@ -0,0 +1,23 @@ +{ + "modelVersion": "6.1.1", + "dependencies": { + }, + "execution": { + // "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | "ultrafine" | false ]. Default: "normal" */ + // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ + // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ + // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ + // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ + // "optimizationStrategy": "memory" /* Define the optimization strategy. Value: [ "memory" | "performance" ]. Default: "memory" */ + }, + "logging": { + // "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ + }, + "debugging": { + // "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ + }, + "nodeOptions": { + // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ + // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ + } +} diff --git a/hvigorfile.ts b/hvigorfile.ts new file mode 100644 index 0000000..47113e2 --- /dev/null +++ b/hvigorfile.ts @@ -0,0 +1,6 @@ +import { appTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/oh-package-lock.json5 b/oh-package-lock.json5 new file mode 100644 index 0000000..c5a91ec --- /dev/null +++ b/oh-package-lock.json5 @@ -0,0 +1,28 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0", + "@ohos/hypium@1.0.25": "@ohos/hypium@1.0.25" + }, + "packages": { + "@ohos/hamock@1.0.0": { + "name": "@ohos/hamock", + "version": "1.0.0", + "integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har", + "registryType": "ohpm" + }, + "@ohos/hypium@1.0.25": { + "name": "@ohos/hypium", + "version": "1.0.25", + "integrity": "sha512-l6uO2pjl8HyEKdekLqQt7tUpWbDqX/42zoAzkagtUVZAW9jT6lMvbe54MVjoLxq/RwQGygRvi6j4GpypSMFSHw==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.25.har", + "registryType": "ohpm" + } + } +} \ No newline at end of file diff --git a/oh-package.json5 b/oh-package.json5 new file mode 100644 index 0000000..01177cc --- /dev/null +++ b/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "modelVersion": "6.1.1", + "description": "Please describe the basic information.", + "dependencies": { + }, + "devDependencies": { + "@ohos/hypium": "1.0.25", + "@ohos/hamock": "1.0.0" + } +} diff --git a/ui/Index.ets b/ui/Index.ets new file mode 100644 index 0000000..24f84db --- /dev/null +++ b/ui/Index.ets @@ -0,0 +1,9 @@ +/** + * @kuge/ui 的唯一公开入口。 + * + * 约定(见 ADR-001 / ADR-003):HAR 模块只通过本文件暴露能力。 + */ +export { Breakpoint, BreakpointUtil } from './src/main/ets/layout/Breakpoint'; +export { AdaptiveScaffold } from './src/main/ets/layout/AdaptiveScaffold'; +export { GridMetrics, GridSpec } from './src/main/ets/layout/GridSpec'; +export { ResponsiveGrid } from './src/main/ets/layout/ResponsiveGrid'; diff --git a/ui/build-profile.json5 b/ui/build-profile.json5 new file mode 100644 index 0000000..37fa510 --- /dev/null +++ b/ui/build-profile.json5 @@ -0,0 +1,9 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default" + } + ] +} diff --git a/ui/hvigorfile.ts b/ui/hvigorfile.ts new file mode 100644 index 0000000..08f99a1 --- /dev/null +++ b/ui/hvigorfile.ts @@ -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. */ +} diff --git a/ui/oh-package.json5 b/ui/oh-package.json5 new file mode 100644 index 0000000..330a747 --- /dev/null +++ b/ui/oh-package.json5 @@ -0,0 +1,9 @@ +{ + "name": "@kuge/ui", + "version": "1.0.0", + "description": "Kuge UI base: breakpoints, adaptive layout, shared components", + "main": "Index.ets", + "author": "Kuge Team", + "license": "Apache-2.0", + "dependencies": {} +} diff --git a/ui/src/main/ets/layout/AdaptiveScaffold.ets b/ui/src/main/ets/layout/AdaptiveScaffold.ets new file mode 100644 index 0000000..3538a5d --- /dev/null +++ b/ui/src/main/ets/layout/AdaptiveScaffold.ets @@ -0,0 +1,53 @@ +/** + * 自适应工作区骨架(1.3.3)。 + * + * 结构:左侧导航栏(可选)+ 对象树(可选)+ 主内容区。 + * sm 下隐藏导航与对象树,主内容占满;md/lg 按断点给出栏宽。 + * 具体页面由 feature 模块通过 @BuilderParam 注入。 + */ +import { Breakpoint, BreakpointUtil } from './Breakpoint'; + +@Component +export struct AdaptiveScaffold { + @State private widthVp: number = 0; + @BuilderParam rail: () => void = this.emptyBuilder; + @BuilderParam tree: () => void = this.emptyBuilder; + @BuilderParam content: () => void = this.emptyBuilder; + + @Builder + emptyBuilder() { + } + + private currentBreakpoint(): Breakpoint { + return BreakpointUtil.of(this.widthVp); + } + + build() { + Row() { + if (BreakpointUtil.railWidthOf(this.currentBreakpoint()) > 0) { + Column() { + this.rail() + } + .width(BreakpointUtil.railWidthOf(this.currentBreakpoint())) + .height('100%') + } + if (BreakpointUtil.showsTree(this.currentBreakpoint())) { + Column() { + this.tree() + } + .width(BreakpointUtil.treeWidthOf(this.currentBreakpoint())) + .height('100%') + } + Column() { + this.content() + } + .layoutWeight(1) + .height('100%') + } + .width('100%') + .height('100%') + .onAreaChange((_oldArea: Area, newArea: Area) => { + this.widthVp = Number(newArea.width); + }) + } +} diff --git a/ui/src/main/ets/layout/Breakpoint.ets b/ui/src/main/ets/layout/Breakpoint.ets new file mode 100644 index 0000000..1dfaafc --- /dev/null +++ b/ui/src/main/ets/layout/Breakpoint.ets @@ -0,0 +1,73 @@ +/** + * 断点体系(1.23.1 / 1.3.3)。 + * + * 纯计算逻辑,不依赖 UI 框架,便于单元测试与在任意入口复用。 + * 断点定义与 ARCH-001 §7、交互原型 UX-001 保持一致: + * sm < 600vp 单栏(平板竖屏) + * md 600–840vp 双栏(平板横屏) + * lg > 840vp 三栏(PC / 2in1) + */ +export enum Breakpoint { + SM = 0, + MD = 1, + LG = 2 +} + +export class BreakpointUtil { + /** sm 的上界(不含)。 */ + static readonly SM_LIMIT: number = 600; + /** md 的上界(不含)。 */ + static readonly MD_LIMIT: number = 840; + + /** 由可用宽度(vp)判定断点。 */ + static of(widthVp: number): Breakpoint { + if (widthVp < BreakpointUtil.SM_LIMIT) { + return Breakpoint.SM; + } + if (widthVp < BreakpointUtil.MD_LIMIT) { + return Breakpoint.MD; + } + return Breakpoint.LG; + } + + /** 主内容列数。 */ + static columnsOf(bp: Breakpoint): number { + switch (bp) { + case Breakpoint.SM: + return 1; + case Breakpoint.MD: + return 2; + default: + return 3; + } + } + + /** 左侧导航栏宽度(vp);sm 下为 0(改用抽屉)。 */ + static railWidthOf(bp: Breakpoint): number { + switch (bp) { + case Breakpoint.SM: + return 0; + case Breakpoint.MD: + return 64; + default: + return 72; + } + } + + /** 对象树宽度(vp);sm 下为 0(作为独立页面)。 */ + static treeWidthOf(bp: Breakpoint): number { + switch (bp) { + case Breakpoint.SM: + return 0; + case Breakpoint.MD: + return 240; + default: + return 280; + } + } + + /** 是否显示对象树(sm 下不并排显示)。 */ + static showsTree(bp: Breakpoint): boolean { + return bp !== Breakpoint.SM; + } +} diff --git a/ui/src/main/ets/layout/GridSpec.ets b/ui/src/main/ets/layout/GridSpec.ets new file mode 100644 index 0000000..668b79d --- /dev/null +++ b/ui/src/main/ets/layout/GridSpec.ets @@ -0,0 +1,94 @@ +/** + * 响应式栅格计算(1.23.2)。 + * + * 纯逻辑:由可用宽度推导栅格列数、边距、间距与列宽,并提供跨列宽度计算。 + * 与 UX-003 规范一致:sm 4 列 / md 8 列 / lg 12 列;边距 16/24/32;间距 8/12/16。 + * ArkUI 组件(ResponsiveGrid)只负责渲染,所有数值来自本类,保证页面间一致。 + */ +import { Breakpoint, BreakpointUtil } from './Breakpoint'; + +export interface GridMetrics { + breakpoint: Breakpoint; + columns: number; + margin: number; + gutter: number; + contentWidth: number; + columnWidth: number; +} + +export class GridSpec { + /** 列数。 */ + static columnsOf(bp: Breakpoint): number { + switch (bp) { + case Breakpoint.SM: + return 4; + case Breakpoint.MD: + return 8; + default: + return 12; + } + } + + /** 左右边距(单侧)。 */ + static marginOf(bp: Breakpoint): number { + switch (bp) { + case Breakpoint.SM: + return 16; + case Breakpoint.MD: + return 24; + default: + return 32; + } + } + + /** 列间距。 */ + static gutterOf(bp: Breakpoint): number { + switch (bp) { + case Breakpoint.SM: + return 8; + case Breakpoint.MD: + return 12; + default: + return 16; + } + } + + /** 由宽度推导完整栅格度量。 */ + static metricsOf(widthVp: number): GridMetrics { + const bp: Breakpoint = BreakpointUtil.of(widthVp); + const columns: number = GridSpec.columnsOf(bp); + const margin: number = GridSpec.marginOf(bp); + const gutter: number = GridSpec.gutterOf(bp); + const contentWidth: number = Math.max(0, widthVp - margin * 2); + const columnWidth: number = (contentWidth - gutter * (columns - 1)) / columns; + return { + breakpoint: bp, + columns: columns, + margin: margin, + gutter: gutter, + contentWidth: contentWidth, + columnWidth: columnWidth + }; + } + + /** 跨 span 列的元素宽度;span 会被裁剪到 [1, columns]。 */ + static spanWidth(metrics: GridMetrics, span: number): number { + let safeSpan: number = Math.floor(span); + if (safeSpan < 1) { + safeSpan = 1; + } + if (safeSpan > metrics.columns) { + safeSpan = metrics.columns; + } + return safeSpan * metrics.columnWidth + (safeSpan - 1) * metrics.gutter; + } + + /** 一行可放置的元素个数(按最小宽度计算),至少 1 个。 */ + static fitCount(metrics: GridMetrics, minItemWidth: number): number { + if (minItemWidth <= 0) { + return 1; + } + const count: number = Math.floor((metrics.contentWidth + metrics.gutter) / (minItemWidth + metrics.gutter)); + return count < 1 ? 1 : count; + } +} diff --git a/ui/src/main/ets/layout/ResponsiveGrid.ets b/ui/src/main/ets/layout/ResponsiveGrid.ets new file mode 100644 index 0000000..6580762 --- /dev/null +++ b/ui/src/main/ets/layout/ResponsiveGrid.ets @@ -0,0 +1,28 @@ +/** + * 响应式栅格容器(1.23.2)。 + * + * 按可用宽度自动折算列宽,子项通过 GridSpec 计算自身宽度。 + * 由于 ArkUI 不支持在子组件内读取父容器度量,这里以「回调参数」方式 + * 把 GridMetrics 传给内容构建器,避免每个页面重复算一遍。 + */ +import { GridMetrics, GridSpec } from './GridSpec'; + +@Component +export struct ResponsiveGrid { + @State private widthVp: number = 0; + @BuilderParam content: (metrics: GridMetrics) => void = this.emptyContent; + + @Builder + emptyContent(_metrics: GridMetrics) { + } + + build() { + Column() { + this.content(GridSpec.metricsOf(this.widthVp)) + } + .width('100%') + .onAreaChange((_oldArea: Area, newArea: Area) => { + this.widthVp = Number(newArea.width); + }) + } +} diff --git a/ui/src/main/module.json5 b/ui/src/main/module.json5 new file mode 100644 index 0000000..bfbe63d --- /dev/null +++ b/ui/src/main/module.json5 @@ -0,0 +1,10 @@ +{ + "module": { + "name": "ui", + "type": "har", + "deviceTypes": [ + "tablet", + "2in1" + ] + } +} diff --git a/ui/src/main/resources/base/element/string.json b/ui/src/main/resources/base/element/string.json new file mode 100644 index 0000000..3892e34 --- /dev/null +++ b/ui/src/main/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "ui_module_desc", + "value": "库阁 UI 基座:断点、自适应布局与通用组件" + } + ] +}