Skip to content

TUI menu, configurable JDBC connections, drivers, docs + CI#1

Merged
jlc488 merged 34 commits into
mainfrom
feat/tui-menu
Jun 13, 2026
Merged

TUI menu, configurable JDBC connections, drivers, docs + CI#1
jlc488 merged 34 commits into
mainfrom
feat/tui-menu

Conversation

@jlc488

@jlc488 jlc488 commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

English

Brings DataLinq from an engine + CLI baseline to a complete, usable tool.

What's included

  • TamboUI TUI (MVC; controller is TUI/DB-free and unit-tested): Migrations menu (number shortcuts, mouse, run / dry-run / run-all), DB Connection screen (two-pane datasource list + structured edit fields, test/save, default source/target, derived-URL preview), Settings (options + folder picker, live menu refresh), About / destructive-confirm dialogs. CJK-aware alignment.
  • Configurable connections: structured type + host/port/database (sqlserver / mariadb / postgresql) or a custom URL — no longer hard-wired MSSQL→MariaDB.
  • JDBC drivers: SQL Server, MariaDB/MySQL, PostgreSQL bundled; others downloadable via datalinq driver <name> (registered through a DriverShim).
  • Distribution: single Shadow fat-jar + jbang alias + datalinq init.
  • Engine integration tests (Testcontainers, PostgreSQL → MariaDB): ETL copy, dry-run rollback, error rollback, SCRIPT — disabledWithoutDocker, so they run in CI and skip on machines without Docker.
  • Docs: README refreshed for configurable DBs, new CHANGELOG, and a GitHub Actions CI workflow (build + test on JDK 21).

Verification

gradlew build green locally (46 pass + 5 Testcontainers tests skipped without Docker). The five engine scenarios were also verified green against real postgres:16 → mariadb:11.4 this session.


한국어

DataLinq를 엔진 + CLI 기준선에서 완성형 도구로 끌어올립니다.

포함 내용

  • TamboUI TUI (MVC; 컨트롤러는 TUI/DB 비의존 + 단위테스트): 마이그레이션 메뉴(숫자 단축키·마우스·실행/dry-run/전체실행), DB 연결 화면(2-패널 데이터소스 목록 + 구조화 입력, 테스트/저장, 기본 소스/타겟, URL 미리보기), 설정(옵션 + 폴더 선택, 메뉴 즉시 갱신), 정보/파괴적-확인 다이얼로그. CJK 정렬 대응.
  • 설정형 연결: type + host/port/database(sqlserver / mariadb / postgresql) 또는 custom URL — MSSQL→MariaDB 고정 제거.
  • JDBC 드라이버: SQL Server·MariaDB/MySQL·PostgreSQL 번들, 그 외는 datalinq driver <name>로 다운로드(DriverShim 등록).
  • 배포: 단일 Shadow fat-jar + jbang + datalinq init.
  • 엔진 통합 테스트(Testcontainers, PostgreSQL → MariaDB): ETL 복사, dry-run 롤백, 오류 롤백, SCRIPT — disabledWithoutDocker로 CI에서는 실행, Docker 없는 로컬에서는 스킵.
  • 문서: README를 설정형 DB로 갱신, CHANGELOG 신설, GitHub Actions CI(JDK 21 build+test) 추가.

검증

로컬 gradlew build 그린(46 통과 + Testcontainers 5건은 Docker 없어 스킵). 다섯 엔진 시나리오는 실제 postgres:16 → mariadb:11.4 로도 이번 세션에서 그린 확인.

jlc488 added 30 commits June 13, 2026 16:40
- load operation.properties as UTF-8 (was ISO-8859-1 -> mangled Korean confirm text)
- set javac encoding + stdout/stderr to UTF-8 (applicationDefaultJvmArgs) for Windows
- copyright headers + README -> DevsLab Co., Ltd. (주식회사 데브스랩), https://devslab.kr
- show destructive confirm text in the CLI list
- i18n/messages_<lang>.properties loaded as UTF-8 (English base + language overlay;
  missing keys fall back to English, then to the key itself)
