Преглед изворни кода

Add PostgreSQL adapter over libpq

Implements the Connection/Command/Transaction backend for PostgreSQL
alongside the existing SQLite adapter, per plan Task 1.

New files under src/postgres/:
- postgres-provider.vala: ConnectionProvider for scheme "postgresql"
  (alias "postgres"). Builds a libpq conninfo from the parsed
  ConnectionString; empty components are omitted so libpq environment
  defaults apply. Host/port given as query options fill in for empty
  URI components, covering the unix-socket form
  postgresql://user@/db?host=/run/postgresql. Empty database names are
  rejected with SqlError.INVALID_CONNECTION_STRING. Recognised libpq
  conninfo keywords in query options are forwarded (sslmode,
  connect_timeout, application_name, ...).
- postgres-connection.vala: wraps a PGconn via PQconnectdb.
  last_insert_rowid is SELECT lastval(); changes comes from PQcmdTuples.
  Errors are wrapped into SqlError using SQLSTATE prefixes
  (08/57/58 -> CONNECTION_FAILED, 25/26 -> TRANSACTION_FAILED,
  22/42883 -> TYPE_ERROR, else EXECUTION_FAILED).
- postgres-command.vala: fluent parameter API mirroring SqliteCommand.
  Named :name placeholders are translated to $n at execution time;
  string literals, quoted identifiers, dollar-quoted strings and
  comments are skipped, and :: casts never start a placeholder.
  Values bind in text format; blobs as bytea hex.
- postgres-result-enumerable.vala: Enumerable<Properties> over an
  owned PGresult with AdvanceTracker; peek_count returns PQntuples.
- postgres-transaction.vala: BEGIN/COMMIT/ROLLBACK via exec.
- postgres-elements.vala: Element types for int/real/text/bool/blob/
  datetime/null plus PostgresElementFactory with OID-based result
  mapping, bytea hex/octal codec, timestamp parsing, and
  Element-to-text parameter conversion (blob first, then DateTime,
  bool, integers, reals, string).

Wiring:
- connection-factory.vala: auto-registers PostgresProvider for
  postgres/postgresql in get_provider/has_provider, plus static fields
  and clear_providers support, mirroring the sqlite pattern.
- meson.build: libpq dependency plus a header-layout probe (Debian
  puts libpq headers under /usr/include/postgresql; this image installs
  them flat), creating a compat include dir when needed.
- src/meson.build: new sources and c_args.
- README.md: PostgreSQL connections section with URI examples and
  adapter notes; libpq listed in dependencies.
- MANIFEST.usm: libpq.so.5 runtime and libpq.pc build deps.

DialectFactory is intentionally untouched; the PostgresDialect is the
next task.

Verified: clean meson setup/compile (88 targets) and all five existing
test suites pass; manual smoke tests against the local PostgreSQL 18.6
server (unix socket /tmp/pgrun:5433) cover connect, table create,
insert with RETURNING, lastval, bytea blob round-trip, bool/double/
int/timestamp round-trips, quoted strings, repeated parameters,
cast-operator SQL, transactions commit/rollback, changes counter,
scalar queries, and error mapping.
clanker пре 1 месец
родитељ
комит
425f74e77b

+ 3 - 1
MANIFEST.usm

@@ -18,7 +18,8 @@
       "lib:libgobject-2.0.so.0",
       "lib:libgio-2.0.so.0",
       "lib:libinvercargill-1.so",
