diff --git a/feasibility_analysis.md b/feasibility_analysis.md index abcf5d7..8148716 100644 --- a/feasibility_analysis.md +++ b/feasibility_analysis.md @@ -4,7 +4,7 @@ 可行性分析用于判断 HyperSql 在当前技术条件、开发时间、经济成本和实际使用场景下是否能够顺利完成。 -HyperSql 是一个基于 Java 24、JavaFX、Maven 和 SQLite 的轻量级数据库图形化管理工具,主要面向学生和 SQLite 初学者。目前项目已经完成了核心数据库浏览和管理功能,后续将继续扩展数据编辑、备份恢复和 AI 辅助 SQL 生成。 +HyperSql 是一个基于 Java 24、JavaFX、Maven 和 SQLite 的轻量级数据库图形化管理工具,主要面向学生和 SQLite 初学者。目前项目已经完成了核心数据库浏览、表管理、数据行编辑和 SQLite 支持范围内的表结构编辑功能,后续将继续扩展备份恢复和 AI 辅助 SQL 生成。 本项目从以下四个方面进行分析: @@ -20,10 +20,10 @@ HyperSql 采用以下技术栈: | 技术 | 作用 | 当前使用情况 | |---|---|---| | Java 24 | 主要开发语言 | 已用于项目主要代码开发 | -| JavaFX | 构建图形化用户界面 | 已实现主界面、表格、菜单、工具栏和创建表对话框 | +| JavaFX | 构建图形化用户界面 | 已实现主界面、表格、菜单、工具栏、创建表对话框、数据行编辑和结构编辑操作入口 | | Maven | 项目构建与依赖管理 | 已完成项目构建配置 | -| SQLite | 本地轻量级数据库 | 作为系统管理对象 | -| SQLite JDBC | Java 程序访问 SQLite 数据库 | 已实现数据库连接、元数据读取和 SQL 执行 | +| SQLite | 本地轻量级数据库 | 作为系统管理对象,并通过原生 ALTER TABLE 支持部分表结构编辑 | +| SQLite JDBC | Java 程序访问 SQLite 数据库 | 已实现数据库连接、元数据读取、SQL 执行、数据行写入和表结构变更 | | AI API | 根据表结构和用户需求生成 SQL | 作为后续扩展功能,当前尚未实现 | ### 技术可行性说明 @@ -34,7 +34,7 @@ HyperSql 采用以下技术栈: 2. **JavaFX 能满足 GUI 需求** - JavaFX 提供菜单栏、按钮、标签、文本框、表格、分页按钮、弹窗和 FXML 等组件。 - - 当前项目已经实现左侧表/视图列表,右侧表数据、表结构和 SQL 执行区域,能够满足数据库管理工具的基本界面需求。 + - 当前项目已经实现左侧表/视图列表,右侧表数据、表结构和 SQL 执行区域,并提供数据行新增/删除按钮、可编辑单元格和表结构编辑按钮,能够满足数据库管理工具的基本界面需求。 3. **SQLite 集成难度低** - SQLite 是文件型数据库,不需要单独部署数据库服务器,适合课程项目和本地数据库管理。 @@ -42,19 +42,24 @@ HyperSql 采用以下技术栈: 4. **SQLite JDBC 访问方式成熟** - 通过 JDBC 可以完成数据库连接、查询、执行 SQL、读取表结构等操作。 - - 当前项目已经实现表/视图列表读取、字段信息读取、表数据分页查询和 SQL 执行。 + - 当前项目已经实现表/视图列表读取、字段信息读取、表数据分页查询、SQL 执行、数据行插入/更新/删除和部分表结构变更。 -5. **表管理功能可以通过 SQL 和 JavaFX UI 实现** +5. **表管理和结构编辑功能可以通过 SQL 和 JavaFX UI 实现** - 当前项目已经通过独立创建表对话框实现了表名输入、字段定义、字段类型选择、主键/非空设置、默认值填写和 SQL 预览。 - 删除表功能已经加入确认流程,降低误操作风险。 + - 表结构编辑只实现 SQLite 原生支持且相对安全的重命名表、新增字段和重命名字段,不实现需要重建表的复杂结构修改。 -6. **AI SQL 生成功能技术上可行,但需要控制范围** +6. **数据行编辑功能技术上可行** + - 当前项目已经通过主键或 SQLite `rowid` 定位数据行,使用参数化 SQL 完成新增、修改和删除。 + - SQL 返回结果只对可识别的单表查询启用编辑,复杂查询保持只读,降低误操作风险。 + +7. **AI SQL 生成功能技术上可行,但需要控制范围** - 后续可以读取当前数据库表结构,结合用户自然语言描述构造提示词,然后调用 AI API 生成 SQL。 - 该功能涉及 API Key 管理、网络请求、调用成本和生成结果校验,因此适合作为扩展功能。 ### 技术可行性结论 -HyperSql 所采用的核心技术已经在当前项目中得到验证。数据库连接、表结构读取、表数据分页显示、SQL 执行、数据库文件管理和表管理功能均已实现,因此项目核心功能在技术上可行。AI SQL 生成属于后续扩展,技术上可实现,但需要在安全性和调用成本方面进行控制。 +HyperSql 所采用的核心技术已经在当前项目中得到验证。数据库连接、表结构读取、表数据分页显示、SQL 执行、数据库文件管理、表管理、数据行编辑和 SQLite 安全结构编辑功能均已实现,因此项目核心功能在技术上可行。AI SQL 生成属于后续扩展,技术上可实现,但需要在安全性和调用成本方面进行控制。 ## 3. 经济可行性 @@ -96,8 +101,9 @@ HyperSql 采用增量迭代模型,可以按照功能优先级逐步完成, | SQL 执行 | 执行 SQL 并显示结果,SQL 查询结果分页 | 已完成 | | 数据库文件管理 | 删除当前数据库文件 | 已完成 | | 表管理 | 创建表 UI、删除表确认流程 | 已完成 | -| 交互修复 | 修复选择表不同步、刷新表列表保留当前表 | 已完成 | -| 数据行编辑 | 图形界面新增、修改、删除数据行 | 待实现 | +| 交互修复 | 修复选择表不同步、刷新表列表保留当前表、SQL 结果区启动空状态 | 已完成 | +| 数据行编辑 | 表数据界面新增、修改、删除数据行;可编辑单表 SQL 结果 | 已完成 | +| 表结构安全编辑 | 重命名表、新增字段、重命名字段 | 已完成 | | 备份恢复 | 数据库备份和恢复 | 待实现 | | AI SQL 生成 | 根据表结构和自然语言生成 SQL | 待实现 | | 测试优化 | 功能测试、界面优化、最终演示准备 | 持续进行 | @@ -105,13 +111,14 @@ HyperSql 采用增量迭代模型,可以按照功能优先级逐步完成, ### 时间可行性说明 1. 当前项目已经完成数据库管理工具的核心可运行版本,可以满足基本演示要求。 -2. 后续开发可以优先完成数据行编辑和备份恢复,因为它们与数据库管理工具的实用性直接相关。 -3. AI SQL 生成可以作为亮点功能,根据剩余时间决定实现深度。 -4. 采用增量迭代方式后,即使扩展功能未全部完成,系统仍然具备可运行、可演示的核心功能。 +2. 数据行编辑和 SQLite 支持范围内的表结构编辑已经完成,软件实用性进一步提高。 +3. 后续开发可以优先完成备份恢复,因为它与数据库管理工具的数据安全性直接相关。 +4. AI SQL 生成可以作为亮点功能,根据剩余时间决定实现深度。 +5. 采用增量迭代方式后,即使扩展功能未全部完成,系统仍然具备可运行、可演示的核心功能。 ### 时间可行性结论 -从当前进展看,HyperSql 的核心功能已经完成,课程周期内完成一个可演示版本具有较高可行性。后续需要合理控制扩展功能范围,避免 AI 调用和复杂数据编辑占用过多开发时间。 +从当前进展看,HyperSql 的核心功能和主要编辑功能已经完成,课程周期内完成一个可演示版本具有较高可行性。后续需要合理控制扩展功能范围,避免 AI 调用和复杂数据库结构重建功能占用过多开发时间。 ## 5. 操作可行性 @@ -128,7 +135,9 @@ HyperSql 面向学生和 SQLite 初学者,软件操作流程较为简单。 ↓ 查看表结构和表数据 ↓ -使用分页按钮浏览更多数据 +新增、修改或删除表数据行 + ↓ +在 SQLite 支持范围内编辑表结构 ↓ 在 SQL 执行区输入并执行 SQL ↓ @@ -141,7 +150,7 @@ HyperSql 面向学生和 SQLite 初学者,软件操作流程较为简单。 - 采用类似数据库管理工具的布局:左侧显示表/视图列表,右侧显示表数据、表结构和 SQL 执行区域。 2. **降低数据库操作门槛** - - 用户可以通过图形界面打开数据库、浏览表结构、查看表数据、创建表和删除表,不必完全依赖命令行工具。 + - 用户可以通过图形界面打开数据库、浏览表结构、查看表数据、新增修改删除数据行、创建表、删除表和进行部分表结构编辑,不必完全依赖命令行工具。 3. **分页降低操作压力** - 表数据和 SQL 查询结果都使用分页显示,每页 100 行,避免一次显示大量数据导致界面卡顿或不便查看。 @@ -150,14 +159,14 @@ HyperSql 面向学生和 SQLite 初学者,软件操作流程较为简单。 - 当未连接数据库、数据库连接失败、SQL 执行错误、创建表失败或删除表失败时,系统会给出提示信息。 5. **危险操作有确认流程** - - 删除数据库文件和删除表都属于破坏性操作,系统在执行前会弹出确认提示。 + - 删除数据库文件、删除表和删除数据行都属于破坏性操作,系统在执行前会弹出确认提示。 6. **当前限制清晰** - - 当前版本已经支持创建表和删除普通表,但尚未实现图形界面数据行编辑、数据库备份恢复和 AI SQL 生成。 + - 当前版本已经支持创建表、删除普通表、数据行编辑和 SQLite 原生支持的部分结构编辑,但尚未实现数据库备份恢复和 AI SQL 生成。 ### 操作可行性结论 -HyperSql 当前功能界面直观,基本操作流程清晰,适合学生和 SQLite 初学者使用。随着后续数据编辑和备份恢复功能完善,软件的实用性会进一步提高。 +HyperSql 当前功能界面直观,基本操作流程清晰,已经支持常见数据浏览、数据编辑和部分结构编辑操作,适合学生和 SQLite 初学者使用。随着后续备份恢复功能完善,软件的数据安全性会进一步提高。 ## 6. 可行性分析总结 @@ -165,11 +174,11 @@ HyperSql 当前功能界面直观,基本操作流程清晰,适合学生和 S | 分析方面 | 结论 | |---|---| -| 技术可行性 | JavaFX、SQLite JDBC 和 Maven 已经支撑当前核心功能实现,AI API 可作为后续扩展 | +| 技术可行性 | JavaFX、SQLite JDBC 和 Maven 已经支撑当前核心功能、数据编辑和结构编辑实现,AI API 可作为后续扩展 | | 经济可行性 | 开发工具和数据库免费,当前功能无额外运行成本 | -| 时间可行性 | 核心功能已经完成,后续扩展可按优先级继续迭代 | +| 时间可行性 | 核心功能和主要编辑功能已经完成,后续扩展可按优先级继续迭代 | | 操作可行性 | 面向学生和初学者,界面简单直观,危险操作有确认提示 | ### 总体结论 -HyperSql 的技术方案成熟,开发成本低,核心功能已经形成可运行版本,适合作为软件工程课程大作业项目继续完善。后续应优先补充数据行编辑和备份恢复功能,再根据时间实现 AI 辅助 SQL 生成。 \ No newline at end of file +HyperSql 的技术方案成熟,开发成本低,核心功能和主要编辑功能已经形成可运行版本,适合作为软件工程课程大作业项目继续完善。后续应优先补充数据库备份恢复功能,再根据时间实现 AI 辅助 SQL 生成。 \ No newline at end of file diff --git a/requirements_analysis.md b/requirements_analysis.md index 57f5ea7..09c1a1c 100644 --- a/requirements_analysis.md +++ b/requirements_analysis.md @@ -11,9 +11,10 @@ HyperSql 是一个面向学生和 SQLite 初学者的轻量级数据库图形化 3. 数据库表结构和表数据需要以更直观的方式展示。 4. 大表或较多查询结果需要分页显示,避免界面卡顿和阅读困难。 5. 创建数据库、创建表、删除表等常见操作需要简洁清晰的图形界面。 -6. 删除数据库文件和删除表等危险操作需要确认流程,降低误操作风险。 +6. 删除数据库文件、删除表和删除数据行等危险操作需要确认流程,降低误操作风险。 +7. 表结构修改需要尊重 SQLite 原生能力,只提供安全可控的结构编辑入口。 -当前版本已经完成了数据库文件管理、表/视图浏览、表结构查看、表数据分页查看、SQL 执行、SQL 结果分页、创建表和删除表等核心功能。数据行编辑、数据库备份恢复和 AI 辅助 SQL 生成作为后续扩展功能继续开发。 +当前版本已经完成了数据库文件管理、表/视图浏览、表结构查看、表数据分页查看、SQL 执行、SQL 结果分页、创建表、删除表、数据行编辑和 SQLite 支持范围内的表结构编辑等核心功能。数据库备份恢复和 AI 辅助 SQL 生成作为后续扩展功能继续开发。 ## 2. 用户需求分析 @@ -35,8 +36,10 @@ HyperSql 是一个面向学生和 SQLite 初学者的轻量级数据库图形化 6. 用户在 SQL 执行区输入 SQL 并查看执行结果。 7. 用户通过图形界面创建新表。 8. 用户删除不需要的普通表,并在删除前进行确认。 -9. 用户刷新表列表,并希望保持当前选中的表不变。 -10. 后续版本中,用户可以通过图形界面编辑数据、备份恢复数据库,或使用 AI 辅助生成 SQL。 +9. 用户通过图形界面新增、修改或删除表中的数据行。 +10. 用户在 SQLite 支持范围内重命名表、新增字段或重命名字段。 +11. 用户刷新表列表,并希望保持当前选中的表不变。 +12. 后续版本中,用户可以备份恢复数据库,或使用 AI 辅助生成 SQL。 ## 3. 功能性需求 @@ -50,8 +53,9 @@ HyperSql 的功能性需求主要包括以下模块: | SQL 执行 | 支持用户输入 SQL 语句并执行,显示结果或错误 | 已实现 | | SQL 结果分页 | 查询结果按每页 100 行分页显示 | 已实现 | | 表管理 | 支持通过 UI 创建表,支持删除选中普通表 | 已实现 | +| 表结构安全编辑 | 支持重命名表、新增字段、重命名字段 | 已实现 | | 状态与错误提示 | 显示连接状态、执行结果和错误信息 | 已实现 | -| 数据编辑 | 支持新增、修改、删除表数据行 | 待实现 | +| 数据编辑 | 支持新增、修改、删除表数据行,并支持可识别单表 SQL 结果编辑 | 已实现 | | 备份与恢复 | 支持数据库文件备份和从备份文件恢复 | 待实现 | | AI SQL 生成 | 根据表结构和用户自然语言需求生成 SQL | 待实现 | @@ -78,7 +82,20 @@ HyperSql 的功能性需求主要包括以下模块: 当前实现状态:已实现,并已修复选择表时数据和结构不同步的问题。 -### 4.3 表数据查看 +### 4.3 表结构安全编辑 + +- 系统应支持在 SQLite 原生能力范围内编辑普通表结构。 +- 系统应支持重命名普通表。 +- 系统应支持新增字段。 +- 系统应支持重命名字段。 +- 系统不应对视图启用结构编辑。 +- 系统不应实现 SQLite 不能直接安全支持的字段类型修改、非空约束修改、默认值修改、主键修改和字段顺序调整。 +- 结构变更前应提示用户确认。 +- 结构变更后应刷新表列表、表结构和表数据。 + +当前实现状态:已实现。当前版本只实现 SQLite 原生 `ALTER TABLE` 相对安全支持的重命名表、新增字段和重命名字段。 + +### 4.4 表数据查看 - 系统应支持点击表名后查看表数据。 - 系统应以表格形式展示数据。 @@ -90,7 +107,7 @@ HyperSql 的功能性需求主要包括以下模块: 当前实现状态:已实现。表数据分页采用数据库侧分页,通过 `LIMIT` 和 `OFFSET` 每次只查询当前页数据。 -### 4.4 SQL 执行 +### 4.5 SQL 执行 - 系统应提供 SQL 输入区域。 - 系统应支持执行查询、插入、更新、删除、建表、删表等 SQL 语句。 @@ -102,7 +119,7 @@ HyperSql 的功能性需求主要包括以下模块: 当前实现状态:已实现。SQL 结果分页采用客户端分页,即 SQL 查询结果先读取到内存中,再按页显示。 -### 4.5 创建表 +### 4.6 创建表 - 系统应提供图形化创建表界面,不能只依赖用户手写 `CREATE TABLE`。 - 创建表界面应简洁易用。 @@ -118,7 +135,7 @@ HyperSql 的功能性需求主要包括以下模块: 当前实现状态:已实现。 -### 4.6 删除表 +### 4.7 删除表 - 系统应支持删除左侧选中的普通表。 - 系统不应通过“删除表”功能删除视图。 @@ -132,13 +149,14 @@ HyperSql 的功能性需求主要包括以下模块: ### 5.1 数据行编辑 -- 系统后续应支持在图形界面中新增表数据。 -- 系统后续应支持修改表格中的数据。 -- 系统后续应支持删除选中的数据行。 -- 对修改和删除操作,应提供必要的确认或错误提示。 -- 数据编辑应优先保证主键识别和 SQL 生成安全。 +- 系统应支持在表数据界面新增表数据行。 +- 系统应支持直接修改表格中的数据,修改后立即保存到 SQLite 数据库。 +- 系统应支持删除选中的数据行,删除前必须提示确认。 +- 系统应通过主键或 SQLite `rowid` 定位数据行,避免误更新。 +- 系统应在可识别的单表 SQL 查询结果中支持新增、修改和删除数据行。 +- JOIN、聚合、表达式、视图等无法安全反写的 SQL 查询结果应保持只读。 -当前实现状态:待实现。 +当前实现状态:已实现。 ### 5.2 数据库备份与恢复 @@ -165,13 +183,13 @@ HyperSql 的功能性需求主要包括以下模块: | 需求类型 | 说明 | 当前体现 | |---|---|---| -| 易用性 | 界面清晰,操作流程简单,适合初学者使用 | 已采用菜单、工具栏、表格和创建表对话框 | -| 可靠性 | 数据库连接、SQL 执行和表操作失败时应有错误提示 | 已实现基础错误提示 | -| 安全性 | 删除数据库、删除表等危险操作前应确认 | 已实现确认流程 | +| 易用性 | 界面清晰,操作流程简单,适合初学者使用 | 已采用菜单、工具栏、表格、创建表对话框、数据行编辑按钮和结构编辑按钮 | +| 可靠性 | 数据库连接、SQL 执行、数据编辑和表操作失败时应有错误提示 | 已实现基础错误提示 | +| 安全性 | 删除数据库、删除表、删除数据行和结构变更等操作前应确认 | 已实现确认流程 | | 性能 | 对常见小型 SQLite 数据库能够快速打开和查询 | 表数据采用数据库侧分页,降低大表加载压力 | -| 可维护性 | 采用模块化设计,便于后续扩展和维护 | 已划分数据库连接、元数据读取、SQL 执行、工具类和控制器 | +| 可维护性 | 采用模块化设计,便于后续扩展和维护 | 已划分数据库连接、元数据读取、SQL 执行、行数据服务、结构编辑服务、工具类和控制器 | | 兼容性 | 支持常见 SQLite 数据库文件,适配主流桌面系统 | 基于 JavaFX 和 SQLite JDBC,具备跨平台基础 | -| 可扩展性 | 后续可扩展数据编辑、备份恢复和 AI SQL 生成 | 已保留 SQL 执行和元数据读取基础能力 | +| 可扩展性 | 后续可扩展备份恢复和 AI SQL 生成 | 已保留 SQL 执行、元数据读取、行数据服务和结构编辑服务基础能力 | ## 7. 需求优先级 @@ -182,7 +200,8 @@ HyperSql 的功能性需求主要包括以下模块: | 高 | 新建/打开/关闭 SQLite 数据库、浏览表结构、查看表数据、执行 SQL | 已实现 | | 高 | 表数据分页、SQL 查询结果分页、刷新表列表保留当前选择 | 已实现 | | 中 | 创建表、删除表、删除数据库文件 | 已实现 | -| 中 | 数据行新增、修改、删除 | 待实现 | +| 中 | 数据行新增、修改、删除 | 已实现 | +| 中 | SQLite 支持范围内的表结构编辑 | 已实现 | | 中 | 数据库备份与恢复 | 待实现 | | 低 | AI 辅助 SQL 生成、SQL 历史记录、界面进一步美化 | 待实现 | @@ -194,7 +213,7 @@ HyperSql 的功能性需求主要包括以下模块: 2. **中优先级需求** - 提升软件实用性和数据安全性。 - - 当前已经完成创建表、删除表和删除数据库文件,后续应继续完成数据行编辑和备份恢复。 + - 当前已经完成创建表、删除表、删除数据库文件、数据行编辑和表结构安全编辑,后续应继续完成备份恢复。 3. **低优先级需求** - 作为项目亮点和扩展功能。 @@ -204,12 +223,13 @@ HyperSql 的功能性需求主要包括以下模块: 当前 HyperSql 仍存在以下限制: -1. 还不能通过图形界面直接新增、修改或删除表中的数据行。 -2. 还没有实现数据库备份与恢复功能。 -3. 还没有实现 AI 辅助 SQL 生成功能。 -4. SQL 查询结果分页属于客户端分页,查询结果会先全部读取到内存中,不适合特别大的查询结果。 -5. 当前删除表功能只支持删除普通表,不支持删除视图。 -6. 当前主要面向 SQLite,不支持 MySQL、PostgreSQL 等远程数据库。 +1. 还没有实现数据库备份与恢复功能。 +2. 还没有实现 AI 辅助 SQL 生成功能。 +3. SQL 查询结果分页属于客户端分页,查询结果会先全部读取到内存中,不适合特别大的查询结果。 +4. SQL 结果编辑只支持可识别的单表查询,JOIN、聚合、表达式等复杂结果保持只读。 +5. 表结构编辑只支持 SQLite 原生安全支持的重命名表、新增字段和重命名字段,不支持需要重建表的复杂结构修改。 +6. 当前删除表功能只支持删除普通表,不支持删除视图。 +7. 当前主要面向 SQLite,不支持 MySQL、PostgreSQL 等远程数据库。 ## 9. 需求分析总结 @@ -224,6 +244,8 @@ HyperSql 的功能性需求主要包括以下模块: 5. SQL 语句执行和 SQL 查询结果分页显示。 6. 图形化创建表。 7. 删除普通表。 -8. 状态提示、错误提示和危险操作确认。 +8. 数据行新增、修改和删除。 +9. SQLite 支持范围内的表结构安全编辑。 +10. 状态提示、错误提示和危险操作确认。 -后续开发应优先完成数据行增删改查和数据库备份恢复,使系统从“可浏览、可执行 SQL、可管理表结构”进一步发展为“可完整管理 SQLite 数据库”的工具。在时间允许的情况下,再实现 AI 辅助 SQL 生成作为项目亮点。 \ No newline at end of file +后续开发应优先完成数据库备份恢复,使系统从“可浏览、可执行 SQL、可编辑数据、可管理表结构”进一步发展为“可完整管理 SQLite 数据库”的工具。在时间允许的情况下,再实现 AI 辅助 SQL 生成作为项目亮点。 \ No newline at end of file diff --git a/software_process_model.md b/software_process_model.md index 5512f61..1f1cc98 100644 --- a/software_process_model.md +++ b/software_process_model.md @@ -6,14 +6,14 @@ 增量迭代模型是指将软件系统划分为多个相对独立的功能增量,每个增量都经过需求分析、设计、编码、测试和反馈优化等过程,最终逐步形成完整的软件系统。 -HyperSql 是一个基于 Java 24、JavaFX 和 Maven 的轻量级 SQLite 图形化管理工具。项目开发过程中,先完成数据库连接、表结构浏览、表数据查看和 SQL 执行等核心功能,再逐步增加数据库文件管理、表管理、分页显示等增强功能,最后根据时间继续扩展数据编辑、备份恢复和 AI 辅助 SQL 生成。 +HyperSql 是一个基于 Java 24、JavaFX 和 Maven 的轻量级 SQLite 图形化管理工具。项目开发过程中,先完成数据库连接、表结构浏览、表数据查看和 SQL 执行等核心功能,再逐步增加数据库文件管理、表管理、分页显示、数据行编辑和表结构安全编辑等增强功能,最后根据时间继续扩展备份恢复和 AI 辅助 SQL 生成。 ## 2. 选择增量迭代模型的原因 HyperSql 选择增量迭代模型,主要有以下原因: 1. **功能模块清晰** - - HyperSql 可以划分为数据库文件管理、数据库连接、表/视图浏览、表结构查看、表数据分页查看、SQL 执行、表管理、数据编辑、备份恢复和 AI SQL 生成等模块。 + - HyperSql 可以划分为数据库文件管理、数据库连接、表/视图浏览、表结构查看、表结构安全编辑、表数据分页查看、SQL 执行、表管理、数据行编辑、备份恢复和 AI SQL 生成等模块。 2. **适合 GUI 软件开发** - JavaFX 图形界面需要不断调整布局和交互方式。采用迭代开发,可以在每个阶段完成可运行版本,并根据测试结果优化用户体验。 @@ -39,10 +39,11 @@ HyperSql 选择增量迭代模型,主要有以下原因: | V1.4 | 数据库文件管理增强 | 新建 SQLite 数据库文件、删除当前数据库文件 | 已完成 | | V1.5 | 表管理功能 | 通过简洁 UI 创建表;删除选中普通表;删除前进行确认 | 已完成 | | V1.6 | 交互与缺陷修复 | 修复表/视图选择时数据与结构不同步问题;刷新表列表时保留当前选中表 | 已完成 | -| V1.7 | 数据行增删改查 | 在图形界面中新增、修改、删除表数据 | 待实现 | -| V1.8 | 备份与恢复 | 数据库文件备份、从备份恢复数据库 | 待实现 | -| V1.9 | AI 辅助 SQL 生成 | 根据表结构和用户自然语言需求调用 AI API 生成 SQL | 待实现 | -| V2.0 | 测试与优化 | 完善异常处理、界面优化、系统测试和最终演示准备 | 持续进行 | +| V1.7 | 数据行增删改查 | 在表数据界面新增、修改、删除数据行;在可识别单表 SQL 结果中支持编辑 | 已完成 | +| V1.8 | 表结构安全编辑 | 支持 SQLite 原生安全的重命名表、新增字段、重命名字段 | 已完成 | +| V1.9 | 备份与恢复 | 数据库文件备份、从备份恢复数据库 | 待实现 | +| V2.0 | AI 辅助 SQL 生成 | 根据表结构和用户自然语言需求调用 AI API 生成 SQL | 待实现 | +| V2.1 | 测试与优化 | 完善异常处理、界面优化、系统测试和最终演示准备 | 持续进行 | ## 4. 单次迭代流程 @@ -101,13 +102,16 @@ HyperSql 选择增量迭代模型,主要有以下原因: - 因为用户没有现成数据库文件,项目增加了新建数据库和删除数据库功能,方便测试和演示。 4. **通过反馈修复问题** - - 在使用过程中发现表/视图选择和刷新逻辑存在体验问题后,及时进行了修复,保证表数据和表结构能够同步更新,并且刷新表列表时保留当前选择。 + - 在使用过程中发现表/视图选择、刷新逻辑和 SQL 结果初始状态存在体验问题后,及时进行了修复,保证表数据和表结构能够同步更新,刷新表列表时保留当前选择,SQL 结果区启动时保持空状态。 -5. **保留后续扩展空间** - - 数据行增删改查、数据库备份恢复和 AI SQL 生成尚未完成,后续可作为新的增量继续实现。 +5. **逐步扩展编辑能力** + - 在只读浏览功能完成后,继续实现了数据行新增、修改、删除,并基于 SQLite 原生 `ALTER TABLE` 能力实现了重命名表、新增字段、重命名字段。 + +6. **保留后续扩展空间** + - 数据库备份恢复和 AI SQL 生成尚未完成,后续可作为新的增量继续实现。 ## 6. 总结 -HyperSql 采用增量迭代模型进行开发是合适的。当前项目已经完成了 SQLite 数据库管理工具的核心可运行版本,包括数据库文件管理、表/视图浏览、表结构查看、表数据分页查看、SQL 执行分页显示、创建表和删除表等功能。 +HyperSql 采用增量迭代模型进行开发是合适的。当前项目已经完成了 SQLite 数据库管理工具的核心可运行版本,包括数据库文件管理、表/视图浏览、表结构查看、表数据分页查看、SQL 执行分页显示、创建表、删除表、数据行增删改查和 SQLite 支持范围内的表结构安全编辑等功能。 -后续开发应继续沿用增量迭代方式,优先完善数据行增删改查和备份恢复等实用功能,再根据课程进度实现 AI 辅助 SQL 生成等扩展功能。 \ No newline at end of file +后续开发应继续沿用增量迭代方式,优先完善数据库备份恢复等实用功能,再根据课程进度实现 AI 辅助 SQL 生成等扩展功能。 \ No newline at end of file diff --git a/src/main/java/com/hypersql/controller/MainController.java b/src/main/java/com/hypersql/controller/MainController.java index 7a4fa46..cd4e859 100644 --- a/src/main/java/com/hypersql/controller/MainController.java +++ b/src/main/java/com/hypersql/controller/MainController.java @@ -3,8 +3,12 @@ package com.hypersql.controller; import com.hypersql.db.DatabaseConnectionManager; import com.hypersql.db.DatabaseMetadataService; import com.hypersql.db.SqlExecutionService; +import com.hypersql.db.RowDataService; +import com.hypersql.db.SchemaEditService; import com.hypersql.model.ColumnInfo; +import com.hypersql.model.EditableQueryResult; import com.hypersql.model.QueryResult; +import com.hypersql.model.RowKey; import com.hypersql.model.TableInfo; import com.hypersql.HyperSqlApplication; import com.hypersql.util.DialogUtils; @@ -17,12 +21,19 @@ import javafx.fxml.FXMLLoader; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Dialog; import javafx.scene.control.Label; import javafx.scene.control.ListView; import javafx.scene.control.TabPane; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TextInputDialog; +import javafx.scene.control.cell.TextFieldTableCell; +import javafx.scene.layout.GridPane; import javafx.stage.FileChooser; import javafx.stage.Modality; import javafx.stage.Stage; @@ -34,14 +45,23 @@ import java.nio.file.Files; import java.nio.file.Path; import java.sql.Connection; import java.sql.SQLException; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.regex.Pattern; import java.util.stream.Collectors; public class MainController { private static final int TABLE_PAGE_SIZE = 100; private static final int SQL_PAGE_SIZE = 100; + private static final List ALLOWED_COLUMN_TYPES = List.of( + "INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATE", "DATETIME" + ); + private static final Pattern NUMBER_PATTERN = Pattern.compile("-?\\d+(\\.\\d+)?"); + private static final Pattern STRING_LITERAL_PATTERN = Pattern.compile("'([^']|'')*'"); @FXML private Label databasePathLabel; @@ -55,6 +75,12 @@ public class MainController { @FXML private TableView> tableDataView; + @FXML + private Button addTableRowButton; + + @FXML + private Button deleteTableRowButton; + @FXML private Button previousTablePageButton; @@ -67,6 +93,15 @@ public class MainController { @FXML private TableView tableStructureView; + @FXML + private Button renameTableButton; + + @FXML + private Button addColumnButton; + + @FXML + private Button renameColumnButton; + @FXML private TextArea sqlTextArea; @@ -76,6 +111,12 @@ public class MainController { @FXML private TableView> sqlResultView; + @FXML + private Button addSqlResultRowButton; + + @FXML + private Button deleteSqlResultRowButton; + @FXML private Button previousSqlPageButton; @@ -91,16 +132,23 @@ public class MainController { private final DatabaseConnectionManager connectionManager = new DatabaseConnectionManager(); private final DatabaseMetadataService metadataService = new DatabaseMetadataService(); private final SqlExecutionService sqlExecutionService = new SqlExecutionService(); + private final RowDataService rowDataService = new RowDataService(); + private final SchemaEditService schemaEditService = new SchemaEditService(); private TableInfo currentTable; private long currentTableTotalRows; private long currentTablePageIndex; + private EditableQueryResult currentTableResult; private QueryResult currentSqlResult; + private EditableQueryResult currentEditableSqlResult; + private String currentSqlText; private long currentSqlPageIndex; private boolean selectingTableProgrammatically; @FXML private void initialize() { - sqlTextArea.setText("SELECT * FROM sqlite_master;"); + sqlTextArea.clear(); + tableDataView.setEditable(true); + sqlResultView.setEditable(true); configureStructureTable(); tableListView.getSelectionModel().selectedItemProperty().addListener((_, _, selectedTable) -> { if (selectingTableProgrammatically) { @@ -303,6 +351,124 @@ public class MainController { } } + @FXML + private void handleRenameTable() { + if (!ensureEditableStructure()) { + return; + } + TextInputDialog dialog = new TextInputDialog(currentTable.name()); + dialog.setTitle("重命名表"); + dialog.setHeaderText(null); + dialog.setContentText("新表名:"); + dialog.initOwner(currentWindow()); + Optional result = dialog.showAndWait(); + if (result.isEmpty()) { + return; + } + + String newName = result.get().trim(); + if (newName.isEmpty()) { + DialogUtils.showError("重命名失败", "表名不能为空。"); + return; + } + if (!newName.equalsIgnoreCase(currentTable.name()) && existingObjectNames().contains(newName.toLowerCase(Locale.ROOT))) { + DialogUtils.showError("重命名失败", "已存在同名表或视图。"); + return; + } + if (!DialogUtils.confirm("确认重命名表", "将表 " + currentTable.name() + " 重命名为 " + newName + ",是否继续?")) { + return; + } + + try { + schemaEditService.renameTable(connection(), currentTable.name(), newName); + refreshTablesSelecting(newName); + status("已重命名表 " + newName); + } catch (SQLException | IllegalArgumentException e) { + DialogUtils.showError("重命名表失败", e.getMessage()); + status("重命名表失败"); + } + } + + @FXML + private void handleAddStructureColumn() { + if (!ensureEditableStructure()) { + return; + } + Optional input = showAddColumnDialog(); + if (input.isEmpty()) { + return; + } + AddColumnInput column = input.get(); + if (columnExists(column.name())) { + DialogUtils.showError("新增字段失败", "已存在同名字段。"); + return; + } + if (column.notNull() && column.defaultValue().isBlank()) { + DialogUtils.showError("新增字段失败", "新增非空字段必须填写默认值。"); + return; + } + if (!column.defaultValue().isBlank() && !isSafeDefaultValue(column.defaultValue())) { + DialogUtils.showError("新增字段失败", "默认值不合法:" + column.defaultValue()); + return; + } + if (!DialogUtils.confirm("确认新增字段", "将在表 " + currentTable.name() + " 中新增字段 " + column.name() + ",是否继续?")) { + return; + } + + try { + schemaEditService.addColumn(connection(), currentTable.name(), buildColumnDefinition(column)); + loadSelectedTable(currentTable); + status("已新增字段 " + column.name()); + } catch (SQLException | IllegalArgumentException e) { + DialogUtils.showError("新增字段失败", e.getMessage()); + status("新增字段失败"); + } + } + + @FXML + private void handleRenameStructureColumn() { + if (!ensureEditableStructure()) { + return; + } + ColumnInfo selectedColumn = tableStructureView.getSelectionModel().getSelectedItem(); + if (selectedColumn == null) { + DialogUtils.showError("未选择字段", "请先在表结构中选择要重命名的字段。"); + return; + } + + TextInputDialog dialog = new TextInputDialog(selectedColumn.name()); + dialog.setTitle("重命名字段"); + dialog.setHeaderText(null); + dialog.setContentText("新字段名:"); + dialog.initOwner(currentWindow()); + Optional result = dialog.showAndWait(); + if (result.isEmpty()) { + return; + } + + String newName = result.get().trim(); + if (newName.isEmpty()) { + DialogUtils.showError("重命名字段失败", "字段名不能为空。"); + return; + } + if (!newName.equalsIgnoreCase(selectedColumn.name()) && columnExists(newName)) { + DialogUtils.showError("重命名字段失败", "已存在同名字段。"); + return; + } + if (!DialogUtils.confirm("确认重命名字段", "将字段 " + selectedColumn.name() + " 重命名为 " + newName + ",是否继续?")) { + return; + } + + try { + schemaEditService.renameColumn(connection(), currentTable.name(), selectedColumn.name(), newName); + loadSelectedTable(currentTable); + status("已重命名字段 " + newName); + } catch (SQLException | IllegalArgumentException e) { + DialogUtils.showError("重命名字段失败", e.getMessage()); + status("重命名字段失败"); + } + } + @FXML private void handleExecuteSql() { if (!ensureConnected()) { @@ -318,14 +484,17 @@ public class MainController { try { QueryResult result = sqlExecutionService.execute(connection(), sql); if (result.resultSet()) { + currentSqlText = sql; currentSqlResult = result; currentSqlPageIndex = 0; + currentEditableSqlResult = rowDataService.buildEditableSqlResult(connection(), sql); renderCurrentSqlPage(); - sqlMessageLabel.setText(result.message()); + sqlMessageLabel.setText(result.message() + ";" + currentEditableSqlResult.message()); status("SQL 执行成功,返回 " + result.rows().size() + " 行"); } else { clearTable(sqlResultView); resetSqlPaginationUi(); + updateSqlRowButtons(false); sqlMessageLabel.setText(result.message()); status("SQL 执行成功," + result.message()); refreshTablesPreservingSelection(); @@ -338,6 +507,52 @@ public class MainController { } } + @FXML + private void handleAddTableRow() { + if (!ensureConnected() || currentTableResult == null || !currentTableResult.editable()) { + DialogUtils.showError("不能新增行", "当前表不可编辑。请选择普通表后再新增数据行。"); + return; + } + addRow(currentTableResult.tableName(), currentTableResult.columns(), () -> { + currentTableTotalRows = metadataService.countTableRows(connection(), currentTableResult.tableName()); + loadCurrentTablePage(); + }); + } + + @FXML + private void handleDeleteSelectedTableRow() { + if (!ensureConnected() || currentTableResult == null || !currentTableResult.editable()) { + DialogUtils.showError("不能删除行", "当前表不可编辑。请选择普通表后再删除数据行。"); + return; + } + deleteSelectedRow(tableDataView, currentTableResult, () -> { + currentTableTotalRows = metadataService.countTableRows(connection(), currentTableResult.tableName()); + long totalPages = totalTablePages(); + if (currentTablePageIndex >= totalPages) { + currentTablePageIndex = totalPages - 1; + } + loadCurrentTablePage(); + }); + } + + @FXML + private void handleAddSqlResultRow() { + if (!ensureConnected() || currentEditableSqlResult == null || !currentEditableSqlResult.editable()) { + DialogUtils.showError("不能新增行", "当前 SQL 结果不是可编辑的单表查询。"); + return; + } + addRow(currentEditableSqlResult.tableName(), currentEditableSqlResult.columns(), this::reloadEditableSqlResult); + } + + @FXML + private void handleDeleteSelectedSqlResultRow() { + if (!ensureConnected() || currentEditableSqlResult == null || !currentEditableSqlResult.editable()) { + DialogUtils.showError("不能删除行", "当前 SQL 结果不是可编辑的单表查询。"); + return; + } + deleteSelectedRow(sqlResultView, currentEditableSqlResult, this::reloadEditableSqlResult); + } + @FXML private void handlePreviousTablePage() { if (currentTable == null || currentTablePageIndex <= 0) { @@ -472,6 +687,7 @@ public class MainController { List columns = metadataService.listColumns(connection(), table.name()); tableStructureView.setItems(FXCollections.observableArrayList(columns)); + updateStructureEditButtons("table".equalsIgnoreCase(table.type())); currentTableTotalRows = metadataService.countTableRows(connection(), table.name()); loadCurrentTablePage(); @@ -490,16 +706,26 @@ public class MainController { } long offset = currentTablePageIndex * TABLE_PAGE_SIZE; - QueryResult preview = metadataService.previewTable(connection(), currentTable.name(), TABLE_PAGE_SIZE, offset); - renderQueryResult(tableDataView, preview); - updateTablePaginationUi(preview.rows().size()); + if ("table".equalsIgnoreCase(currentTable.type())) { + currentTableResult = rowDataService.loadTablePage(connection(), currentTable.name(), TABLE_PAGE_SIZE, offset); + renderEditableRows(tableDataView, currentTableResult, this::refreshCurrentTablePageAfterEdit); + updateTablePaginationUi(currentTableResult.rows().size()); + updateTableRowButtons(true); + } else { + QueryResult preview = metadataService.previewTable(connection(), currentTable.name(), TABLE_PAGE_SIZE, offset); + currentTableResult = EditableQueryResult.readOnly(currentTable.name(), preview.columnNames(), preview.rows(), "视图不可编辑"); + renderRows(tableDataView, preview.columnNames(), preview.rows(), false); + updateTablePaginationUi(preview.rows().size()); + updateTableRowButtons(false); + } } private void renderQueryResult(TableView> tableView, QueryResult result) { - renderRows(tableView, result.columnNames(), result.rows()); + renderRows(tableView, result.columnNames(), result.rows(), false); } - private void renderRows(TableView> tableView, List columnNames, List> resultRows) { + private void renderRows(TableView> tableView, List columnNames, List> resultRows, boolean editable) { + tableView.setEditable(editable); tableView.getColumns().clear(); tableView.getItems().clear(); @@ -522,6 +748,276 @@ public class MainController { tableView.setItems(rows); } + private void renderEditableRows(TableView> tableView, EditableQueryResult result, ThrowingRunnable reloadAction) { + tableView.setEditable(result.editable()); + tableView.getColumns().clear(); + tableView.getItems().clear(); + + for (int columnIndex = 0; columnIndex < result.columnNames().size(); columnIndex++) { + final int index = columnIndex; + String columnName = result.columnNames().get(columnIndex); + TableColumn, String> column = new TableColumn<>(columnName); + column.setCellValueFactory(data -> { + ObservableList row = data.getValue(); + Object value = index < row.size() ? row.get(index) : null; + return new SimpleStringProperty(displayValue(value)); + }); + column.setPrefWidth(140); + if (result.editable() && !columnName.equalsIgnoreCase("rowid")) { + column.setCellFactory(TextFieldTableCell.forTableColumn()); + column.setOnEditCommit(event -> handleCellEdit(result, event.getTablePosition().getRow(), index, event.getNewValue(), reloadAction)); + } + tableView.getColumns().add(column); + } + + ObservableList> rows = FXCollections.observableArrayList(); + for (List row : result.rows()) { + rows.add(FXCollections.observableArrayList(row)); + } + tableView.setItems(rows); + } + + private void handleCellEdit(EditableQueryResult result, int rowIndex, int columnIndex, String newValue, ThrowingRunnable reloadAction) { + if (rowIndex < 0 || rowIndex >= result.rowKeys().size()) { + return; + } + String columnName = result.columnNames().get(columnIndex); + Optional columnInfo = findColumn(result.columns(), columnName); + if (columnInfo.isEmpty()) { + DialogUtils.showError("修改失败", "无法识别字段:" + columnName); + runReload(reloadAction); + return; + } + + try { + Object convertedValue = rowDataService.convertValue(columnInfo.get(), newValue); + rowDataService.updateCell(connection(), result.tableName(), columnName, convertedValue, result.rowKeys().get(rowIndex)); + runReload(reloadAction); + status("已更新字段 " + columnName); + } catch (SQLException | IllegalArgumentException e) { + DialogUtils.showError("修改失败", e.getMessage()); + runReload(reloadAction); + status("修改数据失败"); + } + } + + private void addRow(String tableName, List columns, ThrowingRunnable reloadAction) { + Optional> values = showRowInputDialog(columns); + if (values.isEmpty()) { + return; + } + try { + rowDataService.insertRow(connection(), tableName, columns, values.get()); + runReload(reloadAction); + status("已新增数据行"); + } catch (SQLException | IllegalArgumentException e) { + DialogUtils.showError("新增行失败", e.getMessage()); + status("新增行失败"); + } + } + + private void deleteSelectedRow(TableView> tableView, EditableQueryResult result, ThrowingRunnable reloadAction) { + int rowIndex = tableView.getSelectionModel().getSelectedIndex(); + if (rowIndex < 0 || rowIndex >= result.rowKeys().size()) { + DialogUtils.showError("未选择数据行", "请先选择要删除的数据行。"); + return; + } + if (!DialogUtils.confirm("确认删除数据行", "将永久删除选中的数据行,此操作不可撤销。是否继续?")) { + status("已取消删除数据行"); + return; + } + try { + rowDataService.deleteRow(connection(), result.tableName(), result.rowKeys().get(rowIndex)); + runReload(reloadAction); + status("已删除数据行"); + } catch (SQLException | IllegalArgumentException e) { + DialogUtils.showError("删除行失败", e.getMessage()); + status("删除行失败"); + } + } + + private Optional> showRowInputDialog(List columns) { + Dialog> dialog = new Dialog<>(); + dialog.setTitle("新增数据行"); + dialog.initOwner(currentWindow()); + dialog.getDialogPane().getButtonTypes().addAll(javafx.scene.control.ButtonType.OK, javafx.scene.control.ButtonType.CANCEL); + + GridPane gridPane = new GridPane(); + gridPane.setHgap(8); + gridPane.setVgap(8); + Map fields = new HashMap<>(); + int row = 0; + for (ColumnInfo column : columns) { + Label label = new Label(column.name() + " (" + displayValue(column.type()) + ")"); + TextField field = new TextField(); + field.setPromptText(column.primaryKey() ? "主键可留空使用自动值" : "空白表示 NULL"); + fields.put(column, field); + gridPane.add(label, 0, row); + gridPane.add(field, 1, row); + row++; + } + dialog.getDialogPane().setContent(gridPane); + dialog.setResultConverter(buttonType -> { + if (!javafx.scene.control.ButtonType.OK.equals(buttonType)) { + return null; + } + Map values = new HashMap<>(); + for (Map.Entry entry : fields.entrySet()) { + String text = entry.getValue().getText(); + if (text != null && !text.isBlank()) { + values.put(entry.getKey().name(), rowDataService.convertValue(entry.getKey(), text)); + } + } + return values; + }); + return dialog.showAndWait(); + } + + private Optional findColumn(List columns, String columnName) { + return columns.stream() + .filter(column -> column.name().equalsIgnoreCase(columnName)) + .findFirst(); + } + + private void refreshCurrentTablePageAfterEdit() throws SQLException { + currentTableTotalRows = metadataService.countTableRows(connection(), currentTableResult.tableName()); + loadCurrentTablePage(); + } + + private void reloadEditableSqlResult() throws SQLException { + if (currentSqlText == null) { + return; + } + QueryResult result = sqlExecutionService.execute(connection(), currentSqlText); + currentSqlResult = result; + currentEditableSqlResult = rowDataService.buildEditableSqlResult(connection(), currentSqlText); + long totalPages = totalSqlPages(); + if (currentSqlPageIndex >= totalPages) { + currentSqlPageIndex = totalPages - 1; + } + renderCurrentSqlPage(); + sqlMessageLabel.setText(result.message() + ";" + currentEditableSqlResult.message()); + } + + private void runReload(ThrowingRunnable reloadAction) { + try { + reloadAction.run(); + } catch (SQLException e) { + DialogUtils.showError("刷新数据失败", e.getMessage()); + status("刷新数据失败"); + } + } + + private void updateTableRowButtons(boolean editable) { + addTableRowButton.setDisable(!editable); + deleteTableRowButton.setDisable(!editable); + } + + private void updateSqlRowButtons(boolean editable) { + addSqlResultRowButton.setDisable(!editable); + deleteSqlResultRowButton.setDisable(!editable); + } + + private Optional showAddColumnDialog() { + Dialog dialog = new Dialog<>(); + dialog.setTitle("新增字段"); + dialog.initOwner(currentWindow()); + dialog.getDialogPane().getButtonTypes().addAll(javafx.scene.control.ButtonType.OK, javafx.scene.control.ButtonType.CANCEL); + + TextField nameField = new TextField(); + nameField.setPromptText("字段名"); + ComboBox typeComboBox = new ComboBox<>(FXCollections.observableArrayList(ALLOWED_COLUMN_TYPES)); + typeComboBox.getSelectionModel().select("TEXT"); + CheckBox notNullCheckBox = new CheckBox("非空"); + TextField defaultValueField = new TextField(); + defaultValueField.setPromptText("例如 0、'unknown'、CURRENT_TIMESTAMP"); + + GridPane gridPane = new GridPane(); + gridPane.setHgap(8); + gridPane.setVgap(8); + gridPane.add(new Label("字段名"), 0, 0); + gridPane.add(nameField, 1, 0); + gridPane.add(new Label("类型"), 0, 1); + gridPane.add(typeComboBox, 1, 1); + gridPane.add(notNullCheckBox, 1, 2); + gridPane.add(new Label("默认值"), 0, 3); + gridPane.add(defaultValueField, 1, 3); + dialog.getDialogPane().setContent(gridPane); + + dialog.setResultConverter(buttonType -> { + if (!javafx.scene.control.ButtonType.OK.equals(buttonType)) { + return null; + } + return new AddColumnInput( + nameField.getText() == null ? "" : nameField.getText().trim(), + typeComboBox.getValue(), + notNullCheckBox.isSelected(), + defaultValueField.getText() == null ? "" : defaultValueField.getText().trim() + ); + }); + return dialog.showAndWait(); + } + + private String buildColumnDefinition(AddColumnInput input) { + StringBuilder definition = new StringBuilder(); + definition.append(SqlUtils.quoteIdentifier(input.name())) + .append(" ") + .append(input.type()); + if (input.notNull()) { + definition.append(" NOT NULL"); + } + if (!input.defaultValue().isBlank()) { + definition.append(" DEFAULT ").append(input.defaultValue()); + } + return definition.toString(); + } + + private boolean ensureEditableStructure() { + if (!ensureConnected()) { + return false; + } + if (currentTable == null) { + DialogUtils.showError("未选择表", "请先在左侧选择普通表。"); + return false; + } + if (!"table".equalsIgnoreCase(currentTable.type())) { + DialogUtils.showError("不能编辑视图结构", "当前选中的是视图,SQLite 不支持通过此功能编辑视图结构。"); + return false; + } + return true; + } + + private Set existingObjectNames() { + return tableListView.getItems().stream() + .map(table -> table.name().toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + } + + private boolean columnExists(String columnName) { + return tableStructureView.getItems().stream() + .anyMatch(column -> column.name().equalsIgnoreCase(columnName)); + } + + private boolean isSafeDefaultValue(String value) { + String trimmed = value.trim(); + String upper = trimmed.toUpperCase(Locale.ROOT); + if (trimmed.contains(";") || trimmed.contains("--") || trimmed.contains("/*") || trimmed.contains("*/")) { + return false; + } + return upper.equals("NULL") + || upper.equals("CURRENT_TIME") + || upper.equals("CURRENT_DATE") + || upper.equals("CURRENT_TIMESTAMP") + || NUMBER_PATTERN.matcher(trimmed).matches() + || STRING_LITERAL_PATTERN.matcher(trimmed).matches(); + } + + private void updateStructureEditButtons(boolean editable) { + renameTableButton.setDisable(!editable); + addColumnButton.setDisable(!editable); + renameColumnButton.setDisable(!editable); + } + private void clearTable(TableView> tableView) { tableView.getColumns().clear(); tableView.getItems().clear(); @@ -565,17 +1061,20 @@ public class MainController { private void resetTablePaginationUi() { currentTable = null; + currentTableResult = null; currentTableTotalRows = 0; currentTablePageIndex = 0; tablePageLabel.setText("未选择表"); previousTablePageButton.setDisable(true); nextTablePageButton.setDisable(true); + updateTableRowButtons(false); } private void clearSelectedTableUi() { clearTable(tableDataView); tableStructureView.getItems().clear(); resetTablePaginationUi(); + updateStructureEditButtons(false); } private void renderCurrentSqlPage() { @@ -591,7 +1090,22 @@ public class MainController { int toIndex = Math.min(fromIndex + SQL_PAGE_SIZE, totalRows); List> pageRows = allRows.subList(fromIndex, toIndex); - renderRows(sqlResultView, currentSqlResult.columnNames(), pageRows); + if (currentEditableSqlResult != null && currentEditableSqlResult.editable()) { + EditableQueryResult pageResult = new EditableQueryResult( + currentEditableSqlResult.tableName(), + currentEditableSqlResult.columnNames(), + currentEditableSqlResult.rows().subList(fromIndex, toIndex), + currentEditableSqlResult.rowKeys().subList(fromIndex, toIndex), + currentEditableSqlResult.columns(), + true, + currentEditableSqlResult.message() + ); + renderEditableRows(sqlResultView, pageResult, this::reloadEditableSqlResult); + updateSqlRowButtons(true); + } else { + renderRows(sqlResultView, currentSqlResult.columnNames(), pageRows, false); + updateSqlRowButtons(false); + } updateSqlPaginationUi(pageRows.size()); } @@ -613,10 +1127,13 @@ public class MainController { private void resetSqlPaginationUi() { currentSqlResult = null; + currentEditableSqlResult = null; + currentSqlText = null; currentSqlPageIndex = 0; sqlPageLabel.setText("未执行查询"); previousSqlPageButton.setDisable(true); nextSqlPageButton.setDisable(true); + updateSqlRowButtons(false); } private String displayValue(Object value) { @@ -656,4 +1173,12 @@ public class MainController { Node node = statusLabel; return node.getScene().getWindow(); } + + private record AddColumnInput(String name, String type, boolean notNull, String defaultValue) { + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws SQLException; + } } diff --git a/src/main/java/com/hypersql/db/RowDataService.java b/src/main/java/com/hypersql/db/RowDataService.java new file mode 100644 index 0000000..893f438 --- /dev/null +++ b/src/main/java/com/hypersql/db/RowDataService.java @@ -0,0 +1,272 @@ +package com.hypersql.db; + +import com.hypersql.model.ColumnInfo; +import com.hypersql.model.EditableQueryResult; +import com.hypersql.model.RowKey; +import com.hypersql.util.SqlUtils; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public final class RowDataService { + private static final Pattern SIMPLE_SELECT_PATTERN = Pattern.compile( + "(?is)^\\s*select\\s+(.+?)\\s+from\\s+([\\\"`\\[]?)([\\w]+)\\2(?:\\s+where\\s+.+?)?(?:\\s+order\\s+by\\s+.+?)?(?:\\s+limit\\s+\\d+(?:\\s+offset\\s+\\d+)?)?\\s*;?\\s*$" + ); + + private final DatabaseMetadataService metadataService = new DatabaseMetadataService(); + + public EditableQueryResult loadTablePage(Connection connection, String tableName, int limit, long offset) throws SQLException { + List columns = metadataService.listColumns(connection, tableName); + Optional primaryKey = singlePrimaryKey(columns); + boolean useRowid = primaryKey.isEmpty(); + List displayColumns = columns.stream().map(ColumnInfo::name).toList(); + String selectColumns = displayColumns.stream() + .map(SqlUtils::quoteIdentifier) + .collect(Collectors.joining(", ")); + String sql = "SELECT " + + (useRowid ? "rowid AS __hypersql_rowid, " : "") + + selectColumns + + " FROM " + SqlUtils.quoteIdentifier(tableName) + + " LIMIT ? OFFSET ?"; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, Math.max(limit, 1)); + statement.setLong(2, Math.max(offset, 0L)); + try (ResultSet resultSet = statement.executeQuery()) { + List> rows = new ArrayList<>(); + List rowKeys = new ArrayList<>(); + while (resultSet.next()) { + Object keyValue = useRowid + ? resultSet.getObject("__hypersql_rowid") + : resultSet.getObject(primaryKey.get().name()); + RowKey rowKey = useRowid + ? new RowKey("rowid", keyValue, true) + : new RowKey(primaryKey.get().name(), keyValue, false); + List row = new ArrayList<>(); + for (String columnName : displayColumns) { + row.add(resultSet.getObject(columnName)); + } + rows.add(row); + rowKeys.add(rowKey); + } + return EditableQueryResult.editable(tableName, displayColumns, rows, rowKeys, columns); + } + } + } + + public EditableQueryResult buildEditableSqlResult(Connection connection, String sql) throws SQLException { + Optional tableName = parseEditableTableName(sql); + QueryRows queryRows = query(connection, sql); + if (tableName.isEmpty()) { + return EditableQueryResult.readOnly(null, queryRows.columnNames(), queryRows.rows(), "当前 SQL 结果不是可编辑的单表查询"); + } + if (!isOrdinaryTable(connection, tableName.get())) { + return EditableQueryResult.readOnly(tableName.get(), queryRows.columnNames(), queryRows.rows(), "当前 SQL 结果来源不是普通表,不能编辑"); + } + + List columns = metadataService.listColumns(connection, tableName.get()); + Set realColumnNames = columns.stream() + .map(column -> column.name().toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + List resultColumnNames = queryRows.columnNames(); + for (String columnName : resultColumnNames) { + String normalized = columnName.toLowerCase(Locale.ROOT); + if (!normalized.equals("rowid") && !realColumnNames.contains(normalized)) { + return EditableQueryResult.readOnly(tableName.get(), queryRows.columnNames(), queryRows.rows(), "结果包含非表字段,不能编辑"); + } + } + + Optional primaryKey = singlePrimaryKey(columns); + int keyIndex = -1; + RowKeyFactory keyFactory; + if (primaryKey.isPresent()) { + keyIndex = indexOfColumn(resultColumnNames, primaryKey.get().name()); + if (keyIndex < 0) { + return EditableQueryResult.readOnly(tableName.get(), queryRows.columnNames(), queryRows.rows(), "结果缺少主键字段,不能编辑"); + } + String keyColumn = primaryKey.get().name(); + keyFactory = value -> new RowKey(keyColumn, value, false); + } else { + keyIndex = indexOfColumn(resultColumnNames, "rowid"); + if (keyIndex < 0) { + return EditableQueryResult.readOnly(tableName.get(), queryRows.columnNames(), queryRows.rows(), "无主键表需要查询 rowid 才能编辑"); + } + keyFactory = value -> new RowKey("rowid", value, true); + } + + List rowKeys = new ArrayList<>(); + for (List row : queryRows.rows()) { + rowKeys.add(keyFactory.create(row.get(keyIndex))); + } + return EditableQueryResult.editable(tableName.get(), queryRows.columnNames(), queryRows.rows(), rowKeys, columns); + } + + public int insertRow(Connection connection, String tableName, List columns, Map values) throws SQLException { + Map insertValues = new LinkedHashMap<>(); + for (ColumnInfo column : columns) { + if (values.containsKey(column.name())) { + insertValues.put(column.name(), values.get(column.name())); + } + } + if (insertValues.isEmpty()) { + String sql = "INSERT INTO " + SqlUtils.quoteIdentifier(tableName) + " DEFAULT VALUES"; + try (Statement statement = connection.createStatement()) { + return statement.executeUpdate(sql); + } + } + + String columnSql = insertValues.keySet().stream() + .map(SqlUtils::quoteIdentifier) + .collect(Collectors.joining(", ")); + String placeholders = insertValues.keySet().stream() + .map(_ -> "?") + .collect(Collectors.joining(", ")); + String sql = "INSERT INTO " + SqlUtils.quoteIdentifier(tableName) + + " (" + columnSql + ") VALUES (" + placeholders + ")"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + int index = 1; + for (Object value : insertValues.values()) { + statement.setObject(index++, value); + } + return statement.executeUpdate(); + } + } + + public int updateCell(Connection connection, String tableName, String columnName, Object newValue, RowKey rowKey) throws SQLException { + String keySql = rowKey.rowidKey() ? "rowid" : SqlUtils.quoteIdentifier(rowKey.columnName()); + String sql = "UPDATE " + SqlUtils.quoteIdentifier(tableName) + + " SET " + SqlUtils.quoteIdentifier(columnName) + " = ?" + + " WHERE " + keySql + " = ?"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setObject(1, newValue); + statement.setObject(2, rowKey.value()); + return statement.executeUpdate(); + } + } + + public int deleteRow(Connection connection, String tableName, RowKey rowKey) throws SQLException { + String keySql = rowKey.rowidKey() ? "rowid" : SqlUtils.quoteIdentifier(rowKey.columnName()); + String sql = "DELETE FROM " + SqlUtils.quoteIdentifier(tableName) + + " WHERE " + keySql + " = ?"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setObject(1, rowKey.value()); + return statement.executeUpdate(); + } + } + + public Object convertValue(ColumnInfo column, String value) { + if (value == null || value.isBlank()) { + return null; + } + String trimmed = value.trim(); + String type = column.type() == null ? "" : column.type().toUpperCase(Locale.ROOT); + if (type.contains("INT")) { + return Long.parseLong(trimmed); + } + if (type.contains("REAL") || type.contains("FLOA") || type.contains("DOUB") || type.contains("NUM")) { + return Double.parseDouble(trimmed); + } + if (type.contains("BOOL")) { + if (trimmed.equalsIgnoreCase("true")) { + return 1; + } + if (trimmed.equalsIgnoreCase("false")) { + return 0; + } + if (trimmed.equals("1") || trimmed.equals("0")) { + return Integer.parseInt(trimmed); + } + throw new IllegalArgumentException("布尔值只能填写 true、false、1 或 0"); + } + return value; + } + + private QueryRows query(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql.trim())) { + ResultSetMetaData metaData = resultSet.getMetaData(); + int columnCount = metaData.getColumnCount(); + List columnNames = new ArrayList<>(); + for (int i = 1; i <= columnCount; i++) { + columnNames.add(metaData.getColumnName(i)); + } + + List> rows = new ArrayList<>(); + while (resultSet.next()) { + List row = new ArrayList<>(); + for (int i = 1; i <= columnCount; i++) { + row.add(resultSet.getObject(i)); + } + rows.add(row); + } + return new QueryRows(columnNames, rows); + } + } + + private Optional parseEditableTableName(String sql) { + String withoutTrailingSemicolon = sql == null ? "" : sql.trim(); + if (withoutTrailingSemicolon.contains(";")) { + withoutTrailingSemicolon = withoutTrailingSemicolon.replaceFirst(";\\s*$", ""); + if (withoutTrailingSemicolon.contains(";")) { + return Optional.empty(); + } + } + Matcher matcher = SIMPLE_SELECT_PATTERN.matcher(withoutTrailingSemicolon); + if (!matcher.matches()) { + return Optional.empty(); + } + String selectedColumns = matcher.group(1).toLowerCase(Locale.ROOT); + if (selectedColumns.contains(" join ") || selectedColumns.contains(" count(") || selectedColumns.contains(" sum(") + || selectedColumns.contains(" avg(") || selectedColumns.contains(" min(") || selectedColumns.contains(" max(")) { + return Optional.empty(); + } + return Optional.of(matcher.group(3)); + } + + private boolean isOrdinaryTable(Connection connection, String tableName) throws SQLException { + String sql = "SELECT type FROM sqlite_master WHERE name = ?"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, tableName); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() && "table".equalsIgnoreCase(resultSet.getString("type")); + } + } + } + + private Optional singlePrimaryKey(List columns) { + List primaryKeys = columns.stream() + .filter(ColumnInfo::primaryKey) + .toList(); + return primaryKeys.size() == 1 ? Optional.of(primaryKeys.getFirst()) : Optional.empty(); + } + + private int indexOfColumn(List columnNames, String target) { + for (int i = 0; i < columnNames.size(); i++) { + if (columnNames.get(i).equalsIgnoreCase(target)) { + return i; + } + } + return -1; + } + + private record QueryRows(List columnNames, List> rows) { + } + + private interface RowKeyFactory { + RowKey create(Object value); + } +} diff --git a/src/main/java/com/hypersql/db/SchemaEditService.java b/src/main/java/com/hypersql/db/SchemaEditService.java new file mode 100644 index 0000000..fefca38 --- /dev/null +++ b/src/main/java/com/hypersql/db/SchemaEditService.java @@ -0,0 +1,34 @@ +package com.hypersql.db; + +import com.hypersql.util.SqlUtils; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; + +public final class SchemaEditService { + public void renameTable(Connection connection, String oldName, String newName) throws SQLException { + String sql = "ALTER TABLE " + SqlUtils.quoteIdentifier(oldName) + + " RENAME TO " + SqlUtils.quoteIdentifier(newName); + execute(connection, sql); + } + + public void addColumn(Connection connection, String tableName, String columnDefinition) throws SQLException { + String sql = "ALTER TABLE " + SqlUtils.quoteIdentifier(tableName) + + " ADD COLUMN " + columnDefinition; + execute(connection, sql); + } + + public void renameColumn(Connection connection, String tableName, String oldName, String newName) throws SQLException { + String sql = "ALTER TABLE " + SqlUtils.quoteIdentifier(tableName) + + " RENAME COLUMN " + SqlUtils.quoteIdentifier(oldName) + + " TO " + SqlUtils.quoteIdentifier(newName); + execute(connection, sql); + } + + private void execute(Connection connection, String sql) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute(sql); + } + } +} diff --git a/src/main/java/com/hypersql/model/EditableQueryResult.java b/src/main/java/com/hypersql/model/EditableQueryResult.java new file mode 100644 index 0000000..128f11a --- /dev/null +++ b/src/main/java/com/hypersql/model/EditableQueryResult.java @@ -0,0 +1,36 @@ +package com.hypersql.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public record EditableQueryResult( + String tableName, + List columnNames, + List> rows, + List rowKeys, + List columns, + boolean editable, + String message +) { + public EditableQueryResult { + columnNames = List.copyOf(columnNames); + rows = copyRows(rows); + rowKeys = List.copyOf(rowKeys); + columns = List.copyOf(columns); + } + + public static EditableQueryResult editable(String tableName, List columnNames, List> rows, List rowKeys, List columns) { + return new EditableQueryResult(tableName, columnNames, rows, rowKeys, columns, true, "可编辑"); + } + + public static EditableQueryResult readOnly(String tableName, List columnNames, List> rows, String message) { + return new EditableQueryResult(tableName, columnNames, rows, List.of(), List.of(), false, message); + } + + private static List> copyRows(List> rows) { + return rows.stream() + .map(row -> Collections.unmodifiableList(new ArrayList<>(row))) + .toList(); + } +} diff --git a/src/main/java/com/hypersql/model/RowKey.java b/src/main/java/com/hypersql/model/RowKey.java new file mode 100644 index 0000000..373927d --- /dev/null +++ b/src/main/java/com/hypersql/model/RowKey.java @@ -0,0 +1,4 @@ +package com.hypersql.model; + +public record RowKey(String columnName, Object value, boolean rowidKey) { +} diff --git a/src/main/resources/com/hypersql/main-view.fxml b/src/main/resources/com/hypersql/main-view.fxml index cc1161d..67ce22f 100644 --- a/src/main/resources/com/hypersql/main-view.fxml +++ b/src/main/resources/com/hypersql/main-view.fxml @@ -78,6 +78,9 @@ +