Skip to main content

uqa_sql/schema/
constraint_metadata.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Normalize durable constraint names and identities through a caller-owned identity allocator.
8use std::collections::BTreeSet;
9use uqa_core::RelationIdentity;
10pub mod identity;
11
12/// Explicit names share their relation's event namespace; automatic names also avoid every constraint in the containing schema.
13#[derive(Default)]
14pub struct ConstraintNameScope {
15    pub events: BTreeSet<String>,
16    pub schema: BTreeSet<String>,
17}
18
19#[derive(Debug)]
20pub enum ConstraintMetadataError {
21    Invalid(String),
22    Execution(Box<crate::SQLError>),
23}
24
25impl std::fmt::Display for ConstraintMetadataError {
26    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            Self::Invalid(message) => formatter.write_str(message),
29            Self::Execution(error) => std::fmt::Display::fmt(error, formatter),
30        }
31    }
32}
33
34impl std::error::Error for ConstraintMetadataError {
35    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
36        match self {
37            Self::Invalid(_) => None,
38            Self::Execution(error) => Some(error.as_ref()),
39        }
40    }
41}
42pub type ConstraintMetadataResult<T> = Result<T, ConstraintMetadataError>;
43pub type CatalogIdentityAllocator<'a> = dyn CatalogObjectAllocator + 'a;
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
46pub enum CatalogOidClass {
47    Constraint,
48    Relation,
49}
50
51impl CatalogOidClass {
52    pub const fn class_id(self) -> u32 {
53        match self {
54            Self::Constraint => 2606,
55            Self::Relation => 1259,
56        }
57    }
58
59    pub const fn label(self) -> &'static str {
60        match self {
61            Self::Constraint => "constraint",
62            Self::Relation => "relation",
63        }
64    }
65}
66
67/// Declaration normalization requests identities from its caller. Execution reserves public addresses; isolated declarations and initial migration can derive candidates before validating the complete catalog.
68pub trait CatalogObjectAllocator {
69    /// Validate the owning relation and reserve supplied addresses before allocating another row.
70    fn include_catalog_identity(
71        &mut self,
72        _relation: &RelationIdentity,
73        _class: CatalogOidClass,
74        _identity: crate::ast::ConstraintCatalogIdentity,
75    ) -> ConstraintMetadataResult<()> {
76        Ok(())
77    }
78
79    fn allocate_object_id(&mut self, kind: &str) -> ConstraintMetadataResult<[u8; 16]>;
80
81    fn allocate_catalog_oid(
82        &mut self,
83        class: CatalogOidClass,
84        object_id: &[u8; 16],
85    ) -> ConstraintMetadataResult<i64>;
86}
87
88impl<F> CatalogObjectAllocator for F
89where
90    F: FnMut(&str) -> ConstraintMetadataResult<[u8; 16]>,
91{
92    fn allocate_object_id(&mut self, kind: &str) -> ConstraintMetadataResult<[u8; 16]> {
93        self(kind)
94    }
95
96    fn allocate_catalog_oid(
97        &mut self,
98        class: CatalogOidClass,
99        object_id: &[u8; 16],
100    ) -> ConstraintMetadataResult<i64> {
101        Ok(crate::catalog::oids::stable_object_oid(
102            class.label(),
103            object_id,
104        ))
105    }
106}
107
108pub fn materialize_constraint_metadata(
109    relation: &RelationIdentity,
110    columns: &mut [crate::ast::ColumnDef],
111    constraints: &mut crate::ast::TableConstraintSet,
112    allocate: &mut CatalogIdentityAllocator<'_>,
113) -> ConstraintMetadataResult<bool> {
114    materialize_constraint_metadata_with_names(
115        relation,
116        columns,
117        constraints,
118        allocate,
119        &ConstraintNameScope::default(),
120    )
121}
122
123/// Validate explicit local names before excluding schema-wide names from automatic selection.
124pub fn materialize_constraint_metadata_with_names(
125    relation: &RelationIdentity,
126    columns: &mut [crate::ast::ColumnDef],
127    constraints: &mut crate::ast::TableConstraintSet,
128    allocate: &mut CatalogIdentityAllocator<'_>,
129    names: &ConstraintNameScope,
130) -> ConstraintMetadataResult<bool> {
131    identity::claims::validate_present_identities(columns, constraints)?;
132    for identity in identity::claims::identities(columns, constraints) {
133        allocate.include_catalog_identity(relation, CatalogOidClass::Constraint, identity)?;
134    }
135    // Releases predating typed table-key persistence stored column-level PRIMARY KEY and UNIQUE declarations only as ColumnDef flags. Promote those legacy flags before assigning names so catalog publication always sees named constraints.
136    let mut changed = materialize_column_key_constraints(columns, constraints);
137    let mut used = constraint_names_for_assignment(relation, columns, constraints, names)?;
138
139    let mut column_object_ids = BTreeSet::new();
140    for column in columns.iter_mut() {
141        if column
142            .object_id
143            .is_some_and(|object_id| !column_object_ids.insert(object_id))
144        {
145            column.object_id = None;
146        }
147        changed |= assign_catalog_object_id(&mut column.object_id, "column", allocate)?;
148        if let Some(object_id) = column.object_id {
149            column_object_ids.insert(object_id);
150        }
151        if column.not_null {
152            changed |= assign_constraint_name(
153                &mut column.not_null_name,
154                (&relation.name, &column.name, "not_null"),
155                &mut used,
156            )?;
157            changed |= identity::materialize_not_null_identity(column, allocate)?;
158        }
159        if column.check.is_some() {
160            changed |= assign_constraint_name(
161                &mut column.check_name,
162                (&relation.name, &column.name, "check"),
163                &mut used,
164            )?;
165            changed |= assign_catalog_object_id(
166                &mut column.check_object_id,
167                "CHECK constraint",
168                allocate,
169            )?;
170            changed |= identity::materialize_check_oid(
171                column.check_object_id,
172                &mut column.check_catalog_oid,
173                allocate,
174            )?;
175        }
176        if let Some(reference) = &mut column.references {
177            changed |= assign_constraint_name(
178                &mut reference.name,
179                (&relation.name, &column.name, "fkey"),
180                &mut used,
181            )?;
182            changed |= assign_constraint_object_id(&mut reference.object_id, allocate)?;
183            changed |=
184                identity::foreign_keys::materialize(&mut reference.catalog_identity, allocate)?;
185        }
186    }
187    for constraint in &mut constraints.key_constraints {
188        let (component, label) = match constraint.kind {
189            crate::ast::TableKeyConstraintKind::PrimaryKey => (String::new(), "pkey"),
190            crate::ast::TableKeyConstraintKind::Unique => (
191                constraint_column_component(&constraint.columns, relation)?,
192                "key",
193            ),
194        };
195        changed |= assign_constraint_name(
196            &mut constraint.name,
197            (&relation.name, &component, label),
198            &mut used,
199        )?;
200        changed |= identity::materialize_key_identity(constraint, allocate)?;
201    }
202    changed |= materialize_checks(relation, &mut constraints.checks, &mut used, allocate)?;
203    changed |= synchronize_partition_inherited_foreign_key_ids(constraints);
204    for constraint in &mut constraints.foreign_keys {
205        let component = constraint_column_component(&constraint.local_columns, relation)?;
206        changed |= assign_constraint_name(
207            &mut constraint.name,
208            (&relation.name, &component, "fkey"),
209            &mut used,
210        )?;
211        changed |= assign_constraint_object_id(&mut constraint.object_id, allocate)?;
212        changed |= identity::foreign_keys::materialize(&mut constraint.catalog_identity, allocate)?;
213    }
214    changed |= synchronize_partition_inherited_foreign_key_ids(constraints);
215    changed |= identity::keys::synchronize_provenance(constraints);
216    identity::claims::validate_constraint_identities(columns, constraints)?;
217    Ok(changed)
218}
219
220fn materialize_checks(
221    relation: &RelationIdentity,
222    checks: &mut [crate::ast::TableCheck],
223    used: &mut BTreeSet<String>,
224    allocate: &mut CatalogIdentityAllocator<'_>,
225) -> ConstraintMetadataResult<bool> {
226    let mut changed = false;
227    for constraint in checks {
228        let mut referenced_columns = Vec::new();
229        collect_constraint_columns(&constraint.expr, &mut referenced_columns);
230        let component = if referenced_columns.len() == 1 {
231            referenced_columns[0].as_str()
232        } else {
233            ""
234        };
235        changed |= assign_constraint_name(
236            &mut constraint.name,
237            (&relation.name, component, "check"),
238            used,
239        )?;
240        changed |=
241            assign_catalog_object_id(&mut constraint.object_id, "CHECK constraint", allocate)?;
242        changed |= identity::materialize_check_oid(
243            constraint.object_id,
244            &mut constraint.catalog_oid,
245            allocate,
246        )?;
247    }
248    Ok(changed)
249}
250
251fn constraint_names_for_assignment(
252    relation: &RelationIdentity,
253    columns: &[crate::ast::ColumnDef],
254    constraints: &crate::ast::TableConstraintSet,
255    names: &ConstraintNameScope,
256) -> ConstraintMetadataResult<BTreeSet<String>> {
257    let mut used = BTreeSet::new();
258    for column in columns {
259        record_constraint_name(&mut used, column.not_null_name.as_deref())?;
260        record_constraint_name(&mut used, column.check_name.as_deref())?;
261        record_constraint_name(
262            &mut used,
263            column
264                .references
265                .as_ref()
266                .and_then(|reference| reference.name.as_deref()),
267        )?;
268    }
269    for constraint in &constraints.key_constraints {
270        record_constraint_name(&mut used, constraint.name.as_deref())?;
271    }
272    for constraint in &constraints.checks {
273        record_constraint_name(&mut used, constraint.name.as_deref())?;
274    }
275    for constraint in &constraints.foreign_keys {
276        record_constraint_name(&mut used, constraint.name.as_deref())?;
277    }
278    for name in &names.events {
279        if !used.insert(name.clone()) {
280            return Err(ConstraintMetadataError::Execution(Box::new(
281                crate::schema::constraint_changes::constraint_error(
282                    "42710",
283                    format!(
284                        "constraint \"{name}\" for relation \"{}\" already exists",
285                        relation.name
286                    ),
287                ),
288            )));
289        }
290    }
291    used.extend(names.schema.iter().cloned());
292    Ok(used)
293}
294
295pub fn materialize_column_key_constraints(
296    columns: &[crate::ast::ColumnDef],
297    constraints: &mut crate::ast::TableConstraintSet,
298) -> bool {
299    let mut changed = false;
300    for column in columns {
301        for (present, kind) in [
302            (
303                column.primary_key,
304                crate::ast::TableKeyConstraintKind::PrimaryKey,
305            ),
306            (column.unique, crate::ast::TableKeyConstraintKind::Unique),
307        ] {
308            if !present
309                || constraints.key_constraints.iter().any(|constraint| {
310                    constraint.kind == kind
311                        && constraint.columns.as_slice() == [column.name.as_str()]
312                })
313            {
314                continue;
315            }
316            constraints
317                .key_constraints
318                .push(crate::ast::TableKeyConstraint {
319                    catalog_identity: None,
320                    name: None,
321                    kind,
322                    columns: vec![column.name.clone()],
323                    nulls_not_distinct: false,
324                    without_overlaps: false,
325                });
326            changed = true;
327        }
328    }
329    changed
330}
331
332pub fn foreign_keys_match_without_object_id(
333    left: &crate::ast::ForeignKey,
334    right: &crate::ast::ForeignKey,
335) -> bool {
336    let mut left = left.clone();
337    let mut right = right.clone();
338    left.object_id = None;
339    right.object_id = None;
340    left.catalog_identity = None;
341    right.catalog_identity = None;
342    left == right
343}
344
345/// Attachment provenance tracks one local row even after its name or enforcement flags change. Legacy entries may still lack the independent catalog identity.
346pub fn foreign_key_provenance_matches(
347    left: &crate::ast::ForeignKey,
348    right: &crate::ast::ForeignKey,
349) -> bool {
350    match (left.catalog_identity, right.catalog_identity) {
351        (Some(left), Some(right)) => left == right,
352        _ => {
353            (left.object_id.is_some() && left.object_id == right.object_id)
354                || foreign_keys_match_without_object_id(left, right)
355        }
356    }
357}
358
359pub fn synchronize_partition_inherited_foreign_key_ids(
360    constraints: &mut crate::ast::TableConstraintSet,
361) -> bool {
362    let mut changed = false;
363    for inherited_index in 0..constraints.hierarchy.partition_inherited_foreign_keys.len() {
364        let inherited = &constraints.hierarchy.partition_inherited_foreign_keys[inherited_index];
365        let Some(foreign_key_index) = constraints
366            .foreign_keys
367            .iter()
368            .position(|foreign_key| foreign_key_provenance_matches(foreign_key, inherited))
369        else {
370            continue;
371        };
372        let object_id = constraints.foreign_keys[foreign_key_index]
373            .object_id
374            .or(inherited.object_id);
375        if constraints.foreign_keys[foreign_key_index].object_id != object_id {
376            constraints.foreign_keys[foreign_key_index].object_id = object_id;
377            changed = true;
378        }
379        if constraints.hierarchy.partition_inherited_foreign_keys[inherited_index].object_id
380            != object_id
381        {
382            constraints.hierarchy.partition_inherited_foreign_keys[inherited_index].object_id =
383                object_id;
384            changed = true;
385        }
386        let catalog_identity = constraints.foreign_keys[foreign_key_index].catalog_identity;
387        if constraints.hierarchy.partition_inherited_foreign_keys[inherited_index].catalog_identity
388            != catalog_identity
389        {
390            constraints.hierarchy.partition_inherited_foreign_keys[inherited_index]
391                .catalog_identity = catalog_identity;
392            changed = true;
393        }
394    }
395    changed
396}
397
398fn assign_constraint_object_id(
399    target: &mut Option<[u8; 16]>,
400    allocate: &mut CatalogIdentityAllocator<'_>,
401) -> ConstraintMetadataResult<bool> {
402    assign_catalog_object_id(target, "foreign-key constraint", allocate)
403}
404
405fn assign_catalog_object_id(
406    target: &mut Option<[u8; 16]>,
407    object_kind: &str,
408    allocate: &mut CatalogIdentityAllocator<'_>,
409) -> ConstraintMetadataResult<bool> {
410    if target.is_some() {
411        return Ok(false);
412    }
413    *target = Some(allocate.allocate_object_id(object_kind)?);
414    Ok(true)
415}
416
417fn record_constraint_name(
418    used: &mut BTreeSet<String>,
419    name: Option<&str>,
420) -> ConstraintMetadataResult<()> {
421    let Some(name) = name else {
422        return Ok(());
423    };
424    if name.is_empty() {
425        return Err(ConstraintMetadataError::Invalid(
426            "constraint name must not be empty".into(),
427        ));
428    }
429    if !used.insert(name.to_string()) {
430        return Err(ConstraintMetadataError::Invalid(format!(
431            "constraint `{name}` is declared more than once"
432        )));
433    }
434    Ok(())
435}
436
437pub(super) fn assign_constraint_name(
438    target: &mut Option<String>,
439    parts: (&str, &str, &str),
440    used: &mut BTreeSet<String>,
441) -> ConstraintMetadataResult<bool> {
442    if target.is_some() {
443        return Ok(false);
444    }
445    let base = super::indexes::names::object_name(parts.0, parts.1, parts.2);
446    if used.insert(base.clone()) {
447        *target = Some(base);
448        return Ok(true);
449    }
450    for suffix in 1_u64.. {
451        let label = format!("{}{suffix}", parts.2);
452        let candidate = super::indexes::names::object_name(parts.0, parts.1, &label);
453        if used.insert(candidate.clone()) {
454            *target = Some(candidate);
455            return Ok(true);
456        }
457    }
458    Err(ConstraintMetadataError::Invalid(format!(
459        "constraint name suffix space exhausted for `{base}`"
460    )))
461}
462
463fn constraint_column_component(
464    columns: &[String],
465    relation: &RelationIdentity,
466) -> ConstraintMetadataResult<String> {
467    if columns.is_empty() {
468        return Err(ConstraintMetadataError::Invalid(format!(
469            "constraint on table `{}` has no columns",
470            relation.qualified_name()
471        )));
472    }
473    Ok(columns.join("_"))
474}
475
476fn collect_constraint_columns(expression: &crate::ast::Expr, output: &mut Vec<String>) {
477    use crate::ast::{Expr, FrameBound};
478    match expression {
479        Expr::Column(name) | Expr::QualifiedColumn { column: name, .. } => {
480            if !output.contains(name) {
481                output.push(name.clone());
482            }
483        }
484        Expr::Func {
485            args,
486            order_by,
487            filter,
488            ..
489        } => {
490            for argument in args {
491                collect_constraint_columns(argument, output);
492            }
493            for order in order_by {
494                collect_constraint_columns(&order.expr, output);
495            }
496            if let Some(filter) = filter {
497                collect_constraint_columns(filter, output);
498            }
499        }
500        Expr::Array(items) | Expr::Row(items) | Expr::And(items) | Expr::Or(items) => {
501            for item in items {
502                collect_constraint_columns(item, output);
503            }
504        }
505        Expr::Binary { lhs, rhs, .. } => {
506            collect_constraint_columns(lhs, output);
507            collect_constraint_columns(rhs, output);
508        }
509        Expr::Not(inner)
510        | Expr::UnaryMinus(inner)
511        | Expr::IsNull { expr: inner, .. }
512        | Expr::Cast { expr: inner, .. } => {
513            collect_constraint_columns(inner, output);
514        }
515        Expr::Between { expr, low, high } => {
516            collect_constraint_columns(expr, output);
517            collect_constraint_columns(low, output);
518            collect_constraint_columns(high, output);
519        }
520        Expr::InList { expr, list, .. } => {
521            collect_constraint_columns(expr, output);
522            for item in list {
523                collect_constraint_columns(item, output);
524            }
525        }
526        Expr::WindowCall { args, spec, .. } => {
527            for argument in args {
528                collect_constraint_columns(argument, output);
529            }
530            for expression in &spec.partition_by {
531                collect_constraint_columns(expression, output);
532            }
533            for order in &spec.order_by {
534                collect_constraint_columns(&order.expr, output);
535            }
536            if let Some(frame) = &spec.frame {
537                for bound in [&frame.start, &frame.end] {
538                    if let FrameBound::Preceding(expression) | FrameBound::Following(expression) =
539                        bound
540                    {
541                        collect_constraint_columns(expression, output);
542                    }
543                }
544            }
545        }
546        Expr::Case {
547            base,
548            when,
549            else_branch,
550        } => {
551            if let Some(base) = base {
552                collect_constraint_columns(base, output);
553            }
554            for (condition, result) in when {
555                collect_constraint_columns(condition, output);
556                collect_constraint_columns(result, output);
557            }
558            if let Some(else_branch) = else_branch {
559                collect_constraint_columns(else_branch, output);
560            }
561        }
562        Expr::InSubquery { expr, .. } => collect_constraint_columns(expr, output),
563        Expr::Default
564        | Expr::Star
565        | Expr::QualifiedStar(_)
566        | Expr::InternalColumn(_)
567        | Expr::Literal(_)
568        | Expr::TypedLiteral { .. }
569        | Expr::Param(_)
570        | Expr::ScalarSubquery(_)
571        | Expr::Exists { .. } => {}
572    }
573}