Skip to main content

uqa_sql/catalog/
relation_attributes.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Relation attribute numbers and the physical metadata retained by dropped slots.
8
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeSet;
11
12use super::type_metadata;
13use crate::ast::{AutoIncrementKind, ColumnDef, ColumnType};
14use crate::SQLError;
15
16/// `PostgreSQL`'s `MaxHeapAttributeNumber`, including dropped attributes.
17pub const MAX_ATTRIBUTES: i16 = 1600;
18
19/// A dropped attribute has no live name, type dependency, default or ACL. Its physical type metadata survives even when the old type is subsequently removed.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct DroppedAttribute {
23    pub number: i16,
24    pub type_length: i64,
25    pub type_modifier: i64,
26    pub dimensions: i64,
27    pub by_value: bool,
28    pub alignment: String,
29    pub storage: String,
30    pub collation: i64,
31    pub identity: String,
32    pub is_local: bool,
33    pub inheritance_count: i64,
34}
35
36impl DroppedAttribute {
37    pub fn from_column(
38        column: &ColumnDef,
39        position: usize,
40        is_local: bool,
41        inheritance_count: i64,
42    ) -> Result<Self, SQLError> {
43        Ok(Self {
44            number: column_number(column, position)?,
45            type_length: type_metadata::pg_type_len(&column.ty),
46            type_modifier: type_metadata::pg_type_modifier(&column.ty),
47            dimensions: array_dimension_count(&column.ty),
48            by_value: type_metadata::pg_type_by_value(&column.ty),
49            alignment: type_metadata::pg_type_align(&column.ty).into(),
50            storage: type_metadata::pg_type_storage(&column.ty).into(),
51            collation: type_metadata::pg_type_collation_oid(&column.ty),
52            identity: match column.auto_increment.as_ref().map(|value| value.kind) {
53                Some(AutoIncrementKind::IdentityAlways) => "a",
54                Some(AutoIncrementKind::IdentityByDefault | AutoIncrementKind::Legacy) => "d",
55                Some(AutoIncrementKind::Serial) | None => "",
56            }
57            .into(),
58            is_local,
59            inheritance_count,
60        })
61    }
62
63    pub fn name(&self) -> String {
64        super::composite_type::StoredCompositeAttribute::dropped_name(self.number)
65    }
66}
67
68/// Stored columns use their durable number. Unpublished declarations and derived view/index schemas have their own consecutive positions.
69pub fn column_number(column: &ColumnDef, position: usize) -> Result<i16, SQLError> {
70    let number = match column.attribute_number {
71        Some(number) => number,
72        None => i16::try_from(position + 1)
73            .map_err(|_| SQLError::Internal("attribute number is out of range".into()))?,
74    };
75    if number <= 0 {
76        return Err(SQLError::Internal(
77            "attribute number must be positive".into(),
78        ));
79    }
80    Ok(number)
81}
82
83pub fn column_names(columns: &[ColumnDef]) -> Result<Vec<(i16, String)>, SQLError> {
84    columns
85        .iter()
86        .enumerate()
87        .map(|(index, column)| Ok((column_number(column, index)?, column.name.clone())))
88        .collect()
89}
90
91pub fn column_by_number(columns: &[ColumnDef], number: i64) -> Option<&ColumnDef> {
92    columns.iter().enumerate().find_map(|(index, column)| {
93        column_number(column, index)
94            .is_ok_and(|candidate| i64::from(candidate) == number)
95            .then_some(column)
96    })
97}
98
99pub fn consecutive_names(names: Vec<String>) -> Result<Vec<(i16, String)>, SQLError> {
100    names
101        .into_iter()
102        .enumerate()
103        .map(|(index, name)| {
104            Ok((
105                i16::try_from(index + 1)
106                    .map_err(|_| SQLError::Internal("attribute number is out of range".into()))?,
107                name,
108            ))
109        })
110        .collect()
111}
112
113pub fn array_dimension_count(ty: &ColumnType) -> i64 {
114    let mut dimensions = 0;
115    let mut current = ty;
116    while let ColumnType::Array(element) = current {
117        dimensions += 1;
118        current = element;
119    }
120    dimensions
121}
122
123/// A schema candidate retains numbers only from this relation's existing columns. Inherited or newly declared columns receive fresh local numbers when published.
124pub fn retain_numbers(previous: &[ColumnDef], candidate: &mut [ColumnDef]) {
125    for column in candidate {
126        column.attribute_number = previous
127            .iter()
128            .find(|prior| {
129                column
130                    .object_id
131                    .map_or(prior.name == column.name, |identity| {
132                        prior.object_id == Some(identity)
133                    })
134            })
135            .and_then(|prior| prior.attribute_number);
136    }
137}
138
139/// Assign numbers only to newly published columns, after every live or dropped slot. Existing numbers and the inputs remain unchanged on an invalid layout.
140pub fn materialize(
141    columns: &mut [ColumnDef],
142    dropped: &[DroppedAttribute],
143) -> Result<bool, SQLError> {
144    let mut maximum = columns
145        .iter()
146        .filter_map(|column| column.attribute_number)
147        .chain(dropped.iter().map(|attribute| attribute.number))
148        .max()
149        .unwrap_or(0);
150    let mut numbers = Vec::with_capacity(columns.len());
151    let mut changed = false;
152    for column in columns.iter() {
153        numbers.push(if let Some(number) = column.attribute_number {
154            number
155        } else {
156            maximum = maximum.checked_add(1).ok_or_else(too_many_columns)?;
157            changed = true;
158            maximum
159        });
160    }
161    validate_numbers(&numbers, dropped)?;
162    for (column, number) in columns.iter_mut().zip(numbers) {
163        column.attribute_number = Some(number);
164    }
165    Ok(changed)
166}
167
168/// Load-only restoration rejects missing or contradictory metadata instead of repairing a catalog snapshot.
169pub fn validate(columns: &[ColumnDef], dropped: &[DroppedAttribute]) -> Result<(), SQLError> {
170    let numbers = columns
171        .iter()
172        .map(|column| {
173            column.attribute_number.ok_or_else(|| {
174                SQLError::Internal("relation attributes require initial catalog migration".into())
175            })
176        })
177        .collect::<Result<Vec<_>, _>>()?;
178    validate_numbers(&numbers, dropped)
179}
180
181fn validate_numbers(numbers: &[i16], dropped: &[DroppedAttribute]) -> Result<(), SQLError> {
182    let all: BTreeSet<_> = numbers
183        .iter()
184        .copied()
185        .chain(dropped.iter().map(|attribute| attribute.number))
186        .collect();
187    if all.last().is_some_and(|number| *number > MAX_ATTRIBUTES) {
188        return Err(too_many_columns());
189    }
190    if all.len() != numbers.len() + dropped.len()
191        || all
192            .iter()
193            .copied()
194            .ne(1..=i16::try_from(all.len()).unwrap_or(i16::MAX))
195        || numbers.windows(2).any(|pair| pair[0] >= pair[1])
196    {
197        return Err(SQLError::Internal(
198            "invalid relation attribute slot layout".into(),
199        ));
200    }
201    Ok(())
202}
203
204fn too_many_columns() -> SQLError {
205    SQLError::Routine {
206        sqlstate: "54011".into(),
207        message: format!("tables can have at most {MAX_ATTRIBUTES} columns"),
208    }
209}
210
211#[cfg(test)]
212mod tests;