Compare commits

...

55 Commits

Author SHA1 Message Date
2ced7810d9 docs: -b now covers Postgres (COPY) as well as SQL Server
Update readme + CLAUDE: -b is no longer SQL-Server-only. Describe the Postgres
COPY FROM STDIN path (CopyManager, text-based, CSV-quoted, empty vs NULL) next
to the existing SQL Server SQLServerBulkCopy path; DB2 still falls back to INSERT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:16:00 -04:00
6fe2bea089 feat: -b bulk copy into Postgres dest via COPY FROM STDIN
Extends -b to Postgres destinations: stream the source ResultSet into PG with
COPY <dt> FROM STDIN (FORMAT csv) via the JDBC CopyManager, instead of batched
INSERTs. COPY is text-based so the server parses each field into the column
type — no per-type quoting needed. Every non-null value is CSV-quoted (so
empty string stays distinct from NULL, which is an empty unquoted field);
rows are flushed in 1000-row buffers with a 10k-row progress counter.

Validated DB2->PG: numeric precision (123.4567), jsonb, unicode, embedded
quotes, NULL vs empty-string all correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:04:59 -04:00
a61e018932 docs: document the -b bulk copy path (readme + CLAUDE)
Add the -b flag to the readme/CLAUDE flag lists and describe the bulk copy
migration sub-mode: SQLServerBulkCopy via the BulkSource adapter (SQL Server
dest only), why numeric/string-ish types route through NVARCHAR, the ~111min
-> ~4min win, and the 10k-row live progress counter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:50:58 -04:00
dc2f850530 feat: live progress + clean final count for bulk copy path
The bulk path showed no progress during a load (only the final count). Emit
an in-place counter (\r + rows) every 10k rows from the BulkSource adapter,
which the caller pulls one row at a time, so it streams live. Prefix the
final count print with \r so it starts a fresh line instead of concatenating
onto the last tick (which produced a garbage row count like 3000035000).

Verified: ticks emit at 10k/20k/30k, final row_count parses correctly, and
pipekit's progress-collapse renders it as a single updating line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 08:08:39 -04:00
7be85a2da1 fix: report row count from bulk copy path
The bulk path printed no count, so the trailing " rows written" line had no
number and callers parsing stdout got nothing. Count rows in the BulkSource
adapter (one per getRowData) and print it, matching the INSERT path's
"<n> rows written" so the count is captured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 07:39:56 -04:00
ce76e93a77 feat: -b bulk copy into SQL Server dest via SQLServerBulkCopy
Adds an opt-in -b flag (migration mode, SQL Server dest only) that streams the
source ResultSet straight into SQL Server over the TDS bulk-load protocol
instead of 250-row INSERT...VALUES round trips. A BulkSource adapter
(ISQLServerBulkData) maps PG source types to JDBC types we control: string-ish
types (text/varchar/char/bpchar/json/jsonb/uuid/numeric) go through NVARCHAR via
getString so SQL Server converts losslessly — notably numeric, since PG reports
unconstrained numeric as scale 0 which made a typed DECIMAL path round
(123.45 -> 123). Default stays the INSERT path, so nothing regresses.

