Explorar o código

Add live PostgreSQL integration test suite

Adds src/tests/postgres-test.vala, a standalone main() program mirroring sqlite-test.vala depth: connection string handling (valid/invalid schemes, missing database, unix-socket host option, factory round trip, has_provider), type round trips (integer, real, text, bytea blobs incl empty and 256 KiB, nulls, datetime as timestamptz and text, native boolean, nullable types), CRUD via create_command/with_parameter/execute_scalar/execute_non_query, transactions (commit, rollback, double-commit rejected), last_insert_rowid via identity columns incl inside transactions, lazy enumeration (partial then abandon, peek_count, cache), error paths (syntax error, unique violation, missing parameter) and mixed-type/assignable-type integration checks.

scripts/ensure-postgres.sh: root-runnable, idempotent server bring-up - initdb /tmp/pgdata if missing (trust auth), pg_ctl start on socket /tmp/pgrun port 5433 if down, CREATE DATABASE sqltest if missing (SQL piped over stdin to avoid quoting). scripts/run-postgres-tests.sh: meson test wrapper that skips with exit 77 when no server is available and runs postgres-test with PG_TEST_CONN otherwise. src/meson.build: postgres-test executable plus test entry using the wrapper as command with the exe as arg.

Verified: meson test 6/6 OK with the PostgreSQL suite executing live (0.46s, 39 test groups); wrapper exits 77 when binaries are missing.
clanker hai 1 mes
pai
achega
4fd5b1477f
Modificáronse 4 ficheiros con 1171 adicións e 0 borrados
  1. 101 0
      scripts/ensure-postgres.sh
  2. 24 0
      scripts/run-postgres-tests.sh
  3. 13 0
      src/meson.build
  4. 1033 0
      src/tests/postgres-test.vala

+ 101 - 0
scripts/ensure-postgres.sh

@@ -0,0 +1,101 @@
+#!/bin/bash
+# Ensures a local PostgreSQL server is available for the live integration
+# tests. Idempotent and safe to re-run.
+#
+# Layout (matches the goal plan):
+#   data dir:   /tmp/pgdata
+#   socket dir: /tmp/pgrun
+#   port:       5433
+#   test role:  postgres (the initdb superuser)
+#   test db:    sqltest  (created if missing)
+#
+# Must run as root (uses `su postgres`); if not root we try without su
+# so the script still works in environments where we already are the
+# postgres user or own the server.
+
+set -u
+
+PGBIN="${PGBIN:-/usr/bin}"
+PGDATA="${PGDATA:-/tmp/pgdata}"
+PGSOCK="${PGSOCK:-/tmp/pgrun}"
+PGPORT="${PGPORT:-5433}"
+PGUSER_NAME="${PGUSER_NAME:-postgres}"
+PGDATABASE="${PGDATABASE:-sqltest}"
+
+PSQL="$PGBIN/psql"
+INITDB="$PGBIN/initdb"
+PG_CTL="$PGBIN/pg_ctl"
+PG_ISREADY="$PGBIN/pg_isready"
+
+run_as_pg() {
+    # Run a command as the postgres user when we are root; otherwise
+    # run it directly.
+    if [ "$(id -u)" = "0" ]; then
+        su "$PGUSER_NAME" -s /bin/bash -c "$*"
+    else
+        bash -c "$*"
+    fi
+}
+
+# --- 1. Server binaries present? -----------------------------------------
+for bin in psql initdb pg_ctl; do
+    if [ ! -x "$PGBIN/$bin" ]; then
+        echo "ensure-postgres: $PGBIN/$bin not found — PostgreSQL server not installed" >&2
+        exit 2
+    fi
+done
+
+# --- 2. Data directory ----------------------------------------------------
+if [ ! -f "$PGDATA/PG_VERSION" ]; then
+    echo "ensure-postgres: initialising data directory at $PGDATA"
+    mkdir -p "$(dirname "$PGDATA")"
+    mkdir -p "$PGDATA"
+    chown "$PGUSER_NAME":"$PGUSER_NAME" "$PGDATA" 2>/dev/null || true
+    chmod 700 "$PGDATA"
+    if ! run_as_pg "$INITDB -D $PGDATA --auth=trust -E SQL_ASCII --locale=C" \
+         > /tmp/ensure-postgres-initdb.log 2>&1; then
+        echo "ensure-postgres: initdb failed:" >&2
+        tail -20 /tmp/ensure-postgres-initdb.log >&2
+        exit 3
+    fi
+    # trust auth for local + loopback so the test binary can connect as
+    # the postgres role without a password from any local user.
+    cat > "$PGDATA/pg_hba.conf" << 'HBA'
+local   all             all                                     trust
+host    all             all             127.0.0.1/32            trust
+host    all             all             ::1/128                 trust
+local   replication     all                             trust
+host    replication     all             127.0.0.1/32            trust
+host    replication     all             ::1/128                 trust
+HBA
+    chown "$PGUSER_NAME":"$PGUSER_NAME" "$PGDATA/pg_hba.conf" 2>/dev/null || true
+fi
+
+# --- 3. Server running? ----------------------------------------------------
+mkdir -p "$PGSOCK"
+chown "$PGUSER_NAME":"$PGUSER_NAME" "$PGSOCK" 2>/dev/null || true
+chmod 775 "$PGSOCK"
+
+if ! run_as_pg "$PG_ISREADY -h $PGSOCK -p $PGPORT -q"; then
+    echo "ensure-postgres: starting server on socket $PGSOCK port $PGPORT"
+    if ! run_as_pg "$PG_CTL -D $PGDATA -l /tmp/ensure-postgres.log -w -t 30 start -o '-k $PGSOCK -p $PGPORT'" \
+         >> /tmp/ensure-postgres.log 2>&1; then
+        echo "ensure-postgres: server failed to start:" >&2
+        tail -20 /tmp/ensure-postgres.log >&2
+        exit 4
+    fi
+fi
+
+# --- 4. Test database ------------------------------------------------------
+# SQL is piped over stdin so no nested shell quoting (and no shared
+# temp files with permission races) are needed.
+CHECK_SQL="SELECT 1 FROM pg_database WHERE datname = '$PGDATABASE'"
+CREATE_SQL="CREATE DATABASE \"$PGDATABASE\""
+
+if ! printf '%s\n' "$CHECK_SQL" | run_as_pg "$PSQL -h $PGSOCK -p $PGPORT -U $PGUSER_NAME -d postgres -tA" | grep -q 1; then
+    echo "ensure-postgres: creating database $PGDATABASE"
+    printf '%s\n' "$CREATE_SQL" | run_as_pg "$PSQL -h $PGSOCK -p $PGPORT -U $PGUSER_NAME -d postgres" >/dev/null
+fi
+
+echo "ensure-postgres: server ready (socket $PGSOCK, port $PGPORT, db $PGDATABASE)"
+exit 0

+ 24 - 0
scripts/run-postgres-tests.sh