- Messages helper (MessageFormat args); language from application.yml options.language
  (blank = system locale)
- external + drop-in editable (add a language by dropping a file); no ResourceBundle,
  so a GraalVM native image stays simple
- CLI `i18n [lang]` to inspect; verified en/ko load correctly
- header logo loaded from an external ASCII-art file -> swap branding without rebuild
  (consistent with sql/, i18n/, application.yml)
- Logo loader falls back to a plain "DataLinq" wordmark if the file is missing/empty
- default banner: "DataLinq" (figlet slant); CLI `logo` to preview
- application.yml now holds multiple NAMED datasources (any can be source or target);
  an operation picks by name (source=/target=), falling back to defaults.source/target
- operation.properties: ETL target table moved from `target=` to `table=` (so `source`
  and `target` now mean datasource names, not the table)
- MigrationEngine resolves + opens the operation's source/target by name; SCRIPT needs
  only the target; handlers can open extra datasources via ctx.connection(name)
- options.sql-dir makes the migration folder location configurable (blank = ./sql)
- MigrationHandler: queryFrom (read a named datasource for multi-source joins),
  lookup (2-column reference map), insertBatch (batched writes),
  execute (raw UPDATE/DELETE for second-pass forward-FK fills)
- OrderImportHandler example ("more complex than master/detail"): one flat result set
  -> customers (dedup) + orders (master, FK resolved via a lookup map) + order_items
  (detail) - three target tables in one transaction
- bump build to Java 25 (release 25): best virtual-thread + JDBC behaviour, no
  synchronized pinning (JEP 491)
- BatchRunner runs INDEPENDENT operations concurrently on virtual threads, bounded by
  options.max-parallel (a Semaphore) so it never exceeds the DB's connection capacity;
  structured via a try-with-resources virtual-thread executor (not the preview
  StructuredTaskScope). Per-operation failures are captured, never abort the batch.
- JUnit tests verify the mechanics (all-run, concurrency bound, failure capture) without
  a database.
Built per tamboui's recommended MVC:
- DataLinqController: all state + commands/queries, no TamboUI dependency, unit-tested
  (7 tests: menu build, About nav, destructive confirm flow, cancel, immediate run,
  failure logging, parallel run-all)
- DataLinqApp: pure View (render reads the controller) + key dispatch
- header(logo + mode) / menu(base items + scanned migrations) / center(output|About|confirm)
  / footer; UI strings via Messages (i18n en/ko)
- run flow: select -> destructive i18n confirm -> MigrationEngine -> output panel; failures
  are logged, never crash. 'a' runs all migrations in parallel via BatchRunner (vthreads)
- Main launches the TUI by default; CLI subcommands (list/config/i18n/logo/run) still work

Known follow-up: a migration run is currently synchronous (blocks the UI during the run);
async (virtual thread + redraw) is the next improvement.
Replace options.release=25 with a languageVersion-25 toolchain, so Gradle locates a JDK 25
(auto-detected, e.g. from ~/.jdks) for compile + run regardless of which JDK launches
Gradle. `./gradlew run` no longer needs JAVA_HOME pointed at a JDK 25.
Add --enable-native-access=ALL-UNNAMED to the application JVM args so JLine's native
terminal calls don't emit the Java 25 restricted-method warning (and keep working once
those methods become enforced).
The app uses only Java 21 features (virtual threads are stable since 21; StructuredTaskScope
is not used), so compile to 21 - it then runs on the user's default JDK without needing a
JDK 25 at runtime. Running on JDK 24+/25 still avoids virtual-thread pinning in JDBC drivers
(JEP 491): a runtime throughput bonus, not a requirement. Fixes UnsupportedClassVersionError.
- add a Quit base-menu entry (controller quitRequested -> the View calls quit());
  i18n menu.quit (en/ko)
- About and the destructive confirm now render as centered dialog() popups overlaid via
  stack() on the main UI, instead of replacing the centre panel
- controller test for Quit; base-menu indices updated (4 base items now)
Enable mouse capture (TuiConfig.mouseCapture(true)) and dispatch input to
the controller so every menu item is reachable three ways:

