Selaa lähdekoodia

Add PostgresDialect implementing SqlDialect for PostgreSQL

Implements all 21 SqlDialect methods in src/dialects/postgres-dialect.vala: type translation (bigint/boolean/bytea/timestamptz/uuid/double precision), CRUD builders with named :column placeholders (PostgresCommand rewrites them to $n in first-occurrence order, so OrmSession by-name binding is unchanged), CREATE TABLE/INDEX with IF NOT EXISTS, auto-increment integer primary keys as GENERATED ALWAYS AS IDENTITY, migration DDL (add/drop/rename column, create/drop index, drop FK via native DROP CONSTRAINT deriving the table_column_fkey default name), information_schema-based introspection (columns + primary keys, identity detection via is_identity and serial nextval defaults), and projection select/alias generation mirroring SqliteDialect.

Wires PostgresDialect into DialectFactory for the postgres and postgresql schemes (mirroring the sqlite static-instance pattern) and adds the file to the meson build. Updates README and docs/ARCHITECTURE.md where they said SQLite-only.

Verified: meson compile clean and all 5 meson test suites pass; generated DDL, introspection queries and ALTER/DROP forms validated against a live PostgreSQL 18.6 server on /tmp/pgrun:5433.
clanker 1 kuukausi sitten
vanhempi
sitoutus
f6e321d08a
5 muutettua tiedostoa jossa 873 lisäystä ja 7 poistoa
  1. 4 2
      README.md
  2. 2 2
      docs/ARCHITECTURE.md
  3. 19 3
      src/dialect-factory.vala
  4. 847 0
      src/dialects/postgres-dialect.vala
  5. 1 0
      src/meson.build

+ 4 - 2
README.md

@@ -121,8 +121,10 @@ Notes on the adapter:
   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.
+- `DialectFactory` also ships a PostgreSQL dialect: `DialectFactory.create("postgresql")`
+  (or `"postgres"`) returns a shared `PostgresDialect` instance that generates
+  named-parameter CRUD SQL, identity-column DDL, `information_schema`
+  introspection and standard projection SELECTs. See `src/dialects/postgres-dialect.vala`.
 
 ### Creating Tables and Inserting Data
 

+ 2 - 2
docs/ARCHITECTURE.md

@@ -1,6 +1,6 @@
 # Invercargill-Sql Architecture
 
-A connection-centric SQL library for Vala with backend abstraction, initially implementing SQLite support.
+A connection-centric SQL library for Vala with backend abstraction, implementing SQLite and PostgreSQL support.
 
 ## Overview
 
@@ -8,7 +8,7 @@ Invercargill-Sql provides a simple, minimal API for database operations with the
 
 - **Namespace:** `InvercargillSql`
 - **Architecture:** Connection-centric with interfaces for backend abstraction
-- **Initial Implementation:** SQLite only
+- **Implementations:** SQLite and PostgreSQL (libpq)
 - **Result Type:** Queries return `Invercargill.Enumerable<Invercargill.Properties>` for lazy enumeration
 - **Collection Types:** Uses Invercargill data structures exclusively (no Gee/libgee)
 - **Fluent API:** Command supports method chaining for parameter setting

+ 19 - 3
src/dialect-factory.vala