-      "lib:libsqlite3.so.0"
+      "lib:libsqlite3.so.0",
+      "lib:libpq.so.5"
     ],
     "build": [
       "bin:valac",
@@ -30,6 +31,7 @@
       "pc:gobject-2.0.pc",
       "pc:gio-2.0.pc",
       "pc:sqlite3.pc",
+      "pc:libpq.pc",
       "pc:invercargill-1.pc"
     ],
     "manage": [

+ 46 - 0
README.md

@@ -19,6 +19,7 @@ A Vala SQL library with tight integration into the [Invercargill](https://github
 - `gio-2.0`
 - `invercargill-1`
 - `sqlite3` (internal dependency only)
+- `libpq` (PostgreSQL client library, for the postgresql:// adapter)
 
 ## Building
 
@@ -78,6 +79,51 @@ The library supports URI-style connection strings:
 "sqlite:///db.sqlite?mode=rwc"             // Read-write, create if needed (default)
 ```
 
+### PostgreSQL Connections
+
+The library also ships a PostgreSQL adapter built on libpq (scheme
+`postgresql`, with `postgres` accepted as an alias). Both providers are
+registered automatically — no extra setup is needed:
+
+```vala
+// TCP connection
+var conn = InvercargillSql.ConnectionFactory.create_and_open(
+    "postgresql://user:password@localhost:5432/mydb"
+);
+
+// Unix socket directory via the host option
+var conn = InvercargillSql.ConnectionFactory.create_and_open(
+    "postgresql://user@/run/postgresql/mydb?host=/run/postgresql"
+);
+
+// Short scheme alias, explicit socket dir and port
+var conn = InvercargillSql.ConnectionFactory.create_and_open(
+    "postgres://postgres@/mydb?host=/tmp/pgrun&port=5433"
+);
+
+// Extra libpq options (connect_timeout, sslmode, application_name, ...)
+var conn = InvercargillSql.ConnectionFactory.create_and_open(
+    "postgresql://user@db.example.com:5432/mydb?sslmode=require&connect_timeout=10"
+);
+```
+
+Notes on the adapter:
+
+- Components left out of the URI are omitted from the conninfo handed to
+  libpq, so the usual environment defaults (PGHOST, PGPORT, PGUSER,
+  ~/.pgpass) still apply.
+- Commands keep the same `:name` placeholder style as SQLite; the adapter
+  translates them to libpq `$n` positions at execution time. Placeholders
+  inside string literals, quoted identifiers, dollar-quoted strings and
+  comments are ignored, as are PostgreSQL `::type` cast operators.
+- Values are bound in text format. Booleans bind as `t`/`f`, DateTime as
+  ISO 8601 text, and `Invercargill.BinaryData` as bytea hex (`\x...`),
+  so `bytea` columns round-trip without casts.
+- `connection.last_insert_rowid` is backed by `SELECT lastval()`, valid
+  after any INSERT that used a sequence on that connection.
+- A PostgreSQL dialect for `DialectFactory`/ORM use is tracked separately;
+  today `DialectFactory` remains SQLite-only.
+
 ### Creating Tables and Inserting Data
 
 ```vala

+ 26 - 0
meson.build

@@ -5,6 +5,32 @@ glib_dep = dependency('glib-2.0')
 gobject_dep = dependency('gobject-2.0')
 invercargill_dep = dependency('invercargill-1')
 sqlite_dep = dependency('sqlite3')
+libpq_dep = dependency('libpq')
+
+# libpq: Vala's libpq.vapi emits "#include <postgresql/libpq-fe.h>" in the
+# generated C, but distros disagree on the header location (Debian keeps
+# them under /usr/include/postgresql, Fedora-style layouts install them
+# flat in /usr/include). Probe with the C compiler and, when the flat
+# layout is found, create a compat include dir with a postgresql/ symlink
+# tree in the build directory.
+cc = meson.get_compiler('c')
+libpq_compat_args = []
+if not cc.has_header('postgresql/libpq-fe.h', dependencies: libpq_dep)
+    if not cc.has_header('libpq-fe.h', dependencies: libpq_dep)
+        error('libpq headers not found; install libpq-devel/postgresql-devel')
+    endif
+    pg_includedir = run_command(
+        find_program('pkg-config'), '--variable=includedir', 'libpq',
+        check: true,
+    ).stdout().strip()
+    pg_compat = meson.current_build_dir() / 'pg-compat/postgresql'
+    run_command('mkdir', '-p', pg_compat, check: true)
+    foreach h : ['libpq-fe.h', 'libpq-events.h']
+        run_command('ln', '-sfn', pg_includedir / h, pg_compat / h, check: true)
+    endforeach
+    run_command('ln', '-sfn', pg_includedir / 'libpq', pg_compat / 'libpq', check: true)
+    libpq_compat_args = ['-I' + meson.current_build_dir() / 'pg-compat']
+endif
 
 # Subdirectories
 subdir('src')

+ 21 - 2
src/connection-factory.vala

@@ -23,6 +23,8 @@ namespace InvercargillSql {
 
         private static bool _sqlite_registered = false;
         private static ConnectionProvider? _sqlite_provider = null;
+        private static bool _postgres_registered = false;
+        private static ConnectionProvider? _postgres_provider = null;
 
         /**
          * Creates a new database connection from a connection string.
@@ -89,9 +91,12 @@ namespace InvercargillSql {
             if (scheme_lower == "sqlite") {
                 _sqlite_provider = provider;
                 _sqlite_registered = true;
+            } else if (scheme_lower == "postgres" || scheme_lower == "postgresql") {
+                _postgres_provider = provider;
+                _postgres_registered = true;
             }
-            // For other providers, we would extend this with a dictionary
-            // For now, we only support SQLite natively
+            // Other providers can be registered once a dictionary of
+            // schemes is introduced; sqlite and postgres are the natives.
         }
 
         /**
@@ -114,6 +119,14 @@ namespace InvercargillSql {
                 return _sqlite_provider;
             }
 
+            if (scheme_lower == "postgres" || scheme_lower == "postgresql") {
+                if (!_postgres_registered) {
+                    _postgres_provider = new PostgresProvider();
+                    _postgres_registered = true;
+                }
+                return _postgres_provider;
+            }
+
             throw new SqlError.INVALID_CONNECTION_STRING(
                 "No connection provider registered for scheme: %s".printf(scheme)
             );
@@ -127,6 +140,8 @@ namespace InvercargillSql {
         public static void clear_providers() {
             _sqlite_provider = null;
             _sqlite_registered = false;
+            _postgres_provider = null;
+            _postgres_registered = false;
         }
 
         /**
@@ -142,6 +157,10 @@ namespace InvercargillSql {
                 return _sqlite_registered;
             }
 
+            if (scheme_lower == "postgres" || scheme_lower == "postgresql") {
+                return _postgres_registered;
+            }
+
             return false;
         }
     }

+ 11 - 1
src/meson.build

@@ -7,7 +7,8 @@ dependencies = [
     gobject_dep,
     gio_dep,
     invercargill_dep,
-    sqlite_dep
+    sqlite_dep,
+    libpq_dep
 ]
 
 # Interface sources
@@ -23,6 +24,7 @@ sources += files('connection-factory.vala')
 sources += files('dialect-factory.vala')
 sources += files('providers/connection-provider.vala')
 sources += files('providers/sqlite-provider.vala')
+sources += files('providers/postgres-provider.vala')
 
 # SQLite implementation sources
 sources += files('sqlite/sqlite-connection.vala')
@@ -32,6 +34,13 @@ sources += files('sqlite/sqlite-transaction.vala')
 sources += files('sqlite/sqlite-elements.vala')
 sources += files('sqlite/sqlite-drop-constraint.vala')
 
+# PostgreSQL implementation sources
+sources += files('postgres/postgres-connection.vala')
+sources += files('postgres/postgres-command.vala')
+sources += files('postgres/postgres-result-enumerable.vala')
+sources += files('postgres/postgres-transaction.vala')
+sources += files('postgres/postgres-elements.vala')
+
 # ORM sources
 sources += files('orm/column-type.vala')
 sources += files('orm/column-definition.vala')
@@ -80,6 +89,7 @@ sources += files('migrations/dependency.vala')
 # Build shared library
 invercargill_sql = shared_library('invercargill-sql', sources,
     dependencies: dependencies,
+    c_args: libpq_compat_args,
     install: true,
     vala_gir: 'invercargill_sql-1.0.gir',
     install_dir: [true, true, true, true]

+ 334 - 0
src/postgres/postgres-command.vala

@@ -0,0 +1,334 @@
+using Postgres;
+
+namespace InvercargillSql {
+
+    /*
+     * PostgreSQL implementation of the Command interface.
+     *
+     * libpq has no named-parameter facility, so named ":name" placeholders
+     * in the command text are translated to positional "$n" references at
+     * execution time: the text is scanned left to right (skipping string
+     * literals and quoted identifiers), each distinct name is assigned the
+     * next parameter index, and the parameter values array is built in that
+     * order. Values are passed to PQexecParams in text format, converted
+     * from Invercargill.Element by PostgresElementFactory.
+     *
+     * Blobs are sent as bytea in text (hex) format: encode(\\x...) values
+     * bind to bytea columns directly because PostgreSQL parses the "\\x"
+     * prefix in any text-format bytea input.
+     *
+     * Internal class: use Connection.create_command() to create commands.
+     */
+    internal class PostgresCommand : Object, Command {
+
+        private weak PostgresConnection _connection;
+
+        private string _command_text;
+
+        private Invercargill.DataStructures.PropertyDictionary _parameters;
+
+        public string command_text {
+            get { return _command_text; }
+            set { _command_text = value; }
+        }
+
+        public Invercargill.Properties parameters {
+            get { return _parameters; }
+        }
+
+        internal PostgresCommand(PostgresConnection connection, string sql) {
+            _connection = connection;
+            _parameters = new Invercargill.DataStructures.PropertyDictionary();
+            _command_text = sql;
+        }
+
+        /*
+         * Sets a parameter by name with automatic type wrapping.
+         * If the value is already an Element, it is set directly.
+         */
+
+        public Command with_parameter<T>(string name, T value) {
+            if (typeof(T).is_a(typeof(Invercargill.Element))) {
+                _parameters.set(name, (Invercargill.Element)value);
+            } else {
+                try {
+                    _parameters.set_native<T>(name, value);
+                } catch (Invercargill.ElementError e) {
+                    warning("Failed to set parameter %s: %s", name, e.message);
+                }
+            }
+            return this;
+        }
+
+        /*
+         * Sets a parameter to null.
+         */
+
+        public Command with_null(string name) {
+            _parameters.set(name, new Invercargill.NullElement());
+            return this;
+        }
+
+        /*
+         * Result of translating ":name" placeholders to "$n" form.
+         */
+
+        private class TranslatedQuery {
+            public string sql;
+            public string[] names;
+            public TranslatedQuery(string sql, string[] names) {
+                this.sql = sql;
+                this.names = names;
+            }
+        }
+
+        /*
+         * Scans the command text and rewrites named placeholders.
+         * ":name" becomes "$n" where n is 1-based position of the first
+         * occurrence of that name. Skips single-quoted strings, dollar-
+         * quoted strings, quoted identifiers and comments. A "$n" that is
+         * already present in the text is left untouched; a ':' not followed
+         * by an identifier character is left untouched (e.g. "::" casts).
+         */
+
+        private TranslatedQuery translate_placeholders() throws SqlError {
+            var sql = new StringBuilder();
+            string[] names = new string[0];
+            var text = _command_text;
+            int len = text.length;
+            int i = 0;
+            bool prev_colon = false;
+            while (i < len) {
+                char c = text[i];
+                // string literal
+                if (c == '\'') {
+                    sql.append_c(c);
+                    i++;
+                    while (i < len) {
+                        if (text[i] == '\'') {
+                            if (i + 1 < len && text[i + 1] == '\'') {
+                                sql.append("''");
+                                i += 2;
+                                continue;
+                            }
+                            sql.append_c('\'');
+                            i++;
+                            break;
+                        }
+                        sql.append_c(text[i]);
+                        i++;
+                    }
+                    continue;
+                }
+                // dollar-quoted string: $tag$ ... $tag$
+                if (c == '$' && i + 1 < len && is_tag_char(text[i + 1])) {
+                    int tag_end = find_dollar_tag_end(text, i);
+                    if (tag_end > i) {
+                        string tag = text.substring(i, tag_end - i + 1);
+                        string rest = text.substring(tag_end + 1);
+                        int close = rest.index_of(tag);
+                        if (close >= 0) {
+                            sql.append(text.substring(i, tag_end + 1 + close + tag.length - i));
+                            i = tag_end + 1 + close + tag.length;
+                            continue;
+                        }
+                    }
+                }
+                // quoted identifier
+                if (c == '"') {
+                    sql.append_c(c);
+                    i++;
+                    while (i < len) {
+                        if (text[i] == '"') {
+                            sql.append_c('"');
+                            i++;
+                            break;
+                        }
+                        sql.append_c(text[i]);
+                        i++;
+                    }
+                    continue;
+                }
+                // line comment
+                if (c == '-' && i + 1 < len && text[i + 1] == '-') {
+                    while (i < len && text[i] != '\n') {
+                        sql.append_c(text[i]);
+                        i++;
+                    }
+                    continue;
+                }
+                // block comment
+                if (c == '/' && i + 1 < len && text[i + 1] == '*') {
+                    int end = text.index_of("*/", i + 2);
+                    int stop = end < 0 ? len : end + 2;
+                    sql.append(text.substring(i, stop - i));
+                    i = stop;
+                    continue;
+                }
+                // named placeholder ("::" casts never start one: the
+                // second colon of a cast is skipped via prev_colon)
+                if (c == ':' && i + 1 < len && is_name_char(text[i + 1]) && !prev_colon) {
+                    int j = i + 1;
+                    while (j < len && is_name_char(text[j])) {
+                        j++;
+                    }
+                    string name = text.substring(i + 1, j - i - 1);
+                    int index = index_of_name(names, name);
+                    if (index < 0) {
+                        names += name;
+                        index = names.length - 1;
+                    }
+                    sql.append_printf("$%d", index + 1);
+                    i = j;
+                    continue;
+                }
+                sql.append_c(c);
+                i++;
+                prev_colon = (c == ':');
+            }
+            return new TranslatedQuery(sql.str, names);
+        }
+
+        private static bool is_name_char(char c) {
+            return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+                   (c >= '0' && c <= '9') || c == '_';
+        }
+
+        private static bool is_tag_char(char c) {
+            return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+                   (c >= '0' && c <= '9') || c == '_';
+        }
+
+        private static int index_of_name(string[] names, string name) {
+            for (int k = 0; k < names.length; k++) {
+                if (names[k] == name) {
+                    return k;
+                }
+            }
+            return -1;
+        }
+
+        private static int find_dollar_tag_end(string text, int start) {
+            // start points at '$'; find the closing '$' of the tag
+            int j = start + 1;
+            int len = text.length;
+            while (j < len && is_tag_char(text[j])) {
+                j++;
+            }
+            if (j < len && text[j] == '$' && j > start + 1) {
+                return j;
+            }
+            return -1;
+        }
+
+        /*
+         * Builds the parameter value array in $n order from the translated
+         * names and the PropertyDictionary. Missing parameters throw
+         * INVALID_PARAMETER, matching sqlite's tolerance is not applicable
+         * because PQexecParams requires every $n to have a value.
+         */
+
+        private string[]? build_param_values(string[] names) throws SqlError {
+            if (names.length == 0) {
+                return null;
+            }
+            string[] values = new string[names.length];
+            for (int k = 0; k < names.length; k++) {
+                if (!_parameters.has(names[k])) {
+                    throw new SqlError.INVALID_PARAMETER(
+                        "Missing value for parameter ':%s'".printf(names[k])
+                    );
+                }
+                values[k] = PostgresElementFactory.to_param_text(_parameters[names[k]]);
+            }
+            return values;
+        }
+
+        /*
+         * Executes a query and returns results as Enumerable<Properties>.
+         * The PGresult is owned by the returned enumerable (freed when it
+         * is finalized or the rows are exhausted via cache materialisation).
+         */
+
+        public Invercargill.Enumerable<Invercargill.Properties> execute_query() throws SqlError {
+            _connection.ensure_open();
+            TranslatedQuery tq = translate_placeholders();
+            string[]? values = build_param_values(tq.names);
+            Postgres.Result result = _connection.exec_params_internal(
+                tq.sql, tq.names.length, values, null, null
+            );
+            return new PostgresResultEnumerable((owned)result).cache();
+        }
+
+        /*
+         * Executes a non-query command like INSERT, UPDATE, DELETE.
+         * @return Number of rows affected
+         */
+
+        public int execute_non_query() throws SqlError {
+            _connection.ensure_open();
+            TranslatedQuery tq = translate_placeholders();
+            string[]? values = build_param_values(tq.names);
+            Postgres.Result result = _connection.exec_params_internal(
+                tq.sql, tq.names.length, values, null, null
+            );
+            return _connection.changes;
+        }
+
+        /*
+         * Executes a scalar query returning a single value.
+         * @return The first value of the first row, or null
+         */
+
+        public Invercargill.Element? execute_scalar() throws SqlError {
+            _connection.ensure_open();
+            TranslatedQuery tq = translate_placeholders();
+            string[]? values = build_param_values(tq.names);
+            Postgres.Result result = _connection.exec_params_internal(
+                tq.sql, tq.names.length, values, null, null
+            );
+            if (result.get_status() == ExecStatus.TUPLES_OK &&
+                result.get_n_tuples() > 0 && result.get_n_fields() > 0) {
+                uint oid = (uint)result.get_field_type(0);
+                return PostgresElementFactory.from_result(result, 0, 0, oid);
+            }
+            return null;
+        }
+
+        /*
+         * Resets the command for re-execution with new parameters.
+         */
+
+        public Command reset() {
+            _parameters.clear();
+            return this;
+        }
+
+        /*
+         * Executes a command multiple times with different parameter sets.
+         * Each set is translated and bound independently (no prepared
+         * statement is retained; libpq PQexecParams re-parses each time).
+         */
+
+        public int execute_batch(
+            Invercargill.Enumerable<Invercargill.Properties> parameter_sets
+        ) throws SqlError {
+            _connection.ensure_open();
+            int total_rows = 0;
+            foreach (var param_set in parameter_sets) {
+                _parameters.clear();
+                foreach (var kvp in param_set) {
+                    _parameters.set(kvp.key, kvp.value);
+                }
+                TranslatedQuery tq = translate_placeholders();
+                string[]? values = build_param_values(tq.names);
+                Postgres.Result result = _connection.exec_params_internal(
+                    tq.sql, tq.names.length, values, null, null
+                );
+                total_rows += _connection.changes;
+            }
+            _parameters.clear();
+            return total_rows;
+        }
+    }
+}

+ 189 - 0
src/postgres/postgres-connection.vala

@@ -0,0 +1,189 @@
+using Postgres;
+
+namespace InvercargillSql {
+
+    /*
+     * PostgreSQL implementation of the Connection interface.
+     * Wraps a libpq PGconn (Postgres.Database) for database connectivity.
+     *
+     * last_insert_rowid is implemented with SELECT lastval(): the last value
+     * obtained from any sequence on this connection, which matches SQLite's
+     * "rowid of the most recent INSERT" for identity/serial tables. If no
+     * sequence has been used yet, lastval() errors and we surface 0.
+     *
+     * Internal class: use ConnectionFactory to create connections.
+     */
+    internal class PostgresConnection : Object, Connection {
+
+        private Database? _db;
+
+        private bool _is_open;
+
+        private int _last_changes;
+
+        public string conninfo { get; construct; }
+
+        public string database_name { get; construct; }
+
+        internal PostgresConnection(string conninfo, string database_name) {
+            Object(conninfo: conninfo, database_name: database_name);
+        }
+
+        public bool is_open {
+            get { return _is_open && _db != null && _db.get_status() == ConnectionStatus.OK; }
+        }
+
+        public int64 last_insert_rowid {
+            get { return query_lastval(); }
+        }
+
+        public int changes {
+            get { return _last_changes; }
+        }
+
+        public void open() throws SqlError {
+            if (_is_open) {
+                return;
+            }
+            _db = Postgres.connect_db(conninfo);
+            if (_db.get_status() != ConnectionStatus.OK) {
+                string message = _db.get_error_message();
+                _db = null;
+                throw new SqlError.CONNECTION_FAILED(
+                    "Failed to connect to PostgreSQL database '%s': %s".printf(database_name, message)
+                );
+            }
+            _is_open = true;
+            _last_changes = 0;
+        }
+
+        public void close() throws SqlError {
+            if (_is_open && _db != null) {
+                _db = null;  // PQfinish via unref
+                _is_open = false;
+            }
+        }
+
+        public Command create_command(string sql) {
+            return new PostgresCommand(this, sql);
+        }
+
+        public Transaction begin_transaction() throws SqlError {
+            ensure_open();
+            exec_internal("BEGIN");
+            return new PostgresTransaction(this);
+        }
+
+        public void execute(string sql) throws SqlError {
+            ensure_open();
+            exec_internal(sql);
+        }
+
+        internal unowned Database get_database() {
+            return _db;
+        }
+
+        internal void ensure_open() throws SqlError {
+            if (!_is_open || _db == null) {
+                throw new SqlError.CONNECTION_CLOSED("Connection is not open");
+            }
+        }
+
+        /*
+         * Runs SQL and checks the result status, updating the cached
+         * changes counter. Shared by execute(), PostgresTransaction and
+         * PostgresCommand (which keeps the owned Result for rows).
+         */
+
+        internal Postgres.Result exec_internal(string sql) throws SqlError {
+            Postgres.Result result = _db.exec(sql);
+            check_result(result, sql);
+            update_counters(result);
+            return result;
+        }
+
+        /*
+         * Runs parameterized SQL via PQexecParams. n_params/values/lengths/
+         * formats are the libpq arrays (values may contain nulls for SQL
+         * NULL). Returns the owned PGresult after status checking.
+         */
+
+        internal Postgres.Result exec_params_internal(
+            string command,
+            int n_params,
+            string[]? param_values,
+            int[]? param_lengths,
+            int[]? param_formats
+        ) throws SqlError {
+            Postgres.Result result = _db.exec_params(
+                command, n_params, null, param_values, param_lengths, param_formats, 0
+            );
+            check_result(result, command);
+            update_counters(result);
+            return result;
+        }
+
+        /*
+         * Checks a PGresult status and throws an appropriate SqlError.
+         * Codes derived from SQLSTATE:
+         * 08/57/58 prefixes (connection, intervention, system) -> CONNECTION_FAILED,
+         * 25/26 prefixes (transaction states) -> TRANSACTION_FAILED,
+         * 22xxx/42883 (data/type errors) -> TYPE_ERROR,
+         * otherwise EXECUTION_FAILED.
+         */
+
+        internal void check_result(Postgres.Result result, string sql) throws SqlError {
+            var status = result.get_status();
+            if (status == ExecStatus.COMMAND_OK || status == ExecStatus.TUPLES_OK ||
+                status == ExecStatus.EMPTY_QUERY) {
+                return;
+            }
+            string message = result.get_error_message();
+            string sqlstate = result.get_error_field(FieldCode.SQLSTATE) ?? "";
+            string context = sql.length > 120 ? sql.substring(0, 120) + "..." : sql;
+            string full_message = "%s (SQL: %s)".printf(message, context);
+            if (sqlstate.has_prefix("08") || sqlstate.has_prefix("57") || sqlstate.has_prefix("58")) {
+                _is_open = false;
+                throw new SqlError.CONNECTION_FAILED(full_message);
+            }
+            if (sqlstate.has_prefix("25") || sqlstate.has_prefix("26")) {
+                throw new SqlError.TRANSACTION_FAILED(full_message);
+            }
+            if (sqlstate.has_prefix("22") || sqlstate == "42883") {
+                throw new SqlError.TYPE_ERROR(full_message);
+            }
+            throw new SqlError.EXECUTION_FAILED(full_message);
+        }
+
+        /*
+         * Updates the cached changes counter from a command's PGresult
+         * (PQcmdTuples). last_insert_rowid is fetched lazily via lastval().
+         */
+
+        internal void update_counters(Postgres.Result result) {
+            unowned string tuples = result.get_cmd_tuples();
+            int64 parsed = 0;
+            if (tuples != null && tuples.length > 0 && int64.try_parse(tuples, out parsed)) {
+                _last_changes = (int)parsed;
+            } else {
+                _last_changes = 0;
+            }
+        }
+
+        /*
+         * Runs SELECT lastval() on this connection.
+         * Returns 0 when no sequence has been used yet in the session
+         * (lastval() raises 55006 then, which is not fatal for callers).
+         */
+
+        private int64 query_lastval() throws SqlError {
+            ensure_open();
+            Postgres.Result result = _db.exec("SELECT lastval()");
+            var status = result.get_status();
+            if (status == ExecStatus.TUPLES_OK && result.get_n_tuples() > 0) {
+                return int64.parse(result.get_value(0, 0));
+            }
+            return 0;
+        }
+    }
+}

+ 675 - 0
src/postgres/postgres-elements.vala

@@ -0,0 +1,675 @@
+using Postgres;
+
+namespace InvercargillSql {
+
+    /**
+     * Element wrapping an int64 that can convert to int, int64, bool, or DateTime.
+     * 
+     * PostgreSQL stores all integers as int64, but we want to allow seamless conversion
+     * to int32, bool (0/1), and DateTime (Unix epoch).
+     */
+    internal class PostgresIntegerElement : Object, Invercargill.Element {
+        private int64 _value;
+        private bool _is_null;
+        
+        public PostgresIntegerElement(int64 value, bool is_null = false) {
+            _value = value;
+            _is_null = is_null;
+        }
+        
+        public bool assignable_to_type(GLib.Type type) {
+            return type == typeof(int64) || type == typeof(int64?) ||
+                   type == typeof(uint64) || type == typeof(uint64?) ||
+                   type == typeof(int) || type == typeof(int?) ||
+                   type == typeof(bool) || type == typeof(bool?) ||
+                   type == typeof(DateTime) || type == typeof(DateTime?) ||
+                   type == typeof(double) || type == typeof(double?) ||
+                   type == typeof(float) || type == typeof(float?) ||
+                   type == typeof(string) ||
+                   type == typeof(long) || type == typeof(long?);
+        }
+        
+        public GLib.Type? type() {
+            return typeof(int64?);
+        }
+        
+        public bool is_null() {
+            return _is_null;
+        }
+        
+        public bool try_get_as<T>(out T result) {
+            var requested_type = typeof(T);
+            
+            if (_is_null) {
+                result = null;
+                return false;
+            }
+            
+            // Direct int64
+            if (requested_type == typeof(int64)) {
+                return Invercargill.Nullable.convert_nullability<int64?, T>(_value, out result);
+            }
+            // Convert to uint64
+            if (requested_type == typeof(uint64)) {
+                return Invercargill.Nullable.convert_nullability<uint64?, T>((uint64)_value, out result);
+            }
+            // Convert to int32
+            if (requested_type == typeof(int)) {
+                return Invercargill.Nullable.convert_nullability<int?, T>((int)_value, out result);
+            }
+            // Convert to long
+            if (requested_type == typeof(long)) {
+                return Invercargill.Nullable.convert_nullability<long?, T>((long)_value, out result);
+            }
+            // Convert to bool (0 = false, non-zero = true)
+            if (requested_type == typeof(bool)) {
+                return Invercargill.Nullable.convert_nullability<bool?, T>(_value != 0, out result);
+            }
+            // Convert to DateTime (Unix epoch)
+            if (requested_type == typeof(DateTime)) {
+                var dt = new DateTime.from_unix_local(_value);
+                result = (T)dt;
+                return dt != null;
+            }
+            // Convert to double
+            if (requested_type == typeof(double)) {
+                return Invercargill.Nullable.convert_nullability<double?, T>((double)_value, out result);
+            }
+            // Convert to float
+            if (requested_type == typeof(float)) {
+                return Invercargill.Nullable.convert_nullability<float?, T>((float)_value, out result);
+            }
+            // Convert to string
+            if (requested_type == typeof(string)) {
+                result = (T)_value.to_string();
+                return true;
+            }
+            
+            result = null;
+            return false;
+        }
+        
+        public string to_string() {
+            return _is_null ? "PostgresIntegerElement[null]" : @"PostgresIntegerElement[$_value]";
+        }
+        
+        public int64 value { get { return _value; } }
+    }
+
+    /**
+     * Element wrapping a double that can convert to double, float, or int64.
+     */
+    internal class PostgresRealElement : Object, Invercargill.Element {
+        private double _value;
+        private bool _is_null;
+        
+        public PostgresRealElement(double value, bool is_null = false) {
+            _value = value;
+            _is_null = is_null;
+        }
+        
+        public bool assignable_to_type(GLib.Type type) {
+            return type == typeof(double) || type == typeof(double?) ||
+                   type == typeof(float) || type == typeof(float?) ||
+                   type == typeof(int64) || type == typeof(int64?) ||
+                   type == typeof(int) || type == typeof(int?) ||
+                   type == typeof(long) || type == typeof(long?) ||
+                   type == typeof(string);
+        }
+        
+        public GLib.Type? type() {
+            return typeof(double?);
+        }
+        
+        public bool is_null() {
+            return _is_null;
+        }
+        
+        public bool try_get_as<T>(out T result) {
+            var requested_type = typeof(T);
+            
+            if (_is_null) {
+                result = null;
+                return false;
+            }
+            
+            // Direct double
+            if (requested_type == typeof(double)) {
+                return Invercargill.Nullable.convert_nullability<double?, T>(_value, out result);
+            }
+            // Convert to float
+            if (requested_type == typeof(float)) {
+                return Invercargill.Nullable.convert_nullability<float?, T>((float)_value, out result);
+            }
+            // Convert to int64
+            if (requested_type == typeof(int64)) {
+                return Invercargill.Nullable.convert_nullability<int64?, T>((int64)_value, out result);
+            }
+            // Convert to int
+            if (requested_type == typeof(int)) {
+                return Invercargill.Nullable.convert_nullability<int?, T>((int)_value, out result);
+            }
+            // Convert to long
+            if (requested_type == typeof(long)) {
+                return Invercargill.Nullable.convert_nullability<long?, T>((long)_value, out result);
+            }
+            // Convert to string
+            if (requested_type == typeof(string)) {
+                result = (T)_value.to_string();
+                return true;
+            }
+            
+            result = null;
+            return false;
+        }
+        
+        public string to_string() {
+            return _is_null ? "PostgresRealElement[null]" : @"PostgresRealElement[$_value]";
+        }
+        
+        public double value { get { return _value; } }
+    }
+
+    /**
+     * Element wrapping a string that can convert to string or parsed numeric types.
+     */
+    internal class PostgresTextElement : Object, Invercargill.Element {
+        private string _value;
+        private bool _is_null;
+        
+        public PostgresTextElement(string? value) {
+            _value = value ?? "";
+            _is_null = value == null;
+        }
+        
+        public bool assignable_to_type(GLib.Type type) {
+            return type == typeof(string) ||
+                   type == typeof(int64) || type == typeof(int64?) ||
+                   type == typeof(int) || type == typeof(int?) ||
+                   type == typeof(long) || type == typeof(long?) ||
+                   type == typeof(double) || type == typeof(double?) ||
+                   type == typeof(float) || type == typeof(float?) ||
+                   type == typeof(bool) || type == typeof(bool?);
+        }
+        
+        public GLib.Type? type() {
+            return typeof(string);
+        }
+        
+        public bool is_null() {
+            return _is_null;
+        }
+        
+        public bool try_get_as<T>(out T result) {
+            var requested_type = typeof(T);
+            
+            if (_is_null && requested_type == typeof(string)) {
+                result = (T)"";
+                return true;
+            }
+            
+            if (_is_null) {
+                result = null;
+                return false;
+            }
+            
+            // String
+            if (requested_type == typeof(string)) {
+                result = (T)_value;
+                return true;
+            }
+            // Try to parse as int64
+            if (requested_type == typeof(int64)) {
+                return Invercargill.Nullable.convert_nullability<int64?, T>(int64.parse(_value), out result);
+            }
+            // Try to parse as int
+            if (requested_type == typeof(int)) {
+                return Invercargill.Nullable.convert_nullability<int?, T>(int.parse(_value), out result);
+            }
+            // Try to parse as long
+            if (requested_type == typeof(long)) {
+                return Invercargill.Nullable.convert_nullability<long?, T>(long.parse(_value), out result);
+            }
+            // Try to parse as double
+            if (requested_type == typeof(double)) {
+                return Invercargill.Nullable.convert_nullability<double?, T>(double.parse(_value), out result);
+            }
+            // Try to parse as float
+            if (requested_type == typeof(float)) {
+                return Invercargill.Nullable.convert_nullability<float?, T>((float)double.parse(_value), out result);
+            }
+            // Try to parse as bool
+            if (requested_type == typeof(bool)) {
+                return Invercargill.Nullable.convert_nullability<bool?, T>(_value == "t" || _value == "1" || _value.down() == "true", out result);
+            }
+            
+            result = null;
+            return false;
+        }
+        
+        public string to_string() {
+            if (_is_null) {
+                return "PostgresTextElement[null]";
+            }
+            return "PostgresTextElement[%s]".printf(_value);
+        }
+        
+        public string value { get { return _value; } }
+    }
+
+    /**
+     * Element wrapping binary data (BLOB data) using Invercargill.BinaryData.
+     */
+    internal class PostgresBlobElement : Object, Invercargill.Element {
+        private Invercargill.BinaryData? _value;
+        
+        public PostgresBlobElement(Invercargill.BinaryData? value) {
+            _value = value;
+        }
+        
+        public bool assignable_to_type(GLib.Type type) {
+            return type == typeof(Invercargill.BinaryData);
+        }
+        
+        public GLib.Type? type() {
+            return typeof(Invercargill.BinaryData);
+        }
+        
+        public bool is_null() {
+            return _value == null;
+        }
+        
+        public bool try_get_as<T>(out T result) {
+            var requested_type = typeof(T);
+            
+            if (requested_type == typeof(Invercargill.BinaryData)) {
+                result = (T)_value;
+                return _value != null;
+            }
+            
+            result = null;
+            return false;
+        }
+        
+        public string to_string() {
+            if (_value == null) {
+                return "PostgresBlobElement[null]";
+            }
+            return "PostgresBlobElement[%u bytes]".printf(_value.count());
+        }
+        
+        public Invercargill.BinaryData? value { get { return _value; } }
+    }
+
+    /**
+     * Null element representing PostgreSQL NULL values.
+     */
+    internal class PostgresNullElement : Object, Invercargill.Element {
+        public bool assignable_to_type(GLib.Type type) {
+            return true; // Can "convert" null to any nullable type
+        }
+        
+        public GLib.Type? type() {
+            return Type.NONE;
+        }
+        
+        public bool is_null() {
+            return true;
+        }
+        
+        public bool try_get_as<T>(out T result) {
+            result = null;
+            return false;
+        }
+        
+        public string to_string() {
+            return "PostgresNullElement[null]";
+        }
+    }
+
+    /*
+     * Element wrapping a PostgreSQL boolean ("t"/"f") that converts to
+     * bool, int, or string.
+     */
+    internal class PostgresBoolElement : Object, Invercargill.Element {
+        private bool _value;
+        private bool _is_null;
+
+        public PostgresBoolElement(bool value, bool is_null = false) {
+            _value = value;
+            _is_null = is_null;
+        }
+
+        public bool assignable_to_type(GLib.Type type) {
+            return type == typeof(bool) || type == typeof(bool?) ||
+                   type == typeof(int) || type == typeof(int?) ||
+                   type == typeof(int64) || type == typeof(int64?) ||
+                   type == typeof(string);
+        }
+
+        public GLib.Type? type() {
+            return typeof(bool?);
+        }
+
+        public bool is_null() {
+            return _is_null;
+        }
+
+        public bool try_get_as<T>(out T result) {
+            var requested_type = typeof(T);
+
+            if (_is_null) {
+                result = null;
+                return false;
+            }
+
+            if (requested_type == typeof(bool)) {
+                return Invercargill.Nullable.convert_nullability<bool?, T>(_value, out result);
+            }
+            if (requested_type == typeof(int) || requested_type == typeof(int64)) {
+                int64 as_int = _value ? 1 : 0;
+                if (requested_type == typeof(int)) {
+                    return Invercargill.Nullable.convert_nullability<int?, T>((int)as_int, out result);
+                }
+                return Invercargill.Nullable.convert_nullability<int64?, T>(as_int, out result);
+            }
+            if (requested_type == typeof(string)) {
+                result = (T)(_value ? "t" : "f");
+                return true;
+            }
+
+            result = null;
+            return false;
+        }
+
+        public string to_string() {
+            return _is_null ? "PostgresBoolElement[null]" : @"PostgresBoolElement[$_value]";
+        }
+
+        public bool value { get { return _value; } }
+    }
+
+    /*
+     * Element wrapping a timestamp value that converts to DateTime,
+     * string, or Unix epoch int64.
+     */
+    internal class PostgresDateTimeElement : Object, Invercargill.Element {
+        private DateTime _value;
+        private bool _is_null;
+
+        public PostgresDateTimeElement(DateTime? value) {
+            _value = value ?? new DateTime.from_unix_local(0);
+            _is_null = value == null;
+        }
+
+        public bool assignable_to_type(GLib.Type type) {
+            return type == typeof(DateTime) || type == typeof(DateTime?) ||
+                   type == typeof(string) ||
+                   type == typeof(int64) || type == typeof(int64?);
+        }
+
+        public GLib.Type? type() {
+            return typeof(DateTime?);
+        }
+
+        public bool is_null() {
+            return _is_null;
+        }
+
+        public bool try_get_as<T>(out T result) {
+            var requested_type = typeof(T);
+
+            if (_is_null) {
+                result = null;
+                return false;
+            }
+
+            if (requested_type == typeof(DateTime)) {
+                result = (T)_value;
+                return true;
+            }
+            if (requested_type == typeof(string)) {
+                result = (T)_value.format_iso8601();
+                return true;
+            }
+            if (requested_type == typeof(int64)) {
+                return Invercargill.Nullable.convert_nullability<int64?, T>(_value.to_unix(), out result);
+            }
+
+            result = null;
+            return false;
+        }
+
+        public string to_string() {
+            return _is_null ? "PostgresDateTimeElement[null]" : @"PostgresDateTimeElement[$_value]";
+        }
+
+        public DateTime value { get { return _value; } }
+    }
+
+    /*
+     * Factory to create Elements from PostgreSQL result values and to
+     * convert Element parameter values into libpq text-format strings.
+     */
+    internal class PostgresElementFactory {
+        // Well-known type OIDs for mapping result columns to element types.
+        internal const uint OID_BOOL = 16;
+        internal const uint OID_BYTEA = 17;
+        internal const uint OID_INT8 = 20;
+        internal const uint OID_INT2 = 21;
+        internal const uint OID_INT4 = 23;
+        internal const uint OID_TEXT = 25;
+        internal const uint OID_OID = 26;
+        internal const uint OID_FLOAT4 = 700;
+        internal const uint OID_FLOAT8 = 701;
+        internal const uint OID_VARCHAR = 1043;
+        internal const uint OID_DATE = 1082;
+        internal const uint OID_TIMESTAMP = 1114;
+        internal const uint OID_TIMESTAMPTZ = 1184;
+        internal const uint OID_NUMERIC = 1700;
+
+        /*
+         * Creates an appropriate Element from a PGresult field value.
+         * Unknown OIDs fall back to text, which round-trips correctly for
+         * text, varchar, numeric, uuid, json and friends.
+         */
+        public static Invercargill.Element from_result(Postgres.Result result, int row, int col, uint type_oid) {
+            if (result.is_null(row, col)) {
+                return new PostgresNullElement();
+            }
+            unowned string value = result.get_value(row, col);
+            switch (type_oid) {
+                case OID_BOOL:
+                    return new PostgresBoolElement(value == "t" || value == "true" || value == "1");
+                case OID_BYTEA:
+                    return new PostgresBlobElement(decode_bytea(value));
+                case OID_INT2:
+                case OID_INT4:
+                case OID_INT8:
+                case OID_OID:
+                    return new PostgresIntegerElement(int64.parse(value));
+                case OID_FLOAT4:
+                case OID_FLOAT8:
+                    return new PostgresRealElement(double.parse(value));
+                case OID_TIMESTAMP:
+                case OID_TIMESTAMPTZ:
+                case OID_DATE:
+                    return new PostgresDateTimeElement(parse_timestamp(value));
+                default:
+                    return new PostgresTextElement(value);
+            }
+        }
+        /*
+         * Decodes a text-format bytea value: hex form "\x48656c6c6f"
+         * (server 9+) or legacy octal-escape form. Returns null for empty
+         * bytea, matching SqliteBlobElement(null) semantics.
+         */
+        public static Invercargill.BinaryData? decode_bytea(string value) {
+            if (value.has_prefix("\\x")) {
+                string hex = value.substring(2);
+                if (hex.length == 0) {
+                    return null;
+                }
+                var bytes = new uint8[hex.length / 2];
+                for (int i = 0; i < bytes.length; i++) {
+                    bytes[i] = (uint8)((hex_value(hex[i * 2]) << 4) | hex_value(hex[i * 2 + 1]));
+                }
+                return new Invercargill.DataStructures.ByteBuffer.from_byte_array(bytes);
+            }
+            // Legacy octal escapes: backslash-escaped bytes or \NNN octal.
+            var builder = new uint8[value.length];
+            int out_len = 0;
+            for (int i = 0; i < value.length; i++) {
+                char c = value[i];
+                if (c == '\\' && i + 1 < value.length) {
+                    char n1 = value[i + 1];
+                    if (n1 == '\\') {
+                        builder[out_len++] = '\\';
+                        i += 1;
+                    } else if (n1 >= '0' && n1 <= '7' && i + 3 < value.length &&
+                               value[i + 2] >= '0' && value[i + 2] <= '7' &&
+                               value[i + 3] >= '0' && value[i + 3] <= '7') {
+                        int octal = (n1 - '0') * 64 + (value[i + 2] - '0') * 8 + (value[i + 3] - '0');
+                        builder[out_len++] = (uint8)octal;
+                        i += 3;
+                    } else {
+                        builder[out_len++] = (uint8)n1;
+                        i += 1;
+                    }
+                } else {
+                    builder[out_len++] = (uint8)c;
+                }
+            }
+            if (out_len == 0) {
+                return null;
+            }
+            var trimmed = new uint8[out_len];
+            GLib.Memory.copy(trimmed, builder, out_len);
+            return new Invercargill.DataStructures.ByteBuffer.from_byte_array(trimmed);
+        }
+        /*
+         * Encodes binary data as text-format bytea hex form. PostgreSQL
+         * accepts "\x<hex>" for bytea parameters in text format on any
+         * modern server, so no cast is required at bind time.
+         */
+        public static string encode_bytea(uint8[] data) {
+            var builder = new StringBuilder("\\x");
+            foreach (uint8 b in data) {
+                builder.append_printf("%02x", b);
+            }
+            return builder.str;
+        }
+        private static int hex_value(char c) {
+            if (c >= '0' && c <= '9') return c - '0';
+            if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+            if (c >= 'A' && c <= 'F') return c - 'A' + 10;
+            return 0;
+        }
+        /*
+         * Parses a PostgreSQL text-format timestamp into a DateTime.
+         * Normalises the space separator to "T" and, when no UTC offset is
+         * present, assumes UTC so values stay unambiguous.
+         */
+        public static DateTime? parse_timestamp(string value) {
+            int sp = value.index_of(" ");
+            string normalised = sp >= 0 ? value.substring(0, sp) + "T" + value.substring(sp + 1) : value;
+            bool has_offset = normalised.contains("+") || (
+                normalised.last_index_of("-") > 9 &&
+                normalised.last_index_of("-") < normalised.length - 1 &&
+                normalised[normalised.last_index_of("-") + 1].isdigit()
+            );
+            if (!has_offset) {
+                normalised += "+00:00";
+            }
+            DateTime? dt = new DateTime.from_iso8601(normalised, null);
+            if (dt == null) {
+                dt = new DateTime.from_iso8601(normalised.substring(0, 19), null);
+            }
+            return dt;
+        }
+        /*
+         * Converts an Element parameter value into a text-format parameter
+         * string for PQexecParams, or null for SQL NULL.
+         * Mirrors SqliteElementFactory.bind_to_statement conversions:
+         * DateTime -> ISO 8601 text, bool -> "t"/"f", BinaryData -> bytea hex.
+         */
+        public static string? to_param_text(Invercargill.Element element) {
+            if (element.is_null()) {
+                return null;
+            }
+            // BinaryData first: blobs must not fall through to the string branch.
+            if (element.assignable_to_type(typeof(Invercargill.BinaryData))) {
+                Invercargill.BinaryData? blob;
+                if (element.try_get_as<Invercargill.BinaryData>(out blob) && blob != null) {
+                    uint8[] blob_data = blob.to_array();
+                    return encode_bytea(blob_data);
+                }
+            }
+            // DateTime -> ISO 8601 text
+            if (element.assignable_to_type(typeof(DateTime))) {
+                DateTime? dt;
+                if (element.try_get_as<DateTime>(out dt) && dt != null) {
+                    return dt.format_iso8601();
+                }
+            }
+            // bool -> "t"/"f"
+            if (element.assignable_to_type(typeof(bool))) {
+                bool? b;
+                if (element.try_get_as<bool>(out b) && b != null) {
+                    return b ? "t" : "f";
+                }
+            }
+            // Try int64
+            if (element.assignable_to_type(typeof(int64))) {
+                int64? i;
+                if (element.try_get_as<int64?>(out i) && i != null) {
+                    return i.to_string();
+                }
+            }
+            // Try int
+            if (element.assignable_to_type(typeof(int))) {
+                int? i;
+                if (element.try_get_as<int>(out i) && i != null) {
+                    return i.to_string();
+                }
+            }
+            // Try long
+            if (element.assignable_to_type(typeof(long))) {
+                long? l;
+                if (element.try_get_as<long>(out l) && l != null) {
+                    return l.to_string();
+                }
+            }
+            // Try uint64
+            if (element.assignable_to_type(typeof(uint64))) {
+                uint64? u;
+                if (element.try_get_as<uint64?>(out u) && u != null) {
+                    return u.to_string();
+                }
+            }
+            // Try double
+            if (element.assignable_to_type(typeof(double))) {
+                double? d;
+                if (element.try_get_as<double?>(out d) && d != null) {
+                    return d.to_string();
+                }
+            }
+            // Try float
+            if (element.assignable_to_type(typeof(float))) {
+                float? f;
+                if (element.try_get_as<float?>(out f) && f != null) {
+                    return f.to_string();
+                }
+            }
+            // Try string
+            if (element.assignable_to_type(typeof(string))) {
+                string? s;
+                if (element.try_get_as<string>(out s) && s != null) {
+                    return s;
+                }
+            }
+            // Fallback: SQL NULL, matching the sqlite factory.
+            return null;
+        }
+    }
+}

+ 93 - 0
src/postgres/postgres-result-enumerable.vala

@@ -0,0 +1,93 @@
+using Postgres;
+
+namespace InvercargillSql {
+
+    /**
+     * Enumerable over PostgreSQL query results held in an owned PGresult.
+     * 
+     * All rows are materialised by libpq before enumeration starts, so this
+     * enumerable owns a fully populated PGresult and hands out one
+     * Properties row per advance. Unlike SQLite there is no cursor state to
+     * reset: when the rows run out the PGresult is simply no longer needed.
+     * 
+     * Column metadata (names and type OIDs) is cached up front; values are
+     * converted lazily per row via PostgresElementFactory.
+     * 
+     * This class is internal to the library.
+     */
+    internal class PostgresResultEnumerable : Invercargill.Enumerable<Invercargill.Properties> {
+
+        private Postgres.Result _result;
+        private string[] _column_names;
+        private uint[] _column_oids;
+        private int _column_count;
+        private int _row_count;
+        private int _next_row;
+
+        /**
+         * Creates a new result enumerable.
+         * Takes ownership of the PGresult.
+         * 
+         * @param result The PGresult from a query (ownership transferred)
+         */
+        internal PostgresResultEnumerable(owned Postgres.Result result) {
+            _result = (owned)result;
+            _column_count = _result.get_n_fields();
+            _row_count = _result.get_n_tuples();
+            _next_row = 0;
+            _column_names = new string[_column_count];
+            _column_oids = new uint[_column_count];
+            for (int i = 0; i < _column_count; i++) {
+                _column_names[i] = _result.get_field_name(i);
+                _column_oids[i] = (uint)_result.get_field_type(i);
+            }
+        }
+
+        /**
+         * Returns a tracker for iterating over result rows.
+         */
+        public override Invercargill.Tracker<Invercargill.Properties> get_tracker() {
+            return new Invercargill.AdvanceTracker<Invercargill.Properties>(advance_row);
+        }
+
+        /**
+         * The number of rows, known up front because libpq buffers results.
+         */
+        public override uint? peek_count() {
+            return (uint)_row_count;
+        }
+
+        /**
+         * Returns metadata about this enumerable.
+         */
+        public override Invercargill.EnumerableInfo get_info() {
+            return new Invercargill.EnumerableInfo.infer_ultimate(
+                this,
+                Invercargill.EnumerableCategory.EXTERNAL
+            );
+        }
+
+        /**
+         * Advances to the next buffered row and returns it via the out parameter.
+         * Called by the AdvanceTracker.
+         */
+        private bool advance_row(out Invercargill.Properties? row) {
+            if (_next_row >= _row_count) {
+                row = null;
+                return false;
+            }
+
+            int current = _next_row;
+            _next_row++;
+
+            var props = new Invercargill.DataStructures.PropertyDictionary();
+            for (int i = 0; i < _column_count; i++) {
+                var element = PostgresElementFactory.from_result(_result, current, i, _column_oids[i]);
+                props.set(_column_names[i], element);
+            }
+
+            row = props;
+            return true;
+        }
+    }
+}

+ 58 - 0
src/postgres/postgres-transaction.vala

@@ -0,0 +1,58 @@
+namespace InvercargillSql {
+
+    /**
+     * PostgreSQL implementation of the Transaction interface.
+     * Manages database transactions with commit/rollback support.
+     * 
+     * Note: This class is internal to the library. Use Connection.begin_transaction()
+     * to create transactions.
+     */
+    internal class PostgresTransaction : Object, Transaction {
+        
+        private weak PostgresConnection _connection;
+        private bool _is_active;
+        
+        /**
+         * Creates a new transaction.
+         * The transaction is assumed to have already been started (BEGIN TRANSACTION)
+         * by the connection that created this object.
+         * 
+         * @param connection The connection that owns this transaction
+         */
+        internal PostgresTransaction(PostgresConnection connection) {
+            _connection = connection;
+            _is_active = true;
+        }
+        
+        /**
+         * Whether the transaction is currently active.
+         */
+        public bool is_active { get { return _is_active; } }
+        
+        /**
+         * Commits the transaction.
+         * @throws SqlError if commit fails or transaction is not active
+         */
+        public void commit() throws SqlError {
+            if (!_is_active) {
+                throw new SqlError.TRANSACTION_FAILED("Transaction is not active");
+            }
+            
+            _connection.execute("COMMIT");
+            _is_active = false;
+        }
+        
+        /**
+         * Rolls back the transaction.
+         * @throws SqlError if rollback fails
+         */
+        public void rollback() throws SqlError {
+            if (!_is_active) {
+                throw new SqlError.TRANSACTION_FAILED("Transaction is not active");
+            }
+            
+            _connection.execute("ROLLBACK");
+            _is_active = false;
+        }
+    }
+}

+ 150 - 0
src/providers/postgres-provider.vala

@@ -0,0 +1,150 @@
+using Invercargill.DataStructures;
+
+namespace InvercargillSql {
+
+    /**
+     * PostgreSQL implementation of ConnectionProvider.
+     *
+     * Handles connection strings in standard URI format:
+     * - `postgresql://user:pass@host:port/database` - TCP connection
+     * - `postgresql://user@/run/postgresql/dbname?host=/run/postgresql` - Unix socket dir
+     * - `postgres://user@localhost:5432/db` - Short scheme alias
+     *
+     * The provider builds a libpq conninfo string from the parsed
+     * ConnectionString components. Components that are empty are omitted
+     * so libpq falls back to its own defaults (environment, ~/.pgpass,
+     * compiled-in defaults) — "trust env" behaviour.
+     *
+     * Supported query options: any option key that is a known libpq
+     * conninfo keyword (host, port, user, password, dbname, connect_timeout,
+     * sslmode, application_name, ...) is forwarded verbatim; other options
+     * are ignored.
+     *
+     * Note: This class is internal to the library. Use ConnectionFactory
+     * to create connections.
+     */
+    internal class PostgresProvider : Object, ConnectionProvider {
+
+        /**
+         * The scheme this provider handles: "postgresql".
+         */
+        public string scheme {
+            get { return "postgresql"; }
+        }
+
+        /**
+         * Creates a new PostgreSQL connection from a connection string.
+         *
+         * @param cs The parsed connection string
+         * @return A new PostgresConnection instance
+         * @throws SqlError if connection creation fails
+         */
+        public Connection create_connection(ConnectionString cs) throws SqlError {
+            string conninfo = build_conninfo(cs);
+            return new PostgresConnection(conninfo, cs.database);
+        }
+
+        /**
+         * Builds a libpq conninfo string from the parsed connection string.
+         *
+         * Empty components are omitted. A leading "/" on the host means a
+         * Unix socket directory (libpq "host" accepts a directory path).
+         *
+         * @param cs The parsed connection string
+         * @return conninfo string suitable for PQconnectdb
+         * @throws SqlError.INVALID_CONNECTION_STRING if the database name is empty
+         */
+        internal string build_conninfo(ConnectionString cs) throws SqlError {
+            if (cs.database.length == 0) {
+                throw new SqlError.INVALID_CONNECTION_STRING(
+                    "PostgreSQL connection string missing database name"
+                );
+            }
+
+            var parts = new StringBuilder();
+
+            // Host/port may arrive either as URI components or as query
+            // options (the common unix-socket form is
+            // postgresql://user@/dbname?host=/run/postgresql). URI
+            // components win; otherwise the options fill in.
+            string host = cs.host;
+            if (host.length == 0 && cs.has_option("host")) {
+                host = cs.get_option("host");
+            }
+            append_part(parts, "host", host);
+
+            uint16 port = cs.port;
+            if (port == 0 && cs.has_option("port")) {
+                port = (uint16)int64.parse(cs.get_option("port"));
+            }
+            if (port != 0) {
+                append_part(parts, "port", port.to_string());
+            }
+            append_part(parts, "user", cs.user);
+            append_part(parts, "password", cs.password);
+            append_part(parts, "dbname", cs.database);
+
+            // Forward recognized libpq conninfo keywords from query options.
+            foreach (var kvp in cs.options) {
+                string key = kvp.key.down();
+                if (key == "user" || key == "password" || key == "dbname") {
+                    // Already covered by the parsed components above.
+                    continue;
+                }
+                if (key == "host" || key == "port") {
+                    // Handled above (URI component or fallback).
+                    continue;
+                }
+                switch (key) {
+                    case "connect_timeout":
+                    case "client_encoding":
+                    case "application_name":
+                    case "fallback_application_name":
+                    case "keepalives":
+                    case "keepalives_idle":
+                    case "keepalives_interval":
+                    case "keepalives_count":
+                    case "sslmode":
+                    case "sslcompression":
+                    case "sslcrl":
+                    case "sslrootcert":
+                    case "sslcert":
+                    case "sslkey":
+                    case "sslnegotiation":
+                    case "requiressl":
+                    case "krbsrvname":
+                    case "gsslib":
+                    case "gssdelegation":
+                    case "gssencmode":
+                    case "service":
+                    case "options":
+                        append_part(parts, key, kvp.value);
+                        break;
+                    default:
+                        // Unknown option: ignored, matching sqlite's
+                        // tolerance of unsupported options.
+                        break;
+                }
+            }
+
+            return parts.str;
+        }
+
+        /**
+         * Appends "key = 'value'" to the conninfo builder, escaping single
+         * quotes in the value. Skips empty values.
+         */
+        private void append_part(StringBuilder parts, string key, string value) {
+            if (value.length == 0) {
+                return;
+            }
+            if (parts.len > 0) {
+                parts.append(" ");
+            }
+            parts.append(key);
+            parts.append(" = '");
+            parts.append(value.replace("'", "\\'"));
+            parts.append("'");
+        }
+    }
+}