informix-db/tests/reference/RefClient.java
Ryan Malloy 52259f0152 Phase 8: BYTE/TEXT bind+read via SQ_BBIND/SQ_BLOB/SQ_FETCHBLOB
Implements end-to-end round-trip for BYTE (type 11) and TEXT (type 12)
columns. Python bytes/bytearray map to BYTE; str is auto-encoded as
ISO-8859-1 for TEXT.

Wire protocol — write side:
* SQ_BIND payload carries a 56-byte blob descriptor with size at offset
  [16..19] (per IfxBlob.toIfx). NULL is byte 39=1.
* After all per-param blocks, SQ_BBIND (41) declares blob count, then
  chunked SQ_BLOB (39) messages stream the actual bytes (max 1024
  bytes/chunk per JDBC), terminated by zero-length SQ_BLOB.
* Then SQ_EXECUTE proceeds normally.

Wire protocol — read side:
* SQ_TUPLE returns only the 56-byte descriptor; actual bytes live in
  the blobspace.
* For each BYTE/TEXT column in each row, send SQ_FETCHBLOB with the
  descriptor and read SQ_BLOB chunks until zero-length terminator.
* The locator is only valid while the cursor is open — must dereference
  BEFORE sending CLOSE. Doing it after returns -602 (Cannot open blob).

Server-side prerequisites (one-time setup):
1. blobspace: onspaces -c -b blobspace1 -p /path -o 0 -s 50000
2. logged DB: CREATE DATABASE testdb WITH LOG
3. config + archive:
     onmode -wm LTAPEDEV=/dev/null
     onmode -wm TAPEDEV=/dev/null
     onmode -l
     ontape -s -L 0 -t /dev/null

Without #3, JDBC fails identically to our driver with "BLOB pages can't
be allocated from a chunk until chunk add is logged". This identical
failure was the diagnostic confirmation that our protocol bytes were
correct — same server response = byte-for-byte parity.

Tests: 9 integration tests in tests/test_blob.py — single-chunk,
multi-chunk (5120 bytes), NULL, multi-row, binary-safe, TEXT roundtrip,
ISO-8859-1, NULL TEXT, mixed columns. Plus the Phase 4
test_unsupported_param_type_raises was updated since bytes is no longer
the canonical unsupported type — switched to a custom class.

Total: 53 unit + 107 integration = 160 tests.

The smart-LOB family (BLOB/CLOB) is a separate state-machine extension
deferred to Phase 9 — it uses IfxLocator + LO_OPEN/LO_READ session
protocol against sbspace, not the BBIND/BLOB stream.
2026-05-04 13:13:55 -06:00

197 lines
8.1 KiB
Java

// Phase 0 spike: reference client for capturing the Informix SQLI wire protocol.
//
// Drives the official IBM Informix JDBC driver (com.informix.jdbc.IfxDriver)
// through controlled scenarios so that tcpdump captures of localhost:9088
// can be cross-referenced against the decompiled JDBC source. This is the
// "ground truth" client whose traffic IS by definition spec-correct.
//
// Build:
// javac -cp build/ifxjdbc.jar tests/reference/RefClient.java -d build/
// Run:
// java -cp build/ifxjdbc.jar:build/ tests.reference.RefClient <scenario>
//
// Scenarios: connect-only, select-1, dml-cycle, all
//
// Connection params can be overridden via env vars; defaults are the
// Informix Developer Edition Docker image defaults documented in
// docs/DECISION_LOG.md.
package tests.reference;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Statement;
import java.sql.SQLException;
public class RefClient {
static final String HOST = env("IFX_HOST", "127.0.0.1");
static final String PORT = env("IFX_PORT", "9088");
static final String SERVER = env("IFX_SERVER", "informix");
static final String DATABASE = env("IFX_DATABASE", "sysmaster");
static final String USER = env("IFX_USER", "informix");
static final String PASSWORD = env("IFX_PASSWORD", "in4mix");
public static void main(String[] args) throws Exception {
Class.forName("com.informix.jdbc.IfxDriver");
String scenario = args.length > 0 ? args[0] : "connect-only";
switch (scenario) {
case "connect-only": runConnectOnly(); break;
case "select-1": runSelect1(); break;
case "dml-cycle": runDmlCycle(); break;
case "byte-cycle": runByteCycle(); break;
case "all":
runConnectOnly();
runSelect1();
runDmlCycle();
break;
default:
System.err.println("Unknown scenario: " + scenario);
System.err.println("Valid: connect-only | select-1 | dml-cycle | all");
System.exit(2);
}
}
static String url() {
return String.format(
"jdbc:informix-sqli://%s:%s/%s:INFORMIXSERVER=%s",
HOST, PORT, DATABASE, SERVER
);
}
static void log(String fmt, Object... args) {
System.out.printf("[ref] " + fmt + "%n", args);
}
static String env(String key, String dflt) {
String v = System.getenv(key);
return (v == null || v.isEmpty()) ? dflt : v;
}
// -------------------------------------------------------------------
// Scenario A: connect + immediate disconnect
// -------------------------------------------------------------------
static void runConnectOnly() throws SQLException {
log("=== connect-only ===");
log("URL: %s", url());
try (Connection c = DriverManager.getConnection(url(), USER, PASSWORD)) {
log("connected; product=%s", c.getMetaData().getDatabaseProductName());
// Note: do NOT call getDatabaseProductVersion() — the 4.50.JC10
// JDBC driver throws NumberFormatException on Informix 15.0's
// version string ("150."). Connection itself works fine.
}
log("disconnected.");
}
// -------------------------------------------------------------------
// Scenario B: connect + SELECT 1 + disconnect
// -------------------------------------------------------------------
static void runSelect1() throws SQLException {
log("=== select-1 ===");
try (Connection c = DriverManager.getConnection(url(), USER, PASSWORD);
Statement s = c.createStatement();
ResultSet rs = s.executeQuery("SELECT 1 FROM systables WHERE tabid = 1")) {
ResultSetMetaData md = rs.getMetaData();
log("columns: %d", md.getColumnCount());
for (int i = 1; i <= md.getColumnCount(); i++) {
log(" [%d] name=%s type=%d (%s) precision=%d scale=%d",
i, md.getColumnName(i), md.getColumnType(i),
md.getColumnTypeName(i), md.getPrecision(i), md.getScale(i));
}
int rowNum = 0;
while (rs.next()) {
rowNum++;
log(" row %d: col1=%s", rowNum, rs.getObject(1));
}
log("rows returned: %d", rowNum);
}
}
// -------------------------------------------------------------------
// Scenario C: full DML cycle (CREATE / INSERT / SELECT / DROP)
// Uses a UUID-suffixed table to be safe to re-run.
// -------------------------------------------------------------------
static void runDmlCycle() throws SQLException {
log("=== dml-cycle ===");
String table = "spike_" + Long.toHexString(System.nanoTime());
try (Connection c = DriverManager.getConnection(url(), USER, PASSWORD)) {
c.setAutoCommit(true); // sysmaster is unlogged; force autocommit
try (Statement s = c.createStatement()) {
log("CREATE TEMP TABLE %s", table);
s.execute("CREATE TEMP TABLE " + table +
" (id INTEGER, name VARCHAR(50), val FLOAT)");
}
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO " + table + " VALUES (?, ?, ?)")) {
ps.setInt(1, 42);
ps.setString(2, "hello");
ps.setDouble(3, 3.14);
int n = ps.executeUpdate();
log("INSERT rowcount=%d", n);
}
try (Statement s = c.createStatement();
ResultSet rs = s.executeQuery("SELECT id, name, val FROM " + table)) {
ResultSetMetaData md = rs.getMetaData();
log("SELECT columns:");
for (int i = 1; i <= md.getColumnCount(); i++) {
log(" [%d] %s : %s", i, md.getColumnName(i), md.getColumnTypeName(i));
}
while (rs.next()) {
log(" row: id=%d name=%s val=%f",
rs.getInt(1), rs.getString(2), rs.getDouble(3));
}
}
// Temp table dropped automatically on disconnect; no DROP needed.
}
}
// -------------------------------------------------------------------
// Scenario D: BYTE column write+read cycle. Requires:
// - logged DB (env IFX_DATABASE=testdb)
// - a blobspace named "blobspace1" already created
// Set IFX_DATABASE=testdb before running.
// -------------------------------------------------------------------
static void runByteCycle() throws SQLException {
log("=== byte-cycle ===");
String table = "byte_" + Long.toHexString(System.nanoTime());
try (Connection c = DriverManager.getConnection(url(), USER, PASSWORD)) {
c.setAutoCommit(true);
try (Statement s = c.createStatement()) {
log("CREATE TABLE %s (id INT, data BYTE IN blobspace1)", table);
s.execute("CREATE TABLE " + table + " (id INT, data BYTE IN blobspace1)");
}
byte[] payload = "hello bytes from JDBC".getBytes();
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO " + table + " VALUES (?, ?)")) {
ps.setInt(1, 1);
ps.setBytes(2, payload);
int n = ps.executeUpdate();
log("INSERT rowcount=%d (sent %d bytes)", n, payload.length);
}
try (Statement s = c.createStatement();
ResultSet rs = s.executeQuery("SELECT id, data FROM " + table)) {
while (rs.next()) {
byte[] got = rs.getBytes(2);
log(" row: id=%d data.len=%d data=%s", rs.getInt(1),
got.length, new String(got));
}
}
try (Statement s = c.createStatement()) {
s.execute("DROP TABLE " + table);
}
}
}
}