@@ -0,0 +1,24 @@
+#!/bin/bash
+# Meson test wrapper for the live PostgreSQL integration suite.
+#
+# Skips (exit 77) when no server is reachable, so default `meson test`
+# stays green on machines without PostgreSQL. When PG_TEST_FORCE=1 the
+# ensure script's failure to bring a server up is a hard error instead.
+
+set -u
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+if [ "${PG_TEST_FORCE:-0}" = "1" ]; then
+    "$SCRIPT_DIR/ensure-postgres.sh" || exit 1
+else
+    "$SCRIPT_DIR/ensure-postgres.sh" || {
+        echo "# PostgreSQL integration tests: server unavailable, skipping"
+        exit 77
+    }
+fi
+
+export PG_TEST_CONN="${PG_TEST_CONN:-postgresql://postgres@/sqltest?host=/tmp/pgrun&port=5433}"
+
+exec "$@"

+ 13 - 0
src/meson.build

@@ -158,3 +158,16 @@ projection_test_exe = executable('projection-test', 'tests/projection-test.vala'
 )
 
 test('Projection tests', projection_test_exe)
+
+# PostgreSQL live integration test executable
+postgres_test_exe = executable('postgres-test', 'tests/postgres-test.vala',
+    dependencies: [dependencies, invercargill_sql_dep],
+    link_with: invercargill_sql
+)
+
+# Runs only against a reachable server; the wrapper exits 77 (skip)
+# when ensure-postgres.sh cannot bring a local server up.
+pg_test_runner = find_program('../scripts/run-postgres-tests.sh')
+test('PostgreSQL integration tests', pg_test_runner,
+    args: postgres_test_exe
+)

+ 1033 - 0
src/tests/postgres-test.vala