Validated against live PG->SQL Server: int4/text/jsonb/numeric/date plus nulls,
unicode, quotes, and numeric precision (123.45, 0.123456) all correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:18:45 -04:00
d9fd651c72 docs: update CLAUDE.md for PG streaming and new quoted types
Reflect the two behavior changes: (1) migration mode sets the source
connection to autoCommit=false so PostgreSQL's setFetchSize actually streams
(it's ignored otherwise) — and why query mode is excluded; (2) json/jsonb/
bpchar/uuid are now quoted, plus document the default-emits-unquoted gotcha
for future type additions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:39:47 -04:00
78c832eb1f fix: quote json/jsonb/bpchar/uuid values in migration INSERTs
The migration INSERT builder's switch quoted varchar/text/char/clob/date/time
but let everything else fall to a default that emits rs.getString() unquoted
(correct for numerics, broken for strings). A pg->SQL Server pull of a jsonb
column failed with "Incorrect syntax near 'volume_bucket'" — the JSON text's
embedded double-quotes were read as a SQL identifier. Quote json/jsonb, plus
bpchar (PG char(n)) and uuid, like varchar.

Note: the default case still emits unquoted; other unhandled string types
(e.g. bool->'t'/'f') would need similar handling or a quote-by-default flip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:31:45 -04:00
a1c9ea26ce fix: stream PostgreSQL source (migration mode) instead of buffering it all
setFetchSize(10000) is a no-op on the PostgreSQL JDBC driver while autoCommit
is true — the driver loads the entire ResultSet into memory, OOM/GC-thrashing
on large source tables (a pg->SQL Server pull pinned the box: 4GB heap, swap
full, 0 rows written). PG only uses a server-side cursor when autoCommit is
false AND fetchSize > 0.

Set the source connection to manual commit ONLY in migration mode: the
migration source is read-only so never committing is harmless. Query mode is
excluded on purpose — callers (pipekit's run_dest_sql) run committed DDL/DML
through query mode, and autoCommit=false would roll those back on close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:51:57 -04:00
f632a77e8e Add SQL Server datetime type variants to type handling
Adds DATETIME, DATETIME2, SMALLDATETIME, and DATETIMEOFFSET cases to
the TIMESTAMP branch so SQL Server datetime columns are handled correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:23:07 -04:00
ff4cf25585 Add ~/.jrunnerpass named connection profile support
Implements .pgpass-style credential file for jrunner. Named aliases can
be used with -sc and -dc flags instead of spelling out -scu/-scn/-scp
for each invocation. Explicit flags still take priority over the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:59:04 -05:00
fc93be5a9d Fix global install to use installDist instead of wiping project dir
Option 2 now runs installDist and symlinks /usr/local/bin/jrunner and
/usr/local/bin/jrq to the build output, avoiding the rm -rf of the
project directory when the repo lives at the deploy path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 22:01:51 -05:00
7db2abdf18 Create ~/.jrqrc config template on deploy
deploy.sh now generates a commented ~/.jrqrc template on first install
so users know what to fill in. Skips creation if the file already exists
to avoid overwriting existing credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 06:49:41 -05:00
6a925b83ca Add jrq query wrapper script and deploy integration
Adds jrq, a convenience wrapper that loads connection details from
~/.jrqrc so users can run query mode without typing credentials each
time. deploy.sh now installs jrq alongside jrunner in all deploy modes,
with a /usr/local/bin symlink for global installs. jrq resolves the
jrunner binary relative to its own location to avoid hardcoded paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 06:40:44 -05:00
c0f6e3a6e6 Document streaming architecture and memory usage
Clarify that both query and migration modes use streaming with no array
storage. Query mode streams directly to stdout, while migration mode
streams into a SQL string buffer (250 rows). The 10k fetch size is a
JDBC driver hint for network efficiency, not application-level storage.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 14:28:26 -05:00
ba24b874fc Add automated setup script for new installations
Create setup.sh that checks for and installs dependencies:
- Detects Java 11+ or offers to install via package manager
- Verifies Gradle wrapper presence
- Checks for unzip (needed for deployment)
- Runs test build to verify everything works
- Provides clear next steps after successful setup

Update readme with Quick Start section featuring setup script as the recommended approach for new systems.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 11:01:26 -05:00
424d7d4ebb Update readme, CLAUDE.md, and bump version to 1.1
- Document query mode feature with examples
- Update deploy script documentation
- Add dual mode operation explanation to CLAUDE.md
- Document CSV/TSV output formats
- Update version from 1.0 to 1.1

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 10:58:37 -05:00
56fecff550 Enable tab completion for custom directory input
Use read -e to enable readline support, providing tab completion and line editing when entering custom deployment paths. Also expand tilde to home directory.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 10:54:41 -05:00
1ca507d1dd Make deploy script interactive with local/global/custom options
- Option 1: Local install (uses gradlew installDist)
- Option 2: Global install to /opt/jrunner with symlink
- Option 3: Custom directory (mandatory user input)
- Removed default directory, now requires explicit choice

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 10:51:58 -05:00
1717c7ee2c Make query mode silent for clean piping to pagers
Remove all diagnostic output in query mode - no front matter, timestamps, or metadata. Query results go directly to stdout for seamless piping to visidata/pspg/less.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 14:05:21 -05:00
6c9b0f96a0 Add query-only mode for piping results to visidata/pspg/less
Query mode auto-activates when destination flags are omitted, outputting CSV/TSV to stdout for interactive data exploration of DB2 iSeries queries.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 00:53:26 -05:00
85355efe8f add MIT license
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:44:47 -05:00
57093441c3 fix ownership after deployment
Add chown to set deployed files to current user instead of leaving
them owned by root. This matches the original copy_to_apt.sh behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:38:43 -05:00
f084f8380a update readme for new deploy script and bump version to 1.0
Readme changes:
- Document that deployment directory must exist first
- Show mkdir -p commands before deploy
- Explain atomic deployment behavior (extracts to /tmp first)

Version bump to 1.0:
- Major refactoring: renamed app to jrunner
- Simplified deployment script
- Updated documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:37:05 -05:00
a9bd96b377 simplify deploy script with atomic deployment
Completely rewrote deploy script to be simpler and safer:
- Requires directory to exist (no automatic creation)
- Builds and extracts to /tmp/ FIRST
- Only clears target directory after build/extract succeeds
- If build fails, existing deployment stays untouched

Usage:
  sudo mkdir -p /opt/jr_test
  ./deploy.sh /opt/jr_test

This atomic approach prevents broken deployments from failed builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:32:28 -05:00
c6d34847d5 fix race condition in deploy script rename logic
Previous version failed when /opt/jrunner existed from a prior run,
because mv cannot move over an existing directory. Now the script:

1. Removes target directory if it exists
2. Removes intermediate jrunner/ directory if it exists and differs
   from target (prevents collision)
3. Extracts cleanly
4. Renames to target name

This fixes the issue where ./deploy.sh /opt/jr_test would fail if
/opt/jrunner already existed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:26:14 -05:00
4b8ffcdd1c gitignore accidentally extracted distribution files
Prevent bin/ and lib/ in project root from showing up in git if the
deploy script accidentally extracts to the wrong location.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:20:22 -05:00
e9fc745d20 fix deploy script to honor custom directory names
The zip file contains 'jrunner/' as top-level directory, so unzipping
always created /opt/jrunner regardless of custom path. Now the script
renames the directory after unzipping if a custom name was specified.

Example:
  ./deploy.sh /opt/jr_test

Now correctly creates /opt/jr_test (not /opt/jrunner).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:15:33 -05:00
9cf698c67d add safety checks to deploy script
Prevent dangerous sudo rm -rf operations:
- Check that DEPLOY_DIR is not empty
- Block deployment to critical system directories (/, /usr, /etc, etc.)
- Only remove directory if it already exists
- Show message before removal for clarity

This prevents catastrophic mistakes like accidentally deploying to /
or deleting important system directories.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:09:02 -05:00
20d40f069d ensure parent directory exists in deploy script
Add mkdir -p to create parent directory if it doesn't exist. This allows
deploying to any path without requiring manual directory creation.

Example:
  ./deploy.sh /home/user/testing/jrunner

Now works even if /home/user/testing doesn't exist yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:07:03 -05:00
b0f104927c document deploy script usage in readme
Add clear documentation showing:
- deploy.sh with no arguments uses default /opt/jrunner location
- deploy.sh with argument deploys to custom location for testing
- Explain symlink behavior (only for default location)
- Show usage examples for both deployment types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:03:14 -05:00
0ecb6860bd make deploy location configurable
Allow specifying custom deployment directory as argument, defaulting to
/opt/jrunner if not provided. Symlink to /usr/local/bin only created
for default location to avoid overwriting production.

Usage:
  ./deploy.sh                    # deploys to /opt/jrunner (default)
  ./deploy.sh /opt/jrunner-test  # test deployment

This allows testing new builds without affecting production deployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 22:00:31 -05:00
c41ab99841 update and rename deployment script
Renamed copy_to_apt.sh to deploy.sh (clearer name) and updated to:
- Use new jrunner/ paths instead of app/
- Add build step so script handles full build+deploy
- Create symlink to /usr/local/bin for system-wide access
- Remove unused JR environment variable export
- Add set -e for error handling
- Add progress messages

Usage: ./deploy.sh

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 21:57:54 -05:00
8cdd88d053 rename app module to jrunner for consistency
Changes:
- Rename app/ directory to jrunner/ (preserves git history)
- Update settings.gradle to reference jrunner module
- Update readme.md with new paths (jrunner/build/, /opt/jrunner)
- Update CLAUDE.md documentation with new file paths

Build outputs now named jrunner.zip, jrunner.jar, bin/jrunner instead
of generic "app" names. This makes the project structure clearer and
aligns module name with project name.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 21:53:08 -05:00
809f2a8949 ignore local configuration files
Add run.yml to gitignore as it's a personal config file for local testing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 21:46:07 -05:00
f47eaf4bac upgrade gradle wrapper to 8.5 for java 20 compatibility
Gradle 7.5.1 only supports up to Java 18. Upgraded to 8.5 to support
Java 20 and fix "Unsupported class file major version 64" build error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 21:42:36 -05:00
1392e0a6d8 add CLAUDE.md for AI-assisted development
Documents build commands, architecture, and implementation details to help
Claude Code instances understand the codebase structure and data flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 21:42:05 -05:00
b0ee4c77d9 improve deployment instructions
- Use gradle wrapper (./gradlew) instead of requiring manual install
- Simplify deployment from 5 commands to 2
- Add symlink to /usr/local/bin for system-wide access
- Remove unnecessary ownership changes
- Add update instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 21:41:51 -05:00
b3a9151eff change ownership to current owner; create envvar 2023-03-08 09:12:18 -05:00
e7b7d1bbba add an option to clear out the target table prior to moving data 2023-02-01 16:29:53 -05:00
24fad6aa04 convert type to upper case and handle TEXT data type 2023-02-01 09:04:10 -05:00
b816399cba include integrated auth lib 2023-01-24 12:04:48 -05:00
fe39c6a2ae include mssql driver 2023-01-24 11:44:05 -05:00
pt
c67c7b1360 update copy 2022-12-01 07:13:18 -05:00
05508d8b26 use blocks 2022-11-30 14:26:37 -05:00
1050930667 add instruction and copy script 2022-11-30 14:25:16 -05:00
509873e60b update version 2022-10-27 07:17:13 -04:00
2245ef1ba2 print times 2022-10-27 07:16:42 -04:00
c7884f3605 clean up replacements 2022-10-27 06:58:18 -04:00
efd922b2e0 v.036 2022-10-25 14:43:53 -04:00
3b4af2bf47 set fetch size; replace quotes only after handling null 2022-10-25 14:43:30 -04:00
0615163fad wrap dates 2022-10-25 17:49:26 +00:00
cf5abeddbe push null test into each type 2022-10-25 13:24:15 -04:00
e60a92cfdc wrap timestamps in quotes and print more status info 2022-10-24 15:17:29 +00:00
54ab5645b1 print headers for each section; evaluate null as a potential string object status 2022-10-24 13:28:26 +00:00
13 changed files with 1581 additions and 298 deletions

7
.gitignore vendored
View File

@ -5,3 +5,10 @@
build
*.swp
# Ignore local configuration files
run.yml
# Ignore accidentally extracted distribution files in project root
/bin/
/lib/

183
CLAUDE.md Normal file
View File

@ -0,0 +1,183 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
jrunner is a Java CLI tool for migrating data between databases. It reads data from a source database using SQL queries and writes it to a destination table, batching inserts for performance. The tool supports multiple database types via JDBC drivers including PostgreSQL, IBM AS/400, and Microsoft SQL Server.
## Build and Test Commands
Build the project:
```bash
gradle build
# or use wrapper
./gradlew build
```
Run tests:
```bash
gradle test
# or use wrapper
./gradlew test
```
Build distribution package:
```bash
gradle build
# Creates jrunner/build/distributions/jrunner.zip
```
Local install for testing (recommended):
```bash
./gradlew installDist
# Creates executable at jrunner/build/install/jrunner/bin/jrunner
```
Deploy using interactive script:
```bash
./deploy.sh
# Choose: 1) Local install, 2) Global install to /opt, 3) Custom directory
```
## Architecture
### Single-File Design
The entire application logic resides in `jrunner/src/main/java/jrunner/jrunner.java`. This is a monolithic command-line tool with no abstraction layers or separate modules.
### Dual Mode Operation (v1.1+)
The tool operates in two modes:
**Query Mode** (new in v1.1):
- Activates automatically when destination flags are not provided
- Outputs query results to stdout in CSV or TSV format
- Silent operation - no diagnostic output, just clean data
- Designed for piping to visidata, pspg, less, or other data tools
- Format controlled by -f flag (csv or tsv)
**Migration Mode** (original functionality):
- Activates when destination flags are provided
- Reads from source, writes to destination with batched INSERTs (or bulk copy with `-b`)
- Shows progress counters and timing information
**Bulk Copy** (migration mode, `-b`) — uses the dest's native bulk path; falls
back to the INSERT path for any other dest (e.g. DB2):
*SQL Server dest* — streams the source ResultSet over the TDS bulk-load
protocol via `SQLServerBulkCopy` (no per-batch INSERT round trips; a 1.27M-row,
~298-col load went ~111 min → ~4 min). A `BulkSource` adapter
(`ISQLServerBulkData`) maps source type names to JDBC types we control:
string-ish types (text/varchar/char/bpchar/json/jsonb/uuid **and numeric**) are
declared NVARCHAR and read via `getString` so SQL Server converts losslessly —
numeric goes this route because PG reports unconstrained numeric as scale 0,
which a typed DECIMAL path would round (123.45 → 123).
*Postgres dest* — streams via `COPY <table> FROM STDIN WITH (FORMAT csv)` using
the JDBC `CopyManager`. COPY is text-based, so the server parses each field into
the column type — no per-type handling. Every non-null value is CSV-quoted
(empty string stays distinct from NULL, which is an empty unquoted field); rows
flush in 1000-row buffers.
Both emit a `\r`-counter every 10k rows for live progress and print the final
row count.
### Data Flow
**Query Mode:**
1. Parse command-line arguments (-scu, -scn, -scp for source)
2. Read SQL query from file specified by -sq flag
3. Connect to source database via JDBC
4. Execute source query and fetch results (fetch size: 10,000 rows)
5. Output results to stdout in CSV or TSV format
6. Close connection and exit
**Migration Mode:**
1. Parse command-line arguments (-scu, -scn, -scp for source; -dcu, -dcn, -dcp for destination)
2. Read SQL query from file specified by -sq flag
3. Connect to source and destination databases via JDBC
4. Execute source query and fetch results (fetch size: 10,000 rows)
5. Optionally clear target table before insert if -c flag is set
6. With `-b`: bulk-load via the dest's native path (SQL Server → `SQLServerBulkCopy`,
Postgres → `COPY FROM STDIN`). Otherwise: build batched INSERT statements
(250 rows per batch) and execute them against the destination table (-dt)
### Type Handling
The tool includes explicit handling for different SQL data types in a switch statement (migration mode). Quoted string types: VARCHAR/NVARCHAR, TEXT/NTEXT, CHAR/NCHAR, CLOB/NCLOB, and the PostgreSQL string-ish types JSON, JSONB, BPCHAR (PG `char(n)`), and UUID. Date/time types (DATE, TIME, TIMESTAMP/DATETIME variants) are also quoted. String types get quote escaping (`'` → `''`) and optional trimming.
**Caveat — the `default` case emits values UNQUOTED** (correct for numerics like INT*/NUMERIC, which is why they're not listed). Any *string-typed* column whose JDBC type name isn't in the switch falls here and breaks the generated INSERT with a syntax error (e.g. PostgreSQL `bool``'t'`/`'f'` is currently unhandled). When adding a new source type, decide: numeric → leave to default; anything string-like → add a quoted case. A more robust future fix is to flip the default to quote-as-string with an explicit numeric allowlist.
### Database Drivers
JDBC drivers are configured in `jrunner/build.gradle`:
- PostgreSQL: org.postgresql:postgresql:42.5.0
- IBM AS/400 (JT400): net.sf.jt400:jt400:11.0
- Microsoft SQL Server: com.microsoft.sqlserver:mssql-jdbc:9.2.0.jre8
- SQL Server Integrated Auth: com.microsoft.sqlserver:mssql-jdbc_auth:9.2.0.x64
The AS/400 driver requires explicit Class.forName() registration (line 144).
## Configuration
The project uses a YAML configuration format (run.yml) to specify database connections, SQL script paths, and runtime options. However, the main application currently uses command-line arguments instead of parsing this YAML file.
Command-line flags:
- `-scu` - source JDBC URL
- `-scn` - source username
- `-scp` - source password
- `-dcu` - destination JDBC URL (migration mode only)
- `-dcn` - destination username (migration mode only)
- `-dcp` - destination password (migration mode only)
- `-sq` - path to source SQL query file
- `-dt` - fully qualified destination table name (migration mode only)
- `-t` - trim text fields (default: true)
- `-c` - clear target table before insert (default: true, migration mode only)
- `-b` - bulk load into dest (migration mode): SQL Server via SQLServerBulkCopy, Postgres via COPY
- `-f` - output format: csv, tsv (query mode only, default: csv)
## Key Implementation Details
### Mode Detection
Query mode is automatically detected at runtime (line 131) by checking if all destination flags (dcu, dcn, dcp, dt) are empty. This allows seamless switching between query and migration modes without explicit mode flags.
### Query Mode Output (v1.1+)
Query mode uses dedicated output methods:
- `outputQueryResults()` - Dispatches to format-specific methods
- `outputCSV()` - RFC 4180 compliant CSV with proper quote escaping
- `outputTSV()` - Tab-separated with tabs/newlines replaced by spaces
- All output goes to stdout; no diagnostic messages in query mode
- Helper methods: `escapeCSV()` and `escapeTSV()` for proper formatting
### Memory and Streaming Architecture
Both modes use a streaming architecture with no array storage of result rows:
**Query Mode Streaming:**
- Rows are pulled from the ResultSet via `rs.next()` one at a time
- Each row is immediately formatted and written to stdout
- No accumulation in memory - pure streaming from database to stdout
- The only buffer is the JDBC driver's internal fetch buffer (10,000 rows)
**Migration Mode Streaming:**
- Rows are pulled from the ResultSet via `rs.next()` one at a time
- Each row is converted to a SQL VALUES clause string: `(val1,val2,val3)`
- VALUES clauses are accumulated into a single `sql` string variable
- When 250 rows accumulate, the string is prepended with `INSERT INTO {table} VALUES` and executed
- The `sql` string is cleared and accumulation starts again
- Only holds up to 250 rows worth of SQL text in memory at once
**JDBC Fetch Size:**
- Both modes set `stmt.setFetchSize(10000)` — a hint to fetch 10,000 rows at a time
- The application processes rows one at a time via `rs.next()`; the only buffer is the driver's fetch window
**⚠️ PostgreSQL requires autoCommit=false for fetchSize to take effect.** The PG JDBC driver IGNORES `setFetchSize` while autoCommit is true and instead loads the ENTIRE result set into memory (OOMs / GC-thrashes on large source tables). So in **migration mode** the source connection is set to `setAutoCommit(false)` right after connecting, which enables a server-side cursor and makes streaming actually stream. This is done **only in migration mode** — query mode leaves autoCommit at its default because callers run committed DDL/DML through query mode (e.g. external tools), and autoCommit=false would roll those statements back on connection close. (jt400/MSSQL drivers stream regardless, so only PG is affected.)
### Batch Size (Migration Mode)
INSERT statements are batched at 250 rows (hardcoded around line 356). Rows are streamed into a SQL string buffer as VALUES clauses. When 250 rows accumulate in the string, it is prepended with "INSERT INTO {table} VALUES" and executed, then the string is cleared.
### Error Handling
SQLException handling prints stack trace and exits immediately with System.exit(0). There is no transaction rollback or partial failure recovery.
### Performance Considerations
- Result set fetch size is set to 10,000 rows (line 190)
- Progress counter prints with carriage return for real-time updates (migration mode only)
- Timestamps captured at start and end for duration tracking (migration mode only)
- Query mode has no progress output to keep stdout clean for piping

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Paul Trowbridge
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,296 +0,0 @@
package jrunner;
import java.sql.*;
import java.util.*;
import java.nio.file.Files;
import java.nio.file.Path ;
import java.nio.file.Paths;
public class jrunner {
//static final String QUERY = "SELECT * from rlarp.osm LIMIT 100";
public static void main(String[] args) {
String scu = "";
String scn = "";
String scp = "";
String dcu = "";
String dcn = "";
String dcp = "";
String sq = "";
String dt = "";
Boolean trim = true;
Integer r = 0;
Integer t = 0;
String sql = "";
String nr = "";
String nc = "";
String nl = "\n";
String msg = "";
Connection scon = null;
Connection dcon = null;
Statement stmt = null;
Statement stmtd = null;
ResultSet rs = null;
String[] getv = null;
Integer cols = null;
String[] dtn = null;
msg = "jrunner version 0.32";
msg = msg + nl + "-scu source jdbc url";
msg = msg + nl + "-scn source username";
msg = msg + nl + "-scp source passowrd";
msg = msg + nl + "-dcu destination jdbc url";
msg = msg + nl + "-dcn destination username";
msg = msg + nl + "-dcp destination passowrd";
msg = msg + nl + "-sq path to source query";
msg = msg + nl + "-dt fully qualified name of destination table";
msg = msg + nl + "-t trim text";
msg = msg + nl + "--help info";
//---------------------------------------parse args into variables-------------------------------------------------
for (int i = 0; i < args.length; i = i +1 ){
switch (args[i]) {
//source connection string
case "-scu":
scu = args[i+1];
break;
//source username
case "-scn":
scn = args[i+1];
break;
//source password
case "-scp":
scp = args[i+1];
break;
//destination connection string
case "-dcu":
dcu = args[i+1];
break;
//destination username
case "-dcn":
dcn = args[i+1];
break;
//destination password
case "-dcp":
dcp = args[i+1];
break;
//source query path
case "-sq":
try {
//sq = Files.readAllLines(Paths.get(args[i+1]));
sq = Files.readString(Paths.get(args[i+1]));
}
catch (Exception e) {
//System.out.println(nl + "error reasing source sql file: " + printStackTrace());
e.printStackTrace();
System.exit(0);
return;
}
break;
//destination table name
case "-dt":
dt = args[i+1];
break;
case "-t":
trim = true;
break;
case "-v":
System.out.println(msg);
return;
case "--version":
System.out.println(msg);
return;
case "--help":
System.out.println(msg);
return;
case "-help":
System.out.println(msg);
return;
case "-h":
System.out.println(msg);
return;
case "\\?":
System.out.println(msg);
return;
default:
break;
}
}
System.out.println(scu);
System.out.println(scn);
System.out.println(dcu);
System.out.println(dcn);
System.out.println(sq);
System.out.println(dt);
//return;
//force regstration
try {
Class.forName("com.ibm.as400.access.AS400JDBCDriver");
} catch (ClassNotFoundException cnf) {
System.out.println("The AS400 JDBC driver did not load");
System.exit(0);
}
//-------------------------------------------establish connections-------------------------------------------------
//source database
try {
scon = DriverManager.getConnection(scu, scn, scp);
} catch (SQLException e) {
System.out.println("issue connecting to source:");
e.printStackTrace();
System.exit(0);
}
//destination database
try {
dcon = DriverManager.getConnection(dcu, dcn, dcp);
} catch (SQLException e) {
System.out.println("issue connecting to desctination:");
e.printStackTrace();
System.exit(0);
}
//----------------------------------------open resultset------------------------------------------------------------
try {
stmt = scon.createStatement();
rs = stmt.executeQuery(sq);
//while (rs.next()) {
// System.out.println(rs.getString("x"));
//}
} catch (SQLException e) {
System.out.println("issue retrieving rows from source:");
e.printStackTrace();
System.exit(0);
}
//---------------------------------------build meta---------------------------------------------------------------
try {
cols = rs.getMetaData().getColumnCount();
System.out.println("number of cols: " + cols);
getv = new String[cols + 1];
dtn = new String[cols + 1];
} catch (SQLException e) {
e.printStackTrace();
System.exit(0);
}
try {
for (int i = 1; i <= cols; i++){
dtn[i] = rs.getMetaData().getColumnTypeName(i);
System.out.println(rs.getMetaData().getColumnName(i) + ": " + dtn[i]);
}
} catch (SQLException e) {
e.printStackTrace();
System.exit(0);
}
//-------------------------------build & execute sql-------------------------------------------------------------
try {
while (rs.next()) {
r++;
t++;
nr = "";
for (int i = 1; i <= cols; i++){
nc = rs.getString(i);
if (dtn[i] == "DATE" && nc == "null") {
nc = "NULL";
}
else {
if (rs.wasNull()) {
nc = "NULL";
} else {
switch (dtn[i]){
case "VARCHAR":
nc = rs.getString(i).replace("'","''");
if (trim) { nc = nc.trim();}
nc = "'" + nc + "'";
break;
case "CLOB":
nc = rs.getString(i).replace("'","''");
if (trim) { nc = nc.trim();}
nc = "'" + nc + "'";
break;
case "CHAR":
nc = rs.getString(i).replace("'","''");
if (trim) { nc = nc.trim();}
nc = "'" + nc + "'";
break;
case "DATE":
nc = "'" + rs.getString(i) + "'";
if (nc == "'1/1/0001 12:00:00 AM'") {
nc = "NULL";
}
break;
case "TIME":
nc = "'" + rs.getString(i).replace("'","''") + "'";
break;
case "BIGINT":
nc = rs.getString(i);
default:
if (rs.getString(i) != "") {
nc = rs.getString(i);
}
else {
nc = "NULL";
}
break;
}
}
}
if (i != 1){
nr = nr + ",";
}
nr = nr + nc;
}
//add a comma to the end of the VALUES block to accomodate a new row
if (sql!="") {
sql = sql + ",";
}
//add the new row to the VALUES block
sql = sql + "(" + nr + ")";
if (r == 250){
r = 0;
sql = "INSERT INTO " + dt + " VALUES " + "\n" + sql;
//System.out.println(sql);
try {
stmtd = dcon.createStatement();
stmtd.executeUpdate(sql);
System.out.print("\r" + t);
} catch (SQLException e) {
e.printStackTrace();
System.out.println(sql);
System.exit(0);
}
sql = "";
}
}
//if the sql is not empty, execute
if (sql != "") {
sql = "INSERT INTO " + dt + " VALUES " + "\n" + sql;
try {
stmtd = dcon.createStatement();
stmtd.executeUpdate(sql);
System.out.print("\r" + t);
} catch (SQLException e) {
e.printStackTrace();
System.out.println(sql);
System.exit(0);
}
}
} catch (SQLException e) {
e.printStackTrace();
System.exit(0);
}
//System.out.println(sql);
//---------------------------------------close connections--------------------------------------------------------
try {
scon.close();
dcon.close();
} catch (SQLException e) {
System.out.println("issue closing connections");
e.printStackTrace();
}
}
}

129
deploy.sh Executable file
View File

@ -0,0 +1,129 @@
#!/bin/bash
set -e
echo "jrunner deployment script"
echo "========================="
echo ""
echo "Select deployment option:"
echo " 1) Local install (./jrunner/build/install)"
echo " 2) Global install (installDist + /usr/local/bin symlinks)"
echo " 3) Custom directory"
echo ""
read -p "Enter choice [1-3]: " choice
case $choice in
1)
DEPLOY_MODE="local"
DEPLOY_DIR="./jrunner/build/install/jrunner"
;;
2)
DEPLOY_MODE="global"
DEPLOY_DIR="/opt/jrunner"
;;
3)
DEPLOY_MODE="custom"
read -e -p "Enter deployment directory (required): " DEPLOY_DIR
if [ -z "$DEPLOY_DIR" ]; then
echo "Error: Directory path is required"
exit 1
fi
# Expand tilde to home directory
DEPLOY_DIR="${DEPLOY_DIR/#\~/$HOME}"
;;
*)
echo "Error: Invalid choice"
exit 1
;;
esac
# Prevent deleting critical system directories
case "${DEPLOY_DIR}" in
/|/bin|/boot|/dev|/etc|/lib|/lib64|/proc|/root|/run|/sbin|/sys|/usr|/var)
echo "Error: Cannot deploy to system directory: ${DEPLOY_DIR}"
exit 1
;;
esac
echo ""
echo "Building jrunner..."
./gradlew build
if [ "$DEPLOY_MODE" = "local" ]; then
echo "Installing locally with gradle..."
./gradlew installDist
echo "Installing jrq wrapper script..."
cp jrq "${DEPLOY_DIR}/bin/jrq"
chmod +x "${DEPLOY_DIR}/bin/jrq"
echo ""
echo "✅ Installed locally at: ${DEPLOY_DIR}"
echo "Run './jrunner/build/install/jrunner/bin/jrunner --help' to test"
echo "Run './jrunner/build/install/jrunner/bin/jrq' for query wrapper usage"
elif [ "$DEPLOY_MODE" = "global" ]; then
INSTALL_DIR="$(pwd)/jrunner/build/install/jrunner"
echo "Installing with gradle..."
./gradlew installDist
echo "Installing jrq wrapper script..."
cp jrq "${INSTALL_DIR}/bin/jrq"
chmod +x "${INSTALL_DIR}/bin/jrq"
echo "Creating symlinks at /usr/local/bin/..."
sudo ln -sf "${INSTALL_DIR}/bin/jrunner" /usr/local/bin/jrunner
sudo ln -sf "${INSTALL_DIR}/bin/jrq" /usr/local/bin/jrq
echo ""
echo "✅ Installed at: ${INSTALL_DIR}"
echo "Symlinked to /usr/local/bin/ — run 'jrunner --help' to test"
echo " run 'jrq' for query wrapper usage"
else
# Custom deployment
if [ ! -d "${DEPLOY_DIR}" ]; then
echo "Creating directory: ${DEPLOY_DIR}"
sudo mkdir -p "${DEPLOY_DIR}"
fi
echo "Extracting to temporary location..."
sudo rm -rf /tmp/jrunner-deploy
sudo unzip -q jrunner/build/distributions/jrunner.zip -d /tmp/jrunner-deploy
echo "Deploying to ${DEPLOY_DIR}..."
sudo rm -rf "${DEPLOY_DIR}"/*
sudo mv /tmp/jrunner-deploy/jrunner/* "${DEPLOY_DIR}"/
sudo rm -rf /tmp/jrunner-deploy
echo "Fixing ownership..."
sudo chown -R $USER:$USER "${DEPLOY_DIR}"
# Copy jrq wrapper script
echo "Installing jrq wrapper script..."
sudo cp jrq "${DEPLOY_DIR}/bin/jrq"
sudo chmod +x "${DEPLOY_DIR}/bin/jrq"
echo ""
echo "✅ Deployed to ${DEPLOY_DIR}"
echo "Run '${DEPLOY_DIR}/bin/jrunner --help' to test"
echo "Run '${DEPLOY_DIR}/bin/jrq' for query wrapper usage"
fi
# Create ~/.jrqrc template if it doesn't exist
if [ ! -f "${HOME}/.jrqrc" ]; then
echo "Creating ~/.jrqrc config template..."
cat > "${HOME}/.jrqrc" <<'EOF'
# jrq configuration
# Uncomment and fill in the values below
# JDBC connection URL (required)
# Examples:
# AS/400: jdbc:as400://hostname
# PostgreSQL: jdbc:postgresql://hostname:5432/dbname
# SQL Server: jdbc:sqlserver://hostname:1433;databaseName=mydb
#JR_URL=""
# Database credentials (required)
#JR_USER=""
#JR_PASS=""
# Output format: csv or tsv (default: csv)
#JR_FORMAT="csv"
EOF
echo "Edit ~/.jrqrc to add your connection details before using jrq"
else
echo "~/.jrqrc already exists, skipping template creation"
fi

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

66
jrq Executable file
View File

@ -0,0 +1,66 @@
#!/bin/bash
# jrq - jrunner query wrapper for piping to visidata
# Usage: jrq <sql_file> [format]
# Example: jrq query.sql
# jrq query.sql tsv
set -e
# Configuration
# Override these with environment variables or create ~/.jrqrc
CONFIG_FILE="${HOME}/.jrqrc"
# Default values
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JR_BIN="${JR_BIN:-${SCRIPT_DIR}/jrunner}"
JR_URL="${JR_URL:-}"
JR_USER="${JR_USER:-}"
JR_PASS="${JR_PASS:-}"
JR_FORMAT="${JR_FORMAT:-csv}"
# Load config file if it exists
if [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
fi
# Parse arguments
if [ $# -lt 1 ]; then
echo "Usage: jrq <sql_file> [format]"
echo ""
echo "Configuration (via environment variables or ~/.jrqrc):"
echo " JR_URL - JDBC connection URL (required)"
echo " JR_USER - Database username (required)"
echo " JR_PASS - Database password (required)"
echo " JR_BIN - Path to jrunner binary (default: /opt/jrunner/jrunner/build/install/jrunner/bin/jrunner)"
echo " JR_FORMAT - Output format: csv or tsv (default: csv)"
echo ""
echo "Example ~/.jrqrc:"
echo " JR_URL=\"jdbc:as400://s7830956\""
echo " JR_USER=\"username\""
echo " JR_PASS=\"password\""
echo ""
echo "Example usage:"
echo " jrq query.sql # outputs CSV to stdout"
echo " jrq query.sql | vd # pipe to visidata"
echo " jrq query.sql tsv # output TSV format"
exit 1
fi
SQL_FILE="$1"
FORMAT="${2:-$JR_FORMAT}"
# Validate configuration
if [ -z "$JR_URL" ] || [ -z "$JR_USER" ] || [ -z "$JR_PASS" ]; then
echo "Error: Missing database connection configuration" >&2
echo "Set JR_URL, JR_USER, and JR_PASS environment variables or create ~/.jrqrc" >&2
exit 1
fi
# Validate SQL file exists
if [ ! -f "$SQL_FILE" ]; then
echo "Error: SQL file not found: $SQL_FILE" >&2
exit 1
fi
# Execute jrunner
"$JR_BIN" -scu "$JR_URL" -scn "$JR_USER" -scp "$JR_PASS" -sq "$SQL_FILE" -f "$FORMAT"

View File

@ -26,6 +26,8 @@ dependencies {
//jdbc drivers
implementation 'org.postgresql:postgresql:42.5.0'
implementation 'net.sf.jt400:jt400:11.0'
implementation 'com.microsoft.sqlserver:mssql-jdbc:9.2.0.jre8'
implementation 'com.microsoft.sqlserver:mssql-jdbc_auth:9.2.0.x64'
}
application {

View File

@ -0,0 +1,803 @@
package jrunner;
import java.sql.*;
import java.util.*;
import java.nio.file.Files;
import java.nio.file.Path ;
import java.nio.file.Paths;
import java.time.*;
import java.io.IOException;
import com.microsoft.sqlserver.jdbc.SQLServerBulkCopy;
import com.microsoft.sqlserver.jdbc.SQLServerBulkCopyOptions;
import com.microsoft.sqlserver.jdbc.ISQLServerBulkData;
import com.microsoft.sqlserver.jdbc.SQLServerException;
import org.postgresql.PGConnection;
import org.postgresql.copy.CopyManager;
import org.postgresql.copy.CopyIn;
public class jrunner {
//static final String QUERY = "SELECT * from rlarp.osm LIMIT 100";
public static void main(String[] args) {
String scu = "";
String scn = "";
String scp = "";
String scAlias = "";
String dcu = "";
String dcn = "";
String dcp = "";
String dcAlias = "";
String sq = "";
String dt = "";
Boolean trim = true;
Boolean clear = true;
Boolean bulk = false;
Integer r = 0;
Integer t = 0;
String sql = "";
String nr = "";
String nc = "";
String nl = "\n";
Boolean queryMode = false;
String outputFormat = "csv";
String msg = "";
Connection scon = null;
Connection dcon = null;
Statement stmt = null;
Statement stmtd = null;
ResultSet rs = null;
String[] getv = null;
Integer cols = null;
String[] dtn = null;
Timestamp tsStart = null;
Timestamp tsEnd = null;
msg = "jrunner version 1.2";
msg = msg + nl + "-sc source connection alias (from ~/.jrunnerpass)";
msg = msg + nl + "-scu source jdbc url";
msg = msg + nl + "-scn source username";
msg = msg + nl + "-scp source password";
msg = msg + nl + "-dc destination connection alias (from ~/.jrunnerpass)";
msg = msg + nl + "-dcu destination jdbc url";
msg = msg + nl + "-dcn destination username";
msg = msg + nl + "-dcp destination password";
msg = msg + nl + "-sq path to source query";
msg = msg + nl + "-dt fully qualified name of destination table";
msg = msg + nl + "-t trim text";
msg = msg + nl + "-c clear target table";
msg = msg + nl + "-b bulk copy into destination (SQL Server dest only)";
msg = msg + nl + "-f output format (csv, tsv, table, json) - default: csv";
msg = msg + nl + "--help info";
msg = msg + nl + "";
msg = msg + nl + "~/.jrunnerpass format:";
msg = msg + nl + " [alias]";
msg = msg + nl + " url=jdbc:...";
msg = msg + nl + " user=username";
msg = msg + nl + " pass=password";
//---------------------------------------parse args into variables-------------------------------------------------
for (int i = 0; i < args.length; i = i +1 ){
switch (args[i]) {
//source connection alias
case "-sc":
scAlias = args[i+1];
break;
//source connection string
case "-scu":
scu = args[i+1];
break;
//source username
case "-scn":
scn = args[i+1];
break;
//source password
case "-scp":
scp = args[i+1];
break;
//destination connection alias
case "-dc":
dcAlias = args[i+1];
break;
//destination connection string
case "-dcu":
dcu = args[i+1];
break;
//destination username
case "-dcn":
dcn = args[i+1];
break;
//destination password
case "-dcp":
dcp = args[i+1];
break;
//source query path
case "-sq":
try {
//sq = Files.readAllLines(Paths.get(args[i+1]));
sq = Files.readString(Paths.get(args[i+1]));
}
catch (Exception e) {
//System.out.println(nl + "error reasing source sql file: " + printStackTrace());
e.printStackTrace();
System.exit(0);
return;
}
break;
//destination table name
case "-dt":
dt = args[i+1];
break;
case "-t":
trim = true;
break;
case "-c":
clear = true;
break;
case "-b":
bulk = true;
break;
case "-f":
outputFormat = args[i+1].toLowerCase();
break;
case "-v":
System.out.println(msg);
return;
case "--version":
System.out.println(msg);
return;
case "--help":
System.out.println(msg);
return;
case "-help":
System.out.println(msg);
return;
case "-h":
System.out.println(msg);
return;
case "\\?":
System.out.println(msg);
return;
default:
break;
}
}
// Resolve connection aliases from ~/.jrunnerpass
if (!scAlias.isEmpty() || !dcAlias.isEmpty()) {
Map<String, String[]> connections = loadPassFile();
if (!scAlias.isEmpty()) {
String[] sc = connections.get(scAlias);
if (sc == null) {
System.err.println("Error: source alias '" + scAlias + "' not found in ~/.jrunnerpass");
System.exit(1);
}
if (scu.isEmpty()) scu = sc[0];
if (scn.isEmpty()) scn = sc[1];
if (scp.isEmpty()) scp = sc[2];
}
if (!dcAlias.isEmpty()) {
String[] dc = connections.get(dcAlias);
if (dc == null) {
System.err.println("Error: destination alias '" + dcAlias + "' not found in ~/.jrunnerpass");
System.exit(1);
}
if (dcu.isEmpty()) dcu = dc[0];
if (dcn.isEmpty()) dcn = dc[1];
if (dcp.isEmpty()) dcp = dc[2];
}
}
// Detect query mode when destination flags are not provided
queryMode = dcu.isEmpty() && dcn.isEmpty() && dcp.isEmpty() && dt.isEmpty();
if (!queryMode) {
System.out.println("------------db info---------------------------------------");
System.out.println("source db uri: " + scu);
System.out.println("source db username: " + scn);
System.out.println("destination db uri: " + dcu);
System.out.println("destination username: " + dcn);
System.out.println("------------source sql------------------------------------");
System.out.println(sq);
System.out.println("------------destination table-----------------------------");
System.out.println(dt);
}
//return;
//force regstration
try {
Class.forName("com.ibm.as400.access.AS400JDBCDriver");
} catch (ClassNotFoundException cnf) {
System.out.println("The AS400 JDBC driver did not load");
System.exit(0);
}
//-------------------------------------------establish connections-------------------------------------------------
//source database
if (!queryMode) {
System.out.println("------------open database connections---------------------");
}
try {
scon = DriverManager.getConnection(scu, scn, scp);
// Migration mode only: PostgreSQL ignores setFetchSize unless autoCommit
// is false without this it buffers the ENTIRE result set into memory
// (OOM on big tables). The migration source is read-only, so never
// committing is harmless. Do NOT do this in query mode: callers run
// committed DDL/DML through query mode, and autoCommit=false would roll
// those statements back on connection close.
if (!queryMode) {
scon.setAutoCommit(false);
}
} catch (SQLException e) {
System.out.println("issue connecting to source:");
e.printStackTrace();
System.exit(0);
}
if (!queryMode) {
System.out.println(" ✅ source database");
}
//destination database
if (!queryMode) {
try {
dcon = DriverManager.getConnection(dcu, dcn, dcp);
} catch (SQLException e) {
System.out.println("issue connecting to destination:");
e.printStackTrace();
System.exit(0);
}
System.out.println(" ✅ destination database");
}
//----------------------------------------open resultset------------------------------------------------------------
try {
stmt = scon.createStatement();
stmt.setFetchSize(10000);
tsStart = Timestamp.from(Instant.now());
if (!queryMode) {
System.out.println(tsStart);
}
rs = stmt.executeQuery(sq);
//while (rs.next()) {
// System.out.println(rs.getString("x"));
//}
} catch (SQLException e) {
System.out.println("issue retrieving rows from source:");
e.printStackTrace();
System.exit(0);
}
//---------------------------------------build meta---------------------------------------------------------------
try {
cols = rs.getMetaData().getColumnCount();
if (!queryMode) {
System.out.println("------------source metadata-------------------------------");
System.out.println("number of cols: " + cols);
}
getv = new String[cols + 1];
dtn = new String[cols + 1];
} catch (SQLException e) {
e.printStackTrace();
System.exit(0);
}
try {
for (int i = 1; i <= cols; i++){
dtn[i] = rs.getMetaData().getColumnTypeName(i);
if (!queryMode) {
System.out.println(" * " + rs.getMetaData().getColumnName(i) + ": " + dtn[i]);
}
}
} catch (SQLException e) {
e.printStackTrace();
System.exit(0);
}
//-------------------------clear the target table if requeted----------------------------------------------------
if (!queryMode && clear) {
System.out.println("------------clear target table----------------------------");
sql = "DELETE FROM " + dt;
try {
stmtd = dcon.createStatement();
System.out.println(" " + sql);
stmtd.executeUpdate(sql);
} catch (SQLException e) {
e.printStackTrace();
System.out.println(sql);
System.exit(0);
}
}
if (queryMode) {
//-------------------------------output query results--------------------------------------------------------
try {
outputQueryResults(rs, cols, dtn, outputFormat);
} catch (SQLException e) {
e.printStackTrace();
System.exit(0);
}
} else if (bulk && dcu.toLowerCase().startsWith("jdbc:sqlserver:")) {
//-------------------------------bulk copy (SQL Server dest)-------------------------------------------------
// Stream the source ResultSet straight into SQL Server over the TDS
// bulk-load protocol no per-row INSERT round trips. Source type
// names map to JDBC types via BulkSource (string-ish -> NVARCHAR).
System.out.println("------------bulk copy-------------------------------------");
try {
SQLServerBulkCopy bulkCopy = new SQLServerBulkCopy(dcon);
bulkCopy.setDestinationTableName(dt);
SQLServerBulkCopyOptions options = new SQLServerBulkCopyOptions();
options.setBatchSize(10000);
options.setBulkCopyTimeout(0);
bulkCopy.setBulkCopyOptions(options);
BulkSource src = new BulkSource(rs, cols, dtn, trim);
bulkCopy.writeToServer(src);
bulkCopy.close();
// leading \r starts a fresh line so the count doesn't concatenate
// onto the last progress tick; the trailing " rows written" makes
// it parseable.
System.out.print("\r" + src.rowsWritten());
} catch (Exception e) {
e.printStackTrace();
System.exit(0);
}
} else if (bulk && dcu.toLowerCase().startsWith("jdbc:postgresql:")) {
//-------------------------------bulk copy (COPY, Postgres dest)--------------------------------------------
// Stream the source ResultSet into Postgres via COPY ... FROM STDIN.
// COPY is text-based: each field is sent as CSV text and the server
// parses it into the column type, so there's no per-type quoting.
// Non-null values are always CSV-quoted; NULL is an empty unquoted
// field; column order must match the dest (positional, as always).
System.out.println("------------bulk copy (COPY)------------------------------");
try {
CopyManager cm = ((PGConnection) dcon).getCopyAPI();
CopyIn cin = cm.copyIn("COPY " + dt + " FROM STDIN WITH (FORMAT csv)");
StringBuilder buf = new StringBuilder();
long rows = 0;
while (rs.next()) {
for (int i = 1; i <= cols; i++) {
if (i > 1) { buf.append(','); }
String val = rs.getString(i);
if (!rs.wasNull() && val != null) {
if (trim) { val = val.trim(); }
buf.append('"').append(val.replace("\"", "\"\"")).append('"');
}
// else: empty field -> NULL
}
buf.append('\n');
rows++;
if (rows % 1000 == 0) {
byte[] b = buf.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
cin.writeToCopy(b, 0, b.length);
buf.setLength(0);
if (rows % 10000 == 0) { System.out.print("\r" + rows); System.out.flush(); }
}
}
if (buf.length() > 0) {
byte[] b = buf.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
cin.writeToCopy(b, 0, b.length);
}
cin.endCopy();
System.out.print("\r" + rows);
} catch (Exception e) {
e.printStackTrace();
System.exit(0);
}
} else {
System.out.println("------------row count-------------------------------------");
//-------------------------------build & execute sql-------------------------------------------------------------
try {
sql = "";
while (rs.next()) {
r++;
t++;
nr = "";
for (int i = 1; i <= cols; i++){
switch (dtn[i].toUpperCase()){
// PG string-ish types that otherwise fall to the default
// case and get emitted UNQUOTED (breaking the INSERT):
// jsonb/json carry embedded quotes, bpchar is PG's char(n),
// uuid is a quoted literal. Quote them like varchar.
case "JSON":
case "JSONB":
case "BPCHAR":
case "UUID":
case "VARCHAR":
case "NVARCHAR":
nc = rs.getString(i);
if (rs.wasNull() || nc == null) {
nc = "NULL";
break;
}
nc = nc.replaceAll("'","''");
if (trim) { nc = nc.trim();}
nc = "'" + nc + "'";
break;
case "TEXT":
case "NTEXT":
nc = rs.getString(i);
if (rs.wasNull() || nc == null) {
nc = "NULL";
break;
}
nc = nc.replaceAll("'","''");
if (trim) { nc = nc.trim();}
nc = "'" + nc + "'";
break;
case "CHAR":
case "NCHAR":
nc = rs.getString(i);
if (rs.wasNull() || nc == null) {
nc = "NULL";
break;
}
nc = nc.replaceAll("'","''");
if (trim) { nc = nc.trim();}
nc = "'" + nc + "'";
break;
case "CLOB":
case "NCLOB":
nc = rs.getString(i);
if (rs.wasNull() || nc == null) {
nc = "NULL";
break;
}
nc = nc.replaceAll("'","''");
if (trim) { nc = nc.trim();}
nc = "'" + nc + "'";
break;
case "DATE":
nc = rs.getString(i);
if (rs.wasNull() || nc == null) {
nc = "NULL";
break;
}
nc = "'" + nc + "'";
if (nc == "'1/1/0001 12:00:00 AM'") {
nc = "NULL";
}
break;
case "TIME":
nc = rs.getString(i);
if (rs.wasNull() || nc == null) {
nc = "NULL";
break;
}
nc.replaceAll("'","''");
nc = "'" + nc + "'";
break;
case "TIMESTAMP":
case "DATETIME":
case "DATETIME2":
case "SMALLDATETIME":
case "DATETIMEOFFSET":
nc = rs.getString(i);
if (rs.wasNull() || nc == null) {
nc = "NULL";
break;
}
nc.replaceAll("'","''");
nc = "'" + nc + "'";
break;
case "BIGINT":
nc = rs.getString(i);
if (rs.wasNull() || nc == null) {
nc = "NULL";
break;
}
default:
nc = rs.getString(i);
if (rs.wasNull() || nc == null) {
nc = "NULL";
break;
}
break;
}
if (i != 1){
nr = nr + ",";
}
nr = nr + nc;
}
//add a comma to the end of the VALUES block to accomodate a new row
if (sql!="") {
sql = sql + ",";
}
//add the new row to the VALUES block
sql = sql + "(" + nr + ")";
if (r == 250){
r = 0;
sql = "INSERT INTO " + dt + " VALUES " + "\n" + sql;
//System.out.println(sql);
try {
stmtd = dcon.createStatement();
stmtd.executeUpdate(sql);
System.out.print("\r" + t);
} catch (SQLException e) {
e.printStackTrace();
System.out.println(sql);
System.exit(0);
}
sql = "";
}
}
//if the sql is not empty, execute
if (sql != "") {
sql = "INSERT INTO " + dt + " VALUES " + "\n" + sql;
try {
stmtd = dcon.createStatement();
stmtd.executeUpdate(sql);
System.out.print("\r" + t);
} catch (SQLException e) {
e.printStackTrace();
System.out.println(sql);
System.exit(0);
}
}
} catch (SQLException e) {
e.printStackTrace();
System.exit(0);
}
}
//System.out.println(sql);
//---------------------------------------close connections--------------------------------------------------------
try {
scon.close();
if (!queryMode && dcon != null) {
dcon.close();
}
} catch (SQLException e) {
System.out.println("issue closing connections");
e.printStackTrace();
}
if (!queryMode) {
System.out.println(" rows written");
tsEnd = Timestamp.from(Instant.now());
System.out.println(tsStart);
System.out.println(tsEnd);
}
//long time = Duration.between(tsStart, tsEnd).toMillis();
//System.out.println("time elapsed: " + time);
System.out.println();
}
// Adapts a source ResultSet to SQLServerBulkCopy. Maps source type names to
// JDBC types we control: string-ish PG types (text/varchar/char/bpchar/json/
// jsonb/uuid/...) are declared NVARCHAR and read via getString mirroring the
// INSERT path's "quote it" choices and sidestepping unsupported types (jsonb
// reports as OTHER, which writeToServer(ResultSet) can't handle directly).
static class BulkSource implements ISQLServerBulkData {
private final ResultSet rs;
private final ResultSetMetaData md;
private final int cols;
private final boolean trim;
private final int[] jdbcType;
private final boolean[] asString;
private long rows = 0;
BulkSource(ResultSet rs, int cols, String[] dtn, boolean trim) throws SQLException {
this.rs = rs;
this.cols = cols;
this.trim = trim;
this.md = rs.getMetaData();
this.jdbcType = new int[cols + 1];
this.asString = new boolean[cols + 1];
for (int i = 1; i <= cols; i++) {
String t = (dtn[i] == null ? "" : dtn[i].toUpperCase());
switch (t) {
case "INT2": case "SMALLINT":
jdbcType[i] = Types.SMALLINT; break;
case "INT4": case "INT": case "INTEGER": case "SERIAL":
jdbcType[i] = Types.INTEGER; break;
case "INT8": case "BIGINT": case "BIGSERIAL":
jdbcType[i] = Types.BIGINT; break;
// numeric/decimal: PG reports unconstrained numeric as
// scale 0, which makes bulk copy round (123.45 -> 123). Send
// the exact text instead and let SQL Server convert it into
// the dest column losslessly.
case "FLOAT4": case "REAL":
jdbcType[i] = Types.REAL; break;
case "FLOAT8": case "DOUBLE": case "DOUBLE PRECISION":
jdbcType[i] = Types.DOUBLE; break;
case "BOOL": case "BOOLEAN": case "BIT":
jdbcType[i] = Types.BIT; break;
case "DATE":
jdbcType[i] = Types.DATE; break;
case "TIME":
jdbcType[i] = Types.TIME; break;
case "TIMESTAMP": case "TIMESTAMPTZ":
case "DATETIME": case "DATETIME2": case "SMALLDATETIME":
jdbcType[i] = Types.TIMESTAMP; break;
case "BYTEA": case "BINARY": case "VARBINARY":
jdbcType[i] = Types.VARBINARY; break;
default:
jdbcType[i] = Types.LONGNVARCHAR;
asString[i] = true;
break;
}
}
}
public java.util.Set<Integer> getColumnOrdinals() {
java.util.Set<Integer> ords = new java.util.TreeSet<Integer>();
for (int i = 1; i <= cols; i++) { ords.add(i); }
return ords;
}
public String getColumnName(int column) {
try { return md.getColumnName(column); }
catch (SQLException e) { return "col" + column; }
}
public int getColumnType(int column) { return jdbcType[column]; }
public int getPrecision(int column) {
if (asString[column]) { return 0; } // LONGNVARCHAR -> nvarchar(max)
try {
int p = md.getPrecision(column);
return (p > 0 ? p : 38);
} catch (SQLException e) { return 38; }
}
public int getScale(int column) {
if (jdbcType[column] == Types.DECIMAL) {
try { return md.getScale(column); }
catch (SQLException e) { return 0; }
}
return 0;
}
public boolean next() throws SQLServerException {
try { return rs.next(); }
catch (SQLException e) { throw new RuntimeException(e); }
}
public long rowsWritten() { return rows; }
public Object[] getRowData() throws SQLServerException {
rows++;
// live progress: emit an in-place counter every 10k rows (the
// caller pulls one row at a time, so this runs during the load).
if (rows % 10000 == 0) { System.out.print("\r" + rows); System.out.flush(); }
Object[] row = new Object[cols];
try {
for (int i = 1; i <= cols; i++) {
Object v;
switch (jdbcType[i]) {
case Types.SMALLINT:
case Types.INTEGER:
case Types.BIGINT:
case Types.REAL:
case Types.DOUBLE: v = rs.getObject(i); break;
case Types.DECIMAL: v = rs.getBigDecimal(i); break;
case Types.BIT: v = rs.getBoolean(i); break;
case Types.DATE: v = rs.getDate(i); break;
case Types.TIME: v = rs.getTime(i); break;
case Types.TIMESTAMP:v = rs.getTimestamp(i); break;
case Types.VARBINARY:v = rs.getBytes(i); break;
default: {
String s = rs.getString(i);
if (s != null && trim) { s = s.trim(); }
v = s;
break;
}
}
if (rs.wasNull()) { v = null; }
row[i - 1] = v;
}
} catch (SQLException e) { throw new RuntimeException(e); }
return row;
}
}
private static void outputQueryResults(ResultSet rs, int cols, String[] dtn, String format) throws SQLException {
switch (format) {
case "csv":
outputCSV(rs, cols);
break;
case "tsv":
outputTSV(rs, cols);
break;
case "table":
case "json":
default:
outputCSV(rs, cols);
break;
}
}
private static void outputCSV(ResultSet rs, int cols) throws SQLException {
// Print header row
for (int i = 1; i <= cols; i++) {
if (i > 1) System.out.print(",");
System.out.print(escapeCSV(rs.getMetaData().getColumnName(i)));
}
System.out.println();
// Print data rows
while (rs.next()) {
for (int i = 1; i <= cols; i++) {
if (i > 1) System.out.print(",");
String value = rs.getString(i);
if (rs.wasNull() || value == null) {
// Empty field for NULL
} else {
System.out.print(escapeCSV(value));
}
}
System.out.println();
}
}
private static void outputTSV(ResultSet rs, int cols) throws SQLException {
// Print header row
for (int i = 1; i <= cols; i++) {
if (i > 1) System.out.print("\t");
System.out.print(escapeTSV(rs.getMetaData().getColumnName(i)));
}
System.out.println();
// Print data rows
while (rs.next()) {
for (int i = 1; i <= cols; i++) {
if (i > 1) System.out.print("\t");
String value = rs.getString(i);
if (rs.wasNull() || value == null) {
// Empty field for NULL
} else {
System.out.print(escapeTSV(value));
}
}
System.out.println();
}
}
private static String escapeCSV(String value) {
if (value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r")) {
return "\"" + value.replaceAll("\"", "\"\"") + "\"";
}
return value;
}
private static String escapeTSV(String value) {
return value.replaceAll("\t", " ").replaceAll("\n", " ").replaceAll("\r", " ");
}
// Loads ~/.jrunnerpass and returns a map of alias -> {url, user, pass}
// Format:
// [alias]
// url=jdbc:...
// user=username
// pass=password
private static Map<String, String[]> loadPassFile() {
Map<String, String[]> connections = new LinkedHashMap<>();
Path passFile = Paths.get(System.getProperty("user.home"), ".jrunnerpass");
if (!Files.exists(passFile)) {
System.err.println("Error: ~/.jrunnerpass not found");
System.exit(1);
}
try {
String currentAlias = null;
String url = "", user = "", pass = "";
for (String line : Files.readAllLines(passFile)) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
if (line.startsWith("[") && line.endsWith("]")) {
if (currentAlias != null) {
connections.put(currentAlias, new String[]{url, user, pass});
}
currentAlias = line.substring(1, line.length() - 1).trim();
url = ""; user = ""; pass = "";
} else if (line.startsWith("url=")) {
url = line.substring(4);
} else if (line.startsWith("user=")) {
user = line.substring(5);
} else if (line.startsWith("pass=")) {
pass = line.substring(5);
}
}
if (currentAlias != null) {
connections.put(currentAlias, new String[]{url, user, pass});
}
} catch (IOException e) {
System.err.println("Error reading ~/.jrunnerpass: " + e.getMessage());
System.exit(1);
}
return connections;
}
}

194
readme.md Normal file
View File

@ -0,0 +1,194 @@
## Quick Start
The easiest way to get started on a new system:
```bash
git clone https://gitea.hptrow.me/pt/jrunner.git
cd jrunner
./setup.sh
```
The setup script will:
- Check for Java 11+ (offers to install if missing)
- Verify Gradle wrapper is present
- Run a test build to ensure everything works
- Show you next steps
## Manual Installation
If you prefer to install dependencies manually:
### Install Java JDK
**Option 1: Package manager (recommended)**
```bash
# Ubuntu/Debian
sudo apt update && sudo apt install openjdk-17-jdk
# Fedora/RHEL
sudo dnf install java-17-openjdk-devel
# Arch
sudo pacman -S jdk-openjdk
```
**Option 2: Manual download**
Download from https://www.oracle.com/java/technologies/downloads/
```bash
wget https://download.oracle.com/java/19/latest/jdk-19_linux-x64_bin.tar.gz
tar -xvf jdk-19_linux-x64_bin.tar.gz
sudo mv jdk-19.0.1 /opt/
export JAVA_HOME=/opt/jdk-19.0.1
export PATH=$PATH:$JAVA_HOME/bin
```
Test: `java --version`
### Install Gradle (optional)
Gradle wrapper (`gradlew`) is included in the repo, so manual Gradle installation is not required.
## build
```
./gradlew build
```
## deploy
### using the deploy script (recommended)
Run the interactive deploy script:
```
./deploy.sh
```
The script will prompt you to choose:
1. **Local install** - Fast, no sudo required, installs to `./jrunner/build/install/jrunner`
2. **Global install** - Installs to `/opt/jrunner` with symlink at `/usr/local/bin/jrunner`
3. **Custom directory** - Prompts for path with tab-completion support
The script builds, extracts to a temporary location, and only updates the target directory after the build succeeds. This ensures your existing deployment stays intact if the build fails.
### manual deployment
```
./gradlew build
sudo unzip jrunner/build/distributions/jrunner.zip -d /opt/
sudo ln -sf /opt/jrunner/bin/jrunner /usr/local/bin/jrunner
```
Or for local testing:
```
./gradlew installDist
# Binary at: ./jrunner/build/install/jrunner/bin/jrunner
```
## usage
### Query Mode (new in v1.1)
Query mode outputs results to stdout for piping to visidata, pspg, or less. It activates automatically when destination flags are omitted.
**Basic query to CSV:**
```bash
jrunner -scu "jdbc:as400://hostname" -scn user -scp pass -sq query.sql
```
**Pipe to visidata:**
```bash
jrunner -scu "jdbc:as400://hostname" -scn user -scp pass -sq query.sql | visidata -f csv
```
**TSV format:**
```bash
jrunner -scu "jdbc:as400://hostname" -scn user -scp pass -sq query.sql -f tsv
```
**SQL Server example:**
```bash
jrunner -scu "jdbc:sqlserver://hostname:1433;databaseName=mydb" -scn user -scp pass -sq query.sql
```
**PostgreSQL example:**
```bash
jrunner -scu "jdbc:postgresql://hostname:5432/dbname" -scn user -scp pass -sq query.sql
```
### jrq - Query Mode Wrapper
The `jrq` wrapper script simplifies query mode usage by storing connection details in a config file, eliminating the need to type credentials repeatedly.
**Setup:**
Create `~/.jrqrc` with your database connection details:
```bash
JR_URL="jdbc:as400://s7830956"
JR_USER="myusername"
JR_PASS="mypassword"
```
**Usage:**
```bash
# Output to stdout
jrq query.sql
# Pipe to visidata
jrq query.sql | vd
# Use TSV format
jrq query.sql tsv
# Pipe TSV to less with column display
jrq query.sql tsv | less -S
```
**Configuration options in ~/.jrqrc:**
- `JR_URL` - JDBC connection URL (required)
- `JR_USER` - Database username (required)
- `JR_PASS` - Database password (required)
- `JR_BIN` - Path to jrunner binary (default: /opt/jrunner/jrunner/build/install/jrunner/bin/jrunner)
- `JR_FORMAT` - Default output format: csv or tsv (default: csv)
**Note:** You can also set these as environment variables instead of using a config file:
```bash
export JR_URL="jdbc:as400://hostname"
export JR_USER="username"
export JR_PASS="password"
jrq query.sql | vd
```
### Migration Mode
Full migration mode with both source and destination:
```bash
jrunner -scu jdbc:postgresql://source:5432/sourcedb \
-scn sourceuser \
-scp sourcepass \
-dcu jdbc:postgresql://dest:5432/destdb \
-dcn destuser \
-dcp destpass \
-sq query.sql \
-dt public.target_table
```
### Command-line flags
**Source connection:**
- `-scu` - source JDBC URL
- `-scn` - source username
- `-scp` - source password
- `-sq` - path to source SQL query file
**Destination connection (migration mode only):**
- `-dcu` - destination JDBC URL
- `-dcn` - destination username
- `-dcp` - destination password
- `-dt` - fully qualified destination table name
**Options:**
- `-t` - trim text fields (default: true)
- `-c` - clear target table before insert (default: true)
- `-b` - bulk load into the destination instead of batched INSERTs — far faster
on large/wide tables. SQL Server: TDS bulk-load via SQLServerBulkCopy.
Postgres: COPY FROM STDIN. Other dests (e.g. DB2) fall back to INSERT.
(migration mode only)
- `-f` - output format: csv, tsv (query mode only, default: csv)

View File

@ -8,4 +8,4 @@
*/
rootProject.name = 'jrunner'
include('app')
include('jrunner')