@@ -6,7 +6,7 @@ namespace InvercargillSql {
      * Static factory for creating SQL dialects based on connection schemes.
      * 
      * This factory maintains a registry of dialect creators indexed by scheme.
-     * The SQLite dialect is automatically registered on first use.
+     * The SQLite and PostgreSQL dialects are automatically registered on first use.
      * 
      * Example usage:
      * ```vala
@@ -26,6 +26,8 @@ namespace InvercargillSql {
 
         private static bool _sqlite_registered = false;
         private static SqlDialect? _sqlite_dialect = null;
+        private static bool _postgres_registered = false;
+        private static SqlDialect? _postgres_dialect = null;
 
         /**
          * Creates a SQL dialect based on a scheme string.
@@ -47,6 +49,14 @@ namespace InvercargillSql {
                 return _sqlite_dialect;
             }
 
+            if (scheme_lower == "postgres" || scheme_lower == "postgresql") {
+                if (!_postgres_registered) {
+                    _postgres_dialect = new PostgresDialect();
+                    _postgres_registered = true;
+                }
+                return _postgres_dialect;
+            }
+
             throw new SqlError.INVALID_CONNECTION_STRING(
                 "No SQL dialect registered for scheme: %s".printf(scheme)
             );
@@ -100,9 +110,10 @@ namespace InvercargillSql {
             if (scheme_lower == "sqlite") {
                 _sqlite_dialect = dialect;
                 _sqlite_registered = true;
+            } else if (scheme_lower == "postgres" || scheme_lower == "postgresql") {
+                _postgres_dialect = dialect;
+                _postgres_registered = true;
             }
-            // For other providers, we would extend this with a dictionary
-            // For now, we only support SQLite natively
         }
 
         /**
@@ -113,6 +124,8 @@ namespace InvercargillSql {
         public static void clear_dialects() {
             _sqlite_dialect = null;
             _sqlite_registered = false;
+            _postgres_dialect = null;
+            _postgres_registered = false;
         }
 
         /**
@@ -127,6 +140,9 @@ namespace InvercargillSql {
             if (scheme_lower == "sqlite") {
                 return true; // SQLite is always available
             }
+            if (scheme_lower == "postgres" || scheme_lower == "postgresql") {
+                return true; // PostgreSQL is always available
+            }
 
             return false;
         }

+ 847 - 0
src/dialects/postgres-dialect.vala

@@ -0,0 +1,847 @@
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Migrations;
+using InvercargillSql.Orm;
+using InvercargillSql.Orm.Projections;
+
+namespace InvercargillSql.Dialects {
+
+    /**
+     * PostgreSQL implementation of the SqlDialect interface: type translations,
+     * SQL generation and schema introspection for PostgreSQL databases.
+     *
+     * Placeholders: the CRUD builders emit named ":column" placeholders, exactly
+     * like the SQLite dialect. PostgresCommand rewrites ":name" placeholders to
+     * positional "$n" references at execution time, numbering them in order of
+     * first occurrence and binding values from the named parameter dictionary,
+     * so OrmSession's by-name binding works unchanged. The shared
+     * ExpressionToSqlVisitor likewise emits ":pN" placeholders which the adapter
+     * translates the same way.
+     */
+    public class PostgresDialect : Object, SqlDialect {
+
+        /** Translates a ColumnType to the PostgreSQL type system. */
+        public string translate_type(ColumnType type) {
+            switch (type) {
+                case ColumnType.INT_32:
+                    return "INTEGER";
+                case ColumnType.INT_64:
+                    return "BIGINT";
+                case ColumnType.BOOLEAN:
+                    return "BOOLEAN";
+                case ColumnType.TEXT:
+                    return "TEXT";
+                case ColumnType.UUID:
+                    return "UUID";
+                case ColumnType.DECIMAL:
+                    return "DOUBLE PRECISION";
+                case ColumnType.DATETIME:
+                    // Timestamp with time zone (stored internally in UTC)
+                    return "TIMESTAMPTZ";
+                case ColumnType.BINARY:
+                    return "BYTEA";
+                default:
+                    return "TEXT";
+            }
+        }
+
+        /**
+         * Translates an expression tree to SQL.
+         *
+         * Mirrors SqliteDialect: filter expressions are translated at the call
+         * sites (EntityQuery, ProjectionSqlBuilder) via ExpressionToSqlVisitor,
+         * whose ":pN" placeholders PostgresCommand converts to "$n".
+         */
+        public string translate_expression(Expression expr, EntityMapper mapper) {
+            return "";
+        }
+
+        /** Builds a SELECT statement for all rows in a table. */
+        public string build_select_all(string table_name) {
+            return "SELECT * FROM %s".printf(table_name);
+        }
+
+        /** Builds a SELECT for one row by ID with a named ":column" placeholder. */
+        public string build_select_by_id(string table_name, string id_column) {
+            return "SELECT * FROM %s WHERE %s = :%s".printf(table_name, id_column, id_column);
+        }
+
+        /**
+         * Builds an INSERT with named ":column" placeholders.
+         *
+         * The adapter numbers them "$1..$n" in order of first occurrence, which
+         * is the order of the columns vector, so binding is deterministic.
+         */
+        public string build_insert_sql(string table_name, Vector<string> columns) {
+            var cols = new string[0];
+            var placeholders = new string[0];
+            foreach (var col in columns) {
+                cols += col;
+                placeholders += ":" + col;
+            }
+            return "INSERT INTO %s (%s) VALUES (%s)".printf(
+                table_name,
+                string.joinv(", ", cols),
+                string.joinv(", ", placeholders)
+            );
+        }
+
+        /**
+         * Builds an UPDATE with named ":column" placeholders.
+         *
+         * The ID column is excluded from the SET clause and used only in the
+         * WHERE clause.
+         */
+        public string build_update_sql(string table_name, Vector<string> columns, string id_column) {
+            var set_clauses = new string[0];
+            foreach (var col in columns) {
+                if (col != id_column) {
+                    set_clauses += "%s = :%s".printf(col, col);
+                }
+            }
+            return "UPDATE %s SET %s WHERE %s = :%s".printf(
+                table_name,
+                string.joinv(", ", set_clauses),
+                id_column,
+                id_column
+            );
+        }
+
+        /** Builds a DELETE with a named ":column" placeholder for the ID. */
+        public string build_delete_sql(string table_name, string id_column) {
+            return "DELETE FROM %s WHERE %s = :%s".printf(table_name, id_column, id_column);
+        }
+
+        /**
+         * Builds a CREATE TABLE statement.
+         *
+         * Uses IF NOT EXISTS so re-running schema creation is idempotent.
+         */
+        public string build_create_table(string table_name, Vector<ColumnDefinition> columns) {
+            var column_defs = new string[0];
+            foreach (var col in columns) {
+                column_defs += build_column_def(col);
+            }
+            return "CREATE TABLE IF NOT EXISTS %s (%s)".printf(
+                table_name,
+                string.joinv(", ", column_defs)
+            );
+        }
+
+        /** Builds a CREATE INDEX statement (IF NOT EXISTS for idempotency). */
+        public string build_create_index(string table_name, IndexDefinition index) {
+            var unique = index.is_unique ? "UNIQUE " : "";
+            var cols = new string[0];
+            foreach (var col in index.columns) {
+                cols += col;
+            }
+            return "CREATE %sINDEX IF NOT EXISTS %s ON %s (%s)".printf(
+                unique,
+                index.name,
+                table_name,
+                string.joinv(", ", cols)
+            );
+        }
+
+        /**
+         * Builds a single column definition for CREATE TABLE.
+         *
+         * Auto-increment primary keys become GENERATED ALWAYS AS IDENTITY
+         * columns (PostgreSQL 10+), sized to match the base type.
+         */
+        private string build_column_def(ColumnDefinition col) {
+            var parts = new string[0];
+            parts += col.name;
+            parts += translate_type(col.column_type);
+            if (col.is_primary_key) {
+                parts += "PRIMARY KEY";
+            }
+            if (col.auto_increment && col.is_primary_key) {
+                parts += "GENERATED ALWAYS AS IDENTITY";
+            }
+            if (col.is_required && !col.is_primary_key) {
+                parts += "NOT NULL";
+            }
+            if (col.is_unique && !col.is_primary_key) {
+                parts += "UNIQUE";
+            }
+            if (col.default_now) {
+                parts += "DEFAULT CURRENT_TIMESTAMP";
+            }
+            return string.joinv(" ", parts);
+        }
+
+        /**
+         * Generates SQL for a CREATE TABLE migration operation with constraints.
+         *
+         * Multi-column PRIMARY KEY / UNIQUE / FOREIGN KEY constraints are
+         * emitted as table constraints after the column definitions.
+         */
+        public string create_table_sql(CreateTableOperation op) {
+            var sql = new StringBuilder();
+            sql.append("CREATE TABLE ");
+            sql.append(op.table_name);
+            sql.append(" (\n");
+
+            var parts = new Vector<string>();
+            foreach (var col in op.columns) {
+                parts.add(build_column_sql(col));
+            }
+            foreach (var constraint in op.constraints) {
+                parts.add(build_constraint_sql(constraint));
+            }
+
+            bool first = true;
+            foreach (var part in parts) {
+                if (!first) {
+                    sql.append(",\n");
+                }
+                first = false;
+                sql.append("    ");
+                sql.append(part);
+            }
+            sql.append("\n)");
+            return sql.str;
+        }
+
+        /**
+         * Builds a column definition SQL string for migration operations.
+         *
+         * An auto-increment integer primary key becomes a GENERATED ALWAYS AS
+         * IDENTITY column (PostgreSQL 10+). IDENTITY is skipped when the
+         * column also carries an explicit DEFAULT, which PostgreSQL forbids.
+         */
+        private string build_column_sql(ColumnDefinition col) {
+            var parts = new Vector<string>();
+            parts.add(col.name);
+            parts.add(translate_type(col.column_type));
+
+            if (col.is_primary_key) parts.add("PRIMARY KEY");
+            if (col.auto_increment && col.default_value == null && !col.default_now) {
+                parts.add("GENERATED ALWAYS AS IDENTITY");
+            }
+            if (col.is_required) parts.add("NOT NULL");
+            if (col.is_unique) parts.add("UNIQUE");
+            if (col.default_value != null) {
+                parts.add("DEFAULT " + element_to_sql(col.default_value));
+            }
+            if (col.default_now) {
+                parts.add("DEFAULT CURRENT_TIMESTAMP");
+            }
+            return string.joinv(" ", parts.to_array());
+        }
+
+        /**
+         * Converts an Element default value to its SQL literal representation.
+         *
+         * Booleans render as TRUE/FALSE (not 1/0), timestamps as ISO 8601
+         * text, binary data as hex-encoded BYTEA literals, and strings are
+         * escaped by doubling single quotes. The E'' literal form is used
+         * when the value contains a backslash.
+         */
+        private string element_to_sql(Invercargill.Element element) {
+            if (element.is_null()) {
+                return "NULL";
+            }
+
+            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 "\\\\x" + bytes_to_hex(blob_data);
+                }
+            }
+
+            if (element.assignable_to_type(typeof(bool))) {
+                bool? b;
+                if (element.try_get_as<bool>(out b) && b != null) {
+                    return b ? "TRUE" : "FALSE";
+                }
+            }
+
+            if (element.assignable_to_type(typeof(DateTime))) {
+                DateTime? dt;
+                if (element.try_get_as<DateTime>(out dt) && dt != null) {
+                    return "'" + dt.format_iso8601() + "'";
+                }
+            }
+
+            string? result = try_element_as_string(element);
+            if (result != null) {
+                return quote_string_literal(result);
+            }
+            return "NULL";
+        }
+
+        /**
+         * Renders a SQL string literal, using E'' escaping when the value
+         * contains a backslash (PostgreSQL treats backslashes specially in
+         * ordinary literals when standard_conforming_strings is off).
+         */
+        private string quote_string_literal(string s) {
+            string escaped = s.replace("'", "''");
+            if (s.index_of("\\") >= 0) {
+                escaped = escaped.replace("\\", "\\\\");
+                return "E'" + escaped + "'";
+            }
+            return "'" + escaped + "'";
+        }
+
+        /** Lowercase hex encoding of a byte array. */
+        private string bytes_to_hex(uint8[] data) {
+            var sb = new StringBuilder();
+            foreach (uint8 b in data) {
+                sb.append_printf("%02x", b);
+            }
+            return sb.str;
+        }
+
+        /** Extracts a numeric or string representation of an Element, or null. */
+        private string? try_element_as_string(Invercargill.Element element) {
+            if (element.assignable_to_type(typeof(string))) {
+                string? s;
+                if (element.try_get_as<string>(out s) && s != null) {
+                    return s;
+                }
+            }
+            if (element.assignable_to_type(typeof(int64))) {
+                int64? i;
+                if (element.try_get_as<int64?>(out i) && i != null) {
+                    return i.to_string();
+                }
+            }
+            if (element.assignable_to_type(typeof(int))) {
+                int? i;
+                if (element.try_get_as<int?>(out i) && i != null) {
+                    return i.to_string();
+                }
+            }
+            if (element.assignable_to_type(typeof(long))) {
+                long? l;
+                if (element.try_get_as<long?>(out l) && l != null) {
+                    return l.to_string();
+                }
+            }
+            if (element.assignable_to_type(typeof(double))) {
+                double? d;
+                if (element.try_get_as<double?>(out d) && d != null) {
+                    return d.to_string();
+                }
+            }
+            return null;
+        }
+
+        /**
+         * Builds a table constraint SQL string.
+         *
+         * Constraint types are the SQL-standard keywords ("PRIMARY KEY",
+         * "UNIQUE", "FOREIGN KEY"), matching the SQLite implementation.
+         */
+        private string build_constraint_sql(TableConstraint constraint) {
+            var sql = new StringBuilder();
+
+            if (constraint.name != null) {
+                sql.append("CONSTRAINT ");
+                sql.append(constraint.name);
+                sql.append(" ");
+            }
+
+            sql.append(constraint.constraint_type);
+            sql.append(" (");
+            sql.append(string.joinv(", ", constraint.columns.to_array()));
+            sql.append(")");
+
+            if (constraint.constraint_type == "FOREIGN KEY" && constraint.reference_table != null) {
+                sql.append(" REFERENCES ");
+                sql.append(constraint.reference_table);
+                sql.append(" (");
+                sql.append(string.joinv(", ", constraint.reference_columns.to_array()));
+                sql.append(")");
+
+                if (constraint.on_delete_action != ReferentialAction.NO_ACTION) {
+                    sql.append(" ON DELETE ");
+                    sql.append(constraint.on_delete_action.to_sql());
+                }
+                if (constraint.on_update_action != ReferentialAction.NO_ACTION) {
+                    sql.append(" ON UPDATE ");
+                    sql.append(constraint.on_update_action.to_sql());
+                }
+            }
+
+            return sql.str;
+        }
+
+        /** Generates SQL for a DROP TABLE operation (IF EXISTS for idempotency). */
+        public string drop_table_sql(DropTableOperation op) {
+            return "DROP TABLE IF EXISTS " + op.table_name;
+        }
+
+        /**
+         * Generates SQL for an ADD COLUMN operation.
+         *
+         * When the operation carries a foreign_key_constraint, the REFERENCES
+         * clause is inlined in the column definition (PostgreSQL, unlike
+         * SQLite, also accepts a separate ADD CONSTRAINT clause, but the
+         * inline form keeps a single statement).
+         */
+        public string add_column_sql(AddColumnOperation op) {
+            var sb = new StringBuilder();
+            sb.append("ALTER TABLE ");
+            sb.append(op.table_name);
+            sb.append(" ADD COLUMN ");
+            sb.append(build_column_sql(op.column));
+
+            if (op.foreign_key_constraint != null) {
+                var fk = op.foreign_key_constraint;
+                sb.append(" REFERENCES ");
+                sb.append(fk.reference_table);
+                sb.append(" (");
+                sb.append(string.joinv(", ", fk.reference_columns.to_array()));
+                sb.append(")");
+
+                if (fk.on_delete_action != ReferentialAction.NO_ACTION) {
+                    sb.append(" ON DELETE ");
+                    sb.append(fk.on_delete_action.to_sql());
+                }
+                if (fk.on_update_action != ReferentialAction.NO_ACTION) {
+                    sb.append(" ON UPDATE ");
+                    sb.append(fk.on_update_action.to_sql());
+                }
+            }
+
+            return sb.str;
+        }
+
+        /** Generates SQL for a DROP COLUMN operation. */
+        public string drop_column_sql(DropColumnOperation op) {
+            return "ALTER TABLE " + op.table_name + " DROP COLUMN " + op.column_name;
+        }
+
+        /** Generates SQL for a RENAME COLUMN operation. */
+        public string rename_column_sql(RenameColumnOperation op) {
+            return "ALTER TABLE " + op.table_name + " RENAME COLUMN " + op.old_name + " TO " + op.new_name;
+        }
+
+        /** Generates SQL for a CREATE INDEX migration operation. */
+        public string create_index_sql(CreateIndexOperation op) {
+            var sql = new StringBuilder();
+            sql.append(op.is_unique ? "CREATE UNIQUE INDEX " : "CREATE INDEX ");
+            sql.append(op.index_name);
+            sql.append(" ON ");
+            sql.append(op.table_name);
+            sql.append(" (");
+            sql.append(string.joinv(", ", op.columns.to_array()));
+            sql.append(")");
+            return sql.str;
+        }
+
+        /** Generates SQL for a DROP INDEX migration operation (IF EXISTS). */
+        public string drop_index_sql(DropIndexOperation op) {
+            return "DROP INDEX IF EXISTS " + op.index_name;
+        }
+
+        /**
+         * Generates SQL for a DROP FOREIGN KEY operation.
+         *
+         * PostgreSQL supports dropping constraints natively, so unlike the
+         * SQLite dialect there is no table-recreation dance: the operation
+         * maps directly to ALTER TABLE ... DROP CONSTRAINT. When the caller
+         * identifies the constraint by column instead of by name, the
+         * auto-generated name is derived the way PostgreSQL names FK
+         * constraints (table_column_fkey).
+         */
+        public string drop_foreign_key_sql(DropForeignKeyOperation op) {
+            var sb = new StringBuilder();
+            sb.append("ALTER TABLE ");
+            sb.append(op.table_name);
+            sb.append(" DROP CONSTRAINT ");
+
+            if (op.constraint_name != null) {
+                sb.append(op.constraint_name);
+            } else if (op.column_name != null) {
+                // PostgreSQL names an unnamed FK constraint table_column_fkey
+                sb.append(op.table_name);
+                sb.append("_");
+                sb.append(op.column_name);
+                sb.append("_fkey");
+            } else {
+                // No usable identifier: keep the statement invalid on purpose
+                // so the failure is visible rather than silently dropping
+                // some other constraint.
+                sb.append("<unnamed>");
+            }
+
+            return sb.str;
+        }
+
+        /** Quotes an identifier using PostgreSQL double-quote rules. */
+        private string quote_identifier(string identifier) {
+            return "\"" + identifier.replace("\"", "\"\"") + "\"";
+        }
+
+        // === Schema introspection ===
+
+        /**
+         * Introspects table schema from a PostgreSQL database.
+         *
+         * Column metadata comes from information_schema.columns; primary key
+         * membership from information_schema.key_column_usage joined with
+         * table_constraints. Identity columns (GENERATED ... AS IDENTITY) and
+         * serial-style defaults are reported as auto-increment. Note that on
+         * PostgreSQL 10+ identity columns report is_identity = 'YES' with an
+         * empty column_default, so both signals are checked.
+         *
+         * @param connection The database connection
+         * @param table_name The table to introspect
+         * @return A TableSchema containing column metadata
+         * @throws SqlError if introspection fails
+         */
+        public TableSchema introspect_schema(Connection connection, string table_name) throws SqlError {
+            var schema = new TableSchema();
+            schema.table_name = table_name;
+
+            var columns_sql = new StringBuilder();
+            columns_sql.append("SELECT column_name, data_type, is_nullable, column_default, is_identity ");
+            columns_sql.append("FROM information_schema.columns ");
+            columns_sql.append("WHERE table_name = :table_name ");
+            columns_sql.append("ORDER BY ordinal_position");
+            var command = connection.create_command(columns_sql.str);
+            command.with_parameter<string>("table_name", table_name);
+            var results = command.execute_query();
+
+            foreach (var row in results) {
+                var column = new ColumnSchema();
+
+                var name_elem = row.get("column_name");
+                if (name_elem != null) {
+                    string? name_val = null;
+                    name_elem.try_get_as<string>(out name_val);
+                    column.name = name_val ?? "";
+                }
+
+                var type_elem = row.get("data_type");
+                if (type_elem != null) {
+                    string? type_str = null;
+                    type_elem.try_get_as<string>(out type_str);
+                    column.column_type = parse_postgres_type(type_str ?? "text");
+                }
+
+                var nullable_elem = row.get("is_nullable");
+                if (nullable_elem != null) {
+                    string? nullable = null;
+                    nullable_elem.try_get_as<string>(out nullable);
+                    column.is_required = (nullable == "NO");
+                }
+
+                var default_elem = row.get("column_default");
+                if (default_elem != null && !default_elem.is_null()) {
+                    string? default_str = null;
+                    default_elem.try_get_as<string>(out default_str);
+                    column.auto_increment = is_identity_default(default_str);
+                }
+
+                var identity_elem = row.get("is_identity");
+                if (identity_elem != null && !identity_elem.is_null()) {
+                    string? identity_str = null;
+                    identity_elem.try_get_as<string>(out identity_str);
+                    if (identity_str == "YES") {
+                        column.auto_increment = true;
+                    }
+                }
+
+                schema.columns.add(column);
+            }
+
+            // Determine the primary key column(s)
+            var pk_sql = new StringBuilder();
+            pk_sql.append("SELECT kcu.column_name ");
+            pk_sql.append("FROM information_schema.table_constraints tc ");
+            pk_sql.append("JOIN information_schema.key_column_usage kcu ");
+            pk_sql.append("  ON tc.constraint_name = kcu.constraint_name ");
+            pk_sql.append(" AND tc.table_name = kcu.table_name ");
+            pk_sql.append("WHERE tc.constraint_type = 'PRIMARY KEY' ");
+            pk_sql.append("  AND tc.table_name = :table_name ");
+            pk_sql.append("ORDER BY kcu.ordinal_position");
+            var pk_command = connection.create_command(pk_sql.str);
+            pk_command.with_parameter<string>("table_name", table_name);
+            var pk_results = pk_command.execute_query();
+
+            foreach (var pk_row in pk_results) {
+                var pk_elem = pk_row.get("column_name");
+                if (pk_elem == null) {
+                    continue;
+                }
+                string? pk_name = null;
+                pk_elem.try_get_as<string>(out pk_name);
+                if (pk_name == null) {
+                    continue;
+                }
+
+                var col = schema.get_column(pk_name);
+                if (col != null) {
+                    col.is_primary_key = true;
+                    if (schema.primary_key_column == null) {
+                        schema.primary_key_column = pk_name;
+                    }
+                    // Integer PKs created by this dialect are identity columns
+                    if (col.column_type == ColumnType.INT_64
+                        || col.column_type == ColumnType.INT_32) {
+                        col.auto_increment = true;
+                    }
+                }
+            }
+
+            return schema;
+        }
+
+        /**
+         * Reports whether a column_default string denotes an auto-generated
+         * identity value (IDENTITY columns or old-style serial sequences).
+         */
+        private bool is_identity_default(string? default_str) {
+            if (default_str == null) {
+                return false;
+            }
+            var upper = default_str.up();
+            if ("GENERATED" in upper && "IDENTITY" in upper) {
+                return true;
+            }
+            // serial pseudo-types surface as nextval('..._seq'::regclass)
+            if ("NEXTVAL" in upper) {
+                return true;
+            }
+            return false;
+        }
+
+        /**
+         * Parses a PostgreSQL data_type string (as reported by
+         * information_schema.columns) to a ColumnType.
+         *
+         * Matching is keyword-based, mirroring the SQLite implementation.
+         *
+         * @param type_str The PostgreSQL type string
+         * @return The corresponding ColumnType
+         */
+        private ColumnType parse_postgres_type(string type_str) {
+            var upper = type_str.up();
+
+            if ("BIGINT" in upper || "INT8" in upper) {
+                return ColumnType.INT_64;
+            }
+            if ("SMALLINT" in upper || "INTEGER" in upper || "INT4" in upper
+                || "INT" == upper.strip() || "SERIAL" in upper) {
+                return ColumnType.INT_32;
+            }
+            if ("UUID" in upper) {
+                return ColumnType.UUID;
+            }
+            if ("BYTEA" in upper || "BLOB" in upper || "BINARY" in upper) {
+                return ColumnType.BINARY;
+            }
+            if ("BOOLEAN" in upper || "BOOL" in upper) {
+                return ColumnType.BOOLEAN;
+            }
+            if ("TIMESTAMP" in upper || "DATE" in upper || "TIME" in upper) {
+                return ColumnType.DATETIME;
+            }
+            if ("DOUBLE" in upper || "REAL" in upper || "FLOAT" in upper
+                || "NUMERIC" in upper || "DECIMAL" in upper) {
+                return ColumnType.DECIMAL;
+            }
+            if ("CHAR" in upper || "CLOB" in upper || "TEXT" in upper
+                || "JSON" in upper || "XML" in upper) {
+                return ColumnType.TEXT;
+            }
+
+            // Default to TEXT
+            return ColumnType.TEXT;
+        }
+
+        // === Projection query methods ===
+
+        /**
+         * Builds a SELECT statement with JOINs for a projection query.
+         *
+         * Constructs a complete SELECT from a ProjectionDefinition including
+         * all JOINs, selections, WHERE/HAVING clauses, GROUP BY, ORDER BY,
+         * LIMIT and OFFSET — all standard SQL on PostgreSQL.
+         */
+        public string build_projection_select(
+            ProjectionDefinition definition,
+            VariableTranslator translator,
+            string? where_clause,
+            string? having_clause,
+            Vector<OrderByClause> order_by,
+            int64? limit,
+            int64? offset
+        ) {
+            var sql = new StringBuilder();
+
+            sql.append("SELECT ");
+            build_select_clause(sql, definition, translator);
+
+            sql.append("\nFROM ");
+            build_from_clause(sql, definition, translator);
+
+            foreach (var join in definition.joins) {
+                sql.append("\n");
+                build_join_clause(sql, join, translator);
+            }
+
+            if (where_clause != null && where_clause.length > 0) {
+                sql.append("\nWHERE ");
+                sql.append(where_clause);
+            }
+
+            if (definition.group_by_expressions.length > 0) {
+                sql.append("\nGROUP BY ");
+                bool first = true;
+                foreach (var group_expr in definition.group_by_expressions) {
+                    if (!first) {
+                        sql.append(", ");
+                    }
+                    first = false;
+                    sql.append(translator.translate_expression(group_expr));
+                }
+            }
+
+            if (having_clause != null && having_clause.length > 0) {
+                sql.append("\nHAVING ");
+                sql.append(having_clause);
+            }
+
+            if (order_by.length > 0) {
+                sql.append("\nORDER BY ");
+                bool first = true;
+                foreach (var order in order_by) {
+                    if (!first) {
+                        sql.append(", ");
+                    }
+                    first = false;
+                    sql.append(translator.translate_expression(order.expression));
+                    if (order.descending) {
+                        sql.append(" DESC");
+                    }
+                }
+            }
+
+            if (limit != null) {
+                sql.append("\nLIMIT ");
+                sql.append(limit.to_string());
+            }
+
+            if (offset != null) {
+                sql.append("\nOFFSET ");
+                sql.append(offset.to_string());
+            }
+
+            return sql.str;
+        }
+
+        /**
+         * Builds the SELECT clause with all selections.
+         *
+         * Each selection is formatted as: expression AS unique_alias.
+         * Scalar selections and SCALAR-mode collections translate their
+         * expressions; nested projections and non-scalar collections use
+         * NULL placeholders, matching the SQLite behaviour.
+         */
+        private void build_select_clause(
+            StringBuilder sql,
+            ProjectionDefinition definition,
+            VariableTranslator translator
+        ) {
+            bool first = true;
+            int column_index = 0;
+
+            foreach (var selection in definition.selections) {
+                if (!first) {
+                    sql.append(", ");
+                }
+                first = false;
+                column_index++;
+
+                string column_alias = @"col_$(column_index)_$(definition.projection_type.name())_$(selection.friendly_name)";
+                selection.column_alias = column_alias;
+
+                var scalar_selection = selection as ScalarSelection<Object, Object>;
+                if (scalar_selection != null) {
+                    string translated_expr = translator.translate_expression(scalar_selection.expression);
+                    sql.append(translated_expr);
+                    sql.append(" AS ");
+                    sql.append(column_alias);
+                    continue;
+                }
+
+                var collection_selection = selection as CollectionSelection;
+                if (collection_selection != null && collection_selection.item_mode == CollectionItemMode.SCALAR) {
+                    string translated_expr = translator.translate_expression(collection_selection.entry_point_expression);
+                    sql.append(translated_expr);
+                    sql.append(" AS ");
+                    sql.append(column_alias);
+                    continue;
+                }
+
+                sql.append("NULL AS ");
+                sql.append(column_alias);
+            }
+        }
+
+        /**
+         * Builds the FROM clause with the primary source.
+         *
+         * Format: table_name alias
+         */
+        private void build_from_clause(
+            StringBuilder sql,
+            ProjectionDefinition definition,
+            VariableTranslator translator
+        ) {
+            if (definition.source == null) {
+                return;
+            }
+
+            sql.append(definition.source.table_name);
+            sql.append(" ");
+            sql.append(translator.translate_variable(definition.source.variable_name));
+        }
+
+        /**
+         * Builds a JOIN clause for a join definition.
+         *
+         * Format: LEFT JOIN table_name alias ON condition
+         */
+        private void build_join_clause(
+            StringBuilder sql,
+            JoinDefinition join,
+            VariableTranslator translator
+        ) {
+            sql.append("LEFT JOIN ");
+            sql.append(join.table_name);
+            sql.append(" ");
+            sql.append(translator.translate_variable(join.variable_name));
+            sql.append(" ON ");
+            sql.append(translator.translate_expression(join.join_condition));
+        }
+
+        /**
+         * Builds a subquery wrapper for mixed aggregate/non-aggregate OR
+         * conditions: SELECT * FROM (inner) subq WHERE combined_where.
+         */
+        public string wrap_subquery_for_mixed_or(
+            string inner_query,
+            string combined_where
+        ) {
+            return @"SELECT * FROM ($inner_query) subq WHERE $combined_where";
+        }
+
+        /**
+         * Generates a table alias with type information for debugging.
+         *
+         * Format: val_N_TypeName (e.g., val_1_User, val_2_Order)
+         */
+        public string generate_table_alias(int index, string type_name) {
+            return "val_%d_%s".printf(index, type_name);
+        }
+    }
+}

+ 1 - 0
src/meson.build

@@ -70,6 +70,7 @@ sources += files('orm/projections/projection-query.vala')
 # Dialect sources
 sources += files('dialects/sql-dialect.vala')
 sources += files('dialects/sqlite-dialect.vala')
+sources += files('dialects/postgres-dialect.vala')
 
 # Expression sources
 sources += files('expressions/expression-to-sql-visitor.vala')