- Left-click a menu row to select+activate it (hit-tests the list's
  renderedArea, maps the clicked y to a row index).
- Number keys 1-9 select+activate the matching entry; labels carry a
  leading number prefix so the binding is discoverable.
- Enter activates the highlighted row; wheel scroll is left to the list.
- Single-key actions: d (toggle dry-run), r (rescan), a (run all),
  q (quit), and Y/N inside the destructive-confirm dialog.

activateIndex(int) on the controller selects-then-activates so the number
and click paths share the existing activate() flow (confirm gate for
destructive ops, About popup, Quit). footer.keys updated in both locales.
The launcher loaded i18n/, branding/, sql/, and application.yml relative to
user.dir, so running bin/datalinq.bat from the bin/ folder showed raw message
keys and the fallback wordmark instead of the translations and logo.

- Add Home: locates the running jar via the code source and treats its
  install dir (parent of lib/) as the primary resource base, with the working
  dir as a fallback. resolve(name) returns whichever base actually has the
  resource, so dev runs (exploded classes -> CWD = project root) and packaged
  runs (CWD = anywhere -> install dir) both find their files.
- Bundle i18n/, branding/, sql/, and application.example.yml into the
  distribution image so the install dir contains the defaults. application.yml
  stays out of the image (gitignored, holds credentials).
- AppConfig.load(readFrom, saveTo) seeds from application.example.yml on first
  run while saving edits to application.yml.
- Main resolves every external path through Home instead of user.dir.
…rces

Replace the bin/lib install tree as the primary deliverable with one
self-contained datalinq.jar, runnable from anywhere via java -jar or jbang.

- Shadow plugin builds datalinq.jar. mergeServiceFiles() merges the JDBC
  java.sql.Driver descriptors so BOTH drivers register via ServiceLoader; this
  needs duplicatesStrategy = INCLUDE, otherwise the default drops MariaDB's
  descriptor before the transformer runs and only MSSQL stays registered.
  Signature files are stripped to avoid the signed-jar SecurityException.
- processResources bakes i18n/, branding/, and application.example.yml into the
  jar so a bare jar renders the right logo + translations with zero setup.
- The loaders now fall back to those classpath defaults: Messages overlays an
  external i18n/ file over the bundled one; Logo prefers an external logo.txt
  then the bundled one; AppConfig.loadResource seeds config from the bundled
  example while still saving to the user's application.yml.
- Home also treats the jar's own directory as a resource base, so a dropped jar
  picks up sql/ and overrides placed beside it.
- jbang-catalog.json aliases 'datalinq' to the jar with the UTF-8 /
  enable-native-access java-options.

Verified non-interactively from a scratch dir holding only the jar: logo, ko
translations, and example config all resolve from the classpath; the merged
java.sql.Driver lists both com.microsoft.sqlserver.jdbc.SQLServerDriver and
org.mariadb.jdbc.Driver.
Running the jar needs no setup - the defaults live on the classpath. But to
customise the logo, translations, or config you previously had to hand-create
the files next to the jar. 'datalinq init' writes them into the current folder
in one step: i18n/, branding/logo.txt, application.example.yml, and an empty
sql/ with a README documenting the NN_Title folder + operation.properties
convention. Existing files are skipped, so it is safe to re-run.

Verified from a scratch dir holding only the jar: first run creates 5 files
(Korean kept byte-for-byte through the copy), second run skips them all.
The footer hard-coded "1-9" but only as many digits as there are menu
entries do anything (4 base + N migrations). Parameterise footer.keys with
{0} and fill it with the real range (e.g. "1-8", or "1"), capped at 9 since
only 1-9 are bound.
get() returns the raw Object, which is exactly right for pass-through ETL -
the value flows back into an insert uncast. But handlers that compute with a
value (arithmetic, comparisons) had to write ((Number) r.get("qty")).longValue().

Add getLong/getInt/getBigDecimal/getBool that coerce instead of cast: the same
column can arrive as Integer, Long, or BigDecimal depending on the driver, so a
strict cast - or a <T> get(col, Class<T>) generic - would break on that
variance. BigDecimal goes via the string form to stay exact. All return boxed
types so a SQL NULL stays null. str() already covered the String/key case.

