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