From 9545e48e4dfdfa707c589cdeb2f55d60149abc1f Mon Sep 17 00:00:00 2001 From: TommyLee <844718628@qq.com> Date: Mon, 19 Oct 2026 00:11:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 12 + AppScope/app.json5 | 11 + AppScope/resources/base/element/string.json | 8 + AppScope/resources/base/media/background.png | Bin 0 -> 91942 bytes AppScope/resources/base/media/foreground.png | Bin 0 -> 15325 bytes .../resources/base/media/layered_image.json | 7 + build-profile.json5 | 78 +++++ ci/README.md | 60 ++++ ci/build-hap.ps1 | 65 ++++ ci/signing.template.json5 | 21 ++ code-linter.json5 | 32 ++ core/BuildProfile.ets | 17 + core/Index.ets | 16 + core/build-profile.json5 | 9 + core/hvigorfile.ts | 6 + core/oh-package.json5 | 9 + core/src/main/ets/app/AppInfo.ets | 20 ++ core/src/main/ets/config/AppConfig.ets | 91 ++++++ .../ets/diagnostics/DiagnosticBuilder.ets | 66 ++++ core/src/main/ets/i18n/I18n.ets | 77 +++++ core/src/main/ets/logger/FileLogSink.ets | 139 +++++++++ core/src/main/ets/logger/Logger.ets | 108 +++++++ .../src/main/ets/security/CredentialVault.ets | 123 ++++++++ core/src/main/ets/storage/KeyValueStore.ets | 44 +++ core/src/main/ets/storage/LruCache.ets | 86 ++++++ core/src/main/ets/task/TaskScheduler.ets | 114 +++++++ core/src/main/module.json5 | 10 + .../main/resources/base/element/string.json | 8 + data/Index.ets | 103 +++++++ data/build-profile.json5 | 9 + data/hvigorfile.ts | 6 + data/oh-package.json5 | 9 + data/src/main/ets/audit/AuditLog.ets | 83 +++++ data/src/main/ets/grid/AggregationBuilder.ets | 152 +++++++++ data/src/main/ets/grid/BatchModifier.ets | 66 ++++ data/src/main/ets/grid/DelimitedCodec.ets | 101 ++++++ data/src/main/ets/grid/FieldMapper.ets | 120 ++++++++ data/src/main/ets/grid/FilterBuilder.ets | 162 ++++++++++ data/src/main/ets/grid/FormViewModel.ets | 128 ++++++++ data/src/main/ets/grid/GridModel.ets | 194 ++++++++++++ data/src/main/ets/grid/KeymapRegistry.ets | 142 +++++++++ data/src/main/ets/grid/LobBuffer.ets | 117 +++++++ data/src/main/ets/grid/RendererRegistry.ets | 79 +++++ data/src/main/ets/grid/TableEditor.ets | 184 +++++++++++ data/src/main/ets/grid/Validation.ets | 94 ++++++ data/src/main/ets/io/BatchQueue.ets | 155 ++++++++++ data/src/main/ets/io/CsvHandler.ets | 90 ++++++ data/src/main/ets/io/FormatRegistry.ets | 83 +++++ data/src/main/ets/io/HtmlExporter.ets | 78 +++++ data/src/main/ets/io/JsonCodec.ets | 103 +++++++ data/src/main/ets/io/SpreadsheetModel.ets | 70 +++++ data/src/main/ets/io/SqlExporter.ets | 65 ++++ data/src/main/ets/io/TextEncoding.ets | 77 +++++ data/src/main/ets/io/TypeConverter.ets | 189 ++++++++++++ data/src/main/ets/io/WizardModel.ets | 140 +++++++++ data/src/main/ets/io/XmlCodec.ets | 91 ++++++ data/src/main/ets/metadata/MetadataCache.ets | 110 +++++++ .../src/main/ets/metadata/ObjectTreeModel.ets | 120 ++++++++ data/src/main/ets/metadata/Objects.ets | 56 ++++ data/src/main/ets/metadata/SearchUtil.ets | 63 ++++ data/src/main/ets/schema/BatchOps.ets | 128 ++++++++ data/src/main/ets/schema/ConstraintModel.ets | 124 ++++++++ data/src/main/ets/schema/DdlGenerator.ets | 198 ++++++++++++ data/src/main/ets/schema/IndexModel.ets | 93 ++++++ data/src/main/ets/schema/MiscObjectDdl.ets | 50 +++ data/src/main/ets/schema/ObjectComparator.ets | 65 ++++ data/src/main/ets/schema/RoutineDdl.ets | 56 ++++ data/src/main/ets/schema/SpaceDdl.ets | 84 +++++ data/src/main/ets/schema/TableDiff.ets | 291 ++++++++++++++++++ data/src/main/ets/schema/TableModel.ets | 138 +++++++++ data/src/main/ets/schema/TextDiff.ets | 108 +++++++ data/src/main/ets/schema/TriggerDdl.ets | 74 +++++ data/src/main/ets/schema/ViewDdl.ets | 47 +++ .../main/ets/security/PermissionCatalog.ets | 137 +++++++++ data/src/main/ets/sql/CompletionEngine.ets | 53 ++++ data/src/main/ets/sql/ConsoleParser.ets | 82 +++++ data/src/main/ets/sql/ExplainParser.ets | 92 ++++++ data/src/main/ets/sql/PagedResult.ets | 126 ++++++++ data/src/main/ets/sql/ParameterBinder.ets | 208 +++++++++++++ data/src/main/ets/sql/Profiler.ets | 102 ++++++ data/src/main/ets/sql/QueryHistory.ets | 79 +++++ data/src/main/ets/sql/SnippetStore.ets | 91 ++++++ data/src/main/ets/sql/SqlFormatter.ets | 90 ++++++ data/src/main/ets/sql/SqlHighlighter.ets | 87 ++++++ data/src/main/ets/sql/SqlTokenizer.ets | 213 +++++++++++++ data/src/main/ets/sql/ValueInspector.ets | 146 +++++++++ data/src/main/ets/ui/FormAdapter.ets | 93 ++++++ data/src/main/ets/ui/InputDispatcher.ets | 83 +++++ data/src/main/ets/ui/PointerResolver.ets | 90 ++++++ data/src/main/ets/ui/WindowLayout.ets | 107 +++++++ data/src/main/module.json5 | 10 + .../main/resources/base/element/string.json | 8 + engine/BuildProfile.ets | 17 + engine/Index.ets | 26 ++ engine/build-profile.json5 | 9 + engine/hvigorfile.ts | 6 + engine/oh-package.json5 | 9 + .../main/ets/connection/ConnectionManager.ets | 122 ++++++++ .../main/ets/connection/ConnectionTester.ets | 89 ++++++ .../main/ets/connection/ReconnectPolicy.ets | 55 ++++ engine/src/main/ets/connection/types.ets | 119 +++++++ engine/src/main/ets/driver/MockDriver.ets | 48 +++ engine/src/main/ets/error/AppError.ets | 39 +++ engine/src/main/ets/error/ErrorMapper.ets | 94 ++++++ engine/src/main/ets/plugin/EnginePlugin.ets | 24 ++ engine/src/main/ets/plugin/EngineRegistry.ets | 66 ++++ engine/src/main/ets/plugin/SemVerUtil.ets | 55 ++++ engine/src/main/ets/protocol/FrameCodec.ets | 73 +++++ engine/src/main/module.json5 | 10 + .../main/resources/base/element/string.json | 8 + entry/.gitignore | 6 + entry/build-profile.json5 | 33 ++ entry/hvigorfile.ts | 6 + entry/obfuscation-rules.txt | 23 ++ entry/oh-package-lock.json5 | 33 ++ entry/oh-package.json5 | 14 + .../main/ets/entryability/EntryAbility.ets | 63 ++++ .../entrybackupability/EntryBackupAbility.ets | 16 + entry/src/main/ets/pages/Index.ets | 95 ++++++ entry/src/main/module.json5 | 51 +++ .../main/resources/base/element/color.json | 12 + .../main/resources/base/element/float.json | 8 + .../main/resources/base/element/string.json | 16 + .../main/resources/base/media/background.png | Bin 0 -> 91942 bytes .../main/resources/base/media/foreground.png | Bin 0 -> 8805 bytes .../resources/base/media/layered_image.json | 7 + .../main/resources/base/media/startIcon.png | Bin 0 -> 20093 bytes .../resources/base/profile/backup_config.json | 3 + .../resources/base/profile/main_pages.json | 5 + .../main/resources/dark/element/color.json | 12 + entry/src/mock/mock-config.json5 | 2 + entry/src/ohosTest/ets/test/Ability.test.ets | 35 +++ entry/src/ohosTest/ets/test/List.test.ets | 5 + entry/src/ohosTest/module.json5 | 12 + entry/src/test/List.test.ets | 5 + entry/src/test/LocalUnit.test.ets | 33 ++ hvigor/hvigor-config.json5 | 23 ++ hvigorfile.ts | 6 + oh-package-lock.json5 | 28 ++ oh-package.json5 | 10 + ui/Index.ets | 9 + ui/build-profile.json5 | 9 + ui/hvigorfile.ts | 6 + ui/oh-package.json5 | 9 + ui/src/main/ets/layout/AdaptiveScaffold.ets | 53 ++++ ui/src/main/ets/layout/Breakpoint.ets | 73 +++++ ui/src/main/ets/layout/GridSpec.ets | 94 ++++++ ui/src/main/ets/layout/ResponsiveGrid.ets | 28 ++ ui/src/main/module.json5 | 10 + .../main/resources/base/element/string.json | 8 + 150 files changed, 9559 insertions(+) create mode 100644 .gitignore create mode 100644 AppScope/app.json5 create mode 100644 AppScope/resources/base/element/string.json create mode 100644 AppScope/resources/base/media/background.png create mode 100644 AppScope/resources/base/media/foreground.png create mode 100644 AppScope/resources/base/media/layered_image.json create mode 100644 build-profile.json5 create mode 100644 ci/README.md create mode 100644 ci/build-hap.ps1 create mode 100644 ci/signing.template.json5 create mode 100644 code-linter.json5 create mode 100644 core/BuildProfile.ets create mode 100644 core/Index.ets create mode 100644 core/build-profile.json5 create mode 100644 core/hvigorfile.ts create mode 100644 core/oh-package.json5 create mode 100644 core/src/main/ets/app/AppInfo.ets create mode 100644 core/src/main/ets/config/AppConfig.ets create mode 100644 core/src/main/ets/diagnostics/DiagnosticBuilder.ets create mode 100644 core/src/main/ets/i18n/I18n.ets create mode 100644 core/src/main/ets/logger/FileLogSink.ets create mode 100644 core/src/main/ets/logger/Logger.ets create mode 100644 core/src/main/ets/security/CredentialVault.ets create mode 100644 core/src/main/ets/storage/KeyValueStore.ets create mode 100644 core/src/main/ets/storage/LruCache.ets create mode 100644 core/src/main/ets/task/TaskScheduler.ets create mode 100644 core/src/main/module.json5 create mode 100644 core/src/main/resources/base/element/string.json create mode 100644 data/Index.ets create mode 100644 data/build-profile.json5 create mode 100644 data/hvigorfile.ts create mode 100644 data/oh-package.json5 create mode 100644 data/src/main/ets/audit/AuditLog.ets create mode 100644 data/src/main/ets/grid/AggregationBuilder.ets create mode 100644 data/src/main/ets/grid/BatchModifier.ets create mode 100644 data/src/main/ets/grid/DelimitedCodec.ets create mode 100644 data/src/main/ets/grid/FieldMapper.ets create mode 100644 data/src/main/ets/grid/FilterBuilder.ets create mode 100644 data/src/main/ets/grid/FormViewModel.ets create mode 100644 data/src/main/ets/grid/GridModel.ets create mode 100644 data/src/main/ets/grid/KeymapRegistry.ets create mode 100644 data/src/main/ets/grid/LobBuffer.ets create mode 100644 data/src/main/ets/grid/RendererRegistry.ets create mode 100644 data/src/main/ets/grid/TableEditor.ets create mode 100644 data/src/main/ets/grid/Validation.ets create mode 100644 data/src/main/ets/io/BatchQueue.ets create mode 100644 data/src/main/ets/io/CsvHandler.ets create mode 100644 data/src/main/ets/io/FormatRegistry.ets create mode 100644 data/src/main/ets/io/HtmlExporter.ets create mode 100644 data/src/main/ets/io/JsonCodec.ets create mode 100644 data/src/main/ets/io/SpreadsheetModel.ets create mode 100644 data/src/main/ets/io/SqlExporter.ets create mode 100644 data/src/main/ets/io/TextEncoding.ets create mode 100644 data/src/main/ets/io/TypeConverter.ets create mode 100644 data/src/main/ets/io/WizardModel.ets create mode 100644 data/src/main/ets/io/XmlCodec.ets create mode 100644 data/src/main/ets/metadata/MetadataCache.ets create mode 100644 data/src/main/ets/metadata/ObjectTreeModel.ets create mode 100644 data/src/main/ets/metadata/Objects.ets create mode 100644 data/src/main/ets/metadata/SearchUtil.ets create mode 100644 data/src/main/ets/schema/BatchOps.ets create mode 100644 data/src/main/ets/schema/ConstraintModel.ets create mode 100644 data/src/main/ets/schema/DdlGenerator.ets create mode 100644 data/src/main/ets/schema/IndexModel.ets create mode 100644 data/src/main/ets/schema/MiscObjectDdl.ets create mode 100644 data/src/main/ets/schema/ObjectComparator.ets create mode 100644 data/src/main/ets/schema/RoutineDdl.ets create mode 100644 data/src/main/ets/schema/SpaceDdl.ets create mode 100644 data/src/main/ets/schema/TableDiff.ets create mode 100644 data/src/main/ets/schema/TableModel.ets create mode 100644 data/src/main/ets/schema/TextDiff.ets create mode 100644 data/src/main/ets/schema/TriggerDdl.ets create mode 100644 data/src/main/ets/schema/ViewDdl.ets create mode 100644 data/src/main/ets/security/PermissionCatalog.ets create mode 100644 data/src/main/ets/sql/CompletionEngine.ets create mode 100644 data/src/main/ets/sql/ConsoleParser.ets create mode 100644 data/src/main/ets/sql/ExplainParser.ets create mode 100644 data/src/main/ets/sql/PagedResult.ets create mode 100644 data/src/main/ets/sql/ParameterBinder.ets create mode 100644 data/src/main/ets/sql/Profiler.ets create mode 100644 data/src/main/ets/sql/QueryHistory.ets create mode 100644 data/src/main/ets/sql/SnippetStore.ets create mode 100644 data/src/main/ets/sql/SqlFormatter.ets create mode 100644 data/src/main/ets/sql/SqlHighlighter.ets create mode 100644 data/src/main/ets/sql/SqlTokenizer.ets create mode 100644 data/src/main/ets/sql/ValueInspector.ets create mode 100644 data/src/main/ets/ui/FormAdapter.ets create mode 100644 data/src/main/ets/ui/InputDispatcher.ets create mode 100644 data/src/main/ets/ui/PointerResolver.ets create mode 100644 data/src/main/ets/ui/WindowLayout.ets create mode 100644 data/src/main/module.json5 create mode 100644 data/src/main/resources/base/element/string.json create mode 100644 engine/BuildProfile.ets create mode 100644 engine/Index.ets create mode 100644 engine/build-profile.json5 create mode 100644 engine/hvigorfile.ts create mode 100644 engine/oh-package.json5 create mode 100644 engine/src/main/ets/connection/ConnectionManager.ets create mode 100644 engine/src/main/ets/connection/ConnectionTester.ets create mode 100644 engine/src/main/ets/connection/ReconnectPolicy.ets create mode 100644 engine/src/main/ets/connection/types.ets create mode 100644 engine/src/main/ets/driver/MockDriver.ets create mode 100644 engine/src/main/ets/error/AppError.ets create mode 100644 engine/src/main/ets/error/ErrorMapper.ets create mode 100644 engine/src/main/ets/plugin/EnginePlugin.ets create mode 100644 engine/src/main/ets/plugin/EngineRegistry.ets create mode 100644 engine/src/main/ets/plugin/SemVerUtil.ets create mode 100644 engine/src/main/ets/protocol/FrameCodec.ets create mode 100644 engine/src/main/module.json5 create mode 100644 engine/src/main/resources/base/element/string.json create mode 100644 entry/.gitignore create mode 100644 entry/build-profile.json5 create mode 100644 entry/hvigorfile.ts create mode 100644 entry/obfuscation-rules.txt create mode 100644 entry/oh-package-lock.json5 create mode 100644 entry/oh-package.json5 create mode 100644 entry/src/main/ets/entryability/EntryAbility.ets create mode 100644 entry/src/main/ets/entrybackupability/EntryBackupAbility.ets create mode 100644 entry/src/main/ets/pages/Index.ets create mode 100644 entry/src/main/module.json5 create mode 100644 entry/src/main/resources/base/element/color.json create mode 100644 entry/src/main/resources/base/element/float.json create mode 100644 entry/src/main/resources/base/element/string.json create mode 100644 entry/src/main/resources/base/media/background.png create mode 100644 entry/src/main/resources/base/media/foreground.png create mode 100644 entry/src/main/resources/base/media/layered_image.json create mode 100644 entry/src/main/resources/base/media/startIcon.png create mode 100644 entry/src/main/resources/base/profile/backup_config.json create mode 100644 entry/src/main/resources/base/profile/main_pages.json create mode 100644 entry/src/main/resources/dark/element/color.json create mode 100644 entry/src/mock/mock-config.json5 create mode 100644 entry/src/ohosTest/ets/test/Ability.test.ets create mode 100644 entry/src/ohosTest/ets/test/List.test.ets create mode 100644 entry/src/ohosTest/module.json5 create mode 100644 entry/src/test/List.test.ets create mode 100644 entry/src/test/LocalUnit.test.ets create mode 100644 hvigor/hvigor-config.json5 create mode 100644 hvigorfile.ts create mode 100644 oh-package-lock.json5 create mode 100644 oh-package.json5 create mode 100644 ui/Index.ets create mode 100644 ui/build-profile.json5 create mode 100644 ui/hvigorfile.ts create mode 100644 ui/oh-package.json5 create mode 100644 ui/src/main/ets/layout/AdaptiveScaffold.ets create mode 100644 ui/src/main/ets/layout/Breakpoint.ets create mode 100644 ui/src/main/ets/layout/GridSpec.ets create mode 100644 ui/src/main/ets/layout/ResponsiveGrid.ets create mode 100644 ui/src/main/module.json5 create mode 100644 ui/src/main/resources/base/element/string.json 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 0000000000000000000000000000000000000000..923f2b3f27e915d6871871deea0420eb45ce102f GIT binary patch literal 91942 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 M;JyCV_snDe2la%mzyJUM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..923f2b3f27e915d6871871deea0420eb45ce102f GIT binary patch literal 91942 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

c)ijgxDm}8EsRXFt3h@+Ufr7@DN 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 literal 0 HcmV?d00001 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 基座:断点、自适应布局与通用组件" + } + ] +}