@@ -0,0 +1,1033 @@
+using InvercargillSql;
+
+/**
+ * Live PostgreSQL integration tests, mirroring sqlite-test.vala depth.
+ *
+ * Requires a reachable PostgreSQL server; the connection string comes
+ * from PG_TEST_CONN (default: unix socket dir /tmp/pgrun, port 5433,
+ * user postgres, database postgres).
+ *
+ * Each test owns its table: DROP TABLE IF EXISTS + CREATE keeps the
+ * suite self-contained and re-runnable against the shared database.
+ */
+public int main(string[] args) {
+    print("=== PostgreSQL Integration Tests ===\n\n");
+
+    string? conn_string = GLib.Environment.get_variable("PG_TEST_CONN");
+    if (conn_string == null || conn_string.length == 0) {
+        conn_string = "postgresql://postgres@/postgres?host=/tmp/pgrun&port=5433";
+    }
+    print("Using connection: %s\n\n", conn_string);
+
+    try {
+        print("--- Connection String Tests ---\n");
+        test_connection_string_parsing();
+        test_connection_factory_registration();
+        test_create_and_open_round_trip(conn_string);
+        test_invalid_scheme_rejected();
+        test_missing_database_rejected();
+        test_unreachable_server_rejected();
+
+        var conn = ConnectionFactory.create_and_open(conn_string);
+
+        print("\n--- Integer Element Tests ---\n");
+        test_integer_element_conversions(conn);
+        test_integer_element_bool_conversion(conn);
+        test_integer_element_null_handling(conn);
+
+        print("\n--- Real Element Tests ---\n");
+        test_real_element_conversions(conn);
+        test_real_element_null_handling(conn);
+
+        print("\n--- Text Element Tests ---\n");
+        test_text_element_conversions(conn);
+        test_text_element_bool_parsing(conn);
+        test_text_element_null_handling(conn);
+
+        print("\n--- Blob (bytea) Element Tests ---\n");
+        test_blob_element_round_trip(conn);
+        test_blob_element_empty_and_large(conn);
+        test_blob_element_null_handling(conn);
+
+        print("\n--- Null Element Tests ---\n");
+        test_null_element_handling(conn);
+
+        print("\n--- Round-trip Conversion Tests ---\n");
+        test_datetime_round_trip(conn);
+        test_datetime_timestamptz_round_trip(conn);
+        test_bool_round_trip(conn);
+        test_nullable_types_round_trip(conn);
+
+        print("\n--- CRUD / Fluent API Tests ---\n");
+        test_crud_insert_select_update_delete(conn);
+        test_execute_scalar(conn);
+        test_execute_non_query_changes(conn);
+        test_named_parameter_reuse(conn);
+
+        print("\n--- Transaction Tests ---\n");
+        test_transaction_commit_persists(conn);
+        test_transaction_rollback_discards(conn);
+        test_transaction_is_active(conn);
+
+        print("\n--- last_insert_rowid Tests ---\n");
+        test_last_insert_rowid(conn);
+        test_last_insert_rowid_in_transaction(conn);
+
+        print("\n--- Lazy Enumeration Tests ---\n");
+        test_lazy_enumeration_partial(conn);
+        test_peek_count(conn);
+        test_enumerable_cache(conn);
+
+        print("\n--- Error Path Tests ---\n");
+        test_syntax_error(conn);
+        test_unique_violation(conn);
+        test_missing_parameter(conn);
+
+        print("\n--- Mixed Integration Tests ---\n");
+        test_mixed_type_table(conn);
+        test_element_assignable_types(conn);
+
+        conn.close();
+        print("\n=== All PostgreSQL integration tests passed! ===\n");
+        return 0;
+    } catch (Error e) {
+        printerr("\n=== Test failed: %s ===\n", e.message);
+        return 1;
+    }
+}
+
+// ============================================================================
+// Connection string tests
+// ============================================================================
+
+string CONN() {
+    string? cs = GLib.Environment.get_variable("PG_TEST_CONN");
+    return (cs == null || cs.length == 0)
+        ? "postgresql://postgres@/postgres?host=/tmp/pgrun&port=5433"
+        : cs;
+}
+
+void test_connection_string_parsing() throws SqlError {
+    print("Test: connection string parsing... ");
+
+    var cs = ConnectionString.parse("postgresql://user:secret@dbhost:5432/mydb");
+    assert(cs.scheme == "postgresql");
+    assert(cs.user == "user");
+    assert(cs.password == "secret");
+    assert(cs.host == "dbhost");
+    assert(cs.port == 5432);
+    assert(cs.database == "mydb");
+
+    // Short scheme alias normalises to postgresql.
+    var cs2 = ConnectionString.parse("postgres://bob@host/db");
+    assert(cs2.scheme == "postgres");
+    assert(cs2.user == "bob");
+    assert(cs2.host == "host");
+    assert(cs2.database == "db");
+
+    // Unix socket dir via host= option (empty URI host).
+    var cs3 = ConnectionString.parse("postgresql://postgres@/postgres?host=/tmp/pgrun&port=5433");
+    assert(cs3.database == "postgres");
+    assert(cs3.has_option("host"));
+    assert(cs3.get_option("host") == "/tmp/pgrun");
+    assert(cs3.has_option("port"));
+    assert(cs3.get_option("port") == "5433");
+
+    // IPv6 form parses without throwing.
+    var cs4 = ConnectionString.parse("postgresql://u@[::1]:5433/d");
+    assert(cs4.host == "::1");
+    assert(cs4.port == 5433);
+
+    // Invalid port is rejected at parse time.
+    try {
+        ConnectionString.parse("postgresql://u@h:notaport/d");
+        assert_not_reached();
+    } catch (SqlError e) {
+        assert(e is SqlError.INVALID_CONNECTION_STRING);
+    }
+
+    print("PASSED\n");
+}
+
+void test_connection_factory_registration() throws SqlError {
+    print("Test: ConnectionFactory provider registration... ");
+
+    // Providers register lazily on first use of their scheme. Creating
+    // a postgres connection must make both spellings report present.
+    assert(!ConnectionFactory.has_provider("mysql"));
+    ConnectionFactory.create(CONN());
+    assert(ConnectionFactory.has_provider("postgresql"));
+    assert(ConnectionFactory.has_provider("postgres"));
+    // sqlite registers on first sqlite use, not as a side effect of
+    // postgres connections (factory keeps one flag per scheme).
+    ConnectionFactory.create("sqlite::memory:");
+    assert(ConnectionFactory.has_provider("sqlite"));
+
+    print("PASSED\n");
+}
+
+void test_create_and_open_round_trip(string conn_string) throws SqlError {
+    print("Test: create_and_open round trip... ");
+
+    var conn = ConnectionFactory.create_and_open(conn_string);
+    assert(conn.is_open);
+
+    // A live query proves the socket is actually usable.
+    var scalar = conn.create_command("SELECT 42").execute_scalar();
+    assert(scalar != null);
+    int64? v = null;
+    assert(scalar.try_get_as<int64?>(out v));
+    assert(v == 42);
+
+    conn.close();
+    assert(!conn.is_open);
+
+    print("PASSED\n");
+}
+
+void test_invalid_scheme_rejected() throws SqlError {
+    print("Test: invalid scheme rejected... ");
+
+    try {
+        ConnectionFactory.create_and_open("mysql://user@host/db");
+        assert_not_reached();
+    } catch (SqlError e) {
+        assert(e is SqlError.INVALID_CONNECTION_STRING);
+    }
+
+    // Missing scheme entirely.
+    try {
+        ConnectionFactory.create_and_open("nonsense");
+        assert_not_reached();
+    } catch (SqlError e) {
+        assert(e is SqlError.INVALID_CONNECTION_STRING);
+    }
+
+    print("PASSED\n");
+}
+
+void test_missing_database_rejected() throws SqlError {
+    print("Test: missing database rejected... ");
+
+    // No database component at all -> parse error.
+    try {
+        ConnectionFactory.create_and_open("postgresql://user@host");
+        assert_not_reached();
+    } catch (SqlError e) {
+        assert(e is SqlError.INVALID_CONNECTION_STRING);
+    }
+
+    // Empty database with trailing slash -> provider-level error.
+    try {
+        ConnectionFactory.create_and_open("postgresql://user@host/");
+        assert_not_reached();
+    } catch (SqlError e) {
+        assert(e is SqlError.INVALID_CONNECTION_STRING);
+    }
+
+    print("PASSED\n");
+}
+
+void test_unreachable_server_rejected() throws SqlError {
+    print("Test: unreachable server rejected... ");
+
+    // Port 1 on loopback: nothing listens there in test containers.
+    try {
+        ConnectionFactory.create_and_open(
+            "postgresql://postgres@127.0.0.1:1/postgres?connect_timeout=2"
+        );
+        assert_not_reached();
+    } catch (SqlError e) {
+        assert(e is SqlError.CONNECTION_FAILED);
+    }
+
+    print("PASSED\n");
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+/** Prints the per-test success marker with context. */
+void pass(string what) {
+    print("PASSED (%s)\n", what);
+}
+
+/** Runs a single-parameter command (fluent API) and returns rows affected. */
+int exec_p<T>(Connection conn, string sql, string name, T value) throws SqlError {
+    return conn.create_command(sql).with_parameter<T>(name, value).execute_non_query();
+}
+
+/** Drops table if present, then runs the CREATE TABLE ddl. */
+void drop_and_create(Connection conn, string ddl) throws SqlError {
+    int start = ddl.up().index_of("TABLE ") + 6;
+    int end = ddl.index_of("(", start);
+    string table = ddl.substring(start, end - start).strip();
+    conn.execute("DROP TABLE IF EXISTS " + table);
+    conn.execute(ddl);
+}
+
+// ============================================================================
+// Integer element tests
+// ============================================================================
+
+void test_integer_element_conversions(Connection conn) throws SqlError {
+    print("Test: Integer element conversions... ");
+    drop_and_create(conn, """
+        CREATE TABLE test_int (
+            id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+            value BIGINT)
+    """);
+    string ins = "INSERT INTO test_int (value) VALUES (:val)";
+    exec_p<int64?>(conn, ins, "val", 42);
+    exec_p<int64?>(conn, ins, "val", -123);
+    exec_p<int64?>(conn, ins, "val", int64.MAX);
+
+    var array = conn.create_command("SELECT value FROM test_int ORDER BY id")
+        .execute_query().to_array();
+    assert(array.length == 3);
+
+    var elem = array[0].get("value");
+    int64? v64 = null;
+    int? vi = null;
+    double? vd = null;
+    float? vf = null;
+    string? vs = null;
+    assert(elem.try_get_as<int64?>(out v64) && v64 == 42);
+    assert(elem.try_get_as<int?>(out vi) && vi == 42);
+    assert(elem.try_get_as<double?>(out vd) && vd == 42.0);
+    assert(elem.try_get_as<float?>(out vf) && vf >= 41.9f && vf <= 42.1f);
+    assert(elem.try_get_as<string>(out vs) && vs == "42");
+
+    assert(array[1].get("value").try_get_as<int64?>(out v64) && v64 == -123);
+    assert(array[2].get("value").try_get_as<int64?>(out v64) && v64 == int64.MAX);
+    pass("int conversions");
+}
+
+void test_integer_element_bool_conversion(Connection conn) throws SqlError {
+    print("Test: Integer element bool conversion... ");
+    drop_and_create(conn, """
+        CREATE TABLE test_int_bool (
+            id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+            value BIGINT)
+    """);
+    string ins = "INSERT INTO test_int_bool (value) VALUES (:v)";
+    exec_p<int64?>(conn, ins, "v", 1);
+    exec_p<int64?>(conn, ins, "v", 0);
+
+    var array = conn.create_command("SELECT value FROM test_int_bool ORDER BY id")
+        .execute_query().to_array();
+    assert(array.length == 2);
+    bool? b = null;
+    assert(array[0].get("value").try_get_as<bool?>(out b) && b == true);
+    assert(array[1].get("value").try_get_as<bool?>(out b) && b == false);
+    pass("int->bool");
+}
+
+void test_integer_element_null_handling(Connection conn) throws SqlError {
+    print("Test: Integer element null handling... ");
+    drop_and_create(conn, """
+        CREATE TABLE test_int_null (
+            id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+            value BIGINT)
+    """);
+    conn.create_command("INSERT INTO test_int_null (value) VALUES (:v)")
+        .with_null("v").execute_non_query();
+
+    var array = conn.create_command("SELECT value FROM test_int_null")
+        .execute_query().to_array();
+    assert(array.length == 1);
+    var elem = array[0].get("value");
+    assert(elem != null && elem.is_null());
+    int64? v = null;
+    assert(elem.try_get_as<int64?>(out v) == false);
+    assert(elem.assignable_to_type(typeof(int64?)) == true);
+    pass("int null");
+}
+
+// ============================================================================
+// Real element tests
+// ============================================================================
+
+void test_real_element_conversions(Connection conn) throws SqlError {
+    print("Test: Real element conversions... ");
+    drop_and_create(conn, """
+        CREATE TABLE test_real (
+            id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+            value DOUBLE PRECISION)
+    """);
+    string ins = "INSERT INTO test_real (value) VALUES (:v)";
+    exec_p<double?>(conn, ins, "v", 3.14159);
+    exec_p<double?>(conn, ins, "v", -0.0001);
+    exec_p<double?>(conn, ins, "v", 1.0e300);
+
+    var array = conn.create_command("SELECT value FROM test_real ORDER BY id")
+        .execute_query().to_array();
+    assert(array.length == 3);
+    double? d = null;
+    assert(array[0].get("value").try_get_as<double?>(out d));
+    assert(d >= 3.14158 && d <= 3.14160);
+    assert(array[1].get("value").try_get_as<double?>(out d));
+    assert(d >= -0.00011 && d <= -0.00009);
+    assert(array[2].get("value").try_get_as<double?>(out d));
+    assert(d >= 0.99e300 && d <= 1.01e300);
+
+    string? s = null;
+    assert(array[0].get("value").try_get_as<string>(out s));
+    assert(double.parse(s) >= 3.14158 && double.parse(s) <= 3.14160);
+    float? f = null;
+    assert(array[0].get("value").try_get_as<float?>(out f));
+    assert(f >= 3.1415f && f <= 3.1417f);
+    pass("real conversions");
+}
+
+void test_real_element_null_handling(Connection conn) throws SqlError {
+    print("Test: Real element null handling... ");
+    drop_and_create(conn, """
+        CREATE TABLE test_real_null (
+            id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+            value DOUBLE PRECISION)
+    """);
+    conn.execute("INSERT INTO test_real_null (value) VALUES (NULL)");
+    var array = conn.create_command("SELECT value FROM test_real_null")
+        .execute_query().to_array();
+    assert(array.length == 1);
+    var elem = array[0].get("value");
+    assert(elem.is_null() == true);
+    double? d = null;
+    assert(elem.try_get_as<double?>(out d) == false);
+    assert(elem.assignable_to_type(typeof(double?)) == true);
+    pass("real null");
+}
+
+// ============================================================================
+// Text element tests
+// ============================================================================
+
+void test_text_element_conversions(Connection conn) throws SqlError {
+    print("Test: Text element conversions... ");
+    drop_and_create(conn, "CREATE TABLE test_text (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, value TEXT)");
+    string[] samples = { "hello", "", "with 'quotes'", "üñïçødé", "line1\nline2" };
+    foreach (string sample in samples) {
+        exec_p<string>(conn, "INSERT INTO test_text (value) VALUES (:v)", "v", sample);
+    }
+    var array = conn.create_command("SELECT value FROM test_text ORDER BY id")
+        .execute_query().to_array();
+    assert(array.length == samples.length);
+    for (int i = 0; i < samples.length; i++) {
+        string? s = null;
+        assert(array[i].get("value").try_get_as<string>(out s));
+        assert(s == samples[i]);
+    }
+    conn.execute("DELETE FROM test_text");
+    exec_p<string>(conn, "INSERT INTO test_text (value) VALUES (:v)", "v", "12345");
+    var elem = conn.create_command("SELECT value FROM test_text").execute_scalar();
+    int64? n = null;
+    assert(elem.try_get_as<int64?>(out n) && n == 12345);
+    pass("text conversions");
+}
+
+void test_text_element_bool_parsing(Connection conn) throws SqlError {
+    print("Test: Text element bool parsing... ");
+    drop_and_create(conn, "CREATE TABLE test_text_bool (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, value TEXT)");
+    exec_p<string>(conn, "INSERT INTO test_text_bool (value) VALUES (:v)", "v", "true");
+    exec_p<string>(conn, "INSERT INTO test_text_bool (value) VALUES (:v)", "v", "false");
+    var array = conn.create_command("SELECT value FROM test_text_bool ORDER BY id")
+        .execute_query().to_array();
+    assert(array.length == 2);
+    bool? b = null;
+    assert(array[0].get("value").try_get_as<bool?>(out b) && b == true);
+    assert(array[1].get("value").try_get_as<bool?>(out b) && b == false);
+    pass("text bool");
+}
+
+void test_text_element_null_handling(Connection conn) throws SqlError {
+    print("Test: Text element null handling... ");
+    drop_and_create(conn, "CREATE TABLE test_text_null (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, value TEXT)");
+    conn.create_command("INSERT INTO test_text_null (value) VALUES (:v)")
+        .with_parameter<string?>("v", null).execute_non_query();
+    var array = conn.create_command("SELECT value FROM test_text_null")
+        .execute_query().to_array();
+    assert(array.length == 1);
+    var elem = array[0].get("value");
+    assert(elem.is_null());
+    string? s = null;
+    assert(elem.try_get_as<string>(out s) == false);
+    pass("text null");
+}
+
+// ============================================================================
+// Blob (bytea) element tests
+// ============================================================================
+
+Invercargill.BinaryData wrap_bytes(uint8[] bytes) {
+    return Invercargill.Wrap.byte_array(bytes);
+}
+
+void test_blob_element_round_trip(Connection conn) throws SqlError {
+    print("Test: Blob element round trip... ");
+    drop_and_create(conn, "CREATE TABLE test_blob (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, data BYTEA)");
+    uint8[] bytes = { 0x00, 0x01, 0x02, 0xFE, 0xFF, 0xDE, 0xAD, 0xBE, 0xEF };
+    exec_p<Invercargill.BinaryData>(conn, "INSERT INTO test_blob (data) VALUES (:d)", "d", wrap_bytes(bytes));
+    var elem = conn.create_command("SELECT data FROM test_blob").execute_scalar();
+    assert(elem != null && !elem.is_null());
+    Invercargill.BinaryData? out_data = null;
+    assert(elem.try_get_as<Invercargill.BinaryData>(out out_data));
+    assert(out_data != null);
+    uint8[] out_bytes = out_data.to_array();
+    assert(out_bytes.length == bytes.length);
+    for (int i = 0; i < out_bytes.length; i++) {
+        assert(out_bytes[i] == bytes[i]);
+    }
+    pass("blob round trip");
+}
+
+void test_blob_element_empty_and_large(Connection conn) throws SqlError {
+    print("Test: Blob empty and large... ");
+    drop_and_create(conn, "CREATE TABLE test_blob_sizes (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, data BYTEA)");
+    exec_p<Invercargill.BinaryData>(conn, "INSERT INTO test_blob_sizes (data) VALUES (:d)", "d", wrap_bytes(new uint8[0]));
+    var big = new uint8[256 * 1024];
+    for (int i = 0; i < big.length; i++) {
+        big[i] = (uint8)(i % 251);
+    }
+    exec_p<Invercargill.BinaryData>(conn, "INSERT INTO test_blob_sizes (data) VALUES (:d)", "d", wrap_bytes(big));
+    var array = conn.create_command(
+        "SELECT data, octet_length(data) AS byte_count FROM test_blob_sizes ORDER BY id")
+        .execute_query().to_array();
+    assert(array.length == 2);
+    // Row 0: empty blob — stored as empty, distinct from NULL.
+    int64? stored_len = null;
+    assert(array[0].get("byte_count").try_get_as<int64?>(out stored_len) && stored_len == 0);
+    var empty_elem = array[0].get("data");
+    Invercargill.BinaryData? empty_out = null;
+    if (empty_elem.try_get_as<Invercargill.BinaryData>(out empty_out) && empty_out != null) {
+        assert(empty_out.to_array().length == 0);
+    } else {
+        // Adapter maps empty bytea to null payload (sqlite parity);
+        // octet_length == 0 already proved empty-not-NULL.
+        assert(empty_elem.is_null());
+    }
+    // Row 1: large blob round trips with content intact.
+    var big_elem = array[1].get("data");
+    assert(!big_elem.is_null());
+    assert(array[1].get("byte_count").try_get_as<int64?>(out stored_len) && stored_len == big.length);
+    Invercargill.BinaryData? big_out = null;
+    assert(big_elem.try_get_as<Invercargill.BinaryData>(out big_out));
+    uint8[] round = big_out.to_array();
+    assert(round.length == big.length);
+    for (int i = 0; i < round.length; i += 997) {
+        assert(round[i] == big[i]);
+    }
+    pass("blob sizes");
+}
+
+void test_blob_element_null_handling(Connection conn) throws SqlError {
+    print("Test: Blob element null handling... ");
+    drop_and_create(conn, "CREATE TABLE test_blob_null (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, data BYTEA)");
+    conn.create_command("INSERT INTO test_blob_null (data) VALUES (:d)")
+        .with_null("d").execute_non_query();
+    var array = conn.create_command("SELECT data FROM test_blob_null")
+        .execute_query().to_array();
+    assert(array.length == 1);
+    assert(array[0].get("data").is_null());
+    pass("blob null");
+}
+
+void test_null_element_handling(Connection conn) throws SqlError {
+    print("Test: Null element handling across types... ");
+    drop_and_create(conn, "CREATE TABLE test_nulls (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, i BIGINT, r DOUBLE PRECISION, t TEXT, b BYTEA, f BOOLEAN)");
+    conn.create_command("INSERT INTO test_nulls (i, r, t, b, f) VALUES (:i, :r, :t, :b, :f)")
+        .with_null("i").with_null("r").with_null("t").with_null("b").with_null("f")
+        .execute_non_query();
+    var array = conn.create_command("SELECT i, r, t, b, f FROM test_nulls")
+        .execute_query().to_array();
+    assert(array.length == 1);
+    foreach (string col in new string[] { "i", "r", "t", "b", "f" }) {
+        var elem = array[0].get(col);
+        assert(elem != null && elem.is_null());
+        assert(elem.assignable_to_type(typeof(int64?)) == true);
+        assert(elem.assignable_to_type(typeof(string)) == true);
+    }
+    pass("null elements");
+}
+
+// ============================================================================
+// DateTime round-trips
+// ============================================================================
+
+void test_datetime_round_trip(Connection conn) throws SqlError {
+    print("Test: DateTime round trip (stored as text)... ");
+    drop_and_create(conn, "CREATE TABLE test_dt_text (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, created_at TEXT)");
+    var original = test_dt_source();
+    exec_p<DateTime>(conn, "INSERT INTO test_dt_text (created_at) VALUES (:ts)", "ts", original);
+    var elem = conn.create_command("SELECT created_at FROM test_dt_text").execute_scalar();
+    assert(elem != null);
+    // A TEXT column comes back as a text element; the adapter does not
+    // guess datetime from arbitrary text, so fidelity is checked by
+    // parsing the stored ISO 8601 string (that is what was bound).
+    string? stored = null;
+    assert(elem.try_get_as<string>(out stored));
+    assert(stored != null);
+    var back = new DateTime.from_iso8601(stored, new TimeZone.local());
+    assert(back != null);
+    assert(back.to_unix() == original.to_unix());
+    pass("datetime text");
+}
+
+void test_datetime_timestamptz_round_trip(Connection conn) throws SqlError {
+    drop_and_create(conn, "CREATE TABLE test_dt_tz (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, created_at TIMESTAMPTZ)");
+    exec_p<DateTime>(conn, "INSERT INTO test_dt_tz (created_at) VALUES (:ts)", "ts", test_dt_source());
+    var elem = conn.create_command("SELECT created_at FROM test_dt_tz").execute_scalar();
+    assert(elem != null);
+    DateTime? back = null;
+    assert(elem.try_get_as<DateTime>(out back));
+    assert(back != null);
+    assert(back.to_unix() == test_dt_source().to_unix());
+    pass("datetime timestamptz");
+}
+
+/** Stable timestamp source so insert and verify compare the same instant. */
+DateTime test_dt_source() {
+    return new DateTime.from_unix_local(1718459445);
+}
+
+void test_bool_round_trip(Connection conn) throws SqlError {
+    print("Test: Bool round trip (native boolean)... ");
+    drop_and_create(conn, "CREATE TABLE test_bool (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, is_active BOOLEAN)");
+    exec_p<bool>(conn, "INSERT INTO test_bool (is_active) VALUES (:v)", "v", true);
+    exec_p<bool>(conn, "INSERT INTO test_bool (is_active) VALUES (:v)", "v", false);
+    var array = conn.create_command("SELECT is_active FROM test_bool ORDER BY id")
+        .execute_query().to_array();
+    assert(array.length == 2);
+    bool? b = null;
+    assert(array[0].get("is_active").try_get_as<bool?>(out b) && b == true);
+    assert(array[1].get("is_active").try_get_as<bool?>(out b) && b == false);
+    pass("bool round trip");
+}
+
+void test_nullable_types_round_trip(Connection conn) throws SqlError {
+    print("Test: Nullable types round trip... ");
+    drop_and_create(conn, "CREATE TABLE test_nullable (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, int_val BIGINT, real_val DOUBLE PRECISION, str_val TEXT)");
+    double? r_param = 3.14;
+    conn.create_command("INSERT INTO test_nullable (int_val, real_val, str_val) VALUES (:i, :r, :s)")
+        .with_parameter("i", 42)
+        .with_parameter<double?>("r", r_param)
+        .with_parameter("s", "hello")
+        .execute_non_query();
+    conn.execute("INSERT INTO test_nullable (int_val, real_val, str_val) VALUES (NULL, NULL, NULL)");
+    var array = conn.create_command(
+        "SELECT int_val, real_val, str_val FROM test_nullable ORDER BY id")
+        .execute_query().to_array();
+    assert(array.length == 2);
+    int64? i = null;
+    double? r = null;
+    string? s = null;
+    assert(array[0].get("int_val").try_get_as<int64?>(out i) && i == 42);
+    assert(array[0].get("real_val").try_get_as<double?>(out r));
+    assert(r >= 3.13 && r <= 3.15);
+    assert(array[0].get("str_val").try_get_as<string>(out s) && s == "hello");
+    assert(array[1].get("int_val").is_null());
+    assert(array[1].get("real_val").is_null());
+    assert(array[1].get("str_val").is_null());
+    pass("nullable types");
+}
+
+// ============================================================================
+// CRUD / fluent API tests
+// ============================================================================
+
+void test_crud_insert_select_update_delete(Connection conn) throws SqlError {
+    print("Test: CRUD insert/select/update/delete... ");
+    drop_and_create(conn, "CREATE TABLE crud_people (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, age INT)");
+    string ins = "INSERT INTO crud_people (name, age) VALUES (:n, :a)";
+    conn.create_command(ins)
+        .with_parameter("n", "Alice").with_parameter("a", 30).execute_non_query();
+    conn.create_command("INSERT INTO crud_people (name, age) VALUES (:n, :a)")
+        .with_parameter("n", "Bob").with_parameter("a", 25).execute_non_query();
+    conn.create_command("INSERT INTO crud_people (name, age) VALUES (:n, :a)")
+        .with_parameter("n", "Carol").with_parameter("a", 35).execute_non_query();
+
+    // SELECT with named parameter in WHERE.
+    var found = conn.create_command("SELECT name, age FROM crud_people WHERE age > :min_age ORDER BY age")
+        .with_parameter("min_age", 26).execute_query().to_array();
+    assert(found.length == 2);
+    string? nm = null;
+    assert(found[0].get("name").try_get_as<string>(out nm) && nm == "Alice");
+    assert(found[1].get("name").try_get_as<string>(out nm) && nm == "Carol");
+
+    // UPDATE with parameter, verify affected-rows count.
+    int updated = conn.create_command("UPDATE crud_people SET age = :new_age WHERE name = :who")
+        .with_parameter("new_age", 31).with_parameter("who", "Alice").execute_non_query();
+    assert(updated == 1);
+    var check = conn.create_command("SELECT age FROM crud_people WHERE name = 'Alice'").execute_scalar();
+    int64? a = null;
+    assert(check.try_get_as<int64?>(out a) && a == 31);
+
+    // DELETE with parameter.
+    int deleted = conn.create_command("DELETE FROM crud_people WHERE age < :cutoff")
+        .with_parameter("cutoff", 30).execute_non_query();
+    assert(deleted == 1);
+    var count_elem = conn.create_command("SELECT count(*) AS n FROM crud_people").execute_scalar();
+    int64? remaining = null;
+    assert(count_elem.try_get_as<int64?>(out remaining) && remaining == 2);
+    pass("CRUD");
+}
+
+void test_execute_scalar(Connection conn) throws SqlError {
+    print("Test: execute_scalar variants... ");
+    var forty_two = conn.create_command("SELECT 42 AS v").execute_scalar();
+    int64? v = null;
+    assert(forty_two.try_get_as<int64?>(out v) && v == 42);
+
+    // Scalar over an aggregate.
+    var total = conn.create_command("SELECT 1 + 2 + 3 AS sum").execute_scalar();
+    assert(total.try_get_as<int64?>(out v) && v == 6);
+
+    // No rows -> null element, no crash.
+    var none = conn.create_command("SELECT 1 AS v WHERE 1 = 0").execute_scalar();
+    assert(none == null);
+
+    // Scalar with parameter.
+    var doubled = conn.create_command("SELECT :x * 2 AS d").with_parameter("x", 21).execute_scalar();
+    assert(doubled.try_get_as<int64?>(out v) && v == 42);
+    pass("execute_scalar");
+}
+
+void test_execute_non_query_changes(Connection conn) throws SqlError {
+    print("Test: execute_non_query row counts... ");
+    drop_and_create(conn, "CREATE TABLE changes_probe (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, flag BOOLEAN NOT NULL)");
+    exec_p<bool>(conn, "INSERT INTO changes_probe (flag) VALUES (:f)", "f", true);
+    exec_p<bool>(conn, "INSERT INTO changes_probe (flag) VALUES (:f)", "f", false);
+    exec_p<bool>(conn, "INSERT INTO changes_probe (flag) VALUES (:f)", "f", true);
+    int flipped = conn.create_command("UPDATE changes_probe SET flag = NOT flag").execute_non_query();
+    assert(flipped == 3);
+    // Flags were inserted true, false, true; NOT flips them to
+    // false, true, false, so exactly one row matches "WHERE flag".
+    int removed = conn.create_command("DELETE FROM changes_probe WHERE flag").execute_non_query();
+    assert(removed == 1);
+    // Row counts read through the connection's changes counter.
+    var remaining = conn.create_command("SELECT count(*) AS c FROM changes_probe").execute_scalar();
+    int64? left = null;
+    assert(remaining.try_get_as<int64?>(out left) && left == 2);
+    pass("non_query counts");
+}
+
+void test_named_parameter_reuse(Connection conn) throws SqlError {
+    print("Test: named parameter reuse in one statement... ");
+    // Same :x twice: translator must map both to $1.
+    var elem = conn.create_command("SELECT :x AS a, :x AS b, :x AS c")
+        .with_parameter("x", 7).execute_scalar();
+    int64? v = null;
+    assert(elem.try_get_as<int64?>(out v) && v == 7);
+    var array = conn.create_command("SELECT :x AS a, :x AS b, :x AS c")
+        .with_parameter("x", 7).execute_query().to_array();
+    assert(array.length == 1);
+    assert(array[0].get("b").try_get_as<int64?>(out v) && v == 7);
+    assert(array[0].get("c").try_get_as<int64?>(out v) && v == 7);
+    pass("parameter reuse");
+}
+
+// ============================================================================
+// Transaction tests
+// ============================================================================
+
+void test_transaction_commit_persists(Connection conn) throws SqlError {
+    print("Test: transaction commit persists... ");
+    drop_and_create(conn, "CREATE TABLE txn_commit (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, payload TEXT)");
+    var tx = conn.begin_transaction();
+    assert(tx.is_active);
+    exec_p<string>(conn, "INSERT INTO txn_commit (payload) VALUES (:p)", "p", "committed-row");
+    tx.commit();
+    assert(!tx.is_active);
+    var n = conn.create_command("SELECT count(*) AS c FROM txn_commit").execute_scalar();
+    int64? c = null;
+    assert(n.try_get_as<int64?>(out c) && c == 1);
+    pass("commit persists");
+}
+
+void test_transaction_rollback_discards(Connection conn) throws SqlError {
+    print("Test: transaction rollback discards... ");
+    drop_and_create(conn, "CREATE TABLE txn_rollback (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, payload TEXT)");
+    var tx = conn.begin_transaction();
+    exec_p<string>(conn, "INSERT INTO txn_rollback (payload) VALUES (:p)", "p", "doomed-row");
+    tx.rollback();
+    assert(!tx.is_active);
+    var n = conn.create_command("SELECT count(*) AS c FROM txn_rollback").execute_scalar();
+    int64? c = null;
+    assert(n.try_get_as<int64?>(out c) && c == 0);
+    pass("rollback discards");
+}
+
+void test_transaction_is_active(Connection conn) throws SqlError {
+    print("Test: transaction double-commit/rollback rejected... ");
+    drop_and_create(conn, "CREATE TABLE txn_active (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, v INT)");
+    var tx = conn.begin_transaction();
+    exec_p<int>(conn, "INSERT INTO txn_active (v) VALUES (:v)", "v", 1);
+    tx.commit();
+    try {
+        tx.commit();
+        assert_not_reached();
+    } catch (SqlError e) {
+        assert(e is SqlError.TRANSACTION_FAILED);
+    }
+    var tx2 = conn.begin_transaction();
+    exec_p<int>(conn, "INSERT INTO txn_active (v) VALUES (:v)", "v", 2);
+    tx2.rollback();
+    try {
+        tx2.rollback();
+        assert_not_reached();
+    } catch (SqlError e) {
+        assert(e is SqlError.TRANSACTION_FAILED);
+    }
+    var n = conn.create_command("SELECT count(*) AS c FROM txn_active").execute_scalar();
+    int64? c = null;
+    assert(n.try_get_as<int64?>(out c) && c == 1);
+    pass("txn lifecycle");
+}
+
+// ============================================================================
+// last_insert_rowid tests
+// ============================================================================
+
+void test_last_insert_rowid(Connection conn) throws SqlError {
+    print("Test: last_insert_rowid after INSERT (identity)... ");
+    drop_and_create(conn, "CREATE TABLE rowid_probe (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, label TEXT)");
+    exec_p<string>(conn, "INSERT INTO rowid_probe (label) VALUES (:l)", "l", "first");
+    int64 first_id = conn.last_insert_rowid;
+    assert(first_id > 0);
+    exec_p<string>(conn, "INSERT INTO rowid_probe (label) VALUES (:l)", "l", "second");
+    int64 second_id = conn.last_insert_rowid;
+    assert(second_id > first_id);
+    // The ids must actually key back to the rows we inserted.
+    var label = conn.create_command("SELECT label FROM rowid_probe WHERE id = :i")
+        .with_parameter<int64?>("i", first_id).execute_scalar();
+    string? l = null;
+    assert(label.try_get_as<string>(out l) && l == "first");
+    var label2 = conn.create_command("SELECT label FROM rowid_probe WHERE id = :i")
+        .with_parameter<int64?>("i", second_id).execute_scalar();
+    assert(label2.try_get_as<string>(out l) && l == "second");
+    pass("last_insert_rowid");
+}
+
+void test_last_insert_rowid_in_transaction(Connection conn) throws SqlError {
+    print("Test: last_insert_rowid inside transaction... ");
+    drop_and_create(conn, "CREATE TABLE rowid_txn (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, label TEXT)");
+    var tx = conn.begin_transaction();
+    exec_p<string>(conn, "INSERT INTO rowid_txn (label) VALUES (:l)", "l", "in-txn");
+    int64 id_in_txn = conn.last_insert_rowid;
+    assert(id_in_txn > 0);
+    tx.commit();
+    // After commit the row keyed by the captured id is visible and matches.
+    var label = conn.create_command("SELECT label FROM rowid_txn WHERE id = :i")
+        .with_parameter<int64?>("i", id_in_txn).execute_scalar();
+    string? l = null;
+    assert(label.try_get_as<string>(out l) && l == "in-txn");
+    pass("last_insert_rowid in txn");
+}
+
+// ============================================================================
+// Lazy enumeration tests
+// ============================================================================
+
+void test_lazy_enumeration_partial(Connection conn) throws SqlError {
+    print("Test: lazy enumeration, partial then abandon... ");
+    drop_and_create(conn, "CREATE TABLE lazy_rows (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, seq INT)");
+    var sets = new Invercargill.DataStructures.Vector<Invercargill.Properties>();
+    for (int i = 1; i <= 50; i++) {
+        var p = new Invercargill.DataStructures.PropertyDictionary();
+        p.set_native<int64?>("seq", i);
+        sets.add(p);
+    }
+    // Insert all 50 rows in one batch to have a known population.
+    int total = conn.create_command("INSERT INTO lazy_rows (seq) VALUES (:seq)")
+        .execute_batch(sets);
+    assert(total == 50);
+
+    // Enumerate only the first 3 rows, then abandon the enumerable.
+    var results = conn.create_command("SELECT id, seq FROM lazy_rows ORDER BY id").execute_query();
+    int seen = 0;
+    int64? last_seq = 0;
+    foreach (var walked_row in results) {
+        int64? seq_val = null;
+        assert(walked_row.get("seq").try_get_as<int64?>(out seq_val));
+        assert(seq_val > last_seq);
+        last_seq = seq_val;
+        seen++;
+        if (seen == 3) {
+            break;
+        }
+    }
+    assert(seen == 3);
+
+    // Abandoning mid-enumeration must not break subsequent queries on the
+    // same connection (result lifetime safety).
+    var n = conn.create_command("SELECT count(*) AS c FROM lazy_rows").execute_scalar();
+    int64? c = null;
+    assert(n.try_get_as<int64?>(out c) && c == 50);
+
+    // A fresh enumerable over the same data works fully.
+    int full = 0;
+    int64 seq_sum = 0;
+    foreach (var counted_row in conn.create_command("SELECT seq FROM lazy_rows").execute_query()) {
+        int64? seq_val = null;
+        assert(counted_row.get("seq").try_get_as<int64?>(out seq_val));
+        seq_sum += seq_val;
+        full++;
+    }
+    assert(full == 50);
+    assert(seq_sum == 1275);  // 1+2+...+50
+    pass("lazy partial");
+}
+
+void test_peek_count(Connection conn) throws SqlError {
+    print("Test: peek_count reports buffered row count... ");
+    var empty = conn.create_command("SELECT 1 AS v WHERE false").execute_query();
+    assert(empty.peek_count() == 0);
+    var seven = conn.create_command("SELECT generate_series(1, 7) AS n").execute_query();
+    assert(seven.peek_count() == 7);
+    // peek_count is available without enumerating.
+    int walked = 0;
+    foreach (var series_row in seven) {
+        int64? n_val = null;
+        assert(series_row.get("n").try_get_as<int64?>(out n_val));
+        assert(n_val == walked + 1);
+        walked++;
+    }
+    assert(walked == 7);
+    pass("peek_count");
+}
+
+void test_enumerable_cache(Connection conn) throws SqlError {
+    print("Test: enumerable can be enumerated twice (cache)... ");
+    var rows = conn.create_command("SELECT generate_series(1, 5) AS n").execute_query();
+    int64 first_sum = 0;
+    foreach (var pass_row in rows) {
+        int64? n_val = null;
+        assert(pass_row.get("n").try_get_as<int64?>(out n_val));
+        first_sum += n_val;
+    }
+    int64 second_sum = 0;
+    foreach (var pass_row in rows) {
+        int64? n_val = null;
+        assert(pass_row.get("n").try_get_as<int64?>(out n_val));
+        second_sum += n_val;
+    }
+    assert(first_sum == 15 && second_sum == 15);  // 1+2+3+4+5
+    pass("cache");
+}
+
+// ============================================================================
+// Error path tests
+// ============================================================================
+
+void test_syntax_error(Connection conn) throws SqlError {
+    print("Test: syntax error surfaces as SqlError... ");
+    try {
+        conn.execute("SELEC nope FROM nothing");
+        assert_not_reached();
+    } catch (SqlError e) {
+        // Server message must be carried into the error text.
+        assert(e.message.length > 0);
+        assert("SELEC" in e.message || "syntax" in e.message.down()
+               || e is SqlError.EXECUTION_FAILED || e is SqlError.TYPE_ERROR);
+    }
+    // Connection survives the error and remains usable.
+    var ok = conn.create_command("SELECT 'still-alive' AS s").execute_scalar();
+    string? s = null;
+    assert(ok.try_get_as<string>(out s) && s == "still-alive");
+    pass("syntax error");
+}
+
+void test_unique_violation(Connection conn) throws SqlError {
+    print("Test: unique violation surfaced... ");
+    drop_and_create(conn, "CREATE TABLE uniq_probe (id BIGINT PRIMARY KEY, email TEXT UNIQUE)");
+    conn.create_command("INSERT INTO uniq_probe (id, email) VALUES (:i, :e)")
+        .with_parameter<int64?>("i", 1).with_parameter("e", "a@x").execute_non_query();
+    try {
+        conn.create_command("INSERT INTO uniq_probe (id, email) VALUES (:i, :e)")
+            .with_parameter("i", 1).with_parameter("e", "a@x").execute_non_query();
+        assert_not_reached();
+    } catch (SqlError e) {
+        assert(e.message.length > 0);
+    }
+    var n = conn.create_command("SELECT count(*) AS c FROM uniq_probe").execute_scalar();
+    int64? c = null;
+    assert(n.try_get_as<int64?>(out c) && c == 1);
+    pass("unique violation");
+}
+
+void test_missing_parameter(Connection conn) throws SqlError {
+    print("Test: missing parameter rejected... ");
+    try {
+        conn.create_command("SELECT :absent").execute_non_query();
+        assert_not_reached();
+    } catch (SqlError e) {
+        assert(e is SqlError.INVALID_PARAMETER);
+        assert(":absent" in e.message);
+    }
+    pass("missing parameter");
+}
+
+// ============================================================================
+// Mixed integration tests
+// ============================================================================
+
+void test_mixed_type_table(Connection conn) throws SqlError {
+    print("Test: Mixed type table... ");
+    drop_and_create(conn, "CREATE TABLE mixed_pg (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, age INT, salary DOUBLE PRECISION, is_active BOOLEAN, created_at TIMESTAMPTZ, data BYTEA)");
+    var now = new DateTime.now_local();
+    uint8[] blob_bytes = { 0xDE, 0xAD, 0xBE, 0xEF };
+    double? salary_val = 75000.50;
+    conn.create_command("""
+        INSERT INTO mixed_pg (name, age, salary, is_active, created_at, data)
+        VALUES (:name, :age, :salary, :active, :created, :data)""")
+        .with_parameter("name", "John Doe")
+        .with_parameter("age", 30)
+        .with_parameter<double?>("salary", salary_val)
+        .with_parameter("active", true)
+        .with_parameter<DateTime>("created", now)
+        .with_parameter<Invercargill.BinaryData>("data", wrap_bytes(blob_bytes))
+        .execute_non_query();
+    var row = conn.create_command("SELECT * FROM mixed_pg").execute_query().to_array()[0];
+    string? name = null;
+    int? age = null;
+    double? salary = null;
+    bool? active = null;
+    DateTime? created = null;
+    assert(row.get("name").try_get_as<string>(out name) && name == "John Doe");
+    assert(row.get("age").try_get_as<int?>(out age) && age == 30);
+    assert(row.get("salary").try_get_as<double?>(out salary));
+    assert(salary >= 75000.49 && salary <= 75000.51);
+    assert(row.get("is_active").try_get_as<bool?>(out active) && active == true);
+    assert(row.get("created_at").try_get_as<DateTime>(out created));
+    assert(created != null && created.to_unix() == now.to_unix());
+    var data_elem = row.get("data");
+    assert(data_elem.is_null() == false);
+    pass("mixed table");
+}
+
+void test_element_assignable_types(Connection conn) throws SqlError {
+    print("Test: Element assignable_types over postgres types... ");
+    drop_and_create(conn, "CREATE TABLE assign_pg (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, int_val BIGINT, real_val DOUBLE PRECISION, str_val TEXT, bool_val BOOLEAN)");
+    conn.execute("INSERT INTO assign_pg (int_val, real_val, str_val, bool_val) VALUES (42, 3.14, 'test', true)");
+    var row = conn.create_command("SELECT int_val, real_val, str_val, bool_val FROM assign_pg")
+        .execute_query().to_array()[0];
+    var int_elem = row.get("int_val");
+    var real_elem = row.get("real_val");
+    var str_elem = row.get("str_val");
+    var bool_elem = row.get("bool_val");
+    assert(int_elem.assignable_to_type(typeof(int64)) == true);
+    assert(int_elem.assignable_to_type(typeof(int)) == true);
+    assert(int_elem.assignable_to_type(typeof(string)) == true);
+    assert(int_elem.assignable_to_type(typeof(double)) == true);
+    assert(real_elem.assignable_to_type(typeof(double)) == true);
+    assert(real_elem.assignable_to_type(typeof(float)) == true);
+    assert(real_elem.assignable_to_type(typeof(int64)) == true);
+    assert(real_elem.assignable_to_type(typeof(string)) == true);
+    assert(str_elem.assignable_to_type(typeof(string)) == true);
+    assert(str_elem.assignable_to_type(typeof(int64)) == true);
+    assert(str_elem.assignable_to_type(typeof(double)) == true);
+    assert(bool_elem.assignable_to_type(typeof(bool)) == true);
+    assert(bool_elem.assignable_to_type(typeof(string)) == true);
+    pass("assignable types");
+}