MigrationHandler's Object signatures are unchanged and intentional:
values(Object...) alternates (String key, any value) which no type parameter
can express, and the row maps / bind args are heterogeneous SQL values.
Activating the DB Connection menu entry now opens a dedicated screen instead
of logging 'coming soon'.

Controller (TUI- and JDBC-free, unit-tested):
- Screen enum (MAIN / DB_CONNECTION) drives which full screen the View renders.
- A DatasourceGateway port abstracts read / test / persist so the controller
  stays testable; AppConfigDatasourceGateway backs it with AppConfig +
  DriverManager (5s login timeout) in production.
- Commands: open/close, datasource navigation (clamped), testConnection and
  saveDatasource update a volatile dbStatus line. 4 new controller tests with
  an in-memory fake gateway (12 total in the class, 24 overall).

View (DataLinqApp): a single focusable panel owns all keys - the toolkit
intercepts Tab for focus navigation and a per-element keyHandler fires
regardless of focus, so custom focus juggling is fragile. Instead a unified
Up/Down cursor runs over [datasource rows..., URL, USER, PASSWORD]; typing
edits the active field, Up/Down on a datasource row reseeds the buffers (only
on an actual change, so edits survive), F5 tests (on a virtual thread so the
UI does not freeze), Enter saves, Esc returns. Bilingual db.* strings added.

Follow-ups: add/rename/delete datasources, set defaults from the UI, mask the
password field, async test status already shown via markTesting.
These are classpath resources baked into the jar, so the conventional Java/
Gradle home is src/main/resources - the plugin bundles them automatically, no
manual processResources copy needed. The external-override behaviour is
unchanged: Home still reads an external i18n//branding/ next to the jar (or in
the CWD) ahead of the bundled defaults, and datalinq init still extracts them
from the same classpath paths (/i18n/, /branding/logo.txt).

application.example.yml stays at the project root as a user-facing config
template (.env.example style) and is still copied into the jar for the
first-run seed; sql/ stays at the root as sample external data the scanner
discovers, not a bundled resource.
Two real problems from testing:

1. Editing was unreachable. The unified Up/Down cursor started on the
   datasource list, so reaching the fields meant pressing Down past every
   datasource - not discoverable, and typing did nothing until you got there.
   Replace it with an explicit two-pane model: Left/Right switches between the
   datasource list and the edit fields; Up/Down moves within the active pane
   (highlighted by a cyan border); typing edits the active field. F5/Enter/Esc
   unchanged. Footer hint updated in both locales.

2. Labels misaligned. The field labels were padded by String.length(), but a
   monospace terminal renders Hangul in two cells, so 'URL' (3) and '사용자'
   (3 chars / 6 cells) pushed the colons to different columns. Extract a
   TextWidth helper that measures East-Asian wide glyphs as two cells and pad
   to a column width; the colons now line up in any locale. 5 unit tests.

29 tests total.
Two bits of feedback from testing the form:

- The editable values did not look editable. Render each value as an
  underlined input line (its own styled span via row(), so only the value is
  underlined, not the label) padded to a minimum width so even an empty field
  shows the line. The active field stays yellow with the block cursor.

- Password masking should be a setting (some users want it visible). Add
  options.mask-password (default true, so the password now masks by default
  instead of showing in plain) read into the controller as togglable state;
  the password field renders bullets while still saving the real value. The
  Settings screen will expose the toggle; for now it is read from
  application.yml. 1 controller test for the toggle (30 total).

Label alignment already works via TextWidth, so the labels were left as-is
rather than switched to a form-field widget (which would force Tab-based focus
navigation).
Activating the Settings menu entry now opens an editor for the options.* block
instead of logging 'coming soon'.

Controller (TUI/config-free, unit-tested):
- Screen.SETTINGS + a SettingsGateway port (prod: AppConfig; test: fake).
- Edit buffers for language, dry-run-default, mask-password, batch-size,
  max-parallel, sql-dir, seeded on open. Up/Down moves rows; toggle cycles the
  choice/boolean rows; typing edits the text rows (digits only for the numbers).
