bamaer opened a new issue, #8277:
URL: https://github.com/apache/hop/issues/8277
### Apache Hop version?
SNAPSHOT-20260906 (main, at 00b036b902)
### Java version?
21
### Operating system
macOS
### What happened?
The Database perspective is a good addition to Hop Gui. A non-modal SQL
editor next to a project connection tree is something the modal `SqlEditor`
dialog could never be, and the `IDatabaseWorkbenchHost` split that lets the
same widget run as a perspective, a floating window and a dock tab is a nice
piece of design.
What follows is nine hardening items from a follow-up code read and manual
testing of `f6c2d411b0` (#8212). None of them are objections to the feature.
Each is independent; happy to split if that is easier. Line numbers are against
`f6c2d411b0`..`00b036b902` — nothing in that range touches the files cited.
---
## 1. SQL tab memory is shared by every workbench instance, last writer wins
Three `DatabaseWorkbench` instances can be alive at once: the perspective's,
created in `initialize()` and kept for the session because `HopGui` initializes
every perspective at startup (`HopGui.java:917`), plus the floating window's
(`DatabaseWorkbenchDialog.java:126`) and the dock's
(`DatabaseWorkbenchViews.java:91`). All three read and write one audit entry,
group = namespace / type = `database-sql-tabs`. Each calls `restore(this)` in
its constructor (`DatabaseWorkbench.java:258`), and the only guard,
`hasSqlEditorTabs()`, inspects that instance's own empty list — so each opens
independent copies. `save()` then snapshots only its own tabs as a whole-entry
replace, with no merge or version check (`DatabaseSqlTabMemory.java:117-141`).
The debounce runnable is per instance, so none can suppress another's write.
**Repro** (no live database needed):
1. Database perspective: select a connection, open a SQL tab, type `select
1`.
2. Click **Float** — the perspective flushes to the audit entry (`:1008`),
then the window restores an independent copy.
3. In the floating window change it to `select 2`, close the window
(`saveNow` at `:252`).
4. Back in the perspective, without touching the tab, click **Float** again.
Expected `select 2`; actual `select 1`, because step 4 flushed the stale
instance first. A restart also comes back with `select 1`. Same via **Dock**,
via any edit in the perspective after step 3, or on quit.
*Note:* clicking an already-selected tab is not a trigger (`CTabFolder`
notifies only on index change), and the javadoc's "same mechanism as terminal
tabs" (`DatabaseSqlTabMemory.java:35`) does not hold — terminals live in a
single `HopGuiBottomDock` and cannot race. No file on disk is affected; what is
lost is unsaved buffer content.
---
## 2. Table preview SQL omits the prefix-style row limit
`previewSelectSql` (`DatabaseWorkbench.java:973-978`) appends
`getLimitClause(rowLimit)` and never calls `getLimitClausePrefix`, which has no
occurrence under `ui/`. The two forms are mutually exclusive by design
(`IDatabase.java:249-251`) and the override sets are disjoint — 23 dialects
override one, 10 the other, none both — so for the prefix dialects
`getLimitClause` falls through to `""` and the preview carries no limit.
Affected (eleven): Access, Cache, Firebird, Informix, Interbase, Iris,
MsSqlServer, Sybase, SybaseIQ, Teradata, and MsSqlServerNative, which inherits
the MSSQL prefix and overrides neither.
`Database.getFirstRows` asks for both forms (`Database.java:3845-3850`) —
that is `3b3dc8fdf4`, *"Let a database limit rows before the query as well as
after it, fixes #8013"*, an ancestor of this work.
**Repro:** MS SQL Server connection, "Quote all fields" off, default limit
1000. Connect, expand a schema, Preview a plain-named table. Expected `SELECT
TOP 1000 * FROM dbo.mytable`, actual `SELECT * FROM dbo.mytable`.
The grid is still capped — `setQueryLimit`/`getRows` apply the limit
independently of the SQL text. What is unbounded is the statement the user can
edit, copy or save, and the statement the server receives.
*Adjacent, pre-dating this:* `GenericDatabaseMeta` delegates
`getLimitClause` (`:473-479`) but has no `getLimitClausePrefix` delegation, so
a Generic connection on a prefix dialect loses it everywhere, `getFirstRows`
included.
---
## 3. Failures are captured but never shown
`runOperation` (`DatabaseWorkbench.java:1257-1271`) is the single funnel for
all background work: it catches `Exception`, calls `operation.fail(...)` and
refreshes the panel. No log, no dialog, no rethrow. `fail()` stores the text in
`errorMessage`, but `git grep getErrorMessage` across `ui`, `rap`, `rcp` and
`core` returns no hits, and nothing renders it — the panel has four columns
(Description, Connection, Status, Elapsed) and no per-row tooltip. The
project's own test pins this:
`DatabaseOperationsPanelTest.formatStatusLineOmitsBlankConnection` calls
`fail("boom")` and asserts the line starts with `Connect - Failed - `.
In the SQL editor there is a second effect: `messages` and `queryResults`
are locals inside the lambda (`DatabaseSqlEditorTab.java:399`), and
`connect`/`getRows`/`execStatement` all throw past the terminal `asyncExec(()
-> { resultsPanel.show(...); showResults(); })` at `:433-438` — the only call
site of `show()`, which is also the only thing that clears old result tabs.
**Repro:** run a valid query in a SQL tab, then replace it with `SELECT *
FROM does_not_exist` and Run. Expected the driver error in the Results/Messages
tab; actual the panel still shows the previous run's rows and messages, with
`Execute SQL on <conn> - <conn> - Failed - <elapsed>` as the only new signal.
Variants: on the first run in a fresh tab there is no visible output at all
(the panel is still collapsed and `showResults()` is in the skipped block); on
Run All, messages from statements that already succeeded are discarded with the
exception; a wrong password on a tree double-click gives only `Connect to
<name> - Failed`, and triggering that connect from Run silently drops the
queued execution, since `afterConnected` runs only inside the success
continuation (`:765-774`).
One case *is* surfaced: a `getObjectDdl` failure in table-info is caught and
written into the DDL tab as `-- <message>`
(`DatabaseTableInfoTab.java:199-203`).
*For contrast:* the modal `SqlEditor` this replaces raises an `ErrorDialog`
on a failed SELECT (`:430`), on failed DDL (`:474`) and a `MessageBox` on a
failed connect (`:498`). Silent background failure has one precedent,
`HopGuiSearchResultsPanel.java:529-536`, but even that logs the exception.
---
## 4. Save in the floating window and the dock targets the main window's file
Every save path resolves through `HopGui.getActiveFileTypeHandler()` →
`getActivePerspective().getActiveFileTypeHandler()` (`HopGui.java:2405-2406`).
`activePerspective` is written only by `setActivePerspective`, and neither the
floating window nor the dock sets it — both host a `DatabaseWorkbench` outside
the perspective system. So while a SQL tab has focus, File > Save and the Save
toolbar button (`HopGui.java:1379` → `HopGuiFileDelegate.fileSave:244`) act on
whatever the main window's active perspective has selected. Enablement comes
from that same handler (`HopGui.java:2394`), so Save looks available because
the *other* file is dirty.
**Repro:**
1. Open a pipeline (an Explorer perspective tab — there is no separate
Pipeline perspective on this branch).
2. Tools > Database window. Select a connection, open a SQL editor tab, type
something.
3. With the floating window focused and on top, click Save.
Expected the SQL buffer saved, or a `*.sql` Save As dialog for an untitled
one. Actual the pipeline is saved, silently — `fileSave` finds its filename
already set and calls `typeHandler.save()` (`HopGuiFileDelegate.java:250-256`).
Nothing writes the SQL buffer. The dock tab behaves the same.
`isCloseable()` has the same defect on the close path — it calls
`fileDelegate.fileSaveAs()` (`DatabaseSqlEditorTab.java:704`) instead of its
own `saveAs(String)` (`:575`), then returns `!changed`, still false, so the tab
also refuses to close. That path is rarely reached in practice: closing the
floating *window* prompts for nothing, because its shell dispose listener only
stores geometry (`DatabaseWorkbenchDialog.java:135-143`) and never consults the
open tabs.
*Note:* `git grep fileSaveAs` returns four call sites, no other
`isCloseable()` among them. House style is the opposite —
`HopGuiPipelineGraph.isCloseable` calls `BaseDialog.presentFileDialog` on
itself; `BaseExplorerFileTypeHandler` and `MetadataEditor` call their own
`save()`.
---
## 5. Closed tabs leave their content control alive, so dispose-wired
cleanup never runs
`CTabItem.dispose()` does not dispose the item's control — `destroyItem`
only calls `setVisible(false)` (SWT 3.134.0 `CTabItem.java:130-140`; RAP RWT
4.7.0 identical, so Hop Web does not differ). `disposeTab`
(`DatabaseWorkbench.java:1321-1332`) disposes only the item, and the content
control was parented to the folder, not the item (`:1035, 1055`).
`DatabaseSqlEditorTab` registers a Display-wide `SWT.KeyDown` filter in its
constructor and wires removal to `control.addDisposeListener` (`:242-248`), so
closing a SQL tab leaves both the widget tree and the filter in place until
shell teardown. `DatabaseResultsPanel.show()` in the same code uses the pattern
that works — `item.getControl().dispose()` before `item.dispose()` (`:114-116`).
**Repro** (debugger or heap dump; nothing visible in the GUI): open and
close a SQL tab repeatedly. The breakpoint at `:243` is never hit, and a heap
dump shows one retained `DatabaseSqlEditorTab` per closed tab.
`DatabaseTableInfoTab` leaks its widget tree the same way.
*Scope.* The retained filters are inert (`isInThisEditor` returns false for
an orphan), so the cost is retention, not behaviour. Disposing only the
`CTabItem` is existing house style (`ExplorerPerspective.java:2431`,
`MetadataPerspective.java:3291`), and `HopGuiBottomDock.closeTab` — **not
touched by this PR**, it comes from `04e0b6e11e` (#7356) — already leaks
`HopGuiSearchResultsPanel` identically. Hosting `DatabaseWorkbench` there does
raise the stakes: the orphan keeps its `MetadataChanged`/`ProjectActivated`
registrations (`:231, 239`) and neither guard stops it, so it keeps running
`loadAll()` and `rebuildTree()`. Count invocations rather than expecting
silence — the perspective's own instance already answers every metadata change
at baseline. Whether that half is fixed here or in the dock is a separate call.
---
## 6. A file-backed SQL tab registers with the file-refresh delegate but has
no `reload()`
`openSqlTab` registers every file-backed tab
(`DatabaseWorkbench.java:1057-1060`) into a single `HashMap`, so a second
registration for the same path replaces the first. `DatabaseSqlEditorTab` does
not override `reload()`, inheriting the empty default
(`IHopFileTypeHandler.java:167`), so the monitor still fires and lands on a
no-op — displacing the Explorer's `SqlExplorerFileTypeHandler`, whose inherited
`reload()` does re-read the file. Closing the tab then removes the watch for
everyone: `disposeTab` calls `remove(getFilename())` guarded only for null
(`:1321-1327`), and `DefaultFileMonitor` keeps one agent per name with no
reference counting.
**Repro.** Requires "Reload file if changed on filesystem" in Configuration
> General options — **off by default** (`PropsUi.java:552-554`), and with it
off none of this happens.
1. Explorer perspective: open `queries.sql`, change it outside Hop, wait ~2s
— it refreshes (control step).
2. In that editor's toolbar click **Open in database** and pick a connection.
3. Change the file again. Expected: refreshes as in step 1. Actual: neither
tab refreshes.
4. Close the Database SQL tab, change the file again. Expected: the Explorer
tab refreshes again. Actual: still nothing — the file is unwatched entirely and
cannot recover until that tab is closed and reopened.
*Note:* registering without a `reload()` override is not new —
`ExplorerPerspective.addFile` registers every `IExplorerFileTypeHandler`, and
`Svg`, `GitInfo` and `Sas` define none. The un-refcounted `remove` on close is
the part with a cross-perspective consequence.
---
## 7. `SELECT … FROM … INTO …` changes classification in
`Database.execStatements`
`execStatements` previously used `startsWith("SELECT") &&
!matches("(?is)^(select\\s.*\\sinto\\s).*")`, treating any SELECT containing a
whitespace-delimited INTO as a non-query. It now calls
`SqlQueryClassifier.isQuery` (`Database.java:1620`), whose `hasIntoAtDepthZero`
returns false as soon as it meets FROM or WHERE at depth zero
(`SqlQueryClassifier.java:223-225`), so an INTO after FROM is never reached.
Compiling the current classifier against the old predicate:
```
SELECT id, name FROM customers INTO OUTFILE '/tmp/c.csv' old=false
new=true
SELECT col FROM t INTO @var old=false
new=true
SELECT * INTO dest FROM src old=false
new=false (unchanged)
```
`true` routes to `executeQuery`, `false` routed to `execute` — and these
forms return no result set. The outcome is driver-dependent (established by
running each driver's own classification code, no live server):
mysql-connector-j **9.7.0** rejects it before the statement is sent;
**9.0.0/9.1.0** do not, so it executes and fails later at `rs.next()` as
`HopDatabaseException("Couldn't get row from result set")`;
**mariadb-java-client 3.5.7** returns an empty result set under its default
`permitNoResults`, so it is not observable there.
This reaches beyond the perspective — `execStatements` is called from the
SQL workflow action (`ActionSql.java:181`), Execute SQL script
(`ExecSql.java:203, 309`), Execute SQL script for each row, the DDL transform,
Snowflake `WarehouseManager`, `SqlStatementsDialog`, and `Database`'s own
connect-SQL and `lockTables`. The first two are gated on flags that default to
false, i.e. the changed path is the default.
**Repro.** MySQL with mysql-connector-j 9.7.0. SQL workflow action, "Send
SQL as single statement" unchecked (the default; checking it bypasses this
path), running `SELECT id FROM customers INTO @last_id;`. Expected
(pre-`f6c2d411b0`): executed with `Statement.execute`, `@last_id` set. Actual:
routed through `executeQuery` and rejected. Control: `SELECT id INTO @last_id
FROM customers;` still works, isolating clause order. Use `INTO @var` rather
than `INTO OUTFILE` — OUTFILE needs the FILE privilege and a matching
`secure_file_priv`, which would fail on both builds and mask the difference.
**This is not a pure regression, and a straight revert would reintroduce the
opposite defect.** The old regex had no notion of string literals or
subqueries, so `SELECT * FROM t WHERE note = 'insert into x'` was also
classified as a non-query; the new classifier handles that correctly, and the
sibling change in `BaseDatabaseMeta.getSqlScriptStatements` is a strict
improvement over `startsWith("SELECT") || startsWith("show")`. What is needed
is INTO detection that continues past FROM.
*Coverage:* `SqlQueryClassifierTest.selectIntoIsNotAQuery` asserts only
`SELECT * INTO dest FROM src`, and the HOP-2584 integration workflow uses
`select 1 as a, 2 as b into t`, which has no FROM. Neither covers this ordering.
---
## 8. `openSqlTab` ignores its `dirty` argument for tabs with no filename
`dirty` is read at exactly one place, `tab.applyBuffer(buffer, dirty)`
(`DatabaseWorkbench.java:1039`), reachable only when `filename` is non-empty
*and* `buffer` is non-null. With an empty filename the method takes
`setInitialText(...)` (`:1052`), which suppresses modify and leaves `changed`
false. Three callers pass `dirty = true` with no filename: `openSuggestedSql`
(`:1106`, behind the SQL button of Table Output and ~16 other dialogs),
`generateDdl` (`:936`), and `restoreSqlTab`'s filename-less branch (`:1151`).
The same argument value produces two different outcomes depending on whether a
filename is set.
**Repro:** right-click a table > **Generate DDL**, then close the tab with
its X *without typing in the editor* (one keystroke fires the ModifyListener
and masks it). Actual: title not bold, closes with no save prompt. Expected:
bold title and a "Save file?" prompt, as on the file-backed path.
*Scope:* no SQL text is lost — `snapshotOf` persists the buffer whenever the
filename is empty, regardless of `dirty`. Related: the tab-reuse loop at
`:1024-1033` is gated on the same non-empty filename, so filename-less opens
never dedupe and each click of a dialog's SQL button adds another persisted tab.
---
## 9. The operations list accumulates for the session
`operations` is a plain `ArrayList` and the table is a non-virtual SWT
`Table`, so every entry is a materialised `TableItem`
(`DatabaseOperationsPanel.java:67, 93, 161`). Nothing removes from either — no
accessor, no context menu, and the four toolbar actions are Kill selected,
Minimize, Kill current, Expand; `cancelAll` cancels and removes nothing. There
is no cap, eviction or Clear action. For the perspective-hosted workbench this
spans the whole session, and a project switch does not clear it, so rows naming
a previous project's connections stay in the table.
**Repro:** rows accrue **per user action, not per statement** —
`runOperation` creates one `DatabaseOperation` per call, from four sites:
connect, generate DDL, execute SQL and open table info. Expand the operations
panel and connect / preview / run / open table info repeatedly; every row from
the start of the session is still listed, and `refreshElapsed` walks the whole
list every 500ms while anything is running.
*Retention detail:* `DatabaseOperation.database` is never cleared. The
attached `Database` has been closed, so no connection, statement or tunnel is
held — but `disconnect()` never clears `dbmd` (`Database.java:151`, nulled only
in the constructor), and the table-info path calls `getDatabaseMetaData()`, so
those operations keep a driver `DatabaseMetaData` and typically its closed
`Connection` reachable.
---
*From a code read of `f6c2d411b0` plus manual testing, cross-checked against
the SWT 3.134.0 and RAP 4.7.0 sources and the JDBC drivers named in item 7.
Items needing a live server or a debugger are marked as such in place.*
### Issue Priority
Priority: 2
### Issue Component
Component: Hop Gui, Component: Database
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]