174
setup.sh Executable file
View File

@ -0,0 +1,174 @@
#!/bin/bash
set -e
echo "jrunner Development Environment Setup"
echo "====================================="
echo ""
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if running on Linux
if [[ "$OSTYPE" != "linux-gnu"* ]]; then
echo -e "${YELLOW}Warning: This script is designed for Linux. You may need to manually install dependencies.${NC}"
echo ""
fi
# Function to check if command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Function to get Java version
get_java_version() {
if command_exists java; then
java -version 2>&1 | head -n 1 | awk -F '"' '{print $2}' | awk -F '.' '{print $1}'
else
echo "0"
fi
}
echo "Checking dependencies..."
echo ""
# Check Java
JAVA_VERSION=$(get_java_version)
if [ "$JAVA_VERSION" -ge 11 ]; then
echo -e "${GREEN}${NC} Java $JAVA_VERSION detected"
java -version 2>&1 | head -n 1
JAVA_OK=true
else
echo -e "${RED}${NC} Java 11+ not found"
JAVA_OK=false
fi
# Check Git
if command_exists git; then
echo -e "${GREEN}${NC} Git detected"
DEPENDENCIES_OK=true
else
echo -e "${RED}${NC} Git not found"
DEPENDENCIES_OK=false
fi
# Check if gradlew exists
if [ -f "./gradlew" ]; then
echo -e "${GREEN}${NC} Gradle wrapper found (no need to install Gradle)"
GRADLE_OK=true
else
echo -e "${YELLOW}!${NC} Gradle wrapper not found in current directory"
GRADLE_OK=false
fi
echo ""
# If Java is missing, offer installation help
if [ "$JAVA_OK" = false ]; then
echo -e "${YELLOW}Java 11+ is required to build and run jrunner${NC}"
echo ""
echo "Installation options:"
echo ""
echo "1. Install OpenJDK via package manager (recommended):"
echo " Ubuntu/Debian: sudo apt update && sudo apt install openjdk-17-jdk"
echo " Fedora/RHEL: sudo dnf install java-17-openjdk-devel"
echo " Arch: sudo pacman -S jdk-openjdk"
echo ""
echo "2. Download Oracle JDK manually:"
echo " Visit: https://www.oracle.com/java/technologies/downloads/"
echo " Download JDK 17+ tarball and extract to /opt"
echo " Add to PATH: export JAVA_HOME=/opt/jdk-17 && export PATH=\$PATH:\$JAVA_HOME/bin"
echo ""
read -p "Would you like to install OpenJDK via package manager? (requires sudo) [y/N]: " install_java
if [[ "$install_java" =~ ^[Yy]$ ]]; then
if command_exists apt; then
echo "Installing OpenJDK 17 via apt..."
sudo apt update
sudo apt install -y openjdk-17-jdk
elif command_exists dnf; then
echo "Installing OpenJDK 17 via dnf..."
sudo dnf install -y java-17-openjdk-devel
elif command_exists pacman; then
echo "Installing OpenJDK via pacman..."
sudo pacman -S --noconfirm jdk-openjdk
else
echo -e "${RED}Could not detect package manager. Please install Java manually.${NC}"
exit 1
fi
# Verify installation
JAVA_VERSION=$(get_java_version)
if [ "$JAVA_VERSION" -ge 11 ]; then
echo -e "${GREEN}✓ Java installed successfully${NC}"
JAVA_OK=true
else
echo -e "${RED}✗ Java installation failed${NC}"
exit 1
fi
else
echo ""
echo "Please install Java manually and re-run this script."
exit 1
fi
fi
# Check for unzip (needed for deployment)
if ! command_exists unzip; then
echo -e "${YELLOW}Note: 'unzip' is recommended for deployment${NC}"
read -p "Install unzip? [y/N]: " install_unzip
if [[ "$install_unzip" =~ ^[Yy]$ ]]; then
if command_exists apt; then
sudo apt install -y unzip
elif command_exists dnf; then
sudo dnf install -y unzip
elif command_exists pacman; then
sudo pacman -S --noconfirm unzip
fi
fi
fi
echo ""
echo -e "${GREEN}All required dependencies are installed!${NC}"
echo ""
# Verify we're in the right directory
if [ ! -f "./gradlew" ]; then
echo -e "${RED}Error: gradlew not found. Are you in the jrunner project directory?${NC}"
exit 1
fi
# Make gradlew executable
chmod +x ./gradlew
echo "Testing build system..."
if ./gradlew --version > /dev/null 2>&1; then
echo -e "${GREEN}${NC} Gradle wrapper working"
else
echo -e "${RED}${NC} Gradle wrapper failed"
exit 1
fi
echo ""
echo "Running test build..."
if ./gradlew build; then
echo ""
echo -e "${GREEN}✓ Build successful!${NC}"
echo ""
echo "Next steps:"
echo " 1. Install locally for testing:"
echo " ./gradlew installDist"
echo " ./jrunner/build/install/jrunner/bin/jrunner --help"
echo ""
echo " 2. Or run the interactive deploy script:"
echo " ./deploy.sh"
echo ""
echo " 3. See readme.md for usage examples"
else
echo -e "${RED}✗ Build failed${NC}"
echo "Check the error messages above and try again."
exit 1
fi