- saveSettings() persists all via the gateway; mask-password applies live, and a
  changed sql-dir triggers rescan() so the menu reflects the new folder right
  away - the other half of 'drop a folder -> menu appears'. 4 controller tests
  (17 in the class, 34 total).

Wiring: the operation provider now resolves sql-dir from config on every scan,
so a Settings change rescans the new directory. AppConfig gains setOption();
the existing AppConfig gateway also implements SettingsGateway (one object,
both ports).

View: a single focusable panel, same robust model as the DB screen - Up/Down
rows, Space/Left/Right toggles, type to edit (underlined value rows), Enter
saves, Esc returns. Bilingual settings.* / field.* strings. Language /
batch-size / max-parallel take effect next launch (noted on screen).
Three issues from testing the Settings screen:

- Space saved instead of toggling. The default bindings map Actions.SELECT to
  BOTH Enter and Space, so e.matches(SELECT) caught Space before the toggle
  check. Save now triggers on KeyCode.ENTER only (same fix applied to the DB
  screen, where Space should type a space, not save).

- Rows were cramped. The options column now uses spacing(1) for a blank line
  between rows.

- The sql folder took raw text. Add a directory picker: Space on the sql-dir
  row opens a browser rooted at the current value (or the working dir); Up/Down
  moves, Enter descends into a subfolder / goes up via '..' / selects the
  current folder via '.', Esc cancels. Selecting sets the buffer; Enter then
  saves and rescans. The browse logic lives in the controller (java.nio,
  unit-tested with a @tempdir) so it stays View-free. Manual typing still works
  as a fallback. Bilingual picker.* strings added.

