Skip to main content

qcraft_postgres/
lib.rs

1use qcraft_core::ast::common::{FieldRef, NullsOrder, OrderByDef, OrderDir, SchemaRef};
2use qcraft_core::ast::conditions::{CompareOp, ConditionNode, Conditions, Connector};
3use qcraft_core::ast::custom::CustomBinaryOp;
4use qcraft_core::ast::ddl::{
5    ColumnDef, ConstraintDef, DeferrableConstraint, FieldType, IdentityColumn, IndexColumnDef,
6    IndexDef, IndexExpr, LikeTableDef, MatchType, OnCommitAction, PartitionByDef,
7    PartitionStrategy, ReferentialAction, SchemaDef, SchemaMutationStmt,
8};
9use qcraft_core::ast::dml::{
10    ConflictAction, ConflictTarget, DeleteStmt, InsertSource, InsertStmt, MutationStmt,
11    OnConflictDef, OverridingKind, UpdateStmt,
12};
13use qcraft_core::ast::expr::{
14    AggregationDef, BinaryOp, CaseDef, Expr, UnaryOp, WindowDef, WindowFrameBound, WindowFrameDef,
15    WindowFrameType,
16};
17use qcraft_core::ast::query::{
18    CteDef, CteMaterialized, DistinctDef, FromItem, GroupByItem, JoinCondition, JoinDef, JoinType,
19    LimitDef, LimitKind, LockStrength, QueryStmt, SampleMethod, SelectColumn, SelectLockDef,
20    SetOpDef, SetOperationType, TableSource, WindowNameDef,
21};
22use qcraft_core::ast::tcl::{
23    BeginStmt, CommitStmt, IsolationLevel, LockMode, LockTableStmt, RollbackStmt,
24    SetTransactionStmt, TransactionMode, TransactionScope, TransactionStmt,
25};
26use qcraft_core::ast::value::Value;
27use qcraft_core::error::{RenderError, RenderResult};
28use qcraft_core::render::ctx::{ParamStyle, RenderCtx};
29use qcraft_core::render::escape_like_value;
30use qcraft_core::render::renderer::Renderer;
31
32use std::any::Any;
33
34/// pgvector distance operators.
35#[derive(Debug, Clone, Copy)]
36pub enum PgVectorOp {
37    /// L2 (Euclidean) distance: `<->`
38    L2Distance,
39    /// Inner product (negative): `<#>`
40    InnerProduct,
41    /// Cosine distance: `<=>`
42    CosineDistance,
43    /// L1 (Manhattan) distance: `<+>`
44    L1Distance,
45}
46
47impl CustomBinaryOp for PgVectorOp {
48    fn as_any(&self) -> &dyn Any {
49        self
50    }
51    fn clone_box(&self) -> Box<dyn CustomBinaryOp> {
52        Box::new(*self)
53    }
54}
55
56impl From<PgVectorOp> for BinaryOp {
57    fn from(op: PgVectorOp) -> Self {
58        BinaryOp::Custom(Box::new(op))
59    }
60}
61
62fn render_custom_binary_op(custom: &dyn CustomBinaryOp, ctx: &mut RenderCtx) -> RenderResult<()> {
63    if let Some(op) = custom.as_any().downcast_ref::<PgVectorOp>() {
64        ctx.write(match op {
65            PgVectorOp::L2Distance => " <-> ",
66            PgVectorOp::InnerProduct => " <#> ",
67            PgVectorOp::CosineDistance => " <=> ",
68            PgVectorOp::L1Distance => " <+> ",
69        });
70        Ok(())
71    } else {
72        Err(RenderError::unsupported(
73            "CustomBinaryOp",
74            "unknown custom binary operator; use a wrapping renderer to handle it",
75        ))
76    }
77}
78
79fn render_like_pattern(op: &CompareOp, right: &Expr, ctx: &mut RenderCtx) -> RenderResult<()> {
80    let raw = match right {
81        Expr::Value(Value::Str(s)) => s.as_str(),
82        _ => {
83            return Err(RenderError::unsupported(
84                "CompareOp",
85                "Contains/StartsWith/EndsWith require a string value on the right side",
86            ));
87        }
88    };
89    let escaped = escape_like_value(raw);
90    let pattern = match op {
91        CompareOp::Contains | CompareOp::IContains => format!("%{escaped}%"),
92        CompareOp::StartsWith | CompareOp::IStartsWith => format!("{escaped}%"),
93        CompareOp::EndsWith | CompareOp::IEndsWith => format!("%{escaped}"),
94        _ => unreachable!(),
95    };
96    if ctx.parameterize() {
97        ctx.param(Value::Str(pattern));
98    } else {
99        ctx.string_literal(&pattern);
100    }
101    Ok(())
102}
103
104struct PgCreateTableOpts<'a> {
105    tablespace: Option<&'a str>,
106    partition_by: Option<&'a PartitionByDef>,
107    inherits: Option<&'a [SchemaRef]>,
108    using_method: Option<&'a str>,
109    with_options: Option<&'a [(String, String)]>,
110    on_commit: Option<&'a OnCommitAction>,
111}
112
113pub struct PostgresRenderer {
114    param_style: ParamStyle,
115}
116
117impl PostgresRenderer {
118    pub fn new() -> Self {
119        Self {
120            param_style: ParamStyle::Dollar,
121        }
122    }
123
124    /// Use `%s` placeholders (psycopg / DB-API 2.0) instead of `$1`.
125    pub fn with_param_style(mut self, style: ParamStyle) -> Self {
126        self.param_style = style;
127        self
128    }
129
130    /// Convenience: render a DDL statement to a list of SQL statements + params.
131    ///
132    /// Most DDL operations return a single statement. Partial unique constraints
133    /// (with a WHERE condition) produce an additional `CREATE UNIQUE INDEX … WHERE …`
134    /// statement — this applies to both `CreateTable` and `AddConstraint`.
135    pub fn render_schema_stmt(
136        &self,
137        stmt: &SchemaMutationStmt,
138    ) -> RenderResult<Vec<(String, Vec<Value>)>> {
139        let mut ctx = RenderCtx::new(self.param_style);
140        self.render_schema_mutation(stmt, &mut ctx)?;
141        let mut results = vec![ctx.finish()];
142
143        match stmt {
144            SchemaMutationStmt::CreateTable { schema, .. } => {
145                if let Some(constraints) = &schema.constraints {
146                    for constraint in constraints {
147                        self.pg_partial_unique_index(
148                            &schema.name,
149                            schema.namespace.as_deref(),
150                            constraint,
151                            &mut results,
152                        )?;
153                    }
154                }
155            }
156            SchemaMutationStmt::AddConstraint {
157                schema_ref,
158                constraint,
159                ..
160            } => {
161                if self.is_partial_unique(constraint) {
162                    // Partial unique: discard the incomplete ALTER TABLE statement
163                    // and produce only CREATE UNIQUE INDEX.
164                    results.clear();
165                }
166                self.pg_partial_unique_index(
167                    &schema_ref.name,
168                    schema_ref.namespace.as_deref(),
169                    constraint,
170                    &mut results,
171                )?;
172            }
173            _ => {}
174        }
175
176        Ok(results)
177    }
178
179    fn is_partial_unique(&self, constraint: &ConstraintDef) -> bool {
180        matches!(
181            constraint,
182            ConstraintDef::Unique {
183                condition: Some(_),
184                ..
185            }
186        )
187    }
188
189    /// If `constraint` is a partial unique (has a WHERE condition), render a
190    /// `CREATE UNIQUE INDEX … WHERE …` and push it to `results`.
191    fn pg_partial_unique_index(
192        &self,
193        table_name: &str,
194        namespace: Option<&str>,
195        constraint: &ConstraintDef,
196        results: &mut Vec<(String, Vec<Value>)>,
197    ) -> RenderResult<()> {
198        if let ConstraintDef::Unique {
199            name,
200            columns,
201            condition: Some(cond),
202            ..
203        } = constraint
204        {
205            let mut idx_ctx = RenderCtx::new(self.param_style);
206            idx_ctx.keyword("CREATE UNIQUE INDEX");
207            if let Some(n) = name {
208                idx_ctx.ident(n);
209            }
210            idx_ctx.keyword("ON");
211            if let Some(ns) = namespace {
212                idx_ctx.ident(ns).operator(".");
213            }
214            idx_ctx.ident(table_name);
215            idx_ctx.paren_open();
216            self.pg_comma_idents(columns, &mut idx_ctx);
217            idx_ctx.paren_close();
218            idx_ctx.keyword("WHERE");
219            self.render_condition(cond, &mut idx_ctx)?;
220            results.push(idx_ctx.finish());
221        }
222        Ok(())
223    }
224
225    /// Convenience: render a TCL statement to SQL string + params.
226    pub fn render_transaction_stmt(
227        &self,
228        stmt: &TransactionStmt,
229    ) -> RenderResult<(String, Vec<Value>)> {
230        let mut ctx = RenderCtx::new(self.param_style);
231        self.render_transaction(stmt, &mut ctx)?;
232        Ok(ctx.finish())
233    }
234
235    /// Convenience: render a DML statement to SQL string + params.
236    pub fn render_mutation_stmt(&self, stmt: &MutationStmt) -> RenderResult<(String, Vec<Value>)> {
237        let mut ctx = RenderCtx::new(self.param_style).with_parameterize(true);
238        self.render_mutation(stmt, &mut ctx)?;
239        Ok(ctx.finish())
240    }
241
242    /// Convenience: render a SELECT query to SQL string + params.
243    pub fn render_query_stmt(&self, stmt: &QueryStmt) -> RenderResult<(String, Vec<Value>)> {
244        let mut ctx = RenderCtx::new(self.param_style).with_parameterize(true);
245        self.render_query(stmt, &mut ctx)?;
246        Ok(ctx.finish())
247    }
248}
249
250impl Default for PostgresRenderer {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256// ==========================================================================
257// Renderer trait implementation
258// ==========================================================================
259
260impl Renderer for PostgresRenderer {
261    // ── DDL ──────────────────────────────────────────────────────────────
262
263    fn render_schema_mutation(
264        &self,
265        stmt: &SchemaMutationStmt,
266        ctx: &mut RenderCtx,
267    ) -> RenderResult<()> {
268        match stmt {
269            SchemaMutationStmt::CreateTable {
270                schema,
271                if_not_exists,
272                temporary,
273                unlogged,
274                tablespace,
275                partition_by,
276                inherits,
277                using_method,
278                with_options,
279                on_commit,
280                table_options: _, // PG uses WITH options instead
281                without_rowid: _, // SQLite-specific — Ignore
282                strict: _,        // SQLite-specific — Ignore
283            } => self.pg_create_table(
284                schema,
285                *if_not_exists,
286                *temporary,
287                *unlogged,
288                &PgCreateTableOpts {
289                    tablespace: tablespace.as_deref(),
290                    partition_by: partition_by.as_ref(),
291                    inherits: inherits.as_deref(),
292                    using_method: using_method.as_deref(),
293                    with_options: with_options.as_deref(),
294                    on_commit: on_commit.as_ref(),
295                },
296                ctx,
297            ),
298
299            SchemaMutationStmt::DropTable {
300                schema_ref,
301                if_exists,
302                cascade,
303            } => {
304                ctx.keyword("DROP TABLE");
305                if *if_exists {
306                    ctx.keyword("IF EXISTS");
307                }
308                self.pg_schema_ref(schema_ref, ctx);
309                if *cascade {
310                    ctx.keyword("CASCADE");
311                }
312                Ok(())
313            }
314
315            SchemaMutationStmt::RenameTable {
316                schema_ref,
317                new_name,
318            } => {
319                ctx.keyword("ALTER TABLE");
320                self.pg_schema_ref(schema_ref, ctx);
321                ctx.keyword("RENAME TO").ident(new_name);
322                Ok(())
323            }
324
325            SchemaMutationStmt::TruncateTable {
326                schema_ref,
327                restart_identity,
328                cascade,
329            } => {
330                ctx.keyword("TRUNCATE TABLE");
331                self.pg_schema_ref(schema_ref, ctx);
332                if *restart_identity {
333                    ctx.keyword("RESTART IDENTITY");
334                }
335                if *cascade {
336                    ctx.keyword("CASCADE");
337                }
338                Ok(())
339            }
340
341            SchemaMutationStmt::AddColumn {
342                schema_ref,
343                column,
344                if_not_exists,
345                position: _, // PostgreSQL doesn't support FIRST/AFTER
346            } => {
347                ctx.keyword("ALTER TABLE");
348                self.pg_schema_ref(schema_ref, ctx);
349                ctx.keyword("ADD COLUMN");
350                if *if_not_exists {
351                    ctx.keyword("IF NOT EXISTS");
352                }
353                self.render_column_def(column, ctx)
354            }
355
356            SchemaMutationStmt::DropColumn {
357                schema_ref,
358                name,
359                if_exists,
360                cascade,
361            } => {
362                ctx.keyword("ALTER TABLE");
363                self.pg_schema_ref(schema_ref, ctx);
364                ctx.keyword("DROP COLUMN");
365                if *if_exists {
366                    ctx.keyword("IF EXISTS");
367                }
368                ctx.ident(name);
369                if *cascade {
370                    ctx.keyword("CASCADE");
371                }
372                Ok(())
373            }
374
375            SchemaMutationStmt::RenameColumn {
376                schema_ref,
377                old_name,
378                new_name,
379            } => {
380                ctx.keyword("ALTER TABLE");
381                self.pg_schema_ref(schema_ref, ctx);
382                ctx.keyword("RENAME COLUMN")
383                    .ident(old_name)
384                    .keyword("TO")
385                    .ident(new_name);
386                Ok(())
387            }
388
389            SchemaMutationStmt::AlterColumnType {
390                schema_ref,
391                column_name,
392                new_type,
393                using_expr,
394            } => {
395                ctx.keyword("ALTER TABLE");
396                self.pg_schema_ref(schema_ref, ctx);
397                ctx.keyword("ALTER COLUMN")
398                    .ident(column_name)
399                    .keyword("SET DATA TYPE");
400                self.render_column_type(new_type, ctx)?;
401                if let Some(expr) = using_expr {
402                    ctx.keyword("USING");
403                    self.render_expr(expr, ctx)?;
404                }
405                Ok(())
406            }
407
408            SchemaMutationStmt::AlterColumnDefault {
409                schema_ref,
410                column_name,
411                default,
412            } => {
413                ctx.keyword("ALTER TABLE");
414                self.pg_schema_ref(schema_ref, ctx);
415                ctx.keyword("ALTER COLUMN").ident(column_name);
416                match default {
417                    Some(expr) => {
418                        ctx.keyword("SET DEFAULT");
419                        self.render_expr(expr, ctx)?;
420                    }
421                    None => {
422                        ctx.keyword("DROP DEFAULT");
423                    }
424                }
425                Ok(())
426            }
427
428            SchemaMutationStmt::AlterColumnNullability {
429                schema_ref,
430                column_name,
431                not_null,
432            } => {
433                ctx.keyword("ALTER TABLE");
434                self.pg_schema_ref(schema_ref, ctx);
435                ctx.keyword("ALTER COLUMN").ident(column_name);
436                if *not_null {
437                    ctx.keyword("SET NOT NULL");
438                } else {
439                    ctx.keyword("DROP NOT NULL");
440                }
441                Ok(())
442            }
443
444            SchemaMutationStmt::AddConstraint {
445                schema_ref,
446                constraint,
447                not_valid,
448            } => {
449                ctx.keyword("ALTER TABLE");
450                self.pg_schema_ref(schema_ref, ctx);
451                ctx.keyword("ADD");
452                self.render_constraint(constraint, ctx)?;
453                if *not_valid {
454                    ctx.keyword("NOT VALID");
455                }
456                Ok(())
457            }
458
459            SchemaMutationStmt::DropConstraint {
460                schema_ref,
461                constraint_name,
462                if_exists,
463                cascade,
464            } => {
465                ctx.keyword("ALTER TABLE");
466                self.pg_schema_ref(schema_ref, ctx);
467                ctx.keyword("DROP CONSTRAINT");
468                if *if_exists {
469                    ctx.keyword("IF EXISTS");
470                }
471                ctx.ident(constraint_name);
472                if *cascade {
473                    ctx.keyword("CASCADE");
474                }
475                Ok(())
476            }
477
478            SchemaMutationStmt::RenameConstraint {
479                schema_ref,
480                old_name,
481                new_name,
482            } => {
483                ctx.keyword("ALTER TABLE");
484                self.pg_schema_ref(schema_ref, ctx);
485                ctx.keyword("RENAME CONSTRAINT")
486                    .ident(old_name)
487                    .keyword("TO")
488                    .ident(new_name);
489                Ok(())
490            }
491
492            SchemaMutationStmt::ValidateConstraint {
493                schema_ref,
494                constraint_name,
495            } => {
496                ctx.keyword("ALTER TABLE");
497                self.pg_schema_ref(schema_ref, ctx);
498                ctx.keyword("VALIDATE CONSTRAINT").ident(constraint_name);
499                Ok(())
500            }
501
502            SchemaMutationStmt::CreateIndex {
503                schema_ref,
504                index,
505                if_not_exists,
506                concurrently,
507            } => self.pg_create_index(schema_ref, index, *if_not_exists, *concurrently, ctx),
508
509            SchemaMutationStmt::DropIndex {
510                schema_ref: _,
511                index_name,
512                if_exists,
513                concurrently,
514                cascade,
515            } => {
516                ctx.keyword("DROP INDEX");
517                if *concurrently {
518                    ctx.keyword("CONCURRENTLY");
519                }
520                if *if_exists {
521                    ctx.keyword("IF EXISTS");
522                }
523                ctx.ident(index_name);
524                if *cascade {
525                    ctx.keyword("CASCADE");
526                }
527                Ok(())
528            }
529
530            SchemaMutationStmt::CreateExtension {
531                name,
532                if_not_exists,
533                schema,
534                version,
535                cascade,
536            } => {
537                ctx.keyword("CREATE EXTENSION");
538                if *if_not_exists {
539                    ctx.keyword("IF NOT EXISTS");
540                }
541                ctx.ident(name);
542                if let Some(s) = schema {
543                    ctx.keyword("SCHEMA").ident(s);
544                }
545                if let Some(v) = version {
546                    ctx.keyword("VERSION").string_literal(v);
547                }
548                if *cascade {
549                    ctx.keyword("CASCADE");
550                }
551                Ok(())
552            }
553
554            SchemaMutationStmt::DropExtension {
555                name,
556                if_exists,
557                cascade,
558            } => {
559                ctx.keyword("DROP EXTENSION");
560                if *if_exists {
561                    ctx.keyword("IF EXISTS");
562                }
563                ctx.ident(name);
564                if *cascade {
565                    ctx.keyword("CASCADE");
566                }
567                Ok(())
568            }
569
570            SchemaMutationStmt::CreateCollation {
571                name,
572                if_not_exists,
573                locale,
574                lc_collate,
575                lc_ctype,
576                provider,
577                deterministic,
578                from_collation,
579            } => {
580                ctx.keyword("CREATE COLLATION");
581                if *if_not_exists {
582                    ctx.keyword("IF NOT EXISTS");
583                }
584                ctx.ident(name);
585                if let Some(from) = from_collation {
586                    ctx.keyword("FROM").ident(from);
587                } else {
588                    ctx.write(" (");
589                    let mut first = true;
590                    if let Some(loc) = locale {
591                        ctx.keyword("LOCALE").write(" = ").string_literal(loc);
592                        first = false;
593                    }
594                    if let Some(lc) = lc_collate {
595                        if !first {
596                            ctx.write(", ");
597                        }
598                        ctx.keyword("LC_COLLATE").write(" = ").string_literal(lc);
599                        first = false;
600                    }
601                    if let Some(lc) = lc_ctype {
602                        if !first {
603                            ctx.write(", ");
604                        }
605                        ctx.keyword("LC_CTYPE").write(" = ").string_literal(lc);
606                        first = false;
607                    }
608                    if let Some(prov) = provider {
609                        if !first {
610                            ctx.write(", ");
611                        }
612                        ctx.keyword("PROVIDER").write(" = ").keyword(prov);
613                        first = false;
614                    }
615                    if let Some(det) = deterministic {
616                        if !first {
617                            ctx.write(", ");
618                        }
619                        ctx.keyword("DETERMINISTIC").write(" = ").keyword(if *det {
620                            "TRUE"
621                        } else {
622                            "FALSE"
623                        });
624                    }
625                    ctx.write(")");
626                }
627                Ok(())
628            }
629
630            SchemaMutationStmt::DropCollation {
631                name,
632                if_exists,
633                cascade,
634            } => {
635                ctx.keyword("DROP COLLATION");
636                if *if_exists {
637                    ctx.keyword("IF EXISTS");
638                }
639                ctx.ident(name);
640                if *cascade {
641                    ctx.keyword("CASCADE");
642                }
643                Ok(())
644            }
645
646            SchemaMutationStmt::Custom(_) => Err(RenderError::unsupported(
647                "CustomSchemaMutation",
648                "custom DDL must be handled by a wrapping renderer",
649            )),
650        }
651    }
652
653    fn render_column_def(&self, col: &ColumnDef, ctx: &mut RenderCtx) -> RenderResult<()> {
654        ctx.ident(&col.name);
655        self.render_column_type(&col.field_type, ctx)?;
656
657        if let Some(storage) = &col.storage {
658            ctx.keyword("STORAGE").keyword(storage);
659        }
660
661        if let Some(compression) = &col.compression {
662            ctx.keyword("COMPRESSION").keyword(compression);
663        }
664
665        if let Some(collation) = &col.collation {
666            ctx.keyword("COLLATE").ident(collation);
667        }
668
669        if col.not_null {
670            ctx.keyword("NOT NULL");
671        }
672
673        if let Some(default) = &col.default {
674            ctx.keyword("DEFAULT");
675            self.render_expr(default, ctx)?;
676        }
677
678        if let Some(identity) = &col.identity {
679            self.pg_identity(identity, ctx);
680        }
681
682        if let Some(generated) = &col.generated {
683            ctx.keyword("GENERATED ALWAYS AS").space().paren_open();
684            self.render_expr(&generated.expr, ctx)?;
685            ctx.paren_close().keyword("STORED");
686        }
687
688        Ok(())
689    }
690
691    fn render_column_type(&self, ty: &FieldType, ctx: &mut RenderCtx) -> RenderResult<()> {
692        match ty {
693            FieldType::Scalar(name) => {
694                ctx.keyword(name);
695            }
696            FieldType::Parameterized { name, params } => {
697                ctx.keyword(name).write("(");
698                for (i, p) in params.iter().enumerate() {
699                    if i > 0 {
700                        ctx.comma();
701                    }
702                    ctx.write(p);
703                }
704                ctx.paren_close();
705            }
706            FieldType::Array(inner) => {
707                self.render_column_type(inner, ctx)?;
708                ctx.write("[]");
709            }
710            FieldType::Vector(dim) => {
711                ctx.keyword("VECTOR")
712                    .write("(")
713                    .write(&dim.to_string())
714                    .paren_close();
715            }
716            FieldType::Custom(_) => {
717                return Err(RenderError::unsupported(
718                    "CustomFieldType",
719                    "custom field type must be handled by a wrapping renderer",
720                ));
721            }
722        }
723        Ok(())
724    }
725
726    fn render_constraint(&self, c: &ConstraintDef, ctx: &mut RenderCtx) -> RenderResult<()> {
727        match c {
728            ConstraintDef::PrimaryKey {
729                name,
730                columns,
731                include,
732            } => {
733                if let Some(n) = name {
734                    ctx.keyword("CONSTRAINT").ident(n);
735                }
736                ctx.keyword("PRIMARY KEY").paren_open();
737                self.pg_comma_idents(columns, ctx);
738                ctx.paren_close();
739                if let Some(inc) = include {
740                    ctx.keyword("INCLUDE").paren_open();
741                    self.pg_comma_idents(inc, ctx);
742                    ctx.paren_close();
743                }
744            }
745
746            ConstraintDef::ForeignKey {
747                name,
748                columns,
749                ref_table,
750                ref_columns,
751                on_delete,
752                on_update,
753                deferrable,
754                match_type,
755            } => {
756                if let Some(n) = name {
757                    ctx.keyword("CONSTRAINT").ident(n);
758                }
759                ctx.keyword("FOREIGN KEY").paren_open();
760                self.pg_comma_idents(columns, ctx);
761                ctx.paren_close().keyword("REFERENCES");
762                self.pg_schema_ref(ref_table, ctx);
763                ctx.paren_open();
764                self.pg_comma_idents(ref_columns, ctx);
765                ctx.paren_close();
766                if let Some(mt) = match_type {
767                    ctx.keyword(match mt {
768                        MatchType::Full => "MATCH FULL",
769                        MatchType::Partial => "MATCH PARTIAL",
770                        MatchType::Simple => "MATCH SIMPLE",
771                    });
772                }
773                if let Some(action) = on_delete {
774                    ctx.keyword("ON DELETE");
775                    self.pg_referential_action(action, ctx);
776                }
777                if let Some(action) = on_update {
778                    ctx.keyword("ON UPDATE");
779                    self.pg_referential_action(action, ctx);
780                }
781                if let Some(def) = deferrable {
782                    self.pg_deferrable(def, ctx);
783                }
784            }
785
786            ConstraintDef::Unique {
787                name,
788                columns,
789                include,
790                nulls_distinct,
791                condition,
792            } => {
793                // Partial unique (with WHERE condition) is rendered only as
794                // a separate CREATE UNIQUE INDEX — skip inline constraint.
795                if condition.is_some() {
796                    return Ok(());
797                }
798                if let Some(n) = name {
799                    ctx.keyword("CONSTRAINT").ident(n);
800                }
801                ctx.keyword("UNIQUE");
802                if let Some(false) = nulls_distinct {
803                    ctx.keyword("NULLS NOT DISTINCT");
804                }
805                ctx.paren_open();
806                self.pg_comma_idents(columns, ctx);
807                ctx.paren_close();
808                if let Some(inc) = include {
809                    ctx.keyword("INCLUDE").paren_open();
810                    self.pg_comma_idents(inc, ctx);
811                    ctx.paren_close();
812                }
813            }
814
815            ConstraintDef::Check {
816                name,
817                condition,
818                no_inherit,
819                enforced: _, // PostgreSQL always enforces CHECK
820            } => {
821                if let Some(n) = name {
822                    ctx.keyword("CONSTRAINT").ident(n);
823                }
824                ctx.keyword("CHECK").paren_open();
825                self.render_condition(condition, ctx)?;
826                ctx.paren_close();
827                if *no_inherit {
828                    ctx.keyword("NO INHERIT");
829                }
830            }
831
832            ConstraintDef::Exclusion {
833                name,
834                elements,
835                index_method,
836                condition,
837            } => {
838                if let Some(n) = name {
839                    ctx.keyword("CONSTRAINT").ident(n);
840                }
841                ctx.keyword("EXCLUDE USING")
842                    .keyword(index_method)
843                    .paren_open();
844                for (i, elem) in elements.iter().enumerate() {
845                    if i > 0 {
846                        ctx.comma();
847                    }
848                    ctx.ident(&elem.column)
849                        .keyword("WITH")
850                        .keyword(&elem.operator);
851                }
852                ctx.paren_close();
853                if let Some(cond) = condition {
854                    ctx.keyword("WHERE").paren_open();
855                    self.render_condition(cond, ctx)?;
856                    ctx.paren_close();
857                }
858            }
859
860            ConstraintDef::Custom(_) => {
861                return Err(RenderError::unsupported(
862                    "CustomConstraint",
863                    "custom constraint must be handled by a wrapping renderer",
864                ));
865            }
866        }
867        Ok(())
868    }
869
870    fn render_index_def(&self, idx: &IndexDef, ctx: &mut RenderCtx) -> RenderResult<()> {
871        // Used for inline index rendering (inside CREATE TABLE context).
872        // Full CREATE INDEX is handled in pg_create_index.
873        ctx.ident(&idx.name);
874        if let Some(index_type) = &idx.index_type {
875            ctx.keyword("USING").keyword(index_type);
876        }
877        ctx.paren_open();
878        self.pg_index_columns(&idx.columns, idx.index_type.as_deref(), ctx)?;
879        ctx.paren_close();
880        Ok(())
881    }
882
883    // ── Expressions (basic, needed for DDL) ──────────────────────────────
884
885    fn render_expr(&self, expr: &Expr, ctx: &mut RenderCtx) -> RenderResult<()> {
886        match expr {
887            Expr::Value(val) => self.pg_value(val, ctx),
888
889            Expr::Field(field_ref) => {
890                self.pg_field_ref(field_ref, ctx);
891                Ok(())
892            }
893
894            Expr::Binary { left, op, right } => {
895                self.render_expr(left, ctx)?;
896                // When using %s placeholders (psycopg), literal '%' must be
897                // escaped as '%%' so the driver doesn't treat it as a placeholder.
898                let mod_op = if self.param_style == ParamStyle::Percent {
899                    "%%"
900                } else {
901                    "%"
902                };
903                match op {
904                    BinaryOp::Custom(custom) => {
905                        render_custom_binary_op(custom.as_ref(), ctx)?;
906                    }
907                    _ => {
908                        ctx.keyword(match op {
909                            BinaryOp::Add => "+",
910                            BinaryOp::Sub => "-",
911                            BinaryOp::Mul => "*",
912                            BinaryOp::Div => "/",
913                            BinaryOp::Mod => mod_op,
914                            BinaryOp::BitwiseAnd => "&",
915                            BinaryOp::BitwiseOr => "|",
916                            BinaryOp::ShiftLeft => "<<",
917                            BinaryOp::ShiftRight => ">>",
918                            BinaryOp::Concat => "||",
919                            BinaryOp::Custom(_) => unreachable!(),
920                        });
921                    }
922                };
923                self.render_expr(right, ctx)
924            }
925
926            Expr::Unary { op, expr: inner } => {
927                match op {
928                    UnaryOp::Neg => ctx.write("-"),
929                    UnaryOp::Not => ctx.keyword("NOT"),
930                    UnaryOp::BitwiseNot => ctx.write("~"),
931                };
932                self.render_expr(inner, ctx)
933            }
934
935            Expr::Func { name, args } => {
936                ctx.keyword(name).write("(");
937                for (i, arg) in args.iter().enumerate() {
938                    if i > 0 {
939                        ctx.comma();
940                    }
941                    self.render_expr(arg, ctx)?;
942                }
943                ctx.paren_close();
944                Ok(())
945            }
946
947            Expr::Aggregate(agg) => self.render_aggregate(agg, ctx),
948
949            Expr::Cast {
950                expr: inner,
951                to_type,
952            } => {
953                self.render_expr(inner, ctx)?;
954                ctx.operator("::");
955                ctx.write(to_type);
956                Ok(())
957            }
958
959            Expr::Case(case) => self.render_case(case, ctx),
960
961            Expr::Window(win) => self.render_window(win, ctx),
962
963            Expr::Exists(query) => {
964                ctx.keyword("EXISTS").write("(");
965                self.render_query(query, ctx)?;
966                ctx.paren_close();
967                Ok(())
968            }
969
970            Expr::SubQuery(query) => {
971                ctx.paren_open();
972                self.render_query(query, ctx)?;
973                ctx.paren_close();
974                Ok(())
975            }
976
977            Expr::ArraySubQuery(query) => {
978                ctx.keyword("ARRAY").paren_open();
979                self.render_query(query, ctx)?;
980                ctx.paren_close();
981                Ok(())
982            }
983
984            Expr::Collate { expr, collation } => {
985                self.render_expr(expr, ctx)?;
986                ctx.keyword("COLLATE").ident(collation);
987                Ok(())
988            }
989
990            Expr::JsonArray(items) => {
991                ctx.keyword("jsonb_build_array").write("(");
992                for (i, item) in items.iter().enumerate() {
993                    if i > 0 {
994                        ctx.comma();
995                    }
996                    self.render_expr(item, ctx)?;
997                }
998                ctx.paren_close();
999                Ok(())
1000            }
1001
1002            Expr::JsonObject(pairs) => {
1003                ctx.keyword("jsonb_build_object").write("(");
1004                for (i, (key, val)) in pairs.iter().enumerate() {
1005                    if i > 0 {
1006                        ctx.comma();
1007                    }
1008                    ctx.string_literal(key).comma();
1009                    self.render_expr(val, ctx)?;
1010                }
1011                ctx.paren_close();
1012                Ok(())
1013            }
1014
1015            Expr::JsonAgg {
1016                expr,
1017                distinct,
1018                filter,
1019                order_by,
1020            } => {
1021                ctx.keyword("jsonb_agg").write("(");
1022                if *distinct {
1023                    ctx.keyword("DISTINCT");
1024                }
1025                self.render_expr(expr, ctx)?;
1026                if let Some(ob) = order_by {
1027                    ctx.keyword("ORDER BY");
1028                    self.pg_order_by_list(ob, ctx)?;
1029                }
1030                ctx.paren_close();
1031                if let Some(f) = filter {
1032                    ctx.keyword("FILTER").paren_open().keyword("WHERE");
1033                    self.render_condition(f, ctx)?;
1034                    ctx.paren_close();
1035                }
1036                Ok(())
1037            }
1038
1039            Expr::StringAgg {
1040                expr,
1041                delimiter,
1042                distinct,
1043                filter,
1044                order_by,
1045            } => {
1046                ctx.keyword("string_agg").write("(");
1047                if *distinct {
1048                    ctx.keyword("DISTINCT");
1049                }
1050                self.render_expr(expr, ctx)?;
1051                ctx.comma().string_literal(delimiter);
1052                if let Some(ob) = order_by {
1053                    ctx.keyword("ORDER BY");
1054                    self.pg_order_by_list(ob, ctx)?;
1055                }
1056                ctx.paren_close();
1057                if let Some(f) = filter {
1058                    ctx.keyword("FILTER").paren_open().keyword("WHERE");
1059                    self.render_condition(f, ctx)?;
1060                    ctx.paren_close();
1061                }
1062                Ok(())
1063            }
1064
1065            Expr::Now => {
1066                ctx.keyword("now()");
1067                Ok(())
1068            }
1069
1070            Expr::CurrentTimestamp => {
1071                ctx.keyword("CURRENT_TIMESTAMP");
1072                Ok(())
1073            }
1074            Expr::CurrentDate => {
1075                ctx.keyword("CURRENT_DATE");
1076                Ok(())
1077            }
1078            Expr::CurrentTime => {
1079                ctx.keyword("CURRENT_TIME");
1080                Ok(())
1081            }
1082
1083            Expr::JsonPathText { expr, path } => {
1084                self.render_expr(expr, ctx)?;
1085                ctx.operator("->>'")
1086                    .write(&path.replace('\'', "''"))
1087                    .write("'");
1088                Ok(())
1089            }
1090
1091            Expr::Tuple(exprs) => {
1092                ctx.paren_open();
1093                for (i, expr) in exprs.iter().enumerate() {
1094                    if i > 0 {
1095                        ctx.comma();
1096                    }
1097                    self.render_expr(expr, ctx)?;
1098                }
1099                ctx.paren_close();
1100                Ok(())
1101            }
1102
1103            Expr::Param { type_hint } => {
1104                ctx.placeholder();
1105                if let Some(hint) = type_hint {
1106                    ctx.write("::");
1107                    ctx.write(hint);
1108                }
1109                Ok(())
1110            }
1111
1112            Expr::Raw { sql, params } => {
1113                if params.is_empty() {
1114                    ctx.keyword(sql);
1115                } else {
1116                    ctx.raw_with_params(sql, params);
1117                }
1118                Ok(())
1119            }
1120
1121            Expr::Custom(_) => Err(RenderError::unsupported(
1122                "CustomExpr",
1123                "custom expression must be handled by a wrapping renderer",
1124            )),
1125        }
1126    }
1127
1128    fn render_aggregate(&self, agg: &AggregationDef, ctx: &mut RenderCtx) -> RenderResult<()> {
1129        ctx.keyword(&agg.name).write("(");
1130        if agg.distinct {
1131            ctx.keyword("DISTINCT");
1132        }
1133        if let Some(expr) = &agg.expression {
1134            self.render_expr(expr, ctx)?;
1135        } else {
1136            ctx.write("*");
1137        }
1138        if let Some(args) = &agg.args {
1139            for arg in args {
1140                ctx.comma();
1141                self.render_expr(arg, ctx)?;
1142            }
1143        }
1144        if let Some(order_by) = &agg.order_by {
1145            ctx.keyword("ORDER BY");
1146            self.pg_order_by_list(order_by, ctx)?;
1147        }
1148        ctx.paren_close();
1149        if let Some(filter) = &agg.filter {
1150            ctx.keyword("FILTER").paren_open().keyword("WHERE");
1151            self.render_condition(filter, ctx)?;
1152            ctx.paren_close();
1153        }
1154        Ok(())
1155    }
1156
1157    fn render_window(&self, win: &WindowDef, ctx: &mut RenderCtx) -> RenderResult<()> {
1158        self.render_expr(&win.expression, ctx)?;
1159        ctx.keyword("OVER").paren_open();
1160        if let Some(partition_by) = &win.partition_by {
1161            ctx.keyword("PARTITION BY");
1162            for (i, expr) in partition_by.iter().enumerate() {
1163                if i > 0 {
1164                    ctx.comma();
1165                }
1166                self.render_expr(expr, ctx)?;
1167            }
1168        }
1169        if let Some(order_by) = &win.order_by {
1170            ctx.keyword("ORDER BY");
1171            self.pg_order_by_list(order_by, ctx)?;
1172        }
1173        if let Some(frame) = &win.frame {
1174            self.pg_window_frame(frame, ctx);
1175        }
1176        ctx.paren_close();
1177        Ok(())
1178    }
1179
1180    fn render_case(&self, case: &CaseDef, ctx: &mut RenderCtx) -> RenderResult<()> {
1181        ctx.keyword("CASE");
1182        for clause in &case.cases {
1183            ctx.keyword("WHEN");
1184            self.render_condition(&clause.condition, ctx)?;
1185            ctx.keyword("THEN");
1186            self.render_expr(&clause.result, ctx)?;
1187        }
1188        if let Some(default) = &case.default {
1189            ctx.keyword("ELSE");
1190            self.render_expr(default, ctx)?;
1191        }
1192        ctx.keyword("END");
1193        Ok(())
1194    }
1195
1196    // ── Conditions ───────────────────────────────────────────────────────
1197
1198    fn render_condition(&self, cond: &Conditions, ctx: &mut RenderCtx) -> RenderResult<()> {
1199        // Special case: negated + single Exists child → NOT EXISTS (...)
1200        if cond.negated
1201            && cond.children.len() == 1
1202            && matches!(cond.children[0], ConditionNode::Exists(_))
1203        {
1204            if let ConditionNode::Exists(query) = &cond.children[0] {
1205                ctx.keyword("NOT EXISTS").write("(");
1206                self.render_query(query, ctx)?;
1207                ctx.paren_close();
1208                return Ok(());
1209            }
1210        }
1211
1212        if cond.negated {
1213            ctx.keyword("NOT").paren_open();
1214        }
1215        let connector = match cond.connector {
1216            Connector::And => " AND ",
1217            Connector::Or => " OR ",
1218        };
1219        for (i, child) in cond.children.iter().enumerate() {
1220            if i > 0 {
1221                ctx.write(connector);
1222            }
1223            match child {
1224                ConditionNode::Comparison(comp) => {
1225                    if comp.negate {
1226                        ctx.keyword("NOT").paren_open();
1227                    }
1228                    self.render_compare_op(&comp.op, &comp.left, &comp.right, ctx)?;
1229                    if comp.negate {
1230                        ctx.paren_close();
1231                    }
1232                }
1233                ConditionNode::Group(group) => {
1234                    ctx.paren_open();
1235                    self.render_condition(group, ctx)?;
1236                    ctx.paren_close();
1237                }
1238                ConditionNode::Exists(query) => {
1239                    ctx.keyword("EXISTS").write("(");
1240                    self.render_query(query, ctx)?;
1241                    ctx.paren_close();
1242                }
1243                ConditionNode::Custom(_) => {
1244                    return Err(RenderError::unsupported(
1245                        "CustomCondition",
1246                        "custom condition must be handled by a wrapping renderer",
1247                    ));
1248                }
1249            }
1250        }
1251        if cond.negated {
1252            ctx.paren_close();
1253        }
1254        Ok(())
1255    }
1256
1257    fn render_compare_op(
1258        &self,
1259        op: &CompareOp,
1260        left: &Expr,
1261        right: &Expr,
1262        ctx: &mut RenderCtx,
1263    ) -> RenderResult<()> {
1264        self.render_expr(left, ctx)?;
1265        match op {
1266            CompareOp::Eq => ctx.write(" = "),
1267            CompareOp::Neq => ctx.write(" <> "),
1268            CompareOp::Gt => ctx.write(" > "),
1269            CompareOp::Gte => ctx.write(" >= "),
1270            CompareOp::Lt => ctx.write(" < "),
1271            CompareOp::Lte => ctx.write(" <= "),
1272            CompareOp::Like => ctx.keyword("LIKE"),
1273            CompareOp::ILike => ctx.keyword("ILIKE"),
1274            CompareOp::Contains | CompareOp::StartsWith | CompareOp::EndsWith => {
1275                ctx.keyword("LIKE");
1276                render_like_pattern(op, right, ctx)?;
1277                return Ok(());
1278            }
1279            CompareOp::IContains | CompareOp::IStartsWith | CompareOp::IEndsWith => {
1280                ctx.keyword("ILIKE");
1281                render_like_pattern(op, right, ctx)?;
1282                return Ok(());
1283            }
1284            CompareOp::In => {
1285                if let Expr::Value(Value::Array(items)) = right {
1286                    ctx.keyword("IN").paren_open();
1287                    for (i, item) in items.iter().enumerate() {
1288                        if i > 0 {
1289                            ctx.comma();
1290                        }
1291                        self.pg_value(item, ctx)?;
1292                    }
1293                    ctx.paren_close();
1294                } else {
1295                    ctx.keyword("IN");
1296                    self.render_expr(right, ctx)?;
1297                }
1298                return Ok(());
1299            }
1300            CompareOp::Between => {
1301                ctx.keyword("BETWEEN");
1302                if let Expr::Value(Value::Array(items)) = right {
1303                    if items.len() == 2 {
1304                        self.pg_value(&items[0], ctx)?;
1305                        ctx.keyword("AND");
1306                        self.pg_value(&items[1], ctx)?;
1307                    } else {
1308                        return Err(RenderError::unsupported(
1309                            "Between",
1310                            "BETWEEN requires exactly 2 values",
1311                        ));
1312                    }
1313                } else {
1314                    self.render_expr(right, ctx)?;
1315                }
1316                return Ok(());
1317            }
1318            CompareOp::IsNull => {
1319                ctx.keyword("IS NULL");
1320                return Ok(());
1321            }
1322            CompareOp::Similar => ctx.keyword("SIMILAR TO"),
1323            CompareOp::Regex => ctx.write(" ~ "),
1324            CompareOp::IRegex => ctx.write(" ~* "),
1325            CompareOp::JsonbContains => ctx.write(" @> "),
1326            CompareOp::JsonbContainedBy => ctx.write(" <@ "),
1327            CompareOp::JsonbHasKey => ctx.write(" ? "),
1328            CompareOp::JsonbHasAnyKey => {
1329                ctx.write(" ?| ");
1330                self.render_expr(right, ctx)?;
1331                ctx.write("::text[]");
1332                return Ok(());
1333            }
1334            CompareOp::JsonbHasAllKeys => {
1335                ctx.write(" ?& ");
1336                self.render_expr(right, ctx)?;
1337                ctx.write("::text[]");
1338                return Ok(());
1339            }
1340            CompareOp::FtsMatch => ctx.write(" @@ "),
1341            CompareOp::TrigramSimilar => {
1342                if self.param_style == ParamStyle::Percent {
1343                    ctx.write(" %% ")
1344                } else {
1345                    ctx.write(" % ")
1346                }
1347            }
1348            CompareOp::TrigramWordSimilar => {
1349                if self.param_style == ParamStyle::Percent {
1350                    ctx.write(" <%% ")
1351                } else {
1352                    ctx.write(" <% ")
1353                }
1354            }
1355            CompareOp::TrigramStrictWordSimilar => {
1356                if self.param_style == ParamStyle::Percent {
1357                    ctx.write(" <<%% ")
1358                } else {
1359                    ctx.write(" <<% ")
1360                }
1361            }
1362            CompareOp::RangeContains => ctx.write(" @> "),
1363            CompareOp::RangeContainedBy => ctx.write(" <@ "),
1364            CompareOp::RangeOverlap => ctx.write(" && "),
1365            CompareOp::RangeStrictlyLeft => ctx.write(" << "),
1366            CompareOp::RangeStrictlyRight => ctx.write(" >> "),
1367            CompareOp::RangeNotLeft => ctx.write(" &> "),
1368            CompareOp::RangeNotRight => ctx.write(" &< "),
1369            CompareOp::RangeAdjacent => ctx.write(" -|- "),
1370            CompareOp::Custom(_) => {
1371                return Err(RenderError::unsupported(
1372                    "CustomCompareOp",
1373                    "custom compare op must be handled by a wrapping renderer",
1374                ));
1375            }
1376        };
1377        self.render_expr(right, ctx)
1378    }
1379
1380    // ── Query (stub) ─────────────────────────────────────────────────────
1381
1382    fn render_query(&self, stmt: &QueryStmt, ctx: &mut RenderCtx) -> RenderResult<()> {
1383        // CTEs
1384        if let Some(ctes) = &stmt.ctes {
1385            self.render_ctes(ctes, ctx)?;
1386        }
1387
1388        // Set operation: render directly without SELECT wrapper
1389        if let Some(set_op) = &stmt.set_op {
1390            return self.pg_render_set_op(set_op, ctx);
1391        }
1392
1393        // SELECT
1394        ctx.keyword("SELECT");
1395
1396        // DISTINCT / DISTINCT ON
1397        if let Some(distinct) = &stmt.distinct {
1398            match distinct {
1399                DistinctDef::Distinct => {
1400                    ctx.keyword("DISTINCT");
1401                }
1402                DistinctDef::DistinctOn(exprs) => {
1403                    ctx.keyword("DISTINCT ON").paren_open();
1404                    for (i, expr) in exprs.iter().enumerate() {
1405                        if i > 0 {
1406                            ctx.comma();
1407                        }
1408                        self.render_expr(expr, ctx)?;
1409                    }
1410                    ctx.paren_close();
1411                }
1412            }
1413        }
1414
1415        // Columns
1416        self.render_select_columns(&stmt.columns, ctx)?;
1417
1418        // FROM
1419        if let Some(from) = &stmt.from {
1420            ctx.keyword("FROM");
1421            for (i, item) in from.iter().enumerate() {
1422                if i > 0 {
1423                    ctx.comma();
1424                }
1425                self.pg_render_from_item(item, ctx)?;
1426            }
1427        }
1428
1429        // JOINs
1430        if let Some(joins) = &stmt.joins {
1431            self.render_joins(joins, ctx)?;
1432        }
1433
1434        // WHERE
1435        if let Some(cond) = &stmt.where_clause {
1436            self.render_where(cond, ctx)?;
1437        }
1438
1439        // GROUP BY
1440        if let Some(group_by) = &stmt.group_by {
1441            self.pg_render_group_by(group_by, ctx)?;
1442        }
1443
1444        // HAVING
1445        if let Some(having) = &stmt.having {
1446            ctx.keyword("HAVING");
1447            self.render_condition(having, ctx)?;
1448        }
1449
1450        // WINDOW
1451        if let Some(windows) = &stmt.window {
1452            self.pg_render_window_clause(windows, ctx)?;
1453        }
1454
1455        // ORDER BY
1456        if let Some(order_by) = &stmt.order_by {
1457            self.render_order_by(order_by, ctx)?;
1458        }
1459
1460        // LIMIT / OFFSET
1461        if let Some(limit) = &stmt.limit {
1462            self.render_limit(limit, ctx)?;
1463        }
1464
1465        // FOR UPDATE / SHARE
1466        if let Some(locks) = &stmt.lock {
1467            for lock in locks {
1468                self.render_lock(lock, ctx)?;
1469            }
1470        }
1471
1472        Ok(())
1473    }
1474
1475    fn render_select_columns(
1476        &self,
1477        cols: &[SelectColumn],
1478        ctx: &mut RenderCtx,
1479    ) -> RenderResult<()> {
1480        for (i, col) in cols.iter().enumerate() {
1481            if i > 0 {
1482                ctx.comma();
1483            }
1484            match col {
1485                SelectColumn::Star(None) => {
1486                    ctx.keyword("*");
1487                }
1488                SelectColumn::Star(Some(table)) => {
1489                    ctx.ident(table).operator(".").keyword("*");
1490                }
1491                SelectColumn::Expr { expr, alias } => {
1492                    self.render_expr(expr, ctx)?;
1493                    if let Some(a) = alias {
1494                        ctx.keyword("AS").ident(a);
1495                    }
1496                }
1497                SelectColumn::Field { field, alias } => {
1498                    self.pg_field_ref(field, ctx);
1499                    if let Some(a) = alias {
1500                        ctx.keyword("AS").ident(a);
1501                    }
1502                }
1503            }
1504        }
1505        Ok(())
1506    }
1507    fn render_from(&self, source: &TableSource, ctx: &mut RenderCtx) -> RenderResult<()> {
1508        match source {
1509            TableSource::Table(schema_ref) => {
1510                self.pg_schema_ref(schema_ref, ctx);
1511                if let Some(alias) = &schema_ref.alias {
1512                    ctx.keyword("AS").ident(alias);
1513                }
1514            }
1515            TableSource::SubQuery(sq) => {
1516                ctx.paren_open();
1517                self.render_query(&sq.query, ctx)?;
1518                ctx.paren_close().keyword("AS").ident(&sq.alias);
1519            }
1520            TableSource::SetOp(set_op) => {
1521                ctx.paren_open();
1522                self.pg_render_set_op(set_op, ctx)?;
1523                ctx.paren_close();
1524            }
1525            TableSource::Lateral(inner) => {
1526                ctx.keyword("LATERAL");
1527                self.render_from(&inner.source, ctx)?;
1528            }
1529            TableSource::Function { name, args, alias } => {
1530                ctx.keyword(name).write("(");
1531                for (i, arg) in args.iter().enumerate() {
1532                    if i > 0 {
1533                        ctx.comma();
1534                    }
1535                    self.render_expr(arg, ctx)?;
1536                }
1537                ctx.paren_close();
1538                if let Some(a) = alias {
1539                    ctx.keyword("AS").ident(a);
1540                }
1541            }
1542            TableSource::Values {
1543                rows,
1544                alias,
1545                columns,
1546            } => {
1547                ctx.paren_open().keyword("VALUES");
1548                for (i, row) in rows.iter().enumerate() {
1549                    if i > 0 {
1550                        ctx.comma();
1551                    }
1552                    ctx.paren_open();
1553                    for (j, val) in row.iter().enumerate() {
1554                        if j > 0 {
1555                            ctx.comma();
1556                        }
1557                        self.render_expr(val, ctx)?;
1558                    }
1559                    ctx.paren_close();
1560                }
1561                ctx.paren_close().keyword("AS").ident(alias);
1562                ctx.paren_open();
1563                for (i, c) in columns.iter().enumerate() {
1564                    if i > 0 {
1565                        ctx.comma();
1566                    }
1567                    ctx.ident(c);
1568                }
1569                ctx.paren_close();
1570            }
1571            TableSource::Custom(_) => {
1572                return Err(RenderError::unsupported(
1573                    "CustomTableSource",
1574                    "custom table source must be handled by a wrapping renderer",
1575                ));
1576            }
1577        }
1578        Ok(())
1579    }
1580    fn render_joins(&self, joins: &[JoinDef], ctx: &mut RenderCtx) -> RenderResult<()> {
1581        for join in joins {
1582            if join.natural {
1583                ctx.keyword("NATURAL");
1584            }
1585            ctx.keyword(match join.join_type {
1586                JoinType::Inner => "INNER JOIN",
1587                JoinType::Left => "LEFT JOIN",
1588                JoinType::Right => "RIGHT JOIN",
1589                JoinType::Full => "FULL JOIN",
1590                JoinType::Cross => "CROSS JOIN",
1591                JoinType::CrossApply => "CROSS JOIN LATERAL",
1592                JoinType::OuterApply => "LEFT JOIN LATERAL",
1593            });
1594            self.pg_render_from_item(&join.source, ctx)?;
1595            if !matches!(join.join_type, JoinType::Cross) {
1596                if let Some(condition) = &join.condition {
1597                    match condition {
1598                        JoinCondition::On(cond) => {
1599                            ctx.keyword("ON");
1600                            self.render_condition(cond, ctx)?;
1601                        }
1602                        JoinCondition::Using(cols) => {
1603                            ctx.keyword("USING").paren_open();
1604                            self.pg_comma_idents(cols, ctx);
1605                            ctx.paren_close();
1606                        }
1607                    }
1608                }
1609            }
1610            // CrossApply/OuterApply rendered as LATERAL need ON TRUE if no condition
1611            if matches!(join.join_type, JoinType::OuterApply) && join.condition.is_none() {
1612                ctx.keyword("ON TRUE");
1613            }
1614        }
1615        Ok(())
1616    }
1617    fn render_where(&self, cond: &Conditions, ctx: &mut RenderCtx) -> RenderResult<()> {
1618        ctx.keyword("WHERE");
1619        self.render_condition(cond, ctx)
1620    }
1621    fn render_order_by(&self, order: &[OrderByDef], ctx: &mut RenderCtx) -> RenderResult<()> {
1622        ctx.keyword("ORDER BY");
1623        self.pg_order_by_list(order, ctx)
1624    }
1625    fn render_limit(&self, limit: &LimitDef, ctx: &mut RenderCtx) -> RenderResult<()> {
1626        match &limit.kind {
1627            LimitKind::Limit(n) => {
1628                ctx.keyword("LIMIT");
1629                if ctx.parameterize() {
1630                    ctx.param(Value::BigInt(*n as i64));
1631                } else {
1632                    ctx.space().write(&n.to_string());
1633                }
1634            }
1635            LimitKind::FetchFirst {
1636                count,
1637                with_ties,
1638                percent,
1639            } => {
1640                if let Some(offset) = limit.offset {
1641                    ctx.keyword("OFFSET")
1642                        .space()
1643                        .write(&offset.to_string())
1644                        .keyword("ROWS");
1645                }
1646                ctx.keyword("FETCH FIRST");
1647                if *percent {
1648                    ctx.space().write(&count.to_string()).keyword("PERCENT");
1649                } else {
1650                    ctx.space().write(&count.to_string());
1651                }
1652                if *with_ties {
1653                    ctx.keyword("ROWS WITH TIES");
1654                } else {
1655                    ctx.keyword("ROWS ONLY");
1656                }
1657                return Ok(());
1658            }
1659            LimitKind::Top { count, .. } => {
1660                // PG doesn't support TOP, convert to LIMIT
1661                ctx.keyword("LIMIT");
1662                if ctx.parameterize() {
1663                    ctx.param(Value::BigInt(*count as i64));
1664                } else {
1665                    ctx.space().write(&count.to_string());
1666                }
1667            }
1668        }
1669        if let Some(offset) = limit.offset {
1670            ctx.keyword("OFFSET");
1671            if ctx.parameterize() {
1672                ctx.param(Value::BigInt(offset as i64));
1673            } else {
1674                ctx.space().write(&offset.to_string());
1675            }
1676        }
1677        Ok(())
1678    }
1679    fn render_ctes(&self, ctes: &[CteDef], ctx: &mut RenderCtx) -> RenderResult<()> {
1680        // Check if any CTE is recursive — PG uses WITH RECURSIVE once for all.
1681        let any_recursive = ctes.iter().any(|c| c.recursive);
1682        ctx.keyword("WITH");
1683        if any_recursive {
1684            ctx.keyword("RECURSIVE");
1685        }
1686        for (i, cte) in ctes.iter().enumerate() {
1687            if i > 0 {
1688                ctx.comma();
1689            }
1690            ctx.ident(&cte.name);
1691            if let Some(col_names) = &cte.column_names {
1692                ctx.paren_open();
1693                self.pg_comma_idents(col_names, ctx);
1694                ctx.paren_close();
1695            }
1696            ctx.keyword("AS");
1697            if let Some(mat) = &cte.materialized {
1698                match mat {
1699                    CteMaterialized::Materialized => {
1700                        ctx.keyword("MATERIALIZED");
1701                    }
1702                    CteMaterialized::NotMaterialized => {
1703                        ctx.keyword("NOT MATERIALIZED");
1704                    }
1705                }
1706            }
1707            ctx.paren_open();
1708            self.render_query(&cte.query, ctx)?;
1709            ctx.paren_close();
1710        }
1711        Ok(())
1712    }
1713    fn render_lock(&self, lock: &SelectLockDef, ctx: &mut RenderCtx) -> RenderResult<()> {
1714        ctx.keyword("FOR");
1715        ctx.keyword(match lock.strength {
1716            LockStrength::Update => "UPDATE",
1717            LockStrength::NoKeyUpdate => "NO KEY UPDATE",
1718            LockStrength::Share => "SHARE",
1719            LockStrength::KeyShare => "KEY SHARE",
1720        });
1721        if let Some(of) = &lock.of {
1722            ctx.keyword("OF");
1723            for (i, table) in of.iter().enumerate() {
1724                if i > 0 {
1725                    ctx.comma();
1726                }
1727                self.pg_schema_ref(table, ctx);
1728            }
1729        }
1730        if lock.nowait {
1731            ctx.keyword("NOWAIT");
1732        }
1733        if lock.skip_locked {
1734            ctx.keyword("SKIP LOCKED");
1735        }
1736        Ok(())
1737    }
1738
1739    // ── DML ──────────────────────────────────────────────────────────────
1740
1741    fn render_mutation(&self, stmt: &MutationStmt, ctx: &mut RenderCtx) -> RenderResult<()> {
1742        match stmt {
1743            MutationStmt::Insert(s) => self.render_insert(s, ctx),
1744            MutationStmt::Update(s) => self.render_update(s, ctx),
1745            MutationStmt::Delete(s) => self.render_delete(s, ctx),
1746            MutationStmt::Custom(_) => Err(RenderError::unsupported(
1747                "CustomMutation",
1748                "custom DML must be handled by a wrapping renderer",
1749            )),
1750        }
1751    }
1752
1753    fn render_insert(&self, stmt: &InsertStmt, ctx: &mut RenderCtx) -> RenderResult<()> {
1754        // CTEs
1755        if let Some(ctes) = &stmt.ctes {
1756            self.pg_render_ctes(ctes, ctx)?;
1757        }
1758
1759        ctx.keyword("INSERT INTO");
1760        self.pg_schema_ref(&stmt.table, ctx);
1761
1762        // Column list
1763        if let Some(cols) = &stmt.columns {
1764            ctx.paren_open();
1765            self.pg_comma_idents(cols, ctx);
1766            ctx.paren_close();
1767        }
1768
1769        // OVERRIDING
1770        if let Some(overriding) = &stmt.overriding {
1771            ctx.keyword(match overriding {
1772                OverridingKind::System => "OVERRIDING SYSTEM VALUE",
1773                OverridingKind::User => "OVERRIDING USER VALUE",
1774            });
1775        }
1776
1777        // Source
1778        match &stmt.source {
1779            InsertSource::Values(rows) => {
1780                ctx.keyword("VALUES");
1781                for (i, row) in rows.iter().enumerate() {
1782                    if i > 0 {
1783                        ctx.comma();
1784                    }
1785                    ctx.paren_open();
1786                    for (j, expr) in row.iter().enumerate() {
1787                        if j > 0 {
1788                            ctx.comma();
1789                        }
1790                        self.render_expr(expr, ctx)?;
1791                    }
1792                    ctx.paren_close();
1793                }
1794            }
1795            InsertSource::Select(query) => {
1796                self.render_query(query, ctx)?;
1797            }
1798            InsertSource::DefaultValues => {
1799                ctx.keyword("DEFAULT VALUES");
1800            }
1801        }
1802
1803        // ON CONFLICT
1804        if let Some(conflicts) = &stmt.on_conflict {
1805            for oc in conflicts {
1806                self.render_on_conflict(oc, ctx)?;
1807            }
1808        }
1809
1810        // RETURNING
1811        if let Some(returning) = &stmt.returning {
1812            self.render_returning(returning, ctx)?;
1813        }
1814
1815        Ok(())
1816    }
1817
1818    fn render_update(&self, stmt: &UpdateStmt, ctx: &mut RenderCtx) -> RenderResult<()> {
1819        // CTEs
1820        if let Some(ctes) = &stmt.ctes {
1821            self.pg_render_ctes(ctes, ctx)?;
1822        }
1823
1824        ctx.keyword("UPDATE");
1825
1826        // ONLY
1827        if stmt.only {
1828            ctx.keyword("ONLY");
1829        }
1830
1831        self.pg_schema_ref(&stmt.table, ctx);
1832
1833        // Alias
1834        if let Some(alias) = &stmt.table.alias {
1835            ctx.keyword("AS").ident(alias);
1836        }
1837
1838        // SET
1839        ctx.keyword("SET");
1840        for (i, (col, expr)) in stmt.assignments.iter().enumerate() {
1841            if i > 0 {
1842                ctx.comma();
1843            }
1844            ctx.ident(col).write(" = ");
1845            self.render_expr(expr, ctx)?;
1846        }
1847
1848        // FROM
1849        if let Some(from) = &stmt.from {
1850            ctx.keyword("FROM");
1851            for (i, source) in from.iter().enumerate() {
1852                if i > 0 {
1853                    ctx.comma();
1854                }
1855                self.render_from(source, ctx)?;
1856            }
1857        }
1858
1859        // WHERE
1860        if let Some(cond) = &stmt.where_clause {
1861            ctx.keyword("WHERE");
1862            self.render_condition(cond, ctx)?;
1863        }
1864
1865        // RETURNING
1866        if let Some(returning) = &stmt.returning {
1867            self.render_returning(returning, ctx)?;
1868        }
1869
1870        Ok(())
1871    }
1872
1873    fn render_delete(&self, stmt: &DeleteStmt, ctx: &mut RenderCtx) -> RenderResult<()> {
1874        // CTEs
1875        if let Some(ctes) = &stmt.ctes {
1876            self.pg_render_ctes(ctes, ctx)?;
1877        }
1878
1879        ctx.keyword("DELETE FROM");
1880
1881        // ONLY
1882        if stmt.only {
1883            ctx.keyword("ONLY");
1884        }
1885
1886        self.pg_schema_ref(&stmt.table, ctx);
1887
1888        // Alias
1889        if let Some(alias) = &stmt.table.alias {
1890            ctx.keyword("AS").ident(alias);
1891        }
1892
1893        // USING
1894        if let Some(using) = &stmt.using {
1895            ctx.keyword("USING");
1896            for (i, source) in using.iter().enumerate() {
1897                if i > 0 {
1898                    ctx.comma();
1899                }
1900                self.render_from(source, ctx)?;
1901            }
1902        }
1903
1904        // WHERE
1905        if let Some(cond) = &stmt.where_clause {
1906            ctx.keyword("WHERE");
1907            self.render_condition(cond, ctx)?;
1908        }
1909
1910        // RETURNING
1911        if let Some(returning) = &stmt.returning {
1912            self.render_returning(returning, ctx)?;
1913        }
1914
1915        Ok(())
1916    }
1917
1918    fn render_on_conflict(&self, oc: &OnConflictDef, ctx: &mut RenderCtx) -> RenderResult<()> {
1919        ctx.keyword("ON CONFLICT");
1920
1921        // Target
1922        if let Some(target) = &oc.target {
1923            match target {
1924                ConflictTarget::Columns {
1925                    columns,
1926                    where_clause,
1927                } => {
1928                    ctx.paren_open();
1929                    self.pg_comma_idents(columns, ctx);
1930                    ctx.paren_close();
1931                    if let Some(cond) = where_clause {
1932                        ctx.keyword("WHERE");
1933                        self.render_condition(cond, ctx)?;
1934                    }
1935                }
1936                ConflictTarget::Constraint(name) => {
1937                    ctx.keyword("ON CONSTRAINT").ident(name);
1938                }
1939            }
1940        }
1941
1942        // Action
1943        match &oc.action {
1944            ConflictAction::DoNothing => {
1945                ctx.keyword("DO NOTHING");
1946            }
1947            ConflictAction::DoUpdate {
1948                assignments,
1949                where_clause,
1950            } => {
1951                ctx.keyword("DO UPDATE SET");
1952                for (i, (col, expr)) in assignments.iter().enumerate() {
1953                    if i > 0 {
1954                        ctx.comma();
1955                    }
1956                    ctx.ident(col).write(" = ");
1957                    self.render_expr(expr, ctx)?;
1958                }
1959                if let Some(cond) = where_clause {
1960                    ctx.keyword("WHERE");
1961                    self.render_condition(cond, ctx)?;
1962                }
1963            }
1964        }
1965
1966        Ok(())
1967    }
1968
1969    fn render_returning(&self, cols: &[SelectColumn], ctx: &mut RenderCtx) -> RenderResult<()> {
1970        ctx.keyword("RETURNING");
1971        for (i, col) in cols.iter().enumerate() {
1972            if i > 0 {
1973                ctx.comma();
1974            }
1975            match col {
1976                SelectColumn::Star(None) => {
1977                    ctx.keyword("*");
1978                }
1979                SelectColumn::Star(Some(table)) => {
1980                    ctx.ident(table).operator(".").keyword("*");
1981                }
1982                SelectColumn::Expr { expr, alias } => {
1983                    self.render_expr(expr, ctx)?;
1984                    if let Some(a) = alias {
1985                        ctx.keyword("AS").ident(a);
1986                    }
1987                }
1988                SelectColumn::Field { field, alias } => {
1989                    self.pg_field_ref(field, ctx);
1990                    if let Some(a) = alias {
1991                        ctx.keyword("AS").ident(a);
1992                    }
1993                }
1994            }
1995        }
1996        Ok(())
1997    }
1998
1999    // ── TCL ──────────────────────────────────────────────────────────────
2000
2001    fn render_transaction(&self, stmt: &TransactionStmt, ctx: &mut RenderCtx) -> RenderResult<()> {
2002        match stmt {
2003            TransactionStmt::Begin(s) => self.pg_begin(s, ctx),
2004            TransactionStmt::Commit(s) => self.pg_commit(s, ctx),
2005            TransactionStmt::Rollback(s) => self.pg_rollback(s, ctx),
2006            TransactionStmt::Savepoint(s) => {
2007                ctx.keyword("SAVEPOINT").ident(&s.name);
2008                Ok(())
2009            }
2010            TransactionStmt::ReleaseSavepoint(s) => {
2011                ctx.keyword("RELEASE").keyword("SAVEPOINT").ident(&s.name);
2012                Ok(())
2013            }
2014            TransactionStmt::SetTransaction(s) => self.pg_set_transaction(s, ctx),
2015            TransactionStmt::LockTable(s) => self.pg_lock_table(s, ctx),
2016            TransactionStmt::PrepareTransaction(s) => {
2017                ctx.keyword("PREPARE")
2018                    .keyword("TRANSACTION")
2019                    .string_literal(&s.transaction_id);
2020                Ok(())
2021            }
2022            TransactionStmt::CommitPrepared(s) => {
2023                ctx.keyword("COMMIT")
2024                    .keyword("PREPARED")
2025                    .string_literal(&s.transaction_id);
2026                Ok(())
2027            }
2028            TransactionStmt::RollbackPrepared(s) => {
2029                ctx.keyword("ROLLBACK")
2030                    .keyword("PREPARED")
2031                    .string_literal(&s.transaction_id);
2032                Ok(())
2033            }
2034            TransactionStmt::Custom(_) => Err(RenderError::unsupported(
2035                "Custom TCL",
2036                "not supported by PostgresRenderer",
2037            )),
2038        }
2039    }
2040}
2041
2042// ==========================================================================
2043// PostgreSQL-specific helpers
2044// ==========================================================================
2045
2046impl PostgresRenderer {
2047    // ── TCL helpers ──────────────────────────────────────────────────────
2048
2049    fn pg_begin(&self, stmt: &BeginStmt, ctx: &mut RenderCtx) -> RenderResult<()> {
2050        ctx.keyword("BEGIN");
2051        if let Some(modes) = &stmt.modes {
2052            self.pg_transaction_modes(modes, ctx);
2053        }
2054        Ok(())
2055    }
2056
2057    fn pg_commit(&self, stmt: &CommitStmt, ctx: &mut RenderCtx) -> RenderResult<()> {
2058        ctx.keyword("COMMIT");
2059        if stmt.and_chain {
2060            ctx.keyword("AND").keyword("CHAIN");
2061        }
2062        Ok(())
2063    }
2064
2065    fn pg_rollback(&self, stmt: &RollbackStmt, ctx: &mut RenderCtx) -> RenderResult<()> {
2066        ctx.keyword("ROLLBACK");
2067        if let Some(sp) = &stmt.to_savepoint {
2068            ctx.keyword("TO").keyword("SAVEPOINT").ident(sp);
2069        }
2070        if stmt.and_chain {
2071            ctx.keyword("AND").keyword("CHAIN");
2072        }
2073        Ok(())
2074    }
2075
2076    fn pg_set_transaction(
2077        &self,
2078        stmt: &SetTransactionStmt,
2079        ctx: &mut RenderCtx,
2080    ) -> RenderResult<()> {
2081        ctx.keyword("SET");
2082        match &stmt.scope {
2083            Some(TransactionScope::Session) => {
2084                ctx.keyword("SESSION")
2085                    .keyword("CHARACTERISTICS")
2086                    .keyword("AS")
2087                    .keyword("TRANSACTION");
2088            }
2089            _ => {
2090                ctx.keyword("TRANSACTION");
2091            }
2092        }
2093        if let Some(snap_id) = &stmt.snapshot_id {
2094            ctx.keyword("SNAPSHOT").string_literal(snap_id);
2095        } else {
2096            self.pg_transaction_modes(&stmt.modes, ctx);
2097        }
2098        Ok(())
2099    }
2100
2101    fn pg_transaction_modes(&self, modes: &[TransactionMode], ctx: &mut RenderCtx) {
2102        for (i, mode) in modes.iter().enumerate() {
2103            if i > 0 {
2104                ctx.comma();
2105            }
2106            match mode {
2107                TransactionMode::IsolationLevel(lvl) => {
2108                    ctx.keyword("ISOLATION").keyword("LEVEL");
2109                    ctx.keyword(match lvl {
2110                        IsolationLevel::ReadUncommitted => "READ UNCOMMITTED",
2111                        IsolationLevel::ReadCommitted => "READ COMMITTED",
2112                        IsolationLevel::RepeatableRead => "REPEATABLE READ",
2113                        IsolationLevel::Serializable => "SERIALIZABLE",
2114                        IsolationLevel::Snapshot => "SERIALIZABLE", // PG doesn't have SNAPSHOT
2115                    });
2116                }
2117                TransactionMode::ReadOnly => {
2118                    ctx.keyword("READ ONLY");
2119                }
2120                TransactionMode::ReadWrite => {
2121                    ctx.keyword("READ WRITE");
2122                }
2123                TransactionMode::Deferrable => {
2124                    ctx.keyword("DEFERRABLE");
2125                }
2126                TransactionMode::NotDeferrable => {
2127                    ctx.keyword("NOT DEFERRABLE");
2128                }
2129                TransactionMode::WithConsistentSnapshot => {} // MySQL only, skip
2130            }
2131        }
2132    }
2133
2134    fn pg_lock_table(&self, stmt: &LockTableStmt, ctx: &mut RenderCtx) -> RenderResult<()> {
2135        ctx.keyword("LOCK").keyword("TABLE");
2136        for (i, def) in stmt.tables.iter().enumerate() {
2137            if i > 0 {
2138                ctx.comma();
2139            }
2140            if def.only {
2141                ctx.keyword("ONLY");
2142            }
2143            if let Some(schema) = &def.schema {
2144                ctx.ident(schema).operator(".");
2145            }
2146            ctx.ident(&def.table);
2147        }
2148        // Use mode from first table (PG applies one mode to all).
2149        if let Some(first) = stmt.tables.first() {
2150            ctx.keyword("IN");
2151            ctx.keyword(match first.mode {
2152                LockMode::AccessShare => "ACCESS SHARE",
2153                LockMode::RowShare => "ROW SHARE",
2154                LockMode::RowExclusive => "ROW EXCLUSIVE",
2155                LockMode::ShareUpdateExclusive => "SHARE UPDATE EXCLUSIVE",
2156                LockMode::Share => "SHARE",
2157                LockMode::ShareRowExclusive => "SHARE ROW EXCLUSIVE",
2158                LockMode::Exclusive => "EXCLUSIVE",
2159                LockMode::AccessExclusive => "ACCESS EXCLUSIVE",
2160                _ => "ACCESS EXCLUSIVE", // Non-PG modes default
2161            });
2162            ctx.keyword("MODE");
2163        }
2164        if stmt.nowait {
2165            ctx.keyword("NOWAIT");
2166        }
2167        Ok(())
2168    }
2169
2170    // ── Schema helpers ───────────────────────────────────────────────────
2171
2172    fn pg_schema_ref(&self, schema_ref: &qcraft_core::ast::common::SchemaRef, ctx: &mut RenderCtx) {
2173        if let Some(ns) = &schema_ref.namespace {
2174            ctx.ident(ns).operator(".");
2175        }
2176        ctx.ident(&schema_ref.name);
2177    }
2178
2179    fn pg_field_ref(&self, field_ref: &FieldRef, ctx: &mut RenderCtx) {
2180        if let Some(ns) = &field_ref.namespace {
2181            ctx.ident(ns).operator(".");
2182        }
2183        if !field_ref.table_name.is_empty() {
2184            ctx.ident(&field_ref.table_name).operator(".");
2185        }
2186        ctx.ident(&field_ref.field.name);
2187        let mut child = &field_ref.field.child;
2188        while let Some(c) = child {
2189            ctx.operator("->'")
2190                .write(&c.name.replace('\'', "''"))
2191                .write("'");
2192            child = &c.child;
2193        }
2194    }
2195
2196    fn pg_comma_idents(&self, names: &[String], ctx: &mut RenderCtx) {
2197        for (i, name) in names.iter().enumerate() {
2198            if i > 0 {
2199                ctx.comma();
2200            }
2201            ctx.ident(name);
2202        }
2203    }
2204
2205    fn pg_value(&self, val: &Value, ctx: &mut RenderCtx) -> RenderResult<()> {
2206        // In parameterized mode, NULL goes as a bind parameter (drivers handle
2207        // it correctly via the extended query protocol). Only IS NULL
2208        // comparisons need the keyword — that path is handled separately in
2209        // render_compare_op.
2210        if matches!(val, Value::Null) && !ctx.parameterize() {
2211            ctx.keyword("NULL");
2212            return Ok(());
2213        }
2214
2215        // In parameterized mode, send values as bind parameters (no casts —
2216        // the driver transmits types via the binary protocol and PG infers
2217        // from column context).
2218        if ctx.parameterize() {
2219            ctx.param(val.clone());
2220            return Ok(());
2221        }
2222
2223        // Inline literal mode (DDL defaults, TCL, etc.)
2224        self.pg_value_literal(val, ctx)
2225    }
2226
2227    fn pg_value_literal(&self, val: &Value, ctx: &mut RenderCtx) -> RenderResult<()> {
2228        match val {
2229            Value::Null => {
2230                ctx.keyword("NULL");
2231            }
2232            Value::Bool(b) => {
2233                ctx.keyword(if *b { "TRUE" } else { "FALSE" });
2234            }
2235            Value::Int(n) | Value::BigInt(n) => {
2236                ctx.keyword(&n.to_string());
2237            }
2238            Value::Float(f) => {
2239                ctx.keyword(&f.to_string());
2240            }
2241            Value::Str(s) => {
2242                ctx.string_literal(s);
2243            }
2244            Value::Bytes(b) => {
2245                ctx.write("'\\x");
2246                for byte in b {
2247                    ctx.write(&format!("{byte:02x}"));
2248                }
2249                ctx.write("'");
2250            }
2251            Value::Date(s) | Value::DateTime(s) | Value::Time(s) => {
2252                ctx.string_literal(s);
2253            }
2254            Value::Decimal(s) => {
2255                ctx.keyword(s);
2256            }
2257            Value::Uuid(s) => {
2258                ctx.string_literal(s);
2259            }
2260            Value::Json(s) => {
2261                ctx.string_literal(s);
2262                ctx.write("::json");
2263            }
2264            Value::Jsonb(s) => {
2265                ctx.string_literal(s);
2266                ctx.write("::jsonb");
2267            }
2268            Value::IpNetwork(s) => {
2269                ctx.string_literal(s);
2270                ctx.write("::inet");
2271            }
2272            Value::Array(items) => {
2273                ctx.keyword("ARRAY").write("[");
2274                for (i, item) in items.iter().enumerate() {
2275                    if i > 0 {
2276                        ctx.comma();
2277                    }
2278                    self.pg_value_literal(item, ctx)?;
2279                }
2280                ctx.write("]");
2281            }
2282            Value::Vector(values) => {
2283                let parts: Vec<String> = values.iter().map(|v| v.to_string()).collect();
2284                let literal = format!("[{}]", parts.join(","));
2285                ctx.string_literal(&literal);
2286                ctx.write("::vector");
2287            }
2288            Value::TimeDelta {
2289                years,
2290                months,
2291                days,
2292                seconds,
2293                microseconds,
2294            } => {
2295                ctx.keyword("INTERVAL");
2296                let mut parts = Vec::new();
2297                if *years != 0 {
2298                    parts.push(format!("{years} years"));
2299                }
2300                if *months != 0 {
2301                    parts.push(format!("{months} months"));
2302                }
2303                if *days != 0 {
2304                    parts.push(format!("{days} days"));
2305                }
2306                if *seconds != 0 {
2307                    parts.push(format!("{seconds} seconds"));
2308                }
2309                if *microseconds != 0 {
2310                    parts.push(format!("{microseconds} microseconds"));
2311                }
2312                if parts.is_empty() {
2313                    parts.push("0 seconds".into());
2314                }
2315                ctx.string_literal(&parts.join(" "));
2316            }
2317        }
2318        Ok(())
2319    }
2320
2321    fn pg_referential_action(&self, action: &ReferentialAction, ctx: &mut RenderCtx) {
2322        match action {
2323            ReferentialAction::NoAction => {
2324                ctx.keyword("NO ACTION");
2325            }
2326            ReferentialAction::Restrict => {
2327                ctx.keyword("RESTRICT");
2328            }
2329            ReferentialAction::Cascade => {
2330                ctx.keyword("CASCADE");
2331            }
2332            ReferentialAction::SetNull(cols) => {
2333                ctx.keyword("SET NULL");
2334                if let Some(cols) = cols {
2335                    ctx.paren_open();
2336                    self.pg_comma_idents(cols, ctx);
2337                    ctx.paren_close();
2338                }
2339            }
2340            ReferentialAction::SetDefault(cols) => {
2341                ctx.keyword("SET DEFAULT");
2342                if let Some(cols) = cols {
2343                    ctx.paren_open();
2344                    self.pg_comma_idents(cols, ctx);
2345                    ctx.paren_close();
2346                }
2347            }
2348        }
2349    }
2350
2351    fn pg_deferrable(&self, def: &DeferrableConstraint, ctx: &mut RenderCtx) {
2352        if def.deferrable {
2353            ctx.keyword("DEFERRABLE");
2354        } else {
2355            ctx.keyword("NOT DEFERRABLE");
2356        }
2357        if def.initially_deferred {
2358            ctx.keyword("INITIALLY DEFERRED");
2359        } else {
2360            ctx.keyword("INITIALLY IMMEDIATE");
2361        }
2362    }
2363
2364    fn pg_identity(&self, identity: &IdentityColumn, ctx: &mut RenderCtx) {
2365        if identity.always {
2366            ctx.keyword("GENERATED ALWAYS AS IDENTITY");
2367        } else {
2368            ctx.keyword("GENERATED BY DEFAULT AS IDENTITY");
2369        }
2370        let has_options = identity.start.is_some()
2371            || identity.increment.is_some()
2372            || identity.min_value.is_some()
2373            || identity.max_value.is_some()
2374            || identity.cycle
2375            || identity.cache.is_some();
2376        if has_options {
2377            ctx.paren_open();
2378            if let Some(start) = identity.start {
2379                ctx.keyword("START WITH").keyword(&start.to_string());
2380            }
2381            if let Some(inc) = identity.increment {
2382                ctx.keyword("INCREMENT BY").keyword(&inc.to_string());
2383            }
2384            if let Some(min) = identity.min_value {
2385                ctx.keyword("MINVALUE").keyword(&min.to_string());
2386            }
2387            if let Some(max) = identity.max_value {
2388                ctx.keyword("MAXVALUE").keyword(&max.to_string());
2389            }
2390            if identity.cycle {
2391                ctx.keyword("CYCLE");
2392            }
2393            if let Some(cache) = identity.cache {
2394                ctx.keyword("CACHE").keyword(&cache.to_string());
2395            }
2396            ctx.paren_close();
2397        }
2398    }
2399
2400    fn pg_render_ctes(&self, ctes: &[CteDef], ctx: &mut RenderCtx) -> RenderResult<()> {
2401        // Delegate to the trait method
2402        self.render_ctes(ctes, ctx)
2403    }
2404
2405    fn pg_render_from_item(&self, item: &FromItem, ctx: &mut RenderCtx) -> RenderResult<()> {
2406        if item.only {
2407            ctx.keyword("ONLY");
2408        }
2409        self.render_from(&item.source, ctx)?;
2410        if let Some(sample) = &item.sample {
2411            ctx.keyword("TABLESAMPLE");
2412            ctx.keyword(match sample.method {
2413                SampleMethod::Bernoulli => "BERNOULLI",
2414                SampleMethod::System => "SYSTEM",
2415                SampleMethod::Block => "SYSTEM", // Block maps to SYSTEM on PG
2416            });
2417            ctx.paren_open()
2418                .write(&sample.percentage.to_string())
2419                .paren_close();
2420            if let Some(seed) = sample.seed {
2421                ctx.keyword("REPEATABLE")
2422                    .paren_open()
2423                    .write(&seed.to_string())
2424                    .paren_close();
2425            }
2426        }
2427        Ok(())
2428    }
2429
2430    fn pg_render_group_by(&self, items: &[GroupByItem], ctx: &mut RenderCtx) -> RenderResult<()> {
2431        ctx.keyword("GROUP BY");
2432        for (i, item) in items.iter().enumerate() {
2433            if i > 0 {
2434                ctx.comma();
2435            }
2436            match item {
2437                GroupByItem::Expr(expr) => {
2438                    self.render_expr(expr, ctx)?;
2439                }
2440                GroupByItem::Rollup(exprs) => {
2441                    ctx.keyword("ROLLUP").paren_open();
2442                    for (j, expr) in exprs.iter().enumerate() {
2443                        if j > 0 {
2444                            ctx.comma();
2445                        }
2446                        self.render_expr(expr, ctx)?;
2447                    }
2448                    ctx.paren_close();
2449                }
2450                GroupByItem::Cube(exprs) => {
2451                    ctx.keyword("CUBE").paren_open();
2452                    for (j, expr) in exprs.iter().enumerate() {
2453                        if j > 0 {
2454                            ctx.comma();
2455                        }
2456                        self.render_expr(expr, ctx)?;
2457                    }
2458                    ctx.paren_close();
2459                }
2460                GroupByItem::GroupingSets(sets) => {
2461                    ctx.keyword("GROUPING SETS").paren_open();
2462                    for (j, set) in sets.iter().enumerate() {
2463                        if j > 0 {
2464                            ctx.comma();
2465                        }
2466                        ctx.paren_open();
2467                        for (k, expr) in set.iter().enumerate() {
2468                            if k > 0 {
2469                                ctx.comma();
2470                            }
2471                            self.render_expr(expr, ctx)?;
2472                        }
2473                        ctx.paren_close();
2474                    }
2475                    ctx.paren_close();
2476                }
2477            }
2478        }
2479        Ok(())
2480    }
2481
2482    fn pg_render_window_clause(
2483        &self,
2484        windows: &[WindowNameDef],
2485        ctx: &mut RenderCtx,
2486    ) -> RenderResult<()> {
2487        ctx.keyword("WINDOW");
2488        for (i, win) in windows.iter().enumerate() {
2489            if i > 0 {
2490                ctx.comma();
2491            }
2492            ctx.ident(&win.name).keyword("AS").paren_open();
2493            if let Some(base) = &win.base_window {
2494                ctx.ident(base);
2495            }
2496            if let Some(partition_by) = &win.partition_by {
2497                ctx.keyword("PARTITION BY");
2498                for (j, expr) in partition_by.iter().enumerate() {
2499                    if j > 0 {
2500                        ctx.comma();
2501                    }
2502                    self.render_expr(expr, ctx)?;
2503                }
2504            }
2505            if let Some(order_by) = &win.order_by {
2506                ctx.keyword("ORDER BY");
2507                self.pg_order_by_list(order_by, ctx)?;
2508            }
2509            if let Some(frame) = &win.frame {
2510                self.pg_window_frame(frame, ctx);
2511            }
2512            ctx.paren_close();
2513        }
2514        Ok(())
2515    }
2516
2517    fn pg_render_set_op(&self, set_op: &SetOpDef, ctx: &mut RenderCtx) -> RenderResult<()> {
2518        self.render_query(&set_op.left, ctx)?;
2519        ctx.keyword(match set_op.operation {
2520            SetOperationType::Union => "UNION",
2521            SetOperationType::UnionAll => "UNION ALL",
2522            SetOperationType::Intersect => "INTERSECT",
2523            SetOperationType::IntersectAll => "INTERSECT ALL",
2524            SetOperationType::Except => "EXCEPT",
2525            SetOperationType::ExceptAll => "EXCEPT ALL",
2526        });
2527        self.render_query(&set_op.right, ctx)
2528    }
2529
2530    fn pg_create_table(
2531        &self,
2532        schema: &SchemaDef,
2533        if_not_exists: bool,
2534        temporary: bool,
2535        unlogged: bool,
2536        opts: &PgCreateTableOpts<'_>,
2537        ctx: &mut RenderCtx,
2538    ) -> RenderResult<()> {
2539        let PgCreateTableOpts {
2540            tablespace,
2541            partition_by,
2542            inherits,
2543            using_method,
2544            with_options,
2545            on_commit,
2546        } = opts;
2547        ctx.keyword("CREATE");
2548        if temporary {
2549            ctx.keyword("TEMPORARY");
2550        }
2551        if unlogged {
2552            ctx.keyword("UNLOGGED");
2553        }
2554        ctx.keyword("TABLE");
2555        if if_not_exists {
2556            ctx.keyword("IF NOT EXISTS");
2557        }
2558        if let Some(ns) = &schema.namespace {
2559            ctx.ident(ns).operator(".");
2560        }
2561        ctx.ident(&schema.name);
2562
2563        // Columns + constraints + LIKE
2564        ctx.paren_open();
2565        let mut first = true;
2566        for col in &schema.columns {
2567            if !first {
2568                ctx.comma();
2569            }
2570            first = false;
2571            self.render_column_def(col, ctx)?;
2572        }
2573        if let Some(like_tables) = &schema.like_tables {
2574            for like in like_tables {
2575                if !first {
2576                    ctx.comma();
2577                }
2578                first = false;
2579                self.pg_like_table(like, ctx);
2580            }
2581        }
2582        if let Some(constraints) = &schema.constraints {
2583            for constraint in constraints {
2584                // Partial unique constraints are rendered as separate
2585                // CREATE UNIQUE INDEX statements, not inline.
2586                if self.is_partial_unique(constraint) {
2587                    continue;
2588                }
2589                if !first {
2590                    ctx.comma();
2591                }
2592                first = false;
2593                self.render_constraint(constraint, ctx)?;
2594            }
2595        }
2596        ctx.paren_close();
2597
2598        // INHERITS
2599        if let Some(parents) = inherits {
2600            ctx.keyword("INHERITS").paren_open();
2601            for (i, parent) in parents.iter().enumerate() {
2602                if i > 0 {
2603                    ctx.comma();
2604                }
2605                self.pg_schema_ref(parent, ctx);
2606            }
2607            ctx.paren_close();
2608        }
2609
2610        // PARTITION BY
2611        if let Some(part) = partition_by {
2612            ctx.keyword("PARTITION BY");
2613            ctx.keyword(match part.strategy {
2614                PartitionStrategy::Range => "RANGE",
2615                PartitionStrategy::List => "LIST",
2616                PartitionStrategy::Hash => "HASH",
2617            });
2618            ctx.paren_open();
2619            for (i, col) in part.columns.iter().enumerate() {
2620                if i > 0 {
2621                    ctx.comma();
2622                }
2623                match &col.expr {
2624                    IndexExpr::Column(name) => {
2625                        ctx.ident(name);
2626                    }
2627                    IndexExpr::Expression(expr) => {
2628                        ctx.paren_open();
2629                        self.render_expr(expr, ctx)?;
2630                        ctx.paren_close();
2631                    }
2632                }
2633                if let Some(collation) = &col.collation {
2634                    ctx.keyword("COLLATE").ident(collation);
2635                }
2636                if let Some(opclass) = &col.opclass {
2637                    ctx.keyword(opclass);
2638                }
2639            }
2640            ctx.paren_close();
2641        }
2642
2643        // USING method
2644        if let Some(method) = using_method {
2645            ctx.keyword("USING").keyword(method);
2646        }
2647
2648        // WITH (storage_parameter = value, ...)
2649        if let Some(opts) = with_options {
2650            ctx.keyword("WITH").paren_open();
2651            for (i, (key, value)) in opts.iter().enumerate() {
2652                if i > 0 {
2653                    ctx.comma();
2654                }
2655                ctx.write(key).write(" = ").write(value);
2656            }
2657            ctx.paren_close();
2658        }
2659
2660        // ON COMMIT
2661        if let Some(action) = on_commit {
2662            ctx.keyword("ON COMMIT");
2663            ctx.keyword(match action {
2664                OnCommitAction::PreserveRows => "PRESERVE ROWS",
2665                OnCommitAction::DeleteRows => "DELETE ROWS",
2666                OnCommitAction::Drop => "DROP",
2667            });
2668        }
2669
2670        // TABLESPACE
2671        if let Some(ts) = tablespace {
2672            ctx.keyword("TABLESPACE").ident(ts);
2673        }
2674
2675        Ok(())
2676    }
2677
2678    fn pg_like_table(&self, like: &LikeTableDef, ctx: &mut RenderCtx) {
2679        ctx.keyword("LIKE");
2680        self.pg_schema_ref(&like.source_table, ctx);
2681        for opt in &like.options {
2682            if opt.include {
2683                ctx.keyword("INCLUDING");
2684            } else {
2685                ctx.keyword("EXCLUDING");
2686            }
2687            ctx.keyword(match opt.kind {
2688                qcraft_core::ast::ddl::LikeOptionKind::Comments => "COMMENTS",
2689                qcraft_core::ast::ddl::LikeOptionKind::Compression => "COMPRESSION",
2690                qcraft_core::ast::ddl::LikeOptionKind::Constraints => "CONSTRAINTS",
2691                qcraft_core::ast::ddl::LikeOptionKind::Defaults => "DEFAULTS",
2692                qcraft_core::ast::ddl::LikeOptionKind::Generated => "GENERATED",
2693                qcraft_core::ast::ddl::LikeOptionKind::Identity => "IDENTITY",
2694                qcraft_core::ast::ddl::LikeOptionKind::Indexes => "INDEXES",
2695                qcraft_core::ast::ddl::LikeOptionKind::Statistics => "STATISTICS",
2696                qcraft_core::ast::ddl::LikeOptionKind::Storage => "STORAGE",
2697                qcraft_core::ast::ddl::LikeOptionKind::All => "ALL",
2698            });
2699        }
2700    }
2701
2702    fn pg_create_index(
2703        &self,
2704        schema_ref: &qcraft_core::ast::common::SchemaRef,
2705        index: &IndexDef,
2706        if_not_exists: bool,
2707        concurrently: bool,
2708        ctx: &mut RenderCtx,
2709    ) -> RenderResult<()> {
2710        ctx.keyword("CREATE");
2711        if index.unique {
2712            ctx.keyword("UNIQUE");
2713        }
2714        ctx.keyword("INDEX");
2715        if concurrently {
2716            ctx.keyword("CONCURRENTLY");
2717        }
2718        if if_not_exists {
2719            ctx.keyword("IF NOT EXISTS");
2720        }
2721        ctx.ident(&index.name).keyword("ON");
2722        self.pg_schema_ref(schema_ref, ctx);
2723
2724        if let Some(index_type) = &index.index_type {
2725            ctx.keyword("USING").keyword(index_type);
2726        }
2727
2728        ctx.paren_open();
2729        self.pg_index_columns(&index.columns, index.index_type.as_deref(), ctx)?;
2730        ctx.paren_close();
2731
2732        if let Some(include) = &index.include {
2733            ctx.keyword("INCLUDE").paren_open();
2734            self.pg_comma_idents(include, ctx);
2735            ctx.paren_close();
2736        }
2737
2738        if let Some(nd) = index.nulls_distinct {
2739            if !nd {
2740                ctx.keyword("NULLS NOT DISTINCT");
2741            }
2742        }
2743
2744        if let Some(params) = &index.parameters {
2745            ctx.keyword("WITH").paren_open();
2746            for (i, (key, value)) in params.iter().enumerate() {
2747                if i > 0 {
2748                    ctx.comma();
2749                }
2750                ctx.write(key).write(" = ").write(value);
2751            }
2752            ctx.paren_close();
2753        }
2754
2755        if let Some(ts) = &index.tablespace {
2756            ctx.keyword("TABLESPACE").ident(ts);
2757        }
2758
2759        if let Some(condition) = &index.condition {
2760            ctx.keyword("WHERE");
2761            self.render_condition(condition, ctx)?;
2762        }
2763
2764        Ok(())
2765    }
2766
2767    fn supports_ordering(index_type: Option<&str>) -> bool {
2768        match index_type {
2769            None => true, // default is btree
2770            Some(t) => t.eq_ignore_ascii_case("btree"),
2771        }
2772    }
2773
2774    fn pg_index_columns(
2775        &self,
2776        columns: &[IndexColumnDef],
2777        index_type: Option<&str>,
2778        ctx: &mut RenderCtx,
2779    ) -> RenderResult<()> {
2780        let ordered = Self::supports_ordering(index_type);
2781        for (i, col) in columns.iter().enumerate() {
2782            if i > 0 {
2783                ctx.comma();
2784            }
2785            match &col.expr {
2786                IndexExpr::Column(name) => {
2787                    ctx.ident(name);
2788                }
2789                IndexExpr::Expression(expr) => {
2790                    ctx.paren_open();
2791                    self.render_expr(expr, ctx)?;
2792                    ctx.paren_close();
2793                }
2794            }
2795            if let Some(collation) = &col.collation {
2796                ctx.keyword("COLLATE").ident(collation);
2797            }
2798            if let Some(opclass) = &col.opclass {
2799                ctx.keyword(opclass);
2800            }
2801            if ordered {
2802                if let Some(dir) = col.direction {
2803                    ctx.keyword(match dir {
2804                        OrderDir::Asc => "ASC",
2805                        OrderDir::Desc => "DESC",
2806                    });
2807                }
2808                if let Some(nulls) = col.nulls {
2809                    ctx.keyword(match nulls {
2810                        NullsOrder::First => "NULLS FIRST",
2811                        NullsOrder::Last => "NULLS LAST",
2812                    });
2813                }
2814            }
2815        }
2816        Ok(())
2817    }
2818
2819    fn pg_order_by_list(&self, order_by: &[OrderByDef], ctx: &mut RenderCtx) -> RenderResult<()> {
2820        for (i, ob) in order_by.iter().enumerate() {
2821            if i > 0 {
2822                ctx.comma();
2823            }
2824            self.render_expr(&ob.expr, ctx)?;
2825            ctx.keyword(match ob.direction {
2826                OrderDir::Asc => "ASC",
2827                OrderDir::Desc => "DESC",
2828            });
2829            if let Some(nulls) = &ob.nulls {
2830                ctx.keyword(match nulls {
2831                    NullsOrder::First => "NULLS FIRST",
2832                    NullsOrder::Last => "NULLS LAST",
2833                });
2834            }
2835        }
2836        Ok(())
2837    }
2838
2839    fn pg_window_frame(&self, frame: &WindowFrameDef, ctx: &mut RenderCtx) {
2840        ctx.keyword(match frame.frame_type {
2841            WindowFrameType::Rows => "ROWS",
2842            WindowFrameType::Range => "RANGE",
2843            WindowFrameType::Groups => "GROUPS",
2844        });
2845        if let Some(end) = &frame.end {
2846            ctx.keyword("BETWEEN");
2847            self.pg_frame_bound(&frame.start, ctx);
2848            ctx.keyword("AND");
2849            self.pg_frame_bound(end, ctx);
2850        } else {
2851            self.pg_frame_bound(&frame.start, ctx);
2852        }
2853    }
2854
2855    fn pg_frame_bound(&self, bound: &WindowFrameBound, ctx: &mut RenderCtx) {
2856        match bound {
2857            WindowFrameBound::CurrentRow => {
2858                ctx.keyword("CURRENT ROW");
2859            }
2860            WindowFrameBound::Preceding(None) => {
2861                ctx.keyword("UNBOUNDED PRECEDING");
2862            }
2863            WindowFrameBound::Preceding(Some(n)) => {
2864                ctx.keyword(&n.to_string()).keyword("PRECEDING");
2865            }
2866            WindowFrameBound::Following(None) => {
2867                ctx.keyword("UNBOUNDED FOLLOWING");
2868            }
2869            WindowFrameBound::Following(Some(n)) => {
2870                ctx.keyword(&n.to_string()).keyword("FOLLOWING");
2871            }
2872        }
2873    }
2874}