Add row and schema editing features

Implement safe SQLite row editing and supported schema edits, and update the project analysis documents to match the current feature set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 13:45:47 +08:00
parent 03a347ea31
commit c07f843587
9 changed files with 1000 additions and 73 deletions
+32 -23
View File
@@ -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 生成。
HyperSql 的技术方案成熟,开发成本低,核心功能和主要编辑功能已经形成可运行版本,适合作为软件工程课程大作业项目继续完善。后续应优先补充数据备份恢复功能,再根据时间实现 AI 辅助 SQL 生成。
+52 -30
View File
@@ -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 生成作为项目亮点。
后续开发应优先完成数据库备份恢复,使系统从“可浏览、可执行 SQL、可编辑数据、可管理表结构”进一步发展为“可完整管理 SQLite 数据库”的工具。在时间允许的情况下,再实现 AI 辅助 SQL 生成作为项目亮点。
+15 -11
View File
@@ -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 生成等扩展功能。
后续开发应继续沿用增量迭代方式,优先完善数据备份恢复等实用功能,再根据课程进度实现 AI 辅助 SQL 生成等扩展功能。
@@ -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<String> 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<ObservableList<Object>> tableDataView;
@FXML
private Button addTableRowButton;
@FXML
private Button deleteTableRowButton;
@FXML
private Button previousTablePageButton;
@@ -67,6 +93,15 @@ public class MainController {
@FXML
private TableView<ColumnInfo> 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<ObservableList<Object>> 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<String> 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<AddColumnInput> 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<String> 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<ColumnInfo> 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<ObservableList<Object>> tableView, QueryResult result) {
renderRows(tableView, result.columnNames(), result.rows());
renderRows(tableView, result.columnNames(), result.rows(), false);
}
private void renderRows(TableView<ObservableList<Object>> tableView, List<String> columnNames, List<List<Object>> resultRows) {
private void renderRows(TableView<ObservableList<Object>> tableView, List<String> columnNames, List<List<Object>> 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<ObservableList<Object>> 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<ObservableList<Object>, String> column = new TableColumn<>(columnName);
column.setCellValueFactory(data -> {
ObservableList<Object> 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<ObservableList<Object>> rows = FXCollections.observableArrayList();
for (List<Object> 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> 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<ColumnInfo> columns, ThrowingRunnable reloadAction) {
Optional<Map<String, Object>> 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<ObservableList<Object>> 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<Map<String, Object>> showRowInputDialog(List<ColumnInfo> columns) {
Dialog<Map<String, Object>> 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<ColumnInfo, TextField> 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<String, Object> values = new HashMap<>();
for (Map.Entry<ColumnInfo, TextField> 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<ColumnInfo> findColumn(List<ColumnInfo> 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<AddColumnInput> showAddColumnDialog() {
Dialog<AddColumnInput> 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<String> 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<String> 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<ObservableList<Object>> 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<List<Object>> 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;
}
}
@@ -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<ColumnInfo> columns = metadataService.listColumns(connection, tableName);
Optional<ColumnInfo> primaryKey = singlePrimaryKey(columns);
boolean useRowid = primaryKey.isEmpty();
List<String> 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<List<Object>> rows = new ArrayList<>();
List<RowKey> 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<Object> 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<String> 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<ColumnInfo> columns = metadataService.listColumns(connection, tableName.get());
Set<String> realColumnNames = columns.stream()
.map(column -> column.name().toLowerCase(Locale.ROOT))
.collect(Collectors.toSet());
List<String> 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<ColumnInfo> 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<RowKey> rowKeys = new ArrayList<>();
for (List<Object> 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<ColumnInfo> columns, Map<String, Object> values) throws SQLException {
Map<String, Object> 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<String> columnNames = new ArrayList<>();
for (int i = 1; i <= columnCount; i++) {
columnNames.add(metaData.getColumnName(i));
}
List<List<Object>> rows = new ArrayList<>();
while (resultSet.next()) {
List<Object> 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<String> 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<ColumnInfo> singlePrimaryKey(List<ColumnInfo> columns) {
List<ColumnInfo> primaryKeys = columns.stream()
.filter(ColumnInfo::primaryKey)
.toList();
return primaryKeys.size() == 1 ? Optional.of(primaryKeys.getFirst()) : Optional.empty();
}
private int indexOfColumn(List<String> 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<String> columnNames, List<List<Object>> rows) {
}
private interface RowKeyFactory {
RowKey create(Object value);
}
}
@@ -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);
}
}
}
@@ -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<String> columnNames,
List<List<Object>> rows,
List<RowKey> rowKeys,
List<ColumnInfo> 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<String> columnNames, List<List<Object>> rows, List<RowKey> rowKeys, List<ColumnInfo> columns) {
return new EditableQueryResult(tableName, columnNames, rows, rowKeys, columns, true, "可编辑");
}
public static EditableQueryResult readOnly(String tableName, List<String> columnNames, List<List<Object>> rows, String message) {
return new EditableQueryResult(tableName, columnNames, rows, List.of(), List.of(), false, message);
}
private static List<List<Object>> copyRows(List<List<Object>> rows) {
return rows.stream()
.map(row -> Collections.unmodifiableList(new ArrayList<>(row)))
.toList();
}
}
@@ -0,0 +1,4 @@
package com.hypersql.model;
public record RowKey(String columnName, Object value, boolean rowidKey) {
}
+22 -1
View File
@@ -78,6 +78,9 @@
<padding>
<Insets bottom="8" left="8" right="8" top="8" />
</padding>
<Button fx:id="addTableRowButton" onAction="#handleAddTableRow" text="新增行" />
<Button fx:id="deleteTableRowButton" onAction="#handleDeleteSelectedTableRow" text="删除选中行" />
<Separator />
<Button fx:id="previousTablePageButton" onAction="#handlePreviousTablePage" text="上一页" />
<Button fx:id="nextTablePageButton" onAction="#handleNextTablePage" text="下一页" />
<Label fx:id="tablePageLabel" text="未选择表" />
@@ -88,7 +91,22 @@
</Tab>
<Tab closable="false" text="表结构">
<content>
<TableView fx:id="tableStructureView" />
<BorderPane>
<center>
<TableView fx:id="tableStructureView" />
</center>
<bottom>
<HBox alignment="CENTER_LEFT" spacing="8" styleClass="pagination-bar">
<padding>
<Insets bottom="8" left="8" right="8" top="8" />
</padding>
<Button fx:id="renameTableButton" onAction="#handleRenameTable" text="重命名表" />
<Button fx:id="addColumnButton" onAction="#handleAddStructureColumn" text="新增字段" />
<Button fx:id="renameColumnButton" onAction="#handleRenameStructureColumn" text="重命名字段" />
<Label text="SQLite 当前仅支持这些安全结构变更" />
</HBox>
</bottom>
</BorderPane>
</content>
</Tab>
<Tab closable="false" text="SQL 执行">
@@ -113,6 +131,9 @@
<padding>
<Insets bottom="8" left="8" right="8" top="8" />
</padding>
<Button fx:id="addSqlResultRowButton" onAction="#handleAddSqlResultRow" text="新增行" />
<Button fx:id="deleteSqlResultRowButton" onAction="#handleDeleteSelectedSqlResultRow" text="删除选中行" />
<Separator />
<Button fx:id="previousSqlPageButton" onAction="#handlePreviousSqlPage" text="上一页" />
<Button fx:id="nextSqlPageButton" onAction="#handleNextSqlPage" text="下一页" />
<Label fx:id="sqlPageLabel" text="未执行查询" />