35 tests (controller 18).
spacing(1) put a blank line between every row, which read as too airy. Use a
single blank line between the toggle group and the value group instead - tight
within each group, one break between. TUI gaps are whole lines, so this is the
middle ground between fully tight and a gap on every row.
Esc was unhandled on the main screen, so tamboui's router cleared focus and the
follow-up redraw threw; its error handler then tried to load RenderError and, if
the jar had been overwritten by a rebuild while running, failed with
NoClassDefFoundError. Handle Esc on the main screen directly - it now quits
cleanly (consumed before the focus-clear), so the render-error path is never
entered. The DB/Settings screens already consume Esc (back), so only the main
screen was affected.
Apply spacing(1) to the datasource list and the form fields on the DB
Connection screen so the rows get the same breathing room as the Settings
screen, instead of being packed tight.
The inline [S]/[T] markers read like section headers ('everything below is the
source'), but they actually just flag the one default-source and one
default-target datasource. Replace them:

- The datasource list is now a plain connection list (no markers).
- Below it, an explicit line: 'default source: X' / 'default target: Y'.
- S / T keys set the selected datasource as the default source / target and
  persist immediately (new DatasourceGateway.setDefaultSource/Target).

This also matches the model the user kept: datasources are role-agnostic
connections; an operation picks its source/target by name and the defaults are
just the fallback when it doesn't. So one source + many targets works fine -
each operation routes its own target; the 'default target' is only the
fallback. 1 controller test (19 in the class, 36 total).
Instead of one raw URL, the DB form is now IntelliJ-style:

- A DB type selector (Space cycles MS SQL Server / MariaDB-MySQL / Custom).
- Structured types show Host / Port / Database fields and derive the JDBC URL
  (shown as a live preview); Custom keeps a hand-written URL.
- JdbcUrls builds the URL per type (default ports 1433 / 3306). Only the bundled
  drivers are offered; the MariaDB driver also connects to MySQL servers.

Storage: application.yml datasources now carry type/host/port/database; the URL
is derived. AppConfig.url() derives for structured types and returns the stored
url for custom or legacy (no type) entries, so old 'url:' configs keep working.
DatasourceGateway/AppConfig gain structured accessors + saveStructured; the
controller exposes them and saveDatasourceStructured.

Also: removed the hardcoded 'MS SQL Server -> MariaDB' line from the About box
(the tool is JDBC-generic). Bilingual field.type/host/port/database strings.

Verified: structured config derives the right URLs (sqlserver + mariadb),
legacy url: still resolves. JdbcUrlsTest (4) + a structured save test (controller
20); 41 total.
Bundle the PostgreSQL driver (BSD, ~1MB) as a third structured type
(jdbc:postgresql://host:port/db, default 5432). The fat jar now merges three
java.sql.Driver entries (mssql, mariadb, postgresql).

For any other database, IntelliJ-style on-demand drivers:
- DriverManager ignores drivers loaded by a foreign class loader, so an
  externally-loaded driver is wrapped in a DriverShim (loaded by the app class
  loader, delegating to the real driver) and registered - that is the one real
  source of complexity here.
- Drivers.loadExternal() (called at startup) scans ~/.datalinq/drivers/*.jar,
  loads them in a URLClassLoader and registers each via the shim.
- 'datalinq driver <name>' downloads a catalog driver (mysql / postgresql / h2 /
  sqlite / oracle) from Maven Central into that dir; 'datalinq driver' lists them.
  Users may also just drop a driver jar there. Such drivers are used from the
  Custom type + a hand-written URL.

Avoided bundling Oracle (proprietary) and MySQL Connector/J (GPL) - the MariaDB
driver already connects to MySQL servers, and the rest are one download away.

Verified end-to-end on the real machine: 'driver h2' downloads the jar, and
DriverManager.getConnection("jdbc:h2:mem:test") then connects through the shim
(H2 2.3.232). JdbcUrlsTest (5) + DriversTest (4); 46 total.
drivers: only load external JDBC drivers for the tui/run commands (the ones
that actually open connections). loadExternal() opens a URLClassLoader over
the jars, which on Windows keeps the files locked for the JVM's lifetime;
doing it on every command made `driver <name>` fail to overwrite an
already-downloaded jar with a cryptic FileSystemException - the real reason
an Oracle re-download appeared to "fail". download() now reports HTTP status
clearly (404 -> actionable message) and stages to a .part file before an
atomic move, so a failed download never leaves a truncated jar.

ui: localise the output panel title (was hardcoded "output"); ellipsise the
derived URL preview instead of hard-cutting it at the panel border; indent
the default source/target lines to line up with the datasource list; hide
the menu scrollbar when the list fits (AS_NEEDED) so short menus stay clean.
jlc488 added 4 commits June 14, 2026 00:30
README said a fixed "Source = MS SQL Server, Target = MariaDB" and "TUI wired
next", both stale: connections are now configurable by type (sqlserver /
mariadb / postgresql) + host/port/database or a custom URL, drivers are
bundled/downloadable, and the TUI is done. Reframe as cross-vendor, document
the structured config, the `driver` download flow, and the fat-jar/jbang run
path; drop the same stale phrasing from the jbang catalog description.

Add CHANGELOG.md (Keep a Changelog) with an [Unreleased] entry covering the
initial engine + config + drivers + TUI feature set and the notable fixes.
Cover the engine's core promises end to end against real databases: the
cross-vendor ETL row copy, the dry-run "writes nothing" guarantee (target
rolled back), the rollback-on-error guarantee (a target PK violation leaves
the target unchanged), and SCRIPT execution / SCRIPT dry-run on the target.

The class is annotated @testcontainers(disabledWithoutDocker = true), so it
runs where a Docker daemon is reachable (CI) and is skipped - not failed -
where it is not, keeping the local build green on machines without Docker.

Verified the same five scenarios green against real postgres:16 -> mariadb:11.4
this session (engine logic + assertions), so CI exercises only the container
wiring on top of already-proven behaviour.
Run gradlew build on pull requests and pushes to main. The ubuntu runner
provides a Docker daemon, so the Testcontainers integration tests execute
in CI even though they skip on local machines without Docker.
Cloned on Windows, gradlew lost its +x bit, so the Linux CI runner failed
with 'Permission denied' (exit 126) before Gradle started.
@jlc488
jlc488 merged commit b66c992 into main Jun 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant