informix-db/tests/reference/RefClient.java
Ryan Malloy 389c32434c Phase 9: smart-LOB BLOB/CLOB locator decoding (Phase 10 deferred for fetch)
SELECT on BLOB or CLOB columns no longer requires raw byte interpretation.
The 72-byte server-side locator is wrapped in a typed BlobLocator or
ClobLocator (frozen dataclass) so the column is recognizable as
"server-side reference, not actual bytes".

Wire-protocol findings:
* Smart-LOB columns DON'T appear with their nominal type codes (102/101)
  in SQ_DESCRIBE. They surface as UDTFIXED (41) with extended_id 10
  (BLOB) or 11 (CLOB) and encoded_length=72 (locator size).
* Retrieving the actual bytes requires SQ_FPROUTINE (103) RPC to
  invoke ifx_lo_open, plus SQ_LODATA (97) for chunked transfer, plus
  another SQ_FPROUTINE for ifx_lo_close. That's a Phase 10 lift —
  roughly 2x the protocol surface of Phase 8.

Server config needed (added to Phase 7 setup):
* sbspace: onspaces -c -S sbspace1 ...
* default sbspace: onmode -wm SBSPACENAME=sbspace1

What ships in Phase 9:
* informix_db.BlobLocator(raw: bytes) — 72-byte frozen wrapper
* informix_db.ClobLocator(raw: bytes) — distinct type, same shape
* Row decoder branch in _resultset.parse_tuple_payload
* Wire constants SQ_LODATA=97, SQ_FPROUTINE=103, SQ_FPARAM=104

Tests:
* 11 unit tests in test_blob_locator_unit.py (no Informix needed) —
  construction, immutability, equality, hash, repr safety, size
  validation.
* 4 integration tests in test_smart_lob.py — fixture seeds via JDBC
  reference client (smart-LOB writes also need deferred protocols).
* RefBlob.java helper in tests/reference/ for seeding via JDBC.

Total: 64 unit + 111 integration = 175 tests.

Locator design note: __repr__ omits the raw bytes (they're opaque to
the client). Same-bytes locators of different families compare
unequal — BlobLocator(x) != ClobLocator(x) — to avoid silent type
confusion.
2026-05-04 13:26:15 -06:00

239 lines
9.9 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 "blob-cycle": runBlobCycle(); 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);
}
}
}
// -------------------------------------------------------------------
// Scenario E: BLOB (smart-LOB) write+read cycle. Requires:
// - logged DB (env IFX_DATABASE=testdb)
// - sbspace1 (smart-large-object space) already created
// Set IFX_DATABASE=testdb before running.
// -------------------------------------------------------------------
static void runBlobCycle() throws SQLException {
log("=== blob-cycle ===");
String table = "blob_" + 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 BLOB)", table);
s.execute("CREATE TABLE " + table + " (id INT, data BLOB)");
}
byte[] payload = "hello smart-LOB blob 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);
}
}
}
}