-
Notifications
You must be signed in to change notification settings - Fork 0
Add write support: INSERT/UPDATE/DELETE/DDL/TCL execution #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| # SQLiteView 書き込み対応計画 | ||
|
|
||
| ## Context | ||
|
|
||
| SQLiteViewは現在、読み取り専用のSQLiteビューア(SELECT/WITH/PRAGMAのみ許可)。 | ||
| これをINSERT/UPDATE/DELETE/CREATE/DROP等の書き込みクエリも実行可能なSQLクライアントに拡張する。 | ||
|
|
||
| ## 変更対象ファイル | ||
|
|
||
| - `src/sqliteviewer/database.py` — DB層: 読み取り制限の解除、クエリ分類、結果型の拡張 | ||
| - `src/sqliteviewer/mainwindow.py` — UI層: 書き込み結果の表示、確認ダイアログ、自動リフレッシュ | ||
| - `src/sqliteviewer/sql_highlighter.py` — ハイライト: 書き込み系キーワード追加 | ||
| - `tests/test_database.py` — テスト: 書き込み系テストの追加 | ||
|
|
||
| ## 実装ステップ | ||
|
|
||
| ### Step 1: database.py — DB層の変更(完了) | ||
|
|
||
| - [x] `QueryResult` に `affected_rows: Optional[int]` と `is_write_operation: bool` を追加 | ||
| - [x] `_classify_query()` を追加 — `read`/`dml`/`ddl`/`tcl`/`unknown` を返す | ||
| - [x] `is_destructive_query()` を追加 — DROP・WHERE無しDELETEを検出し `(bool, reason)` を返す | ||
| - [x] `execute_query()` を書き換え — `_assert_read_only()` 削除、`cursor.description is None` で書き込み判定 | ||
| - [x] `_assert_read_only()` を削除 | ||
| - [x] docstring `"read-only SQLite interactions"` → `"SQLite database interactions"` に更新 | ||
|
|
||
| > 接続設定(`isolation_level=None`)は変更なし。ユーザーが明示的に BEGIN/COMMIT/ROLLBACK でトランザクション制御可能。 | ||
|
|
||
| ### Step 2: tests/test_database.py — テスト更新(完了) | ||
|
|
||
| - [x] `test_execute_query_restricts_writes` → `test_execute_query_allows_writes` に置換(UPDATE実行→affected_rows確認→SELECT で結果検証) | ||
| - [x] INSERT のテストを追加 | ||
| - [x] DELETE のテストを追加 | ||
| - [x] DDL(CREATE TABLE / DROP TABLE)のテストを追加 | ||
| - [x] トランザクション(BEGIN→INSERT→ROLLBACK)のテストを追加 | ||
| - [x] クエリ分類(`_classify_query`)のテストを追加 | ||
| - [x] 破壊的操作検出(`is_destructive_query`)のテストを追加 | ||
|
|
||
| > 10テストすべて pass 確認済み。 | ||
|
|
||
| ### Step 3: sql_highlighter.py — キーワード追加(完了) | ||
|
|
||
| - [x] DML キーワードを追加: INSERT, UPDATE, DELETE, SET, VALUES, INTO, REPLACE | ||
| - [x] DDL キーワードを追加: CREATE, ALTER, DROP, TABLE, VIEW, INDEX, TRIGGER, COLUMN, ADD, RENAME, IF, PRIMARY, KEY, UNIQUE, CHECK, DEFAULT, FOREIGN, REFERENCES, CONSTRAINT, AUTOINCREMENT | ||
| - [x] TCL キーワードを追加: BEGIN, COMMIT, ROLLBACK, TRANSACTION, SAVEPOINT, RELEASE | ||
| - [x] その他を追加: BETWEEN, EXCEPT, INTERSECT, ELSE, EXPLAIN, VACUUM, REINDEX, ATTACH, DETACH, CASCADE, RESTRICT, CONFLICT, ABORT, FAIL, IGNORE, TEMPORARY, TEMP | ||
| - [x] 型名を追加: INTEGER, TEXT, REAL, BLOB, NUMERIC | ||
|
|
||
| ### Step 4: mainwindow.py — UI層の変更(完了) | ||
|
|
||
| - [x] プレースホルダー更新: `"Write a read-only SQL query…"` → `"Write a SQL statement…"` | ||
| - [x] `_run_query()` を書き換え — 破壊的クエリの確認ダイアログ(`QMessageBox.warning`)追加 | ||
| - [x] 書き込み結果の表示: `"{N} row(s) affected"` または `"Statement executed successfully"` | ||
| - [x] `_refresh_after_write()` を追加 — DDL後はテーブルリスト・プレビュー・スキーマをリフレッシュ、DML後は選択中テーブルのプレビューをリフレッシュ | ||
| - [x] `_refresh_tables()` を改善 — リフレッシュ後に以前の選択テーブルを復元 | ||
| - [x] About ダイアログのバージョンを `0.1.0` → `0.2.1` に修正 | ||
|
|
||
| ## 検証方法 | ||
|
|
||
| ```bash | ||
| # ユニットテスト実行 | ||
| uv run python -m pytest tests/ -v | ||
|
|
||
| # 手動テスト(GUIが使える場合) | ||
| uv run sqliteview test.db | ||
| # → INSERT/UPDATE/DELETE/CREATE TABLE/DROP TABLE/BEGIN/ROLLBACK を実行し動作確認 | ||
| ``` | ||
|
|
||
| ## 現状 | ||
|
|
||
| **実装完了。** 全ステップの実装・テストが完了しており、10テストすべて pass。 | ||
| SQLiteView は INSERT/UPDATE/DELETE/CREATE/DROP/BEGIN/COMMIT/ROLLBACK 等の書き込みクエリを実行可能な SQL クライアントとなった。 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,7 +63,7 @@ def __init__(self) -> None: | |
| self.schema_view.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap) | ||
|
|
||
| self.query_editor = QPlainTextEdit() | ||
| self.query_editor.setPlaceholderText("Write a read-only SQL query…") | ||
| self.query_editor.setPlaceholderText("Write a SQL statement…") | ||
| self.query_editor.setTabStopDistance(4 * self.query_editor.fontMetrics().horizontalAdvance(' ')) | ||
| SqlHighlighter(self.query_editor.document()) | ||
|
|
||
|
|
@@ -182,6 +182,10 @@ def open_database(self, path: str) -> None: | |
| self._remember_recent_file(path) | ||
|
|
||
| def _refresh_tables(self) -> None: | ||
| # Remember currently selected table before clearing | ||
| selected_items = self.table_list.selectedItems() | ||
| previously_selected = selected_items[0].text() if selected_items else None | ||
|
|
||
| self.table_list.clear() | ||
|
|
||
| try: | ||
|
|
@@ -197,7 +201,12 @@ def _refresh_tables(self) -> None: | |
| for table in tables: | ||
| QListWidgetItem(table, self.table_list) | ||
|
|
||
| self.table_list.setCurrentRow(0) | ||
| # Restore selection if the table still exists, otherwise select first row | ||
| if previously_selected and previously_selected in tables: | ||
| idx = tables.index(previously_selected) | ||
| self.table_list.setCurrentRow(idx) | ||
| else: | ||
| self.table_list.setCurrentRow(0) | ||
|
|
||
| def _close_database(self) -> None: | ||
| self.database_service.close() | ||
|
|
@@ -251,19 +260,64 @@ def _populate_table(self, view: QTableView, result: QueryResult) -> None: | |
|
|
||
| def _run_query(self) -> None: | ||
| query = self.query_editor.toPlainText() | ||
|
|
||
| is_destructive, reason = self.database_service.is_destructive_query(query) | ||
| if is_destructive: | ||
| reply = QMessageBox.warning( | ||
| self, | ||
| "Potentially destructive operation", | ||
| f"{reason}\n\nDo you want to proceed?", | ||
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, | ||
| QMessageBox.StandardButton.No, | ||
| ) | ||
| if reply != QMessageBox.StandardButton.Yes: | ||
| return | ||
|
|
||
| try: | ||
| result = self.database_service.execute_query(query) | ||
| except DatabaseError as exc: | ||
| QMessageBox.critical(self, "Query failed", str(exc)) | ||
| return | ||
|
|
||
| self.query_result = result | ||
| self._populate_table(self.query_result_view, result) | ||
| status = f"Returned {len(result.rows)} row(s)" | ||
| if result.truncated: | ||
| status += " (truncated)" | ||
| self.query_status_label.setText(status) | ||
| self.status_bar.showMessage("Query executed successfully.", 4000) | ||
| if result.is_write_operation: | ||
| self.query_result = None | ||
| self.query_result_view.setModel(None) | ||
| if result.affected_rows is not None: | ||
| status = f"{result.affected_rows} row(s) affected" | ||
| else: | ||
| status = "Statement executed successfully" | ||
| self.query_status_label.setText(status) | ||
| self.status_bar.showMessage("Statement executed successfully.", 4000) | ||
| self._refresh_after_write(query) | ||
| else: | ||
| self.query_result = result | ||
| self._populate_table(self.query_result_view, result) | ||
| status = f"Returned {len(result.rows)} row(s)" | ||
| if result.truncated: | ||
| status += " (truncated)" | ||
| self.query_status_label.setText(status) | ||
| self.status_bar.showMessage("Query executed successfully.", 4000) | ||
|
|
||
| def _refresh_after_write(self, sql: str) -> None: | ||
| """Refresh UI panels after a write operation.""" | ||
|
|
||
| query_type = self.database_service.classify_query(sql) | ||
|
|
||
| if query_type == "ddl": | ||
| # Table list may have changed; also refresh preview and schema | ||
| self._refresh_tables() | ||
| selected_items = self.table_list.selectedItems() | ||
| if selected_items: | ||
| table_name = selected_items[0].text() | ||
| self._load_table_preview(table_name) | ||
| self._load_table_schema(table_name) | ||
| elif query_type == "dml": | ||
| # Refresh preview only if the affected table is currently selected | ||
| selected_items = self.table_list.selectedItems() | ||
| if selected_items: | ||
| table_name = selected_items[0].text() | ||
| if table_name.lower() in sql.lower(): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DML操作後にデータプレビューを更新する現在のロジックは、単純な部分文字列チェック(
より信頼性の高いアプローチとして、SQLを簡易的に解析して、操作対象のテーブル名をより正確に特定することを検討してください。例えば、正規表現を用いて |
||
| self._load_table_preview(table_name) | ||
|
|
||
| def _export_results(self) -> None: | ||
| if not self.query_result or not self.query_result.columns: | ||
|
|
@@ -289,8 +343,8 @@ def _show_about_dialog(self) -> None: | |
| QMessageBox.about( | ||
| self, | ||
| "About SQLite Viewer", | ||
| "<b>SQLite Viewer</b><br/>Version 0.1.0<br/><br/>" | ||
| "A lightweight desktop viewer for SQLite databases.", | ||
| "<b>SQLite Viewer</b><br/>Version 0.2.1<br/><br/>" | ||
| "A lightweight desktop client for SQLite databases.", | ||
| ) | ||
|
|
||
| def _load_recent_files(self) -> None: | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a write statement succeeds,
_run_queryclears only the table view model but leavesself.query_resultuntouched. If the user previously ran aSELECT, then runsINSERT/UPDATE/DELETE, the UI shows no result rows yetExport Resultsstill exports the oldSELECTdataset from memory, which can silently produce incorrect exports.Useful? React with 👍 / 👎.