Skip to main content

pgevolve_core/parse/builder/
table_like.rs

1//! `CREATE TABLE … (LIKE source [INCLUDING …])` resolution.
2//!
3//! `build_table` skips `TableLikeClause` elements; `process_file` records one
4//! [`PendingLike`] per clause. After every table is in the catalog,
5//! [`apply_pending_likes`] expands each clause against the source table — the
6//! clone is fully decoupled in Postgres, so we must materialize concrete IR.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use pg_query::NodeEnum;
11use pg_query::protobuf::CreateStmt;
12
13use crate::identifier::{Identifier, QualifiedName};
14use crate::ir::catalog::Catalog;
15use crate::ir::column::Column;
16use crate::ir::constraint::{Constraint, ConstraintKind};
17use crate::ir::index::{Index, IndexColumnExpr, IndexParent};
18use crate::ir::statistic::{Statistic, StatisticColumn};
19use crate::parse::builder::{choose_name, shared};
20use crate::parse::error::{ParseError, SourceLocation};
21
22/// The `INCLUDING`/`EXCLUDING` option bitmask from a `TableLikeClause`.
23/// Stores Postgres's raw `1<<n` bits; `INCLUDING ALL` sets all of them.
24#[derive(Debug, Clone, Copy)]
25pub struct TableLikeOptions(u32);
26
27impl TableLikeOptions {
28    const COMMENTS: u32 = 1 << 0;
29    const COMPRESSION: u32 = 1 << 1;
30    const CONSTRAINTS: u32 = 1 << 2;
31    const DEFAULTS: u32 = 1 << 3;
32    const GENERATED: u32 = 1 << 4;
33    const IDENTITY: u32 = 1 << 5;
34    const INDEXES: u32 = 1 << 6;
35    const STATISTICS: u32 = 1 << 7;
36    const STORAGE: u32 = 1 << 8;
37
38    /// Construct from raw Postgres option bits.
39    #[must_use]
40    pub const fn new(bits: u32) -> Self {
41        Self(bits)
42    }
43    /// Whether `INCLUDING COMMENTS` is set.
44    #[must_use]
45    pub const fn comments(self) -> bool {
46        self.0 & Self::COMMENTS != 0
47    }
48    /// Whether `INCLUDING COMPRESSION` is set.
49    #[must_use]
50    pub const fn compression(self) -> bool {
51        self.0 & Self::COMPRESSION != 0
52    }
53    /// Whether `INCLUDING CONSTRAINTS` is set.
54    #[must_use]
55    pub const fn constraints(self) -> bool {
56        self.0 & Self::CONSTRAINTS != 0
57    }
58    /// Whether `INCLUDING DEFAULTS` is set.
59    #[must_use]
60    pub const fn defaults(self) -> bool {
61        self.0 & Self::DEFAULTS != 0
62    }
63    /// Whether `INCLUDING GENERATED` is set.
64    #[must_use]
65    pub const fn generated(self) -> bool {
66        self.0 & Self::GENERATED != 0
67    }
68    /// Whether `INCLUDING IDENTITY` is set.
69    #[must_use]
70    pub const fn identity(self) -> bool {
71        self.0 & Self::IDENTITY != 0
72    }
73    /// Whether `INCLUDING INDEXES` is set.
74    #[must_use]
75    pub const fn indexes(self) -> bool {
76        self.0 & Self::INDEXES != 0
77    }
78    /// Whether `INCLUDING STATISTICS` is set.
79    #[must_use]
80    pub const fn statistics(self) -> bool {
81        self.0 & Self::STATISTICS != 0
82    }
83    /// Whether `INCLUDING STORAGE` is set.
84    #[must_use]
85    pub const fn storage(self) -> bool {
86        self.0 & Self::STORAGE != 0
87    }
88}
89
90/// One unresolved `LIKE` clause, captured during `process_file`.
91#[derive(Debug, Clone)]
92pub struct PendingLike {
93    /// Schema-qualified name of the table being created (the clone).
94    pub target: QualifiedName,
95    /// Schema-qualified name of the source table to copy from.
96    pub source: QualifiedName,
97    /// `INCLUDING`/`EXCLUDING` option bitmask from the clause.
98    pub options: TableLikeOptions,
99    /// Number of explicitly-listed columns that preceded this clause in the
100    /// `CREATE TABLE` element list — the insertion point for copied columns.
101    pub explicit_cols_before: usize,
102    /// Source location for error reporting.
103    pub location: SourceLocation,
104}
105
106/// Scan a `CREATE TABLE`'s element list for `LIKE` clauses, recording each
107/// with the count of explicit columns that preceded it (for ordering).
108pub fn extract_pending_likes(
109    create: &CreateStmt,
110    target: &QualifiedName,
111    default_schema: Option<&Identifier>,
112    location: &SourceLocation,
113) -> Result<Vec<PendingLike>, ParseError> {
114    let mut out = Vec::new();
115    let mut explicit_cols = 0usize;
116    for elt in &create.table_elts {
117        match elt.node.as_ref() {
118            Some(NodeEnum::ColumnDef(_)) => explicit_cols += 1,
119            Some(NodeEnum::TableLikeClause(like)) => {
120                let rv = like
121                    .relation
122                    .as_ref()
123                    .ok_or_else(|| ParseError::Structural {
124                        location: location.clone(),
125                        message: "LIKE clause missing source relation".into(),
126                    })?;
127                let source = shared::resolve_qname(rv, default_schema, location)?;
128                out.push(PendingLike {
129                    target: target.clone(),
130                    source,
131                    options: TableLikeOptions::new(like.options),
132                    explicit_cols_before: explicit_cols,
133                    location: location.clone(),
134                });
135            }
136            _ => {}
137        }
138    }
139    Ok(out)
140}
141
142/// Copy a source column for a `LIKE` clause, gating optional attributes on the
143/// corresponding `INCLUDING` option bits.  Always copies name, type, collation,
144/// and not-null; everything else is controlled by `opts`.
145fn copy_column(src: &Column, opts: TableLikeOptions) -> Column {
146    Column {
147        name: src.name.clone(),
148        ty: src.ty.clone(),
149        nullable: src.nullable,
150        collation: src.collation.clone(),
151        default: if opts.defaults() {
152            src.default.clone()
153        } else {
154            None
155        },
156        identity: if opts.identity() {
157            src.identity.clone()
158        } else {
159            None
160        },
161        generated: if opts.generated() {
162            src.generated.clone()
163        } else {
164            None
165        },
166        storage: if opts.storage() { src.storage } else { None },
167        compression: if opts.compression() {
168            src.compression
169        } else {
170            None
171        },
172        // comments are copied in a separate pass after deferred comments are applied
173        // (see apply_pending_like_comments)
174        comment: None,
175    }
176}
177
178/// One materialized snapshot of columns, constraints, indexes, and statistics
179/// to be spliced into a LIKE target.  Built during the immutable-borrow phase;
180/// applied during the mutable-borrow phase, keeping the two borrows cleanly
181/// separate.
182struct LikeSnapshot {
183    explicit_cols_before: usize,
184    cols: Vec<Column>,
185    constraints: Vec<Constraint>,
186    /// Plain (non-constraint) indexes to append to `catalog.indexes`.
187    /// Populated only when `INCLUDING INDEXES` is set.
188    indexes: Vec<Index>,
189    /// Extended statistics to append to `catalog.statistics`.
190    /// Populated only when `INCLUDING STATISTICS` is set.
191    statistics: Vec<Statistic>,
192}
193
194/// Build a [`LikeSnapshot`] for one `like` clause against `catalog`, updating
195/// `taken` so that any subsequent clause for the same target doesn't reuse a
196/// just-generated constraint name.
197///
198/// The `target_name` and `target_schema` arguments identify the clone table
199/// whose schema the new constraint qnames should carry.
200fn snapshot_like(
201    like: &PendingLike,
202    target_name: &Identifier,
203    target_schema: &Identifier,
204    catalog: &Catalog,
205    taken: &mut choose_name::TakenNames,
206) -> Result<LikeSnapshot, ParseError> {
207    let src = catalog.tables.iter().find(|t| t.qname == like.source)
208        .ok_or_else(|| {
209            // Give a more specific diagnostic: distinguish "exists as a view/MV
210            // (unsupported source kind)" from "does not exist at all".
211            let is_view = catalog.views.iter().any(|v| v.qname == like.source);
212            let is_mv   = catalog.materialized_views.iter().any(|v| v.qname == like.source);
213            let message = if is_view {
214                format!(
215                    "LIKE source {} is a view; LIKE requires a base table (views are not supported as LIKE sources)",
216                    like.source
217                )
218            } else if is_mv {
219                format!(
220                    "LIKE source {} is a materialized view; LIKE requires a base table (materialized views are not supported as LIKE sources)",
221                    like.source
222                )
223            } else {
224                format!(
225                    "LIKE source table {} not found (must be a table declared in the schema)",
226                    like.source
227                )
228            };
229            ParseError::Structural {
230                location: like.location.clone(),
231                message,
232            }
233        })?;
234
235    let cols: Vec<Column> = src
236        .columns
237        .iter()
238        .map(|c| copy_column(c, like.options))
239        .collect();
240    let mut constraints: Vec<Constraint> = Vec::new();
241
242    // INCLUDING CONSTRAINTS → copy CHECK constraints.
243    // PK/UNIQUE belong to INCLUDING INDEXES (handled below); FOREIGN KEY is
244    // never copied by LIKE regardless of options.
245    if like.options.constraints() {
246        // Postgres copies CHECK constraint names verbatim on LIKE INCLUDING CONSTRAINTS;
247        // it does NOT re-derive the name for the clone table. Since we now correctly
248        // name inline column checks as {table}_{col}_check, verbatim copy converges
249        // with what a manually-written equivalent table would produce (fixes #48,
250        // reverts #45's re-derivation heuristic which was incorrect).
251        for c in &src.constraints {
252            if matches!(c.kind, ConstraintKind::Check { .. }) {
253                constraints.push(Constraint {
254                    qname: QualifiedName::new(target_schema.clone(), c.qname.name.clone()),
255                    kind: c.kind.clone(),
256                    deferrable: c.deferrable,
257                    comment: None,
258                });
259            }
260        }
261    }
262
263    // INCLUDING INDEXES → copy PK/UNIQUE constraints (re-derived names) and
264    // plain (CREATE INDEX) indexes.  EXCLUDE constraints aren't modeled in
265    // ConstraintKind.  FOREIGN KEY is never copied by LIKE.
266    let indexes = if like.options.indexes() {
267        copy_index_constraints(
268            &src.constraints,
269            target_name,
270            target_schema,
271            &like.location,
272            taken,
273            &mut constraints,
274        )?;
275        copy_plain_indexes(
276            &like.source,
277            target_name,
278            target_schema,
279            &like.location,
280            &catalog.indexes,
281            taken,
282        )?
283    } else {
284        Vec::new()
285    };
286
287    // INCLUDING STATISTICS → copy extended statistics targeting the source.
288    let statistics = if like.options.statistics() {
289        copy_statistics(
290            &like.source,
291            target_name,
292            target_schema,
293            &like.location,
294            &catalog.statistics,
295            taken,
296        )?
297    } else {
298        Vec::new()
299    };
300
301    Ok(LikeSnapshot {
302        explicit_cols_before: like.explicit_cols_before,
303        cols,
304        constraints,
305        indexes,
306        statistics,
307    })
308}
309
310/// Copy PK and UNIQUE constraints from `src_constraints` into `out`, assigning
311/// Postgres-faithful names for the clone table (`target_name` / `target_schema`).
312fn copy_index_constraints(
313    src_constraints: &[Constraint],
314    target_name: &Identifier,
315    target_schema: &Identifier,
316    location: &crate::parse::error::SourceLocation,
317    taken: &mut choose_name::TakenNames,
318    out: &mut Vec<Constraint>,
319) -> Result<(), ParseError> {
320    for c in src_constraints {
321        match &c.kind {
322            ConstraintKind::PrimaryKey { columns, include } => {
323                let generated = choose_name::choose_index_name(
324                    target_name.as_str(),
325                    &[],
326                    choose_name::IndexNameKind::Pkey,
327                    taken,
328                );
329                let qname = QualifiedName::new(
330                    target_schema.clone(),
331                    Identifier::from_unquoted(&generated).map_err(|e| ParseError::Structural {
332                        location: location.clone(),
333                        message: format!(
334                            "generated PK constraint name {generated:?} is not a valid identifier: {e}"
335                        ),
336                    })?,
337                );
338                out.push(Constraint {
339                    qname,
340                    kind: ConstraintKind::PrimaryKey {
341                        columns: columns.clone(),
342                        include: include.clone(),
343                    },
344                    deferrable: c.deferrable,
345                    comment: None,
346                });
347            }
348            ConstraintKind::Unique {
349                columns,
350                include,
351                nulls_distinct,
352            } => {
353                let col_opts: Vec<Option<&str>> =
354                    columns.iter().map(|col| Some(col.as_str())).collect();
355                let generated = choose_name::choose_index_name(
356                    target_name.as_str(),
357                    &col_opts,
358                    choose_name::IndexNameKind::Unique,
359                    taken,
360                );
361                let qname = QualifiedName::new(
362                    target_schema.clone(),
363                    Identifier::from_unquoted(&generated).map_err(|e| ParseError::Structural {
364                        location: location.clone(),
365                        message: format!(
366                            "generated UNIQUE constraint name {generated:?} is not a valid identifier: {e}"
367                        ),
368                    })?,
369                );
370                out.push(Constraint {
371                    qname,
372                    kind: ConstraintKind::Unique {
373                        columns: columns.clone(),
374                        include: include.clone(),
375                        nulls_distinct: *nulls_distinct,
376                    },
377                    deferrable: c.deferrable,
378                    comment: None,
379                });
380            }
381            // CHECK belongs to INCLUDING CONSTRAINTS (handled in snapshot_like),
382            // FOREIGN KEY is never copied by LIKE.
383            ConstraintKind::Check { .. } | ConstraintKind::ForeignKey(_) => {}
384        }
385    }
386    Ok(())
387}
388
389/// Build retargeted copies of every plain (non-constraint) index on `source`,
390/// assigning Postgres-faithful names via `choose_index_name`.
391///
392/// ## Why no double-copy risk
393/// `catalog.indexes` is populated exclusively from `CREATE INDEX` statements.
394/// PK and UNIQUE *constraint*-backing indexes are stored as table constraints
395/// (`Constraint::PrimaryKey` / `Constraint::Unique`), never as `Index`
396/// entries.  Therefore every `catalog.indexes` entry whose `on` targets the
397/// source table is a plain index, with no overlap with the constraint path.
398fn copy_plain_indexes(
399    source: &QualifiedName,
400    target_name: &Identifier,
401    target_schema: &Identifier,
402    location: &crate::parse::error::SourceLocation,
403    catalog_indexes: &[Index],
404    taken: &mut choose_name::TakenNames,
405) -> Result<Vec<Index>, ParseError> {
406    let mut out = Vec::new();
407    let source_parent = IndexParent::Table(source.clone());
408    for idx in catalog_indexes.iter().filter(|i| i.on == source_parent) {
409        let col_opts: Vec<Option<&str>> = idx
410            .columns
411            .iter()
412            .map(|col| match &col.expr {
413                IndexColumnExpr::Column(id) => Some(id.as_str()),
414                IndexColumnExpr::Expression(_) => None,
415            })
416            .collect();
417        // Plain `CREATE [UNIQUE] INDEX` always uses `_idx` suffix (Postgres
418        // `ChooseIndexName` with `isconstraint=false`), regardless of
419        // uniqueness.  The `_key` suffix is only for UNIQUE *constraints*.
420        let generated = choose_name::choose_index_name(
421            target_name.as_str(),
422            &col_opts,
423            choose_name::IndexNameKind::Plain,
424            taken,
425        );
426        let new_qname = QualifiedName::new(
427            target_schema.clone(),
428            Identifier::from_unquoted(&generated).map_err(|e| ParseError::Structural {
429                location: location.clone(),
430                message: format!(
431                    "generated index name {generated:?} is not a valid identifier: {e}"
432                ),
433            })?,
434        );
435        out.push(Index {
436            qname: new_qname,
437            on: IndexParent::Table(QualifiedName::new(
438                target_schema.clone(),
439                target_name.clone(),
440            )),
441            method: idx.method,
442            columns: idx.columns.clone(),
443            include: idx.include.clone(),
444            unique: idx.unique,
445            nulls_not_distinct: idx.nulls_not_distinct,
446            predicate: idx.predicate.clone(),
447            tablespace: idx.tablespace.clone(),
448            // Index comments are out of scope for INCLUDING INDEXES;
449            // they are not propagated (similar to INCLUDING COMMENTS
450            // which is a separate option).
451            comment: None,
452            storage: idx.storage.clone(),
453        });
454    }
455    Ok(out)
456}
457
458/// Build retargeted copies of every extended statistic on `source`,
459/// assigning Postgres-faithful names via `choose_index_name` with
460/// [`choose_name::IndexNameKind::Stat`].
461///
462/// Column name fragments follow the same convention as index names:
463/// `StatisticColumn::Column` contributes the column name, and
464/// `StatisticColumn::Expression` contributes `None` (mapped to `"expr"`
465/// by `name_addition`).
466fn copy_statistics(
467    source: &QualifiedName,
468    target_name: &Identifier,
469    target_schema: &Identifier,
470    location: &crate::parse::error::SourceLocation,
471    catalog_statistics: &[Statistic],
472    taken: &mut choose_name::TakenNames,
473) -> Result<Vec<Statistic>, ParseError> {
474    let clone_qname = QualifiedName::new(target_schema.clone(), target_name.clone());
475    let mut out = Vec::new();
476    for stat in catalog_statistics.iter().filter(|s| &s.target == source) {
477        // Stat naming matches PG's ChooseExtendedStatisticName across PG 14–18,
478        // verified by tests/table_like_round_trip.rs::like_statistics_name_matches_live_pg.
479        let col_opts: Vec<Option<&str>> = stat
480            .columns
481            .iter()
482            .map(|col| match col {
483                StatisticColumn::Column(id) => Some(id.as_str()),
484                StatisticColumn::Expression(_) => None,
485            })
486            .collect();
487        let generated = choose_name::choose_index_name(
488            target_name.as_str(),
489            &col_opts,
490            choose_name::IndexNameKind::Stat,
491            taken,
492        );
493        let new_qname = QualifiedName::new(
494            target_schema.clone(),
495            Identifier::from_unquoted(&generated).map_err(|e| ParseError::Structural {
496                location: location.clone(),
497                message: format!(
498                    "generated statistics name {generated:?} is not a valid identifier: {e}"
499                ),
500            })?,
501        );
502        out.push(Statistic {
503            qname: new_qname,
504            target: clone_qname.clone(),
505            kinds: stat.kinds,
506            columns: stat.columns.clone(),
507            statistics_target: stat.statistics_target,
508            owner: None,
509            comment: None,
510        });
511    }
512    Ok(out)
513}
514
515/// Return the pending-LIKE targets in dependency order: every target whose
516/// LIKE sources are themselves targets comes after those sources.
517///
518/// A stall with remaining targets means a cycle or self-reference; the error
519/// names all involved tables.
520fn resolve_order(
521    by_target: &BTreeMap<QualifiedName, Vec<&PendingLike>>,
522) -> Result<Vec<QualifiedName>, ParseError> {
523    let mut unresolved: BTreeSet<QualifiedName> = by_target.keys().cloned().collect();
524    let mut ordered: Vec<QualifiedName> = Vec::with_capacity(by_target.len());
525
526    while !unresolved.is_empty() {
527        // Pick targets whose every source is already outside the unresolved set.
528        let ready: Vec<QualifiedName> = unresolved
529            .iter()
530            .filter(|target| {
531                by_target[*target]
532                    .iter()
533                    .all(|like| !unresolved.contains(&like.source))
534            })
535            .cloned()
536            .collect();
537
538        if ready.is_empty() {
539            // No progress with targets remaining → cycle (includes self-reference).
540            let involved = unresolved
541                .iter()
542                .map(ToString::to_string)
543                .collect::<Vec<_>>()
544                .join(", ");
545            // Use any remaining target's location for the diagnostic.
546            let location = unresolved
547                .iter()
548                .next()
549                .and_then(|t| by_target.get(t))
550                .and_then(|likes| likes.first())
551                .map_or_else(
552                    || SourceLocation::new(std::path::PathBuf::new(), 0, 0),
553                    |like| like.location.clone(),
554                );
555            return Err(ParseError::Structural {
556                location,
557                message: format!("LIKE forms a cycle involving {involved}"),
558            });
559        }
560
561        for target in ready {
562            unresolved.remove(&target);
563            ordered.push(target);
564        }
565    }
566
567    Ok(ordered)
568}
569
570/// Resolve every pending `LIKE` against the assembled catalog.
571///
572/// LIKE clauses can chain (`x (LIKE z)` where `z (LIKE a)`), so we cannot
573/// simply iterate targets in name order — a dependent must be expanded only
574/// after every table it copies from is fully materialized. We resolve in
575/// rounds: each round fully resolves any target whose sources are all already
576/// materialized (i.e. not themselves still-pending targets). If a round makes
577/// no progress while targets remain, the remaining set is a cycle or
578/// self-reference and we error.
579///
580/// Note: takes `&[PendingLike]` so the caller retains the slice for a
581/// subsequent [`apply_pending_like_comments`] pass.
582pub fn apply_pending_likes(
583    catalog: &mut Catalog,
584    pending: &[PendingLike],
585) -> Result<(), ParseError> {
586    // Group by target so multiple LIKE clauses on one table share an
587    // insertion-offset accumulator and a deterministic processing order.
588    let mut by_target: BTreeMap<QualifiedName, Vec<&PendingLike>> = BTreeMap::new();
589    for p in pending {
590        by_target.entry(p.target.clone()).or_default().push(p);
591    }
592
593    for target in resolve_order(&by_target)? {
594        // target is always a by_target key; or_default is a defensive no-op.
595        let mut likes = by_target.remove(&target).unwrap_or_default();
596        likes.sort_by_key(|p| p.explicit_cols_before); // stable; preserves clause order on ties
597
598        // Build a name-collision tracker seeded with every name already live in
599        // the target's schema.  One `TakenNames` per target so that all LIKE
600        // clauses for the same clone share a single counter state — meaning
601        // two different LIKE clauses on the same clone won't independently
602        // produce colliding names.
603        //
604        // `from_schema` borrows `catalog` immutably; we finish the borrow
605        // completely before we need the mutable borrow below.
606        let mut taken = choose_name::TakenNames::from_schema(catalog, &target.schema);
607
608        // Snapshot pass (immutable borrow): gather columns + constraints for
609        // every clause, driving `taken` forward so names stay globally unique.
610        let mut snapshots: Vec<LikeSnapshot> = Vec::with_capacity(likes.len());
611        for like in &likes {
612            snapshots.push(snapshot_like(
613                like,
614                &target.name,
615                &target.schema,
616                catalog,
617                &mut taken,
618            )?);
619        }
620
621        // Mutation pass: apply all snapshots to the (now mutably-borrowed) target.
622        // Column and constraint mutations go to `catalog.tables`; index
623        // mutations go to `catalog.indexes`.  Both happen after ALL
624        // snapshotting above, so the immutable borrows are fully released.
625        let mut inserted = 0usize;
626        for snap in snapshots {
627            let n = snap.cols.len();
628            let tgt = catalog
629                .tables
630                .iter_mut()
631                .find(|t| t.qname == target)
632                .ok_or_else(|| ParseError::Structural {
633                    location: likes.first().map_or_else(
634                        || SourceLocation::new(std::path::PathBuf::new(), 0, 0),
635                        |l| l.location.clone(),
636                    ),
637                    message: format!("LIKE target table {target} vanished"),
638                })?;
639            let at = (snap.explicit_cols_before + inserted).min(tgt.columns.len());
640            tgt.columns.splice(at..at, snap.cols);
641            inserted += n;
642            tgt.constraints.extend(snap.constraints);
643            // Push retargeted plain indexes after table mutations so that
644            // the immutable borrow of `catalog.indexes` (in snapshot_like)
645            // is already complete when we mutate here.
646            catalog.indexes.extend(snap.indexes);
647            // Push retargeted statistics after table mutations so that
648            // the immutable borrow of `catalog.statistics` (in snapshot_like)
649            // is already complete when we mutate here.
650            catalog.statistics.extend(snap.statistics);
651        }
652    }
653    Ok(())
654}
655
656/// Second pass: copy `INCLUDING COMMENTS` from each source to its clone(s),
657/// respecting the same dependency ordering as [`apply_pending_likes`].
658///
659/// This pass runs AFTER the `deferred_comments` loop so that every table and
660/// column already has its own comments applied before we propagate them via
661/// LIKE.  Only clauses with `opts.comments()` do anything; clones whose
662/// comment is already set (via an explicit `COMMENT ON TABLE/COLUMN`) keep
663/// theirs — the explicit comment wins.
664pub fn apply_pending_like_comments(
665    catalog: &mut Catalog,
666    pending: &[PendingLike],
667) -> Result<(), ParseError> {
668    // Respect the same dependency ordering as apply_pending_likes: process a
669    // source before any clone that LIKEs it, so chained LIKE WITH COMMENTS
670    // propagates through the whole chain.
671    let mut by_target: BTreeMap<QualifiedName, Vec<&PendingLike>> = BTreeMap::new();
672    for p in pending {
673        if p.options.comments() {
674            by_target.entry(p.target.clone()).or_default().push(p);
675        }
676    }
677
678    if by_target.is_empty() {
679        return Ok(());
680    }
681
682    for target in resolve_order(&by_target)? {
683        let likes = by_target.remove(&target).unwrap_or_default();
684        for like in likes {
685            // Snapshot source table comment and per-column comments before
686            // borrowing the target mutably.
687            let (src_table_comment, src_col_comments): (
688                Option<String>,
689                Vec<(Identifier, Option<String>)>,
690            ) = {
691                let src = catalog
692                    .tables
693                    .iter()
694                    .find(|t| t.qname == like.source)
695                    .ok_or_else(|| ParseError::Structural {
696                        location: like.location.clone(),
697                        message: format!(
698                            "LIKE source table {} not found during comment copy",
699                            like.source
700                        ),
701                    })?;
702                let table_comment = src.comment.clone();
703                let col_comments = src
704                    .columns
705                    .iter()
706                    .map(|c| (c.name.clone(), c.comment.clone()))
707                    .collect();
708                (table_comment, col_comments)
709            };
710
711            let tgt = catalog
712                .tables
713                .iter_mut()
714                .find(|t| t.qname == target)
715                .ok_or_else(|| ParseError::Structural {
716                    location: like.location.clone(),
717                    message: format!("LIKE target table {target} vanished during comment copy"),
718                })?;
719
720            // Only propagate if the clone has no explicit comment (explicit wins).
721            if tgt.comment.is_none() {
722                tgt.comment = src_table_comment;
723            }
724
725            // Propagate column comments: match by name, explicit clone comment wins.
726            for (col_name, col_comment) in src_col_comments {
727                if let Some(tgt_col) = tgt.columns.iter_mut().find(|c| c.name == col_name)
728                    && tgt_col.comment.is_none()
729                {
730                    tgt_col.comment = col_comment;
731                }
732            }
733        }
734    }
735    Ok(())
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use crate::ir::column_type::ColumnType;
742    use std::path::PathBuf;
743
744    fn loc() -> SourceLocation {
745        SourceLocation::new(PathBuf::from("t.sql"), 1, 1)
746    }
747    fn id(s: &str) -> Identifier {
748        Identifier::from_unquoted(s).unwrap()
749    }
750    fn qn(s: &str, n: &str) -> QualifiedName {
751        QualifiedName::new(id(s), id(n))
752    }
753
754    #[test]
755    fn end_to_end_bare_like_via_parse_directory() {
756        let dir = tempfile::tempdir().unwrap();
757        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
758        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
759        std::fs::write(
760            dir.path().join("pub/t.sql"),
761            "CREATE TABLE pub.base (id int PRIMARY KEY, name text);\n\
762             CREATE TABLE pub.clone (LIKE pub.base);\n",
763        )
764        .unwrap();
765        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
766        let clone = cat
767            .tables
768            .iter()
769            .find(|t| t.qname.name.as_str() == "clone")
770            .unwrap();
771        assert_eq!(
772            clone
773                .columns
774                .iter()
775                .map(|c| c.name.as_str().to_string())
776                .collect::<Vec<_>>(),
777            vec!["id".to_string(), "name".to_string()]
778        );
779    }
780
781    #[test]
782    fn bare_like_copies_columns_names_types_notnull() {
783        let dir = tempfile::tempdir().unwrap();
784        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
785        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
786        std::fs::write(
787            dir.path().join("pub/t.sql"),
788            "CREATE TABLE pub.base (id integer NOT NULL, name text);\n\
789             CREATE TABLE pub.clone (LIKE pub.base);\n",
790        )
791        .unwrap();
792        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
793        let clone = cat
794            .tables
795            .iter()
796            .find(|t| t.qname.name.as_str() == "clone")
797            .unwrap();
798        let got: Vec<_> = clone
799            .columns
800            .iter()
801            .map(|c| (c.name.as_str().to_string(), c.ty.clone(), c.nullable))
802            .collect();
803        assert_eq!(
804            got,
805            vec![
806                ("id".into(), ColumnType::Integer, false),
807                ("name".into(), ColumnType::Text, true),
808            ]
809        );
810        assert!(
811            clone.columns.iter().all(|c| c.default.is_none()),
812            "bare LIKE copies no defaults"
813        );
814    }
815
816    #[test]
817    fn like_unknown_source_errors() {
818        let pend = PendingLike {
819            target: qn("pub", "clone"),
820            source: qn("pub", "missing"),
821            options: TableLikeOptions::new(0),
822            explicit_cols_before: 0,
823            location: loc(),
824        };
825        // Assemble a real catalog via the parse pipeline (`Table` has no
826        // `Default` and a literal would be verbose), then feed it a pending
827        // LIKE whose source table does not exist.
828        let dir = tempfile::tempdir().unwrap();
829        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
830        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
831        std::fs::write(
832            dir.path().join("pub/t.sql"),
833            "CREATE TABLE pub.clone (id int);\n",
834        )
835        .unwrap();
836        let (mut cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
837        assert!(apply_pending_likes(&mut cat, &[pend]).is_err());
838    }
839
840    /// A chain `z (LIKE a)` then `x (LIKE z)` must resolve fully regardless of
841    /// the order the targets sort in. Here the dependent (`x`) sorts AFTER its
842    /// source (`z`), so qname order happens to be correct.
843    #[test]
844    fn chained_like_resolves_dependent_after_source() {
845        let dir = tempfile::tempdir().unwrap();
846        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
847        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
848        std::fs::write(
849            dir.path().join("pub/t.sql"),
850            "CREATE TABLE pub.a (id int PRIMARY KEY, name text);\n\
851             CREATE TABLE pub.z (LIKE pub.a);\n\
852             CREATE TABLE pub.x (LIKE pub.z);\n",
853        )
854        .unwrap();
855        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
856        for tname in ["a", "z", "x"] {
857            let t = cat
858                .tables
859                .iter()
860                .find(|t| t.qname.name.as_str() == tname)
861                .unwrap();
862            assert_eq!(
863                t.columns
864                    .iter()
865                    .map(|c| c.name.as_str().to_string())
866                    .collect::<Vec<_>>(),
867                vec!["id".to_string(), "name".to_string()],
868                "table {tname} should have both copied columns",
869            );
870        }
871    }
872
873    /// Same chain, but names chosen so the dependent (`aaa (LIKE zzz)`) sorts
874    /// BEFORE its source (`zzz (LIKE base)`). A naive qname-sorted pass would
875    /// resolve `aaa` before `zzz` is materialized and leave it empty; the
876    /// dependency-ordered pass must still fully populate both.
877    #[test]
878    fn chained_like_resolves_when_dependent_sorts_before_source() {
879        let dir = tempfile::tempdir().unwrap();
880        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
881        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
882        std::fs::write(
883            dir.path().join("pub/t.sql"),
884            "CREATE TABLE pub.base (id int PRIMARY KEY, name text);\n\
885             CREATE TABLE pub.aaa (LIKE pub.zzz);\n\
886             CREATE TABLE pub.zzz (LIKE pub.base);\n",
887        )
888        .unwrap();
889        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
890        for tname in ["base", "aaa", "zzz"] {
891            let t = cat
892                .tables
893                .iter()
894                .find(|t| t.qname.name.as_str() == tname)
895                .unwrap();
896            assert_eq!(
897                t.columns
898                    .iter()
899                    .map(|c| c.name.as_str().to_string())
900                    .collect::<Vec<_>>(),
901                vec!["id".to_string(), "name".to_string()],
902                "table {tname} should have both copied columns",
903            );
904        }
905    }
906
907    /// A self-referential LIKE (`c (LIKE c)`) is a cycle and must error rather
908    /// than silently leave `c` with no columns.
909    #[test]
910    fn self_referential_like_errors() {
911        let dir = tempfile::tempdir().unwrap();
912        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
913        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
914        std::fs::write(
915            dir.path().join("pub/t.sql"),
916            "CREATE TABLE pub.c (LIKE pub.c);\n",
917        )
918        .unwrap();
919        let err = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap_err();
920        assert!(
921            matches!(err, crate::parse::ParseError::Structural { .. }),
922            "got {err:?}"
923        );
924    }
925
926    /// A 2-cycle (`p (LIKE q)`, `q (LIKE p)`) must also error.
927    #[test]
928    fn two_cycle_like_errors() {
929        let dir = tempfile::tempdir().unwrap();
930        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
931        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
932        std::fs::write(
933            dir.path().join("pub/t.sql"),
934            "CREATE TABLE pub.p (LIKE pub.q);\n\
935             CREATE TABLE pub.q (LIKE pub.p);\n",
936        )
937        .unwrap();
938        let err = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap_err();
939        assert!(
940            matches!(err, crate::parse::ParseError::Structural { .. }),
941            "got {err:?}"
942        );
943    }
944
945    #[test]
946    fn like_preserves_position_among_explicit_columns() {
947        let dir = tempfile::tempdir().unwrap();
948        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
949        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
950        std::fs::write(
951            dir.path().join("pub/t.sql"),
952            "CREATE TABLE pub.base (a int, b int);\n\
953             CREATE TABLE pub.c (x int, LIKE pub.base, y text);\n",
954        )
955        .unwrap();
956        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
957        let c = cat
958            .tables
959            .iter()
960            .find(|t| t.qname.name.as_str() == "c")
961            .unwrap();
962        assert_eq!(
963            c.columns
964                .iter()
965                .map(|c| c.name.as_str().to_string())
966                .collect::<Vec<_>>(),
967            vec!["x", "a", "b", "y"]
968        );
969    }
970
971    #[test]
972    fn including_defaults_and_storage_gate_attributes() {
973        let dir = tempfile::tempdir().unwrap();
974        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
975        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
976        std::fs::write(
977            dir.path().join("pub/t.sql"),
978            "CREATE TABLE pub.base (id int DEFAULT 7, doc text STORAGE EXTERNAL);\n\
979             CREATE TABLE pub.bare (LIKE pub.base);\n\
980             CREATE TABLE pub.full (LIKE pub.base INCLUDING DEFAULTS INCLUDING STORAGE);\n",
981        )
982        .unwrap();
983        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
984        let bare = cat
985            .tables
986            .iter()
987            .find(|t| t.qname.name.as_str() == "bare")
988            .unwrap();
989        assert!(bare.columns[0].default.is_none());
990        assert!(bare.columns[1].storage.is_none());
991        let full = cat
992            .tables
993            .iter()
994            .find(|t| t.qname.name.as_str() == "full")
995            .unwrap();
996        assert!(
997            full.columns[0].default.is_some(),
998            "INCLUDING DEFAULTS copies default"
999        );
1000        assert!(
1001            full.columns[1].storage.is_some(),
1002            "INCLUDING STORAGE copies storage"
1003        );
1004    }
1005
1006    #[test]
1007    fn multiple_like_clauses_expand_in_order() {
1008        let dir = tempfile::tempdir().unwrap();
1009        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1010        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1011        std::fs::write(
1012            dir.path().join("pub/t.sql"),
1013            "CREATE TABLE pub.l (a int);\nCREATE TABLE pub.r (b int);\n\
1014             CREATE TABLE pub.c (LIKE pub.l, mid int, LIKE pub.r);\n",
1015        )
1016        .unwrap();
1017        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1018        let c = cat
1019            .tables
1020            .iter()
1021            .find(|t| t.qname.name.as_str() == "c")
1022            .unwrap();
1023        assert_eq!(
1024            c.columns
1025                .iter()
1026                .map(|c| c.name.as_str().to_string())
1027                .collect::<Vec<_>>(),
1028            vec!["a", "mid", "b"]
1029        );
1030    }
1031
1032    // ── INCLUDING COMMENTS tests ──────────────────────────────────────────────
1033
1034    #[test]
1035    fn including_comments_copies_table_comment() {
1036        let dir = tempfile::tempdir().unwrap();
1037        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1038        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1039        std::fs::write(
1040            dir.path().join("pub/t.sql"),
1041            "CREATE TABLE pub.base (id int);\n\
1042             COMMENT ON TABLE pub.base IS 'hi';\n\
1043             CREATE TABLE pub.c (LIKE pub.base INCLUDING COMMENTS);\n",
1044        )
1045        .unwrap();
1046        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1047        let c = cat
1048            .tables
1049            .iter()
1050            .find(|t| t.qname.name.as_str() == "c")
1051            .unwrap();
1052        assert_eq!(
1053            c.comment.as_deref(),
1054            Some("hi"),
1055            "INCLUDING COMMENTS should propagate the source table comment to the clone"
1056        );
1057    }
1058
1059    #[test]
1060    fn including_comments_copies_column_comment() {
1061        let dir = tempfile::tempdir().unwrap();
1062        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1063        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1064        std::fs::write(
1065            dir.path().join("pub/t.sql"),
1066            "CREATE TABLE pub.base (id int);\n\
1067             COMMENT ON COLUMN pub.base.id IS 'col';\n\
1068             CREATE TABLE pub.clone (LIKE pub.base INCLUDING COMMENTS);\n",
1069        )
1070        .unwrap();
1071        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1072        let clone = cat
1073            .tables
1074            .iter()
1075            .find(|t| t.qname.name.as_str() == "clone")
1076            .unwrap();
1077        let id_col = clone
1078            .columns
1079            .iter()
1080            .find(|c| c.name.as_str() == "id")
1081            .unwrap();
1082        assert_eq!(
1083            id_col.comment.as_deref(),
1084            Some("col"),
1085            "INCLUDING COMMENTS should propagate the source column comment to the clone"
1086        );
1087    }
1088
1089    #[test]
1090    fn bare_like_does_not_copy_comments() {
1091        let dir = tempfile::tempdir().unwrap();
1092        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1093        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1094        std::fs::write(
1095            dir.path().join("pub/t.sql"),
1096            "CREATE TABLE pub.base (id int);\n\
1097             COMMENT ON TABLE pub.base IS 'tbl';\n\
1098             COMMENT ON COLUMN pub.base.id IS 'col';\n\
1099             CREATE TABLE pub.clone (LIKE pub.base);\n",
1100        )
1101        .unwrap();
1102        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1103        let clone = cat
1104            .tables
1105            .iter()
1106            .find(|t| t.qname.name.as_str() == "clone")
1107            .unwrap();
1108        assert!(
1109            clone.comment.is_none(),
1110            "bare LIKE must not copy table comment"
1111        );
1112        assert!(
1113            clone.columns.iter().all(|c| c.comment.is_none()),
1114            "bare LIKE must not copy column comments"
1115        );
1116    }
1117
1118    /// Regression guard: COMMENT ON COLUMN targeting a LIKE-derived column must
1119    /// succeed. The brief's suggested reorder (`deferred_comments` before
1120    /// `apply_pending_likes`) would have broken this case because `apply_comment`
1121    /// would error when the clone's columns don't yet exist.
1122    #[test]
1123    fn comment_on_clone_like_derived_column_succeeds() {
1124        let dir = tempfile::tempdir().unwrap();
1125        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1126        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1127        std::fs::write(
1128            dir.path().join("pub/t.sql"),
1129            "CREATE TABLE pub.base (id int);\n\
1130             CREATE TABLE pub.clone (LIKE pub.base);\n\
1131             COMMENT ON COLUMN pub.clone.id IS 'x';\n",
1132        )
1133        .unwrap();
1134        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1135        let clone = cat
1136            .tables
1137            .iter()
1138            .find(|t| t.qname.name.as_str() == "clone")
1139            .unwrap();
1140        let id_col = clone
1141            .columns
1142            .iter()
1143            .find(|c| c.name.as_str() == "id")
1144            .unwrap();
1145        assert_eq!(
1146            id_col.comment.as_deref(),
1147            Some("x"),
1148            "COMMENT ON COLUMN clone.id must apply to the LIKE-derived column"
1149        );
1150    }
1151
1152    #[test]
1153    fn explicit_clone_comment_wins_over_including_comments() {
1154        let dir = tempfile::tempdir().unwrap();
1155        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1156        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1157        std::fs::write(
1158            dir.path().join("pub/t.sql"),
1159            "CREATE TABLE pub.base (id int);\n\
1160             COMMENT ON TABLE pub.base IS 'from_base';\n\
1161             CREATE TABLE pub.clone (LIKE pub.base INCLUDING COMMENTS);\n\
1162             COMMENT ON TABLE pub.clone IS 'explicit';\n",
1163        )
1164        .unwrap();
1165        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1166        let clone = cat
1167            .tables
1168            .iter()
1169            .find(|t| t.qname.name.as_str() == "clone")
1170            .unwrap();
1171        assert_eq!(
1172            clone.comment.as_deref(),
1173            Some("explicit"),
1174            "explicit COMMENT ON TABLE clone must win over INCLUDING COMMENTS propagation"
1175        );
1176    }
1177
1178    // ── INCLUDING CONSTRAINTS tests ───────────────────────────────────────────
1179
1180    #[test]
1181    fn including_constraints_copies_check() {
1182        let dir = tempfile::tempdir().unwrap();
1183        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1184        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1185        std::fs::write(
1186            dir.path().join("pub/t.sql"),
1187            "CREATE TABLE pub.base (n int, CONSTRAINT n_pos CHECK (n > 0));\n\
1188             CREATE TABLE pub.bare (LIKE pub.base);\n\
1189             CREATE TABLE pub.c (LIKE pub.base INCLUDING CONSTRAINTS);\n",
1190        )
1191        .unwrap();
1192        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1193        let bare = cat
1194            .tables
1195            .iter()
1196            .find(|t| t.qname.name.as_str() == "bare")
1197            .unwrap();
1198        assert!(
1199            bare.constraints
1200                .iter()
1201                .all(|c| !matches!(c.kind, crate::ir::constraint::ConstraintKind::Check { .. })),
1202            "bare LIKE must not copy CHECK constraints"
1203        );
1204        let c = cat
1205            .tables
1206            .iter()
1207            .find(|t| t.qname.name.as_str() == "c")
1208            .unwrap();
1209        assert_eq!(
1210            c.constraints
1211                .iter()
1212                .filter(|c| matches!(c.kind, crate::ir::constraint::ConstraintKind::Check { .. }))
1213                .count(),
1214            1,
1215            "INCLUDING CONSTRAINTS should copy exactly one CHECK constraint"
1216        );
1217    }
1218
1219    #[test]
1220    fn including_constraints_does_not_copy_pk_or_unique() {
1221        let dir = tempfile::tempdir().unwrap();
1222        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1223        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1224        std::fs::write(
1225            dir.path().join("pub/t.sql"),
1226            "CREATE TABLE pub.base (id int PRIMARY KEY, e text UNIQUE, n int CHECK (n>0));\n\
1227             CREATE TABLE pub.c (LIKE pub.base INCLUDING CONSTRAINTS);\n",
1228        )
1229        .unwrap();
1230        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1231        let c = cat
1232            .tables
1233            .iter()
1234            .find(|t| t.qname.name.as_str() == "c")
1235            .unwrap();
1236        assert_eq!(
1237            c.constraints
1238                .iter()
1239                .filter(|c| matches!(c.kind, crate::ir::constraint::ConstraintKind::Check { .. }))
1240                .count(),
1241            1,
1242            "INCLUDING CONSTRAINTS should copy the CHECK constraint"
1243        );
1244        assert!(
1245            c.constraints.iter().all(|c| !matches!(
1246                c.kind,
1247                crate::ir::constraint::ConstraintKind::PrimaryKey { .. }
1248            )),
1249            "INCLUDING CONSTRAINTS must not copy PrimaryKey (belongs to INCLUDING INDEXES)"
1250        );
1251        assert!(
1252            c.constraints
1253                .iter()
1254                .all(|c| !matches!(c.kind, crate::ir::constraint::ConstraintKind::Unique { .. })),
1255            "INCLUDING CONSTRAINTS must not copy Unique (belongs to INCLUDING INDEXES)"
1256        );
1257    }
1258
1259    /// An UNNAMED inline column CHECK (`n int CHECK (n > 0)`) on `base` is now
1260    /// correctly named `base_n_check` by pgevolve (Postgres-faithful column-level
1261    /// naming).  When copied via `LIKE … INCLUDING CONSTRAINTS` the clone receives
1262    /// the name verbatim (`base_n_check`), because Postgres copies CHECK names
1263    /// verbatim on LIKE (fixes #48, reverts #45's re-derivation).
1264    #[test]
1265    fn like_constraints_copies_check_name_verbatim() {
1266        let dir = tempfile::tempdir().unwrap();
1267        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1268        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1269        // Inline column CHECK → pgevolve now names it `base_n_check` (column-level form).
1270        std::fs::write(
1271            dir.path().join("pub/t.sql"),
1272            "CREATE TABLE pub.base (n int CHECK (n > 0));\n\
1273             CREATE TABLE pub.clone (LIKE pub.base INCLUDING CONSTRAINTS);\n",
1274        )
1275        .unwrap();
1276        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1277        let clone = cat
1278            .tables
1279            .iter()
1280            .find(|t| t.qname.name.as_str() == "clone")
1281            .unwrap();
1282        let check = clone
1283            .constraints
1284            .iter()
1285            .find(|c| matches!(c.kind, crate::ir::constraint::ConstraintKind::Check { .. }))
1286            .expect("clone must have a copied CHECK constraint");
1287        assert_eq!(
1288            check.qname.name.as_str(),
1289            "base_n_check",
1290            "unnamed source CHECK name must be copied verbatim (base_n_check), got {:?}",
1291            check.qname.name.as_str(),
1292        );
1293        assert_eq!(
1294            check.qname.schema.as_str(),
1295            "pub",
1296            "copied CHECK must be in clone's schema"
1297        );
1298    }
1299
1300    /// An EXPLICITLY-NAMED source CHECK (`CONSTRAINT n_pos CHECK …`) must keep
1301    /// its source name when copied via `LIKE … INCLUDING CONSTRAINTS`; only the
1302    /// schema is updated to the clone's schema.
1303    #[test]
1304    fn like_constraints_preserves_explicit_check_name() {
1305        let dir = tempfile::tempdir().unwrap();
1306        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1307        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1308        // Explicitly-named CHECK → name must be preserved verbatim in the clone.
1309        std::fs::write(
1310            dir.path().join("pub/t.sql"),
1311            "CREATE TABLE pub.base (n int, CONSTRAINT n_pos CHECK (n > 0));\n\
1312             CREATE TABLE pub.clone (LIKE pub.base INCLUDING CONSTRAINTS);\n",
1313        )
1314        .unwrap();
1315        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1316        let clone = cat
1317            .tables
1318            .iter()
1319            .find(|t| t.qname.name.as_str() == "clone")
1320            .unwrap();
1321        let check = clone
1322            .constraints
1323            .iter()
1324            .find(|c| matches!(c.kind, crate::ir::constraint::ConstraintKind::Check { .. }))
1325            .expect("clone must have a copied CHECK constraint");
1326        assert_eq!(
1327            check.qname.name.as_str(),
1328            "n_pos",
1329            "explicitly-named source CHECK must be preserved as n_pos, got {:?}",
1330            check.qname.name.as_str(),
1331        );
1332        assert_eq!(
1333            check.qname.schema.as_str(),
1334            "pub",
1335            "copied CHECK must be in clone's schema"
1336        );
1337    }
1338
1339    // ── INCLUDING INDEXES tests ───────────────────────────────────────────────
1340
1341    #[test]
1342    fn including_indexes_copies_pk_and_unique_with_pg_names() {
1343        use crate::ir::constraint::ConstraintKind::{PrimaryKey, Unique};
1344        let dir = tempfile::tempdir().unwrap();
1345        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1346        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1347        std::fs::write(
1348            dir.path().join("pub/t.sql"),
1349            "CREATE TABLE pub.base (id int PRIMARY KEY, email text UNIQUE);\n\
1350             CREATE TABLE pub.c (LIKE pub.base INCLUDING INDEXES);\n",
1351        )
1352        .unwrap();
1353        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1354        let c = cat
1355            .tables
1356            .iter()
1357            .find(|t| t.qname.name.as_str() == "c")
1358            .unwrap();
1359        let names: Vec<_> = c
1360            .constraints
1361            .iter()
1362            .map(|k| k.qname.name.as_str().to_string())
1363            .collect();
1364        assert!(names.contains(&"c_pkey".to_string()), "got {names:?}");
1365        assert!(names.contains(&"c_email_key".to_string()), "got {names:?}");
1366        assert_eq!(
1367            c.constraints
1368                .iter()
1369                .filter(|k| matches!(k.kind, PrimaryKey { .. }))
1370                .count(),
1371            1
1372        );
1373        assert_eq!(
1374            c.constraints
1375                .iter()
1376                .filter(|k| matches!(k.kind, Unique { .. }))
1377                .count(),
1378            1
1379        );
1380    }
1381
1382    #[test]
1383    fn bare_like_copies_no_pk_or_unique() {
1384        let dir = tempfile::tempdir().unwrap();
1385        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1386        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1387        std::fs::write(
1388            dir.path().join("pub/t.sql"),
1389            "CREATE TABLE pub.base (id int PRIMARY KEY, email text UNIQUE);\n\
1390             CREATE TABLE pub.d (LIKE pub.base);\n",
1391        )
1392        .unwrap();
1393        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1394        let d = cat
1395            .tables
1396            .iter()
1397            .find(|t| t.qname.name.as_str() == "d")
1398            .unwrap();
1399        assert!(
1400            d.constraints.iter().all(|c| !matches!(
1401                c.kind,
1402                crate::ir::constraint::ConstraintKind::PrimaryKey { .. }
1403            )),
1404            "bare LIKE must not copy PK"
1405        );
1406        assert!(
1407            d.constraints
1408                .iter()
1409                .all(|c| !matches!(c.kind, crate::ir::constraint::ConstraintKind::Unique { .. })),
1410            "bare LIKE must not copy UNIQUE"
1411        );
1412    }
1413
1414    // ── INCLUDING INDEXES: plain index tests ─────────────────────────────────
1415
1416    /// `INCLUDING INDEXES` copies a plain `CREATE INDEX` to the clone, with a
1417    /// Postgres-faithful re-derived name (`<target>_<cols>_idx`).
1418    #[test]
1419    fn including_indexes_copies_plain_index() {
1420        let dir = tempfile::tempdir().unwrap();
1421        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1422        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1423        std::fs::write(
1424            dir.path().join("pub/t.sql"),
1425            "CREATE TABLE pub.base (a int, b int);\n\
1426             CREATE INDEX ON pub.base (a, b);\n\
1427             CREATE TABLE pub.c (LIKE pub.base INCLUDING INDEXES);\n",
1428        )
1429        .unwrap();
1430        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1431        let idx: Vec<_> = cat
1432            .indexes
1433            .iter()
1434            .filter(|i| i.on.qname().name.as_str() == "c")
1435            .map(|i| i.qname.name.as_str().to_string())
1436            .collect();
1437        assert_eq!(idx, vec!["c_a_b_idx".to_string()], "got {idx:?}");
1438    }
1439
1440    /// `CREATE UNIQUE INDEX` copied via `INCLUDING INDEXES` keeps the `_idx`
1441    /// suffix (not `_key`), because Postgres uses `_idx` for plain indexes
1442    /// regardless of uniqueness; `_key` is only for UNIQUE *constraints*.
1443    #[test]
1444    fn unique_index_keeps_idx_suffix() {
1445        let dir = tempfile::tempdir().unwrap();
1446        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1447        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1448        std::fs::write(
1449            dir.path().join("pub/t.sql"),
1450            "CREATE TABLE pub.base (a int);\n\
1451             CREATE UNIQUE INDEX ON pub.base (a);\n\
1452             CREATE TABLE pub.c (LIKE pub.base INCLUDING INDEXES);\n",
1453        )
1454        .unwrap();
1455        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1456        let copied: Vec<_> = cat
1457            .indexes
1458            .iter()
1459            .filter(|i| i.on.qname().name.as_str() == "c")
1460            .collect();
1461        assert_eq!(
1462            copied.len(),
1463            1,
1464            "expected exactly one copied index, got {copied:?}"
1465        );
1466        assert_eq!(
1467            copied[0].qname.name.as_str(),
1468            "c_a_idx",
1469            "unique plain index must use _idx suffix, not _key"
1470        );
1471        assert!(copied[0].unique, "copied index must preserve unique flag");
1472    }
1473
1474    /// A bare `LIKE` (no `INCLUDING INDEXES`) must not copy any plain indexes.
1475    #[test]
1476    fn bare_like_copies_no_indexes() {
1477        let dir = tempfile::tempdir().unwrap();
1478        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1479        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1480        std::fs::write(
1481            dir.path().join("pub/t.sql"),
1482            "CREATE TABLE pub.base (a int, b int);\n\
1483             CREATE INDEX ON pub.base (a, b);\n\
1484             CREATE TABLE pub.d (LIKE pub.base);\n",
1485        )
1486        .unwrap();
1487        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1488        let targeting_d: Vec<_> = cat
1489            .indexes
1490            .iter()
1491            .filter(|i| i.on.qname().name.as_str() == "d")
1492            .collect();
1493        assert!(
1494            targeting_d.is_empty(),
1495            "bare LIKE must not copy plain indexes, got {targeting_d:?}"
1496        );
1497    }
1498
1499    // ── INCLUDING STATISTICS tests ────────────────────────────────────────────
1500
1501    #[test]
1502    fn including_statistics_copies_stats() {
1503        let dir = tempfile::tempdir().unwrap();
1504        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1505        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1506        std::fs::write(
1507            dir.path().join("pub/t.sql"),
1508            "CREATE TABLE pub.base (a int, b int);\n\
1509             CREATE STATISTICS pub.base_stat (ndistinct) ON a, b FROM pub.base;\n\
1510             CREATE TABLE pub.c (LIKE pub.base INCLUDING STATISTICS);\n",
1511        )
1512        .unwrap();
1513        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1514        let copied: Vec<_> = cat
1515            .statistics
1516            .iter()
1517            .filter(|s| s.target.name.as_str() == "c")
1518            .collect();
1519        assert_eq!(copied.len(), 1, "expected one copied statistic");
1520        assert_eq!(
1521            copied[0].qname.name.as_str(),
1522            "c_a_b_stat",
1523            "generated statistic qname should be c_a_b_stat, got {:?}",
1524            copied[0].qname.name.as_str(),
1525        );
1526    }
1527
1528    #[test]
1529    fn bare_like_copies_no_stats() {
1530        let dir = tempfile::tempdir().unwrap();
1531        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1532        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1533        std::fs::write(
1534            dir.path().join("pub/t.sql"),
1535            "CREATE TABLE pub.base (a int, b int);\n\
1536             CREATE STATISTICS pub.base_stat (ndistinct) ON a, b FROM pub.base;\n\
1537             CREATE TABLE pub.d (LIKE pub.base);\n",
1538        )
1539        .unwrap();
1540        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1541        let targeting_d: Vec<_> = cat
1542            .statistics
1543            .iter()
1544            .filter(|s| s.target.name.as_str() == "d")
1545            .collect();
1546        assert!(
1547            targeting_d.is_empty(),
1548            "bare LIKE must not copy statistics, got {targeting_d:?}"
1549        );
1550    }
1551
1552    // ── INCLUDING ALL integration tests ──────────────────────────────────────
1553
1554    /// `INCLUDING ALL` copies defaults, PK+UNIQUE constraints (via INDEXES),
1555    /// CHECK constraints (via CONSTRAINTS), and plain indexes.
1556    #[test]
1557    fn including_all_copies_everything() {
1558        use crate::ir::constraint::ConstraintKind::{Check, PrimaryKey};
1559        let dir = tempfile::tempdir().unwrap();
1560        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1561        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1562        std::fs::write(
1563            dir.path().join("pub/t.sql"),
1564            "CREATE TABLE pub.base (id int PRIMARY KEY DEFAULT 1, n int CHECK (n > 0));\n\
1565             CREATE INDEX ON pub.base (n);\n\
1566             CREATE TABLE pub.c (LIKE pub.base INCLUDING ALL);\n",
1567        )
1568        .unwrap();
1569        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1570        let c = cat
1571            .tables
1572            .iter()
1573            .find(|t| t.qname.name.as_str() == "c")
1574            .unwrap();
1575        // DEFAULTS: id column should carry the default
1576        assert!(
1577            c.columns[0].default.is_some(),
1578            "INCLUDING ALL must copy DEFAULT (DEFAULTS bit)"
1579        );
1580        // INDEXES: PK constraint copied
1581        assert!(
1582            c.constraints
1583                .iter()
1584                .any(|k| matches!(k.kind, PrimaryKey { .. })),
1585            "INCLUDING ALL must copy PrimaryKey constraint (INDEXES bit)"
1586        );
1587        // CONSTRAINTS: CHECK constraint copied
1588        assert!(
1589            c.constraints.iter().any(|k| matches!(k.kind, Check { .. })),
1590            "INCLUDING ALL must copy Check constraint (CONSTRAINTS bit)"
1591        );
1592        // INDEXES (plain): a plain index targeting the clone must exist
1593        assert!(
1594            cat.indexes
1595                .iter()
1596                .any(|i| i.on.qname().name.as_str() == "c"),
1597            "INCLUDING ALL must copy plain index (INDEXES bit)"
1598        );
1599    }
1600
1601    /// LIKE of a VIEW source must produce a clear error mentioning "LIKE source".
1602    #[test]
1603    fn like_non_table_source_errors_clearly() {
1604        let dir = tempfile::tempdir().unwrap();
1605        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1606        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1607        std::fs::write(
1608            dir.path().join("pub/t.sql"),
1609            "CREATE TABLE pub.base (id int);\n\
1610             CREATE VIEW pub.v AS SELECT id FROM pub.base;\n\
1611             CREATE TABLE pub.c (LIKE pub.v);\n",
1612        )
1613        .unwrap();
1614        let err = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap_err();
1615        assert!(
1616            format!("{err}").contains("LIKE source"),
1617            "error should mention 'LIKE source', got: {err}"
1618        );
1619    }
1620
1621    /// `INCLUDING ALL` on a table with CHECK + PK + UNIQUE must not double-copy
1622    /// any constraint kind.  Each kind must appear exactly once.
1623    #[test]
1624    fn including_all_no_double_copy_of_check() {
1625        use crate::ir::constraint::ConstraintKind::{Check, PrimaryKey, Unique};
1626        let dir = tempfile::tempdir().unwrap();
1627        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1628        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1629        std::fs::write(
1630            dir.path().join("pub/t.sql"),
1631            "CREATE TABLE pub.base (n int CHECK (n > 0), id int PRIMARY KEY, e text UNIQUE);\n\
1632             CREATE TABLE pub.c (LIKE pub.base INCLUDING ALL);\n",
1633        )
1634        .unwrap();
1635        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1636        let c = cat
1637            .tables
1638            .iter()
1639            .find(|t| t.qname.name.as_str() == "c")
1640            .unwrap();
1641        let check_count = c
1642            .constraints
1643            .iter()
1644            .filter(|k| matches!(k.kind, Check { .. }))
1645            .count();
1646        let pk_count = c
1647            .constraints
1648            .iter()
1649            .filter(|k| matches!(k.kind, PrimaryKey { .. }))
1650            .count();
1651        let uq_count = c
1652            .constraints
1653            .iter()
1654            .filter(|k| matches!(k.kind, Unique { .. }))
1655            .count();
1656        assert_eq!(
1657            check_count, 1,
1658            "expected exactly 1 Check, got {check_count}"
1659        );
1660        assert_eq!(pk_count, 1, "expected exactly 1 PrimaryKey, got {pk_count}");
1661        assert_eq!(uq_count, 1, "expected exactly 1 Unique, got {uq_count}");
1662    }
1663
1664    /// A UNIQUE constraint-backing index and a plain `CREATE INDEX` on the same
1665    /// column must receive distinct names; they must not collide even though they
1666    /// share the same `taken` namespace.
1667    #[test]
1668    fn constraint_and_index_share_name_namespace() {
1669        let dir = tempfile::tempdir().unwrap();
1670        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1671        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1672        std::fs::write(
1673            dir.path().join("pub/t.sql"),
1674            // UNIQUE constraint on `a` → backing name base_a_key;
1675            // plain index on `a`        → name base_a_idx.
1676            "CREATE TABLE pub.base (a int UNIQUE);\n\
1677             CREATE INDEX ON pub.base (a);\n\
1678             CREATE TABLE pub.c (LIKE pub.base INCLUDING ALL);\n",
1679        )
1680        .unwrap();
1681        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1682        let c = cat
1683            .tables
1684            .iter()
1685            .find(|t| t.qname.name.as_str() == "c")
1686            .unwrap();
1687        // The UNIQUE constraint should be named c_a_key.
1688        let uq_name = c
1689            .constraints
1690            .iter()
1691            .find(|k| matches!(k.kind, crate::ir::constraint::ConstraintKind::Unique { .. }))
1692            .map(|k| k.qname.name.as_str().to_string())
1693            .expect("expected a Unique constraint on c");
1694        // The plain index should be named c_a_idx.
1695        let idx_name = cat
1696            .indexes
1697            .iter()
1698            .find(|i| i.on.qname().name.as_str() == "c")
1699            .map(|i| i.qname.name.as_str().to_string())
1700            .expect("expected a plain index on c");
1701        // They must not collide.
1702        assert_ne!(
1703            uq_name, idx_name,
1704            "Unique constraint name and plain index name must not collide: both are {uq_name:?}"
1705        );
1706        // They should follow the Postgres naming conventions.
1707        assert_eq!(
1708            uq_name, "c_a_key",
1709            "Unique constraint should be c_a_key, got {uq_name:?}"
1710        );
1711        assert_eq!(
1712            idx_name, "c_a_idx",
1713            "Plain index should be c_a_idx, got {idx_name:?}"
1714        );
1715    }
1716
1717    /// Multi-hop LIKE: `leaf (LIKE mid INCLUDING ALL)` where `mid (LIKE base
1718    /// INCLUDING ALL)`.  The leaf must carry the PK from the base through the
1719    /// intermediate clone.
1720    #[test]
1721    fn chained_like_including_all_propagates() {
1722        use crate::ir::constraint::ConstraintKind::PrimaryKey;
1723        let dir = tempfile::tempdir().unwrap();
1724        std::fs::create_dir_all(dir.path().join("pub")).unwrap();
1725        std::fs::write(dir.path().join("pub/_schema.sql"), "CREATE SCHEMA pub;\n").unwrap();
1726        std::fs::write(
1727            dir.path().join("pub/t.sql"),
1728            "CREATE TABLE pub.base (id int PRIMARY KEY);\n\
1729             CREATE TABLE pub.mid  (LIKE pub.base INCLUDING ALL);\n\
1730             CREATE TABLE pub.leaf (LIKE pub.mid  INCLUDING ALL);\n",
1731        )
1732        .unwrap();
1733        let (cat, _) = crate::parse::parse_directory_with_locations(dir.path(), &[]).unwrap();
1734        let leaf = cat
1735            .tables
1736            .iter()
1737            .find(|t| t.qname.name.as_str() == "leaf")
1738            .unwrap();
1739        // The `id` column must propagate all the way to leaf.
1740        assert!(
1741            leaf.columns.iter().any(|c| c.name.as_str() == "id"),
1742            "leaf must have the id column propagated via mid"
1743        );
1744        // leaf must have a PrimaryKey constraint named leaf_pkey.
1745        let pk = leaf
1746            .constraints
1747            .iter()
1748            .find(|k| matches!(k.kind, PrimaryKey { .. }))
1749            .expect("leaf must have a PrimaryKey constraint");
1750        assert_eq!(
1751            pk.qname.name.as_str(),
1752            "leaf_pkey",
1753            "leaf PK should be named leaf_pkey, got {:?}",
1754            pk.qname.name.as_str()
1755        );
1756    }
1757}