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:
+32
-23
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
可行性分析用于判断 HyperSql 在当前技术条件、开发时间、经济成本和实际使用场景下是否能够顺利完成。
|
可行性分析用于判断 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 | 主要开发语言 | 已用于项目主要代码开发 |
|
| Java 24 | 主要开发语言 | 已用于项目主要代码开发 |
|
||||||
| JavaFX | 构建图形化用户界面 | 已实现主界面、表格、菜单、工具栏和创建表对话框 |
|
| JavaFX | 构建图形化用户界面 | 已实现主界面、表格、菜单、工具栏、创建表对话框、数据行编辑和结构编辑操作入口 |
|
||||||
| Maven | 项目构建与依赖管理 | 已完成项目构建配置 |
|
| Maven | 项目构建与依赖管理 | 已完成项目构建配置 |
|
||||||
| SQLite | 本地轻量级数据库 | 作为系统管理对象 |
|
| SQLite | 本地轻量级数据库 | 作为系统管理对象,并通过原生 ALTER TABLE 支持部分表结构编辑 |
|
||||||
| SQLite JDBC | Java 程序访问 SQLite 数据库 | 已实现数据库连接、元数据读取和 SQL 执行 |
|
| SQLite JDBC | Java 程序访问 SQLite 数据库 | 已实现数据库连接、元数据读取、SQL 执行、数据行写入和表结构变更 |
|
||||||
| AI API | 根据表结构和用户需求生成 SQL | 作为后续扩展功能,当前尚未实现 |
|
| AI API | 根据表结构和用户需求生成 SQL | 作为后续扩展功能,当前尚未实现 |
|
||||||
|
|
||||||
### 技术可行性说明
|
### 技术可行性说明
|
||||||
@@ -34,7 +34,7 @@ HyperSql 采用以下技术栈:
|
|||||||
|
|
||||||
2. **JavaFX 能满足 GUI 需求**
|
2. **JavaFX 能满足 GUI 需求**
|
||||||
- JavaFX 提供菜单栏、按钮、标签、文本框、表格、分页按钮、弹窗和 FXML 等组件。
|
- JavaFX 提供菜单栏、按钮、标签、文本框、表格、分页按钮、弹窗和 FXML 等组件。
|
||||||
- 当前项目已经实现左侧表/视图列表,右侧表数据、表结构和 SQL 执行区域,能够满足数据库管理工具的基本界面需求。
|
- 当前项目已经实现左侧表/视图列表,右侧表数据、表结构和 SQL 执行区域,并提供数据行新增/删除按钮、可编辑单元格和表结构编辑按钮,能够满足数据库管理工具的基本界面需求。
|
||||||
|
|
||||||
3. **SQLite 集成难度低**
|
3. **SQLite 集成难度低**
|
||||||
- SQLite 是文件型数据库,不需要单独部署数据库服务器,适合课程项目和本地数据库管理。
|
- SQLite 是文件型数据库,不需要单独部署数据库服务器,适合课程项目和本地数据库管理。
|
||||||
@@ -42,19 +42,24 @@ HyperSql 采用以下技术栈:
|
|||||||
|
|
||||||
4. **SQLite JDBC 访问方式成熟**
|
4. **SQLite JDBC 访问方式成熟**
|
||||||
- 通过 JDBC 可以完成数据库连接、查询、执行 SQL、读取表结构等操作。
|
- 通过 JDBC 可以完成数据库连接、查询、执行 SQL、读取表结构等操作。
|
||||||
- 当前项目已经实现表/视图列表读取、字段信息读取、表数据分页查询和 SQL 执行。
|
- 当前项目已经实现表/视图列表读取、字段信息读取、表数据分页查询、SQL 执行、数据行插入/更新/删除和部分表结构变更。
|
||||||
|
|
||||||
5. **表管理功能可以通过 SQL 和 JavaFX UI 实现**
|
5. **表管理和结构编辑功能可以通过 SQL 和 JavaFX UI 实现**
|
||||||
- 当前项目已经通过独立创建表对话框实现了表名输入、字段定义、字段类型选择、主键/非空设置、默认值填写和 SQL 预览。
|
- 当前项目已经通过独立创建表对话框实现了表名输入、字段定义、字段类型选择、主键/非空设置、默认值填写和 SQL 预览。
|
||||||
- 删除表功能已经加入确认流程,降低误操作风险。
|
- 删除表功能已经加入确认流程,降低误操作风险。
|
||||||
|
- 表结构编辑只实现 SQLite 原生支持且相对安全的重命名表、新增字段和重命名字段,不实现需要重建表的复杂结构修改。
|
||||||
|
|
||||||
6. **AI SQL 生成功能技术上可行,但需要控制范围**
|
6. **数据行编辑功能技术上可行**
|
||||||
|
- 当前项目已经通过主键或 SQLite `rowid` 定位数据行,使用参数化 SQL 完成新增、修改和删除。
|
||||||
|
- SQL 返回结果只对可识别的单表查询启用编辑,复杂查询保持只读,降低误操作风险。
|
||||||
|
|
||||||
|
7. **AI SQL 生成功能技术上可行,但需要控制范围**
|
||||||
- 后续可以读取当前数据库表结构,结合用户自然语言描述构造提示词,然后调用 AI API 生成 SQL。
|
- 后续可以读取当前数据库表结构,结合用户自然语言描述构造提示词,然后调用 AI API 生成 SQL。
|
||||||
- 该功能涉及 API Key 管理、网络请求、调用成本和生成结果校验,因此适合作为扩展功能。
|
- 该功能涉及 API Key 管理、网络请求、调用成本和生成结果校验,因此适合作为扩展功能。
|
||||||
|
|
||||||
### 技术可行性结论
|
### 技术可行性结论
|
||||||
|
|
||||||
HyperSql 所采用的核心技术已经在当前项目中得到验证。数据库连接、表结构读取、表数据分页显示、SQL 执行、数据库文件管理和表管理功能均已实现,因此项目核心功能在技术上可行。AI SQL 生成属于后续扩展,技术上可实现,但需要在安全性和调用成本方面进行控制。
|
HyperSql 所采用的核心技术已经在当前项目中得到验证。数据库连接、表结构读取、表数据分页显示、SQL 执行、数据库文件管理、表管理、数据行编辑和 SQLite 安全结构编辑功能均已实现,因此项目核心功能在技术上可行。AI SQL 生成属于后续扩展,技术上可实现,但需要在安全性和调用成本方面进行控制。
|
||||||
|
|
||||||
## 3. 经济可行性
|
## 3. 经济可行性
|
||||||
|
|
||||||
@@ -96,8 +101,9 @@ HyperSql 采用增量迭代模型,可以按照功能优先级逐步完成,
|
|||||||
| SQL 执行 | 执行 SQL 并显示结果,SQL 查询结果分页 | 已完成 |
|
| SQL 执行 | 执行 SQL 并显示结果,SQL 查询结果分页 | 已完成 |
|
||||||
| 数据库文件管理 | 删除当前数据库文件 | 已完成 |
|
| 数据库文件管理 | 删除当前数据库文件 | 已完成 |
|
||||||
| 表管理 | 创建表 UI、删除表确认流程 | 已完成 |
|
| 表管理 | 创建表 UI、删除表确认流程 | 已完成 |
|
||||||
| 交互修复 | 修复选择表不同步、刷新表列表保留当前表 | 已完成 |
|
| 交互修复 | 修复选择表不同步、刷新表列表保留当前表、SQL 结果区启动空状态 | 已完成 |
|
||||||
| 数据行编辑 | 图形界面新增、修改、删除数据行 | 待实现 |
|
| 数据行编辑 | 表数据界面新增、修改、删除数据行;可编辑单表 SQL 结果 | 已完成 |
|
||||||
|
| 表结构安全编辑 | 重命名表、新增字段、重命名字段 | 已完成 |
|
||||||
| 备份恢复 | 数据库备份和恢复 | 待实现 |
|
| 备份恢复 | 数据库备份和恢复 | 待实现 |
|
||||||
| AI SQL 生成 | 根据表结构和自然语言生成 SQL | 待实现 |
|
| AI SQL 生成 | 根据表结构和自然语言生成 SQL | 待实现 |
|
||||||
| 测试优化 | 功能测试、界面优化、最终演示准备 | 持续进行 |
|
| 测试优化 | 功能测试、界面优化、最终演示准备 | 持续进行 |
|
||||||
@@ -105,13 +111,14 @@ HyperSql 采用增量迭代模型,可以按照功能优先级逐步完成,
|
|||||||
### 时间可行性说明
|
### 时间可行性说明
|
||||||
|
|
||||||
1. 当前项目已经完成数据库管理工具的核心可运行版本,可以满足基本演示要求。
|
1. 当前项目已经完成数据库管理工具的核心可运行版本,可以满足基本演示要求。
|
||||||
2. 后续开发可以优先完成数据行编辑和备份恢复,因为它们与数据库管理工具的实用性直接相关。
|
2. 数据行编辑和 SQLite 支持范围内的表结构编辑已经完成,软件实用性进一步提高。
|
||||||
3. AI SQL 生成可以作为亮点功能,根据剩余时间决定实现深度。
|
3. 后续开发可以优先完成备份恢复,因为它与数据库管理工具的数据安全性直接相关。
|
||||||
4. 采用增量迭代方式后,即使扩展功能未全部完成,系统仍然具备可运行、可演示的核心功能。
|
4. AI SQL 生成可以作为亮点功能,根据剩余时间决定实现深度。
|
||||||
|
5. 采用增量迭代方式后,即使扩展功能未全部完成,系统仍然具备可运行、可演示的核心功能。
|
||||||
|
|
||||||
### 时间可行性结论
|
### 时间可行性结论
|
||||||
|
|
||||||
从当前进展看,HyperSql 的核心功能已经完成,课程周期内完成一个可演示版本具有较高可行性。后续需要合理控制扩展功能范围,避免 AI 调用和复杂数据编辑占用过多开发时间。
|
从当前进展看,HyperSql 的核心功能和主要编辑功能已经完成,课程周期内完成一个可演示版本具有较高可行性。后续需要合理控制扩展功能范围,避免 AI 调用和复杂数据库结构重建功能占用过多开发时间。
|
||||||
|
|
||||||
## 5. 操作可行性
|
## 5. 操作可行性
|
||||||
|
|
||||||
@@ -128,7 +135,9 @@ HyperSql 面向学生和 SQLite 初学者,软件操作流程较为简单。
|
|||||||
↓
|
↓
|
||||||
查看表结构和表数据
|
查看表结构和表数据
|
||||||
↓
|
↓
|
||||||
使用分页按钮浏览更多数据
|
新增、修改或删除表数据行
|
||||||
|
↓
|
||||||
|
在 SQLite 支持范围内编辑表结构
|
||||||
↓
|
↓
|
||||||
在 SQL 执行区输入并执行 SQL
|
在 SQL 执行区输入并执行 SQL
|
||||||
↓
|
↓
|
||||||
@@ -141,7 +150,7 @@ HyperSql 面向学生和 SQLite 初学者,软件操作流程较为简单。
|
|||||||
- 采用类似数据库管理工具的布局:左侧显示表/视图列表,右侧显示表数据、表结构和 SQL 执行区域。
|
- 采用类似数据库管理工具的布局:左侧显示表/视图列表,右侧显示表数据、表结构和 SQL 执行区域。
|
||||||
|
|
||||||
2. **降低数据库操作门槛**
|
2. **降低数据库操作门槛**
|
||||||
- 用户可以通过图形界面打开数据库、浏览表结构、查看表数据、创建表和删除表,不必完全依赖命令行工具。
|
- 用户可以通过图形界面打开数据库、浏览表结构、查看表数据、新增修改删除数据行、创建表、删除表和进行部分表结构编辑,不必完全依赖命令行工具。
|
||||||
|
|
||||||
3. **分页降低操作压力**
|
3. **分页降低操作压力**
|
||||||
- 表数据和 SQL 查询结果都使用分页显示,每页 100 行,避免一次显示大量数据导致界面卡顿或不便查看。
|
- 表数据和 SQL 查询结果都使用分页显示,每页 100 行,避免一次显示大量数据导致界面卡顿或不便查看。
|
||||||
@@ -150,14 +159,14 @@ HyperSql 面向学生和 SQLite 初学者,软件操作流程较为简单。
|
|||||||
- 当未连接数据库、数据库连接失败、SQL 执行错误、创建表失败或删除表失败时,系统会给出提示信息。
|
- 当未连接数据库、数据库连接失败、SQL 执行错误、创建表失败或删除表失败时,系统会给出提示信息。
|
||||||
|
|
||||||
5. **危险操作有确认流程**
|
5. **危险操作有确认流程**
|
||||||
- 删除数据库文件和删除表都属于破坏性操作,系统在执行前会弹出确认提示。
|
- 删除数据库文件、删除表和删除数据行都属于破坏性操作,系统在执行前会弹出确认提示。
|
||||||
|
|
||||||
6. **当前限制清晰**
|
6. **当前限制清晰**
|
||||||
- 当前版本已经支持创建表和删除普通表,但尚未实现图形界面数据行编辑、数据库备份恢复和 AI SQL 生成。
|
- 当前版本已经支持创建表、删除普通表、数据行编辑和 SQLite 原生支持的部分结构编辑,但尚未实现数据库备份恢复和 AI SQL 生成。
|
||||||
|
|
||||||
### 操作可行性结论
|
### 操作可行性结论
|
||||||
|
|
||||||
HyperSql 当前功能界面直观,基本操作流程清晰,适合学生和 SQLite 初学者使用。随着后续数据编辑和备份恢复功能完善,软件的实用性会进一步提高。
|
HyperSql 当前功能界面直观,基本操作流程清晰,已经支持常见数据浏览、数据编辑和部分结构编辑操作,适合学生和 SQLite 初学者使用。随着后续备份恢复功能完善,软件的数据安全性会进一步提高。
|
||||||
|
|
||||||
## 6. 可行性分析总结
|
## 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
@@ -11,9 +11,10 @@ HyperSql 是一个面向学生和 SQLite 初学者的轻量级数据库图形化
|
|||||||
3. 数据库表结构和表数据需要以更直观的方式展示。
|
3. 数据库表结构和表数据需要以更直观的方式展示。
|
||||||
4. 大表或较多查询结果需要分页显示,避免界面卡顿和阅读困难。
|
4. 大表或较多查询结果需要分页显示,避免界面卡顿和阅读困难。
|
||||||
5. 创建数据库、创建表、删除表等常见操作需要简洁清晰的图形界面。
|
5. 创建数据库、创建表、删除表等常见操作需要简洁清晰的图形界面。
|
||||||
6. 删除数据库文件和删除表等危险操作需要确认流程,降低误操作风险。
|
6. 删除数据库文件、删除表和删除数据行等危险操作需要确认流程,降低误操作风险。
|
||||||
|
7. 表结构修改需要尊重 SQLite 原生能力,只提供安全可控的结构编辑入口。
|
||||||
|
|
||||||
当前版本已经完成了数据库文件管理、表/视图浏览、表结构查看、表数据分页查看、SQL 执行、SQL 结果分页、创建表和删除表等核心功能。数据行编辑、数据库备份恢复和 AI 辅助 SQL 生成作为后续扩展功能继续开发。
|
当前版本已经完成了数据库文件管理、表/视图浏览、表结构查看、表数据分页查看、SQL 执行、SQL 结果分页、创建表、删除表、数据行编辑和 SQLite 支持范围内的表结构编辑等核心功能。数据库备份恢复和 AI 辅助 SQL 生成作为后续扩展功能继续开发。
|
||||||
|
|
||||||
## 2. 用户需求分析
|
## 2. 用户需求分析
|
||||||
|
|
||||||
@@ -35,8 +36,10 @@ HyperSql 是一个面向学生和 SQLite 初学者的轻量级数据库图形化
|
|||||||
6. 用户在 SQL 执行区输入 SQL 并查看执行结果。
|
6. 用户在 SQL 执行区输入 SQL 并查看执行结果。
|
||||||
7. 用户通过图形界面创建新表。
|
7. 用户通过图形界面创建新表。
|
||||||
8. 用户删除不需要的普通表,并在删除前进行确认。
|
8. 用户删除不需要的普通表,并在删除前进行确认。
|
||||||
9. 用户刷新表列表,并希望保持当前选中的表不变。
|
9. 用户通过图形界面新增、修改或删除表中的数据行。
|
||||||
10. 后续版本中,用户可以通过图形界面编辑数据、备份恢复数据库,或使用 AI 辅助生成 SQL。
|
10. 用户在 SQLite 支持范围内重命名表、新增字段或重命名字段。
|
||||||
|
11. 用户刷新表列表,并希望保持当前选中的表不变。
|
||||||
|
12. 后续版本中,用户可以备份恢复数据库,或使用 AI 辅助生成 SQL。
|
||||||
|
|
||||||
## 3. 功能性需求
|
## 3. 功能性需求
|
||||||
|
|
||||||
@@ -50,8 +53,9 @@ HyperSql 的功能性需求主要包括以下模块:
|
|||||||
| SQL 执行 | 支持用户输入 SQL 语句并执行,显示结果或错误 | 已实现 |
|
| SQL 执行 | 支持用户输入 SQL 语句并执行,显示结果或错误 | 已实现 |
|
||||||
| SQL 结果分页 | 查询结果按每页 100 行分页显示 | 已实现 |
|
| SQL 结果分页 | 查询结果按每页 100 行分页显示 | 已实现 |
|
||||||
| 表管理 | 支持通过 UI 创建表,支持删除选中普通表 | 已实现 |
|
| 表管理 | 支持通过 UI 创建表,支持删除选中普通表 | 已实现 |
|
||||||
|
| 表结构安全编辑 | 支持重命名表、新增字段、重命名字段 | 已实现 |
|
||||||
| 状态与错误提示 | 显示连接状态、执行结果和错误信息 | 已实现 |
|
| 状态与错误提示 | 显示连接状态、执行结果和错误信息 | 已实现 |
|
||||||
| 数据编辑 | 支持新增、修改、删除表数据行 | 待实现 |
|
| 数据编辑 | 支持新增、修改、删除表数据行,并支持可识别单表 SQL 结果编辑 | 已实现 |
|
||||||
| 备份与恢复 | 支持数据库文件备份和从备份文件恢复 | 待实现 |
|
| 备份与恢复 | 支持数据库文件备份和从备份文件恢复 | 待实现 |
|
||||||
| AI SQL 生成 | 根据表结构和用户自然语言需求生成 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` 每次只查询当前页数据。
|
当前实现状态:已实现。表数据分页采用数据库侧分页,通过 `LIMIT` 和 `OFFSET` 每次只查询当前页数据。
|
||||||
|
|
||||||
### 4.4 SQL 执行
|
### 4.5 SQL 执行
|
||||||
|
|
||||||
- 系统应提供 SQL 输入区域。
|
- 系统应提供 SQL 输入区域。
|
||||||
- 系统应支持执行查询、插入、更新、删除、建表、删表等 SQL 语句。
|
- 系统应支持执行查询、插入、更新、删除、建表、删表等 SQL 语句。
|
||||||
@@ -102,7 +119,7 @@ HyperSql 的功能性需求主要包括以下模块:
|
|||||||
|
|
||||||
当前实现状态:已实现。SQL 结果分页采用客户端分页,即 SQL 查询结果先读取到内存中,再按页显示。
|
当前实现状态:已实现。SQL 结果分页采用客户端分页,即 SQL 查询结果先读取到内存中,再按页显示。
|
||||||
|
|
||||||
### 4.5 创建表
|
### 4.6 创建表
|
||||||
|
|
||||||
- 系统应提供图形化创建表界面,不能只依赖用户手写 `CREATE TABLE`。
|
- 系统应提供图形化创建表界面,不能只依赖用户手写 `CREATE TABLE`。
|
||||||
- 创建表界面应简洁易用。
|
- 创建表界面应简洁易用。
|
||||||
@@ -118,7 +135,7 @@ HyperSql 的功能性需求主要包括以下模块:
|
|||||||
|
|
||||||
当前实现状态:已实现。
|
当前实现状态:已实现。
|
||||||
|
|
||||||
### 4.6 删除表
|
### 4.7 删除表
|
||||||
|
|
||||||
- 系统应支持删除左侧选中的普通表。
|
- 系统应支持删除左侧选中的普通表。
|
||||||
- 系统不应通过“删除表”功能删除视图。
|
- 系统不应通过“删除表”功能删除视图。
|
||||||
@@ -132,13 +149,14 @@ HyperSql 的功能性需求主要包括以下模块:
|
|||||||
|
|
||||||
### 5.1 数据行编辑
|
### 5.1 数据行编辑
|
||||||
|
|
||||||
- 系统后续应支持在图形界面中新增表数据。
|
- 系统应支持在表数据界面新增表数据行。
|
||||||
- 系统后续应支持修改表格中的数据。
|
- 系统应支持直接修改表格中的数据,修改后立即保存到 SQLite 数据库。
|
||||||
- 系统后续应支持删除选中的数据行。
|
- 系统应支持删除选中的数据行,删除前必须提示确认。
|
||||||
- 对修改和删除操作,应提供必要的确认或错误提示。
|
- 系统应通过主键或 SQLite `rowid` 定位数据行,避免误更新。
|
||||||
- 数据编辑应优先保证主键识别和 SQL 生成安全。
|
- 系统应在可识别的单表 SQL 查询结果中支持新增、修改和删除数据行。
|
||||||
|
- JOIN、聚合、表达式、视图等无法安全反写的 SQL 查询结果应保持只读。
|
||||||
|
|
||||||
当前实现状态:待实现。
|
当前实现状态:已实现。
|
||||||
|
|
||||||
### 5.2 数据库备份与恢复
|
### 5.2 数据库备份与恢复
|
||||||
|
|
||||||
@@ -165,13 +183,13 @@ HyperSql 的功能性需求主要包括以下模块:
|
|||||||
|
|
||||||
| 需求类型 | 说明 | 当前体现 |
|
| 需求类型 | 说明 | 当前体现 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 易用性 | 界面清晰,操作流程简单,适合初学者使用 | 已采用菜单、工具栏、表格和创建表对话框 |
|
| 易用性 | 界面清晰,操作流程简单,适合初学者使用 | 已采用菜单、工具栏、表格、创建表对话框、数据行编辑按钮和结构编辑按钮 |
|
||||||
| 可靠性 | 数据库连接、SQL 执行和表操作失败时应有错误提示 | 已实现基础错误提示 |
|
| 可靠性 | 数据库连接、SQL 执行、数据编辑和表操作失败时应有错误提示 | 已实现基础错误提示 |
|
||||||
| 安全性 | 删除数据库、删除表等危险操作前应确认 | 已实现确认流程 |
|
| 安全性 | 删除数据库、删除表、删除数据行和结构变更等操作前应确认 | 已实现确认流程 |
|
||||||
| 性能 | 对常见小型 SQLite 数据库能够快速打开和查询 | 表数据采用数据库侧分页,降低大表加载压力 |
|
| 性能 | 对常见小型 SQLite 数据库能够快速打开和查询 | 表数据采用数据库侧分页,降低大表加载压力 |
|
||||||
| 可维护性 | 采用模块化设计,便于后续扩展和维护 | 已划分数据库连接、元数据读取、SQL 执行、工具类和控制器 |
|
| 可维护性 | 采用模块化设计,便于后续扩展和维护 | 已划分数据库连接、元数据读取、SQL 执行、行数据服务、结构编辑服务、工具类和控制器 |
|
||||||
| 兼容性 | 支持常见 SQLite 数据库文件,适配主流桌面系统 | 基于 JavaFX 和 SQLite JDBC,具备跨平台基础 |
|
| 兼容性 | 支持常见 SQLite 数据库文件,适配主流桌面系统 | 基于 JavaFX 和 SQLite JDBC,具备跨平台基础 |
|
||||||
| 可扩展性 | 后续可扩展数据编辑、备份恢复和 AI SQL 生成 | 已保留 SQL 执行和元数据读取基础能力 |
|
| 可扩展性 | 后续可扩展备份恢复和 AI SQL 生成 | 已保留 SQL 执行、元数据读取、行数据服务和结构编辑服务基础能力 |
|
||||||
|
|
||||||
## 7. 需求优先级
|
## 7. 需求优先级
|
||||||
|
|
||||||
@@ -182,7 +200,8 @@ HyperSql 的功能性需求主要包括以下模块:
|
|||||||
| 高 | 新建/打开/关闭 SQLite 数据库、浏览表结构、查看表数据、执行 SQL | 已实现 |
|
| 高 | 新建/打开/关闭 SQLite 数据库、浏览表结构、查看表数据、执行 SQL | 已实现 |
|
||||||
| 高 | 表数据分页、SQL 查询结果分页、刷新表列表保留当前选择 | 已实现 |
|
| 高 | 表数据分页、SQL 查询结果分页、刷新表列表保留当前选择 | 已实现 |
|
||||||
| 中 | 创建表、删除表、删除数据库文件 | 已实现 |
|
| 中 | 创建表、删除表、删除数据库文件 | 已实现 |
|
||||||
| 中 | 数据行新增、修改、删除 | 待实现 |
|
| 中 | 数据行新增、修改、删除 | 已实现 |
|
||||||
|
| 中 | SQLite 支持范围内的表结构编辑 | 已实现 |
|
||||||
| 中 | 数据库备份与恢复 | 待实现 |
|
| 中 | 数据库备份与恢复 | 待实现 |
|
||||||
| 低 | AI 辅助 SQL 生成、SQL 历史记录、界面进一步美化 | 待实现 |
|
| 低 | AI 辅助 SQL 生成、SQL 历史记录、界面进一步美化 | 待实现 |
|
||||||
|
|
||||||
@@ -194,7 +213,7 @@ HyperSql 的功能性需求主要包括以下模块:
|
|||||||
|
|
||||||
2. **中优先级需求**
|
2. **中优先级需求**
|
||||||
- 提升软件实用性和数据安全性。
|
- 提升软件实用性和数据安全性。
|
||||||
- 当前已经完成创建表、删除表和删除数据库文件,后续应继续完成数据行编辑和备份恢复。
|
- 当前已经完成创建表、删除表、删除数据库文件、数据行编辑和表结构安全编辑,后续应继续完成备份恢复。
|
||||||
|
|
||||||
3. **低优先级需求**
|
3. **低优先级需求**
|
||||||
- 作为项目亮点和扩展功能。
|
- 作为项目亮点和扩展功能。
|
||||||
@@ -204,12 +223,13 @@ HyperSql 的功能性需求主要包括以下模块:
|
|||||||
|
|
||||||
当前 HyperSql 仍存在以下限制:
|
当前 HyperSql 仍存在以下限制:
|
||||||
|
|
||||||
1. 还不能通过图形界面直接新增、修改或删除表中的数据行。
|
1. 还没有实现数据库备份与恢复功能。
|
||||||
2. 还没有实现数据库备份与恢复功能。
|
2. 还没有实现 AI 辅助 SQL 生成功能。
|
||||||
3. 还没有实现 AI 辅助 SQL 生成功能。
|
3. SQL 查询结果分页属于客户端分页,查询结果会先全部读取到内存中,不适合特别大的查询结果。
|
||||||
4. SQL 查询结果分页属于客户端分页,查询结果会先全部读取到内存中,不适合特别大的查询结果。
|
4. SQL 结果编辑只支持可识别的单表查询,JOIN、聚合、表达式等复杂结果保持只读。
|
||||||
5. 当前删除表功能只支持删除普通表,不支持删除视图。
|
5. 表结构编辑只支持 SQLite 原生安全支持的重命名表、新增字段和重命名字段,不支持需要重建表的复杂结构修改。
|
||||||
6. 当前主要面向 SQLite,不支持 MySQL、PostgreSQL 等远程数据库。
|
6. 当前删除表功能只支持删除普通表,不支持删除视图。
|
||||||
|
7. 当前主要面向 SQLite,不支持 MySQL、PostgreSQL 等远程数据库。
|
||||||
|
|
||||||
## 9. 需求分析总结
|
## 9. 需求分析总结
|
||||||
|
|
||||||
@@ -224,6 +244,8 @@ HyperSql 的功能性需求主要包括以下模块:
|
|||||||
5. SQL 语句执行和 SQL 查询结果分页显示。
|
5. SQL 语句执行和 SQL 查询结果分页显示。
|
||||||
6. 图形化创建表。
|
6. 图形化创建表。
|
||||||
7. 删除普通表。
|
7. 删除普通表。
|
||||||
8. 状态提示、错误提示和危险操作确认。
|
8. 数据行新增、修改和删除。
|
||||||
|
9. SQLite 支持范围内的表结构安全编辑。
|
||||||
|
10. 状态提示、错误提示和危险操作确认。
|
||||||
|
|
||||||
后续开发应优先完成数据行增删改查和数据库备份恢复,使系统从“可浏览、可执行 SQL、可管理表结构”进一步发展为“可完整管理 SQLite 数据库”的工具。在时间允许的情况下,再实现 AI 辅助 SQL 生成作为项目亮点。
|
后续开发应优先完成数据库备份恢复,使系统从“可浏览、可执行 SQL、可编辑数据、可管理表结构”进一步发展为“可完整管理 SQLite 数据库”的工具。在时间允许的情况下,再实现 AI 辅助 SQL 生成作为项目亮点。
|
||||||
+15
-11
@@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
增量迭代模型是指将软件系统划分为多个相对独立的功能增量,每个增量都经过需求分析、设计、编码、测试和反馈优化等过程,最终逐步形成完整的软件系统。
|
增量迭代模型是指将软件系统划分为多个相对独立的功能增量,每个增量都经过需求分析、设计、编码、测试和反馈优化等过程,最终逐步形成完整的软件系统。
|
||||||
|
|
||||||
HyperSql 是一个基于 Java 24、JavaFX 和 Maven 的轻量级 SQLite 图形化管理工具。项目开发过程中,先完成数据库连接、表结构浏览、表数据查看和 SQL 执行等核心功能,再逐步增加数据库文件管理、表管理、分页显示等增强功能,最后根据时间继续扩展数据编辑、备份恢复和 AI 辅助 SQL 生成。
|
HyperSql 是一个基于 Java 24、JavaFX 和 Maven 的轻量级 SQLite 图形化管理工具。项目开发过程中,先完成数据库连接、表结构浏览、表数据查看和 SQL 执行等核心功能,再逐步增加数据库文件管理、表管理、分页显示、数据行编辑和表结构安全编辑等增强功能,最后根据时间继续扩展备份恢复和 AI 辅助 SQL 生成。
|
||||||
|
|
||||||
## 2. 选择增量迭代模型的原因
|
## 2. 选择增量迭代模型的原因
|
||||||
|
|
||||||
HyperSql 选择增量迭代模型,主要有以下原因:
|
HyperSql 选择增量迭代模型,主要有以下原因:
|
||||||
|
|
||||||
1. **功能模块清晰**
|
1. **功能模块清晰**
|
||||||
- HyperSql 可以划分为数据库文件管理、数据库连接、表/视图浏览、表结构查看、表数据分页查看、SQL 执行、表管理、数据编辑、备份恢复和 AI SQL 生成等模块。
|
- HyperSql 可以划分为数据库文件管理、数据库连接、表/视图浏览、表结构查看、表结构安全编辑、表数据分页查看、SQL 执行、表管理、数据行编辑、备份恢复和 AI SQL 生成等模块。
|
||||||
|
|
||||||
2. **适合 GUI 软件开发**
|
2. **适合 GUI 软件开发**
|
||||||
- JavaFX 图形界面需要不断调整布局和交互方式。采用迭代开发,可以在每个阶段完成可运行版本,并根据测试结果优化用户体验。
|
- JavaFX 图形界面需要不断调整布局和交互方式。采用迭代开发,可以在每个阶段完成可运行版本,并根据测试结果优化用户体验。
|
||||||
@@ -39,10 +39,11 @@ HyperSql 选择增量迭代模型,主要有以下原因:
|
|||||||
| V1.4 | 数据库文件管理增强 | 新建 SQLite 数据库文件、删除当前数据库文件 | 已完成 |
|
| V1.4 | 数据库文件管理增强 | 新建 SQLite 数据库文件、删除当前数据库文件 | 已完成 |
|
||||||
| V1.5 | 表管理功能 | 通过简洁 UI 创建表;删除选中普通表;删除前进行确认 | 已完成 |
|
| V1.5 | 表管理功能 | 通过简洁 UI 创建表;删除选中普通表;删除前进行确认 | 已完成 |
|
||||||
| V1.6 | 交互与缺陷修复 | 修复表/视图选择时数据与结构不同步问题;刷新表列表时保留当前选中表 | 已完成 |
|
| V1.6 | 交互与缺陷修复 | 修复表/视图选择时数据与结构不同步问题;刷新表列表时保留当前选中表 | 已完成 |
|
||||||
| V1.7 | 数据行增删改查 | 在图形界面中新增、修改、删除表数据 | 待实现 |
|
| V1.7 | 数据行增删改查 | 在表数据界面新增、修改、删除数据行;在可识别单表 SQL 结果中支持编辑 | 已完成 |
|
||||||
| V1.8 | 备份与恢复 | 数据库文件备份、从备份恢复数据库 | 待实现 |
|
| V1.8 | 表结构安全编辑 | 支持 SQLite 原生安全的重命名表、新增字段、重命名字段 | 已完成 |
|
||||||
| V1.9 | AI 辅助 SQL 生成 | 根据表结构和用户自然语言需求调用 AI API 生成 SQL | 待实现 |
|
| V1.9 | 备份与恢复 | 数据库文件备份、从备份恢复数据库 | 待实现 |
|
||||||
| V2.0 | 测试与优化 | 完善异常处理、界面优化、系统测试和最终演示准备 | 持续进行 |
|
| V2.0 | AI 辅助 SQL 生成 | 根据表结构和用户自然语言需求调用 AI API 生成 SQL | 待实现 |
|
||||||
|
| V2.1 | 测试与优化 | 完善异常处理、界面优化、系统测试和最终演示准备 | 持续进行 |
|
||||||
|
|
||||||
## 4. 单次迭代流程
|
## 4. 单次迭代流程
|
||||||
|
|
||||||
@@ -101,13 +102,16 @@ HyperSql 选择增量迭代模型,主要有以下原因:
|
|||||||
- 因为用户没有现成数据库文件,项目增加了新建数据库和删除数据库功能,方便测试和演示。
|
- 因为用户没有现成数据库文件,项目增加了新建数据库和删除数据库功能,方便测试和演示。
|
||||||
|
|
||||||
4. **通过反馈修复问题**
|
4. **通过反馈修复问题**
|
||||||
- 在使用过程中发现表/视图选择和刷新逻辑存在体验问题后,及时进行了修复,保证表数据和表结构能够同步更新,并且刷新表列表时保留当前选择。
|
- 在使用过程中发现表/视图选择、刷新逻辑和 SQL 结果初始状态存在体验问题后,及时进行了修复,保证表数据和表结构能够同步更新,刷新表列表时保留当前选择,SQL 结果区启动时保持空状态。
|
||||||
|
|
||||||
5. **保留后续扩展空间**
|
5. **逐步扩展编辑能力**
|
||||||
- 数据行增删改查、数据库备份恢复和 AI SQL 生成尚未完成,后续可作为新的增量继续实现。
|
- 在只读浏览功能完成后,继续实现了数据行新增、修改、删除,并基于 SQLite 原生 `ALTER TABLE` 能力实现了重命名表、新增字段、重命名字段。
|
||||||
|
|
||||||
|
6. **保留后续扩展空间**
|
||||||
|
- 数据库备份恢复和 AI SQL 生成尚未完成,后续可作为新的增量继续实现。
|
||||||
|
|
||||||
## 6. 总结
|
## 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.DatabaseConnectionManager;
|
||||||
import com.hypersql.db.DatabaseMetadataService;
|
import com.hypersql.db.DatabaseMetadataService;
|
||||||
import com.hypersql.db.SqlExecutionService;
|
import com.hypersql.db.SqlExecutionService;
|
||||||
|
import com.hypersql.db.RowDataService;
|
||||||
|
import com.hypersql.db.SchemaEditService;
|
||||||
import com.hypersql.model.ColumnInfo;
|
import com.hypersql.model.ColumnInfo;
|
||||||
|
import com.hypersql.model.EditableQueryResult;
|
||||||
import com.hypersql.model.QueryResult;
|
import com.hypersql.model.QueryResult;
|
||||||
|
import com.hypersql.model.RowKey;
|
||||||
import com.hypersql.model.TableInfo;
|
import com.hypersql.model.TableInfo;
|
||||||
import com.hypersql.HyperSqlApplication;
|
import com.hypersql.HyperSqlApplication;
|
||||||
import com.hypersql.util.DialogUtils;
|
import com.hypersql.util.DialogUtils;
|
||||||
@@ -17,12 +21,19 @@ import javafx.fxml.FXMLLoader;
|
|||||||
import javafx.scene.Node;
|
import javafx.scene.Node;
|
||||||
import javafx.scene.Scene;
|
import javafx.scene.Scene;
|
||||||
import javafx.scene.control.Button;
|
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.Label;
|
||||||
import javafx.scene.control.ListView;
|
import javafx.scene.control.ListView;
|
||||||
import javafx.scene.control.TabPane;
|
import javafx.scene.control.TabPane;
|
||||||
import javafx.scene.control.TableColumn;
|
import javafx.scene.control.TableColumn;
|
||||||
import javafx.scene.control.TableView;
|
import javafx.scene.control.TableView;
|
||||||
import javafx.scene.control.TextArea;
|
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.FileChooser;
|
||||||
import javafx.stage.Modality;
|
import javafx.stage.Modality;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
@@ -34,14 +45,23 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public class MainController {
|
public class MainController {
|
||||||
private static final int TABLE_PAGE_SIZE = 100;
|
private static final int TABLE_PAGE_SIZE = 100;
|
||||||
private static final int SQL_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
|
@FXML
|
||||||
private Label databasePathLabel;
|
private Label databasePathLabel;
|
||||||
@@ -55,6 +75,12 @@ public class MainController {
|
|||||||
@FXML
|
@FXML
|
||||||
private TableView<ObservableList<Object>> tableDataView;
|
private TableView<ObservableList<Object>> tableDataView;
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private Button addTableRowButton;
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private Button deleteTableRowButton;
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
private Button previousTablePageButton;
|
private Button previousTablePageButton;
|
||||||
|
|
||||||
@@ -67,6 +93,15 @@ public class MainController {
|
|||||||
@FXML
|
@FXML
|
||||||
private TableView<ColumnInfo> tableStructureView;
|
private TableView<ColumnInfo> tableStructureView;
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private Button renameTableButton;
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private Button addColumnButton;
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private Button renameColumnButton;
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
private TextArea sqlTextArea;
|
private TextArea sqlTextArea;
|
||||||
|
|
||||||
@@ -76,6 +111,12 @@ public class MainController {
|
|||||||
@FXML
|
@FXML
|
||||||
private TableView<ObservableList<Object>> sqlResultView;
|
private TableView<ObservableList<Object>> sqlResultView;
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private Button addSqlResultRowButton;
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
private Button deleteSqlResultRowButton;
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
private Button previousSqlPageButton;
|
private Button previousSqlPageButton;
|
||||||
|
|
||||||
@@ -91,16 +132,23 @@ public class MainController {
|
|||||||
private final DatabaseConnectionManager connectionManager = new DatabaseConnectionManager();
|
private final DatabaseConnectionManager connectionManager = new DatabaseConnectionManager();
|
||||||
private final DatabaseMetadataService metadataService = new DatabaseMetadataService();
|
private final DatabaseMetadataService metadataService = new DatabaseMetadataService();
|
||||||
private final SqlExecutionService sqlExecutionService = new SqlExecutionService();
|
private final SqlExecutionService sqlExecutionService = new SqlExecutionService();
|
||||||
|
private final RowDataService rowDataService = new RowDataService();
|
||||||
|
private final SchemaEditService schemaEditService = new SchemaEditService();
|
||||||
private TableInfo currentTable;
|
private TableInfo currentTable;
|
||||||
private long currentTableTotalRows;
|
private long currentTableTotalRows;
|
||||||
private long currentTablePageIndex;
|
private long currentTablePageIndex;
|
||||||
|
private EditableQueryResult currentTableResult;
|
||||||
private QueryResult currentSqlResult;
|
private QueryResult currentSqlResult;
|
||||||
|
private EditableQueryResult currentEditableSqlResult;
|
||||||
|
private String currentSqlText;
|
||||||
private long currentSqlPageIndex;
|
private long currentSqlPageIndex;
|
||||||
private boolean selectingTableProgrammatically;
|
private boolean selectingTableProgrammatically;
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
private void initialize() {
|
private void initialize() {
|
||||||
sqlTextArea.setText("SELECT * FROM sqlite_master;");
|
sqlTextArea.clear();
|
||||||
|
tableDataView.setEditable(true);
|
||||||
|
sqlResultView.setEditable(true);
|
||||||
configureStructureTable();
|
configureStructureTable();
|
||||||
tableListView.getSelectionModel().selectedItemProperty().addListener((_, _, selectedTable) -> {
|
tableListView.getSelectionModel().selectedItemProperty().addListener((_, _, selectedTable) -> {
|
||||||
if (selectingTableProgrammatically) {
|
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
|
@FXML
|
||||||
private void handleExecuteSql() {
|
private void handleExecuteSql() {
|
||||||
if (!ensureConnected()) {
|
if (!ensureConnected()) {
|
||||||
@@ -318,14 +484,17 @@ public class MainController {
|
|||||||
try {
|
try {
|
||||||
QueryResult result = sqlExecutionService.execute(connection(), sql);
|
QueryResult result = sqlExecutionService.execute(connection(), sql);
|
||||||
if (result.resultSet()) {
|
if (result.resultSet()) {
|
||||||
|
currentSqlText = sql;
|
||||||
currentSqlResult = result;
|
currentSqlResult = result;
|
||||||
currentSqlPageIndex = 0;
|
currentSqlPageIndex = 0;
|
||||||
|
currentEditableSqlResult = rowDataService.buildEditableSqlResult(connection(), sql);
|
||||||
renderCurrentSqlPage();
|
renderCurrentSqlPage();
|
||||||
sqlMessageLabel.setText(result.message());
|
sqlMessageLabel.setText(result.message() + ";" + currentEditableSqlResult.message());
|
||||||
status("SQL 执行成功,返回 " + result.rows().size() + " 行");
|
status("SQL 执行成功,返回 " + result.rows().size() + " 行");
|
||||||
} else {
|
} else {
|
||||||
clearTable(sqlResultView);
|
clearTable(sqlResultView);
|
||||||
resetSqlPaginationUi();
|
resetSqlPaginationUi();
|
||||||
|
updateSqlRowButtons(false);
|
||||||
sqlMessageLabel.setText(result.message());
|
sqlMessageLabel.setText(result.message());
|
||||||
status("SQL 执行成功," + result.message());
|
status("SQL 执行成功," + result.message());
|
||||||
refreshTablesPreservingSelection();
|
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
|
@FXML
|
||||||
private void handlePreviousTablePage() {
|
private void handlePreviousTablePage() {
|
||||||
if (currentTable == null || currentTablePageIndex <= 0) {
|
if (currentTable == null || currentTablePageIndex <= 0) {
|
||||||
@@ -472,6 +687,7 @@ public class MainController {
|
|||||||
|
|
||||||
List<ColumnInfo> columns = metadataService.listColumns(connection(), table.name());
|
List<ColumnInfo> columns = metadataService.listColumns(connection(), table.name());
|
||||||
tableStructureView.setItems(FXCollections.observableArrayList(columns));
|
tableStructureView.setItems(FXCollections.observableArrayList(columns));
|
||||||
|
updateStructureEditButtons("table".equalsIgnoreCase(table.type()));
|
||||||
|
|
||||||
currentTableTotalRows = metadataService.countTableRows(connection(), table.name());
|
currentTableTotalRows = metadataService.countTableRows(connection(), table.name());
|
||||||
loadCurrentTablePage();
|
loadCurrentTablePage();
|
||||||
@@ -490,16 +706,26 @@ public class MainController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
long offset = currentTablePageIndex * TABLE_PAGE_SIZE;
|
long offset = currentTablePageIndex * TABLE_PAGE_SIZE;
|
||||||
QueryResult preview = metadataService.previewTable(connection(), currentTable.name(), TABLE_PAGE_SIZE, offset);
|
if ("table".equalsIgnoreCase(currentTable.type())) {
|
||||||
renderQueryResult(tableDataView, preview);
|
currentTableResult = rowDataService.loadTablePage(connection(), currentTable.name(), TABLE_PAGE_SIZE, offset);
|
||||||
updateTablePaginationUi(preview.rows().size());
|
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) {
|
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.getColumns().clear();
|
||||||
tableView.getItems().clear();
|
tableView.getItems().clear();
|
||||||
|
|
||||||
@@ -522,6 +748,276 @@ public class MainController {
|
|||||||
tableView.setItems(rows);
|
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) {
|
private void clearTable(TableView<ObservableList<Object>> tableView) {
|
||||||
tableView.getColumns().clear();
|
tableView.getColumns().clear();
|
||||||
tableView.getItems().clear();
|
tableView.getItems().clear();
|
||||||
@@ -565,17 +1061,20 @@ public class MainController {
|
|||||||
|
|
||||||
private void resetTablePaginationUi() {
|
private void resetTablePaginationUi() {
|
||||||
currentTable = null;
|
currentTable = null;
|
||||||
|
currentTableResult = null;
|
||||||
currentTableTotalRows = 0;
|
currentTableTotalRows = 0;
|
||||||
currentTablePageIndex = 0;
|
currentTablePageIndex = 0;
|
||||||
tablePageLabel.setText("未选择表");
|
tablePageLabel.setText("未选择表");
|
||||||
previousTablePageButton.setDisable(true);
|
previousTablePageButton.setDisable(true);
|
||||||
nextTablePageButton.setDisable(true);
|
nextTablePageButton.setDisable(true);
|
||||||
|
updateTableRowButtons(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearSelectedTableUi() {
|
private void clearSelectedTableUi() {
|
||||||
clearTable(tableDataView);
|
clearTable(tableDataView);
|
||||||
tableStructureView.getItems().clear();
|
tableStructureView.getItems().clear();
|
||||||
resetTablePaginationUi();
|
resetTablePaginationUi();
|
||||||
|
updateStructureEditButtons(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void renderCurrentSqlPage() {
|
private void renderCurrentSqlPage() {
|
||||||
@@ -591,7 +1090,22 @@ public class MainController {
|
|||||||
int toIndex = Math.min(fromIndex + SQL_PAGE_SIZE, totalRows);
|
int toIndex = Math.min(fromIndex + SQL_PAGE_SIZE, totalRows);
|
||||||
List<List<Object>> pageRows = allRows.subList(fromIndex, toIndex);
|
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());
|
updateSqlPaginationUi(pageRows.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,10 +1127,13 @@ public class MainController {
|
|||||||
|
|
||||||
private void resetSqlPaginationUi() {
|
private void resetSqlPaginationUi() {
|
||||||
currentSqlResult = null;
|
currentSqlResult = null;
|
||||||
|
currentEditableSqlResult = null;
|
||||||
|
currentSqlText = null;
|
||||||
currentSqlPageIndex = 0;
|
currentSqlPageIndex = 0;
|
||||||
sqlPageLabel.setText("未执行查询");
|
sqlPageLabel.setText("未执行查询");
|
||||||
previousSqlPageButton.setDisable(true);
|
previousSqlPageButton.setDisable(true);
|
||||||
nextSqlPageButton.setDisable(true);
|
nextSqlPageButton.setDisable(true);
|
||||||
|
updateSqlRowButtons(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String displayValue(Object value) {
|
private String displayValue(Object value) {
|
||||||
@@ -656,4 +1173,12 @@ public class MainController {
|
|||||||
Node node = statusLabel;
|
Node node = statusLabel;
|
||||||
return node.getScene().getWindow();
|
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) {
|
||||||
|
}
|
||||||
@@ -78,6 +78,9 @@
|
|||||||
<padding>
|
<padding>
|
||||||
<Insets bottom="8" left="8" right="8" top="8" />
|
<Insets bottom="8" left="8" right="8" top="8" />
|
||||||
</padding>
|
</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="previousTablePageButton" onAction="#handlePreviousTablePage" text="上一页" />
|
||||||
<Button fx:id="nextTablePageButton" onAction="#handleNextTablePage" text="下一页" />
|
<Button fx:id="nextTablePageButton" onAction="#handleNextTablePage" text="下一页" />
|
||||||
<Label fx:id="tablePageLabel" text="未选择表" />
|
<Label fx:id="tablePageLabel" text="未选择表" />
|
||||||
@@ -88,7 +91,22 @@
|
|||||||
</Tab>
|
</Tab>
|
||||||
<Tab closable="false" text="表结构">
|
<Tab closable="false" text="表结构">
|
||||||
<content>
|
<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>
|
</content>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab closable="false" text="SQL 执行">
|
<Tab closable="false" text="SQL 执行">
|
||||||
@@ -113,6 +131,9 @@
|
|||||||
<padding>
|
<padding>
|
||||||
<Insets bottom="8" left="8" right="8" top="8" />
|
<Insets bottom="8" left="8" right="8" top="8" />
|
||||||
</padding>
|
</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="previousSqlPageButton" onAction="#handlePreviousSqlPage" text="上一页" />
|
||||||
<Button fx:id="nextSqlPageButton" onAction="#handleNextSqlPage" text="下一页" />
|
<Button fx:id="nextSqlPageButton" onAction="#handleNextSqlPage" text="下一页" />
|
||||||
<Label fx:id="sqlPageLabel" text="未执行查询" />
|
<Label fx:id="sqlPageLabel" text="未执行查询" />
|
||||||
|
|||||||
Reference in New Issue
Block a user