Skip to main content

uqa_sql/semantics/partition/
admission.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! `PostgreSQL` admission of a transformed partition bound (`check_new_partition_bound`): a second default partition, an empty range, a broken hash modulus chain, and overlap with an existing partition, each reported against the partitions `PostgreSQL` names.
8
9use super::bounds::parent_partition_key;
10use super::datum_text::{range_bound_text, stored_datum};
11use super::PartitionContext;
12use crate::ast::{PartitionBound, PartitionRangeDatum};
13use crate::expr::EngineHook;
14use crate::SQLError;
15use std::cmp::Ordering;
16
17/// One existing partition of the parent with its stored bound.
18struct Sibling {
19    name: String,
20    bound: PartitionBound,
21}
22
23/// Validate that `bound`, already transformed, can be added for `partition` under `parent`.
24pub fn validate_new_partition_bound(
25    context: &PartitionContext<'_>,
26    parent: &str,
27    partition: &str,
28    bound: &PartitionBound,
29) -> Result<(), SQLError> {
30    let (_, keys) = parent_partition_key(context, parent)?;
31    let siblings = sibling_bounds(context, parent)?;
32    let partition = local_relation_name(partition)?;
33    let overlapping = match bound {
34        PartitionBound::Default => {
35            if let Some(default) = siblings
36                .iter()
37                .find(|sibling| matches!(sibling.bound, PartitionBound::Default))
38            {
39                return Err(invalid_object_definition(format!(
40                    "partition \"{partition}\" conflicts with existing default partition \"{}\"",
41                    default.name
42                )));
43            }
44            None
45        }
46        PartitionBound::List(values) => list_overlap(values, &siblings)?,
47        PartitionBound::Range { lower, upper } => {
48            if compare_range_points(lower, upper)? != Ordering::Less {
49                let types = keys.iter().map(|key| key.ty.clone()).collect::<Vec<_>>();
50                let engine: &dyn EngineHook = context.assignment;
51                return Err(SQLError::Diagnostic {
52                    sqlstate: "42P17".into(),
53                    message: format!("empty range bound specified for partition \"{partition}\""),
54                    detail: Some(format!(
55                        "Specified lower bound {} is greater than or equal to upper bound {}.",
56                        range_bound_text(lower, &types, Some(engine))?,
57                        range_bound_text(upper, &types, Some(engine))?
58                    )),
59                    hint: None,
60                });
61            }
62            range_overlap(lower, upper, &siblings)?
63        }
64        PartitionBound::Hash { modulus, remainder } => {
65            hash_overlap(*modulus, *remainder, &siblings)?
66        }
67    };
68    match overlapping {
69        Some(sibling) => Err(invalid_object_definition(format!(
70            "partition \"{partition}\" would overlap partition \"{sibling}\""
71        ))),
72        None => Ok(()),
73    }
74}
75
76fn sibling_bounds(context: &PartitionContext<'_>, parent: &str) -> Result<Vec<Sibling>, SQLError> {
77    let mut siblings = Vec::new();
78    for child in context.catalog.direct_hierarchy_children(parent)? {
79        let hierarchy = context
80            .catalog
81            .try_table_hierarchy(&child)
82            .map_err(|error| SQLError::Internal(format!("read sibling partition: {error}")))?;
83        if let Some(bound) = hierarchy.partition_bound {
84            siblings.push(Sibling {
85                name: local_relation_name(&child)?,
86                bound,
87            });
88        }
89    }
90    Ok(siblings)
91}
92
93/// The first value of the new list, in declared order, that an existing partition accepts names that partition; NULL overlaps the partition that accepts NULL.
94fn list_overlap(
95    values: &[crate::ast::Expr],
96    siblings: &[Sibling],
97) -> Result<Option<String>, SQLError> {
98    for value in values {
99        let value = stored_datum(value)?;
100        for sibling in siblings {
101            let PartitionBound::List(existing) = &sibling.bound else {
102                continue;
103            };
104            for candidate in existing {
105                if stored_datum(candidate)?.cmp(value) == Ordering::Equal {
106                    return Ok(Some(sibling.name.clone()));
107                }
108            }
109        }
110    }
111    Ok(None)
112}
113
114/// The partition containing the new lower bound, or else the next partition when the new range does not fit in the gap before it: the first overlapping partition in bound order.
115fn range_overlap(
116    lower: &[PartitionRangeDatum],
117    upper: &[PartitionRangeDatum],
118    siblings: &[Sibling],
119) -> Result<Option<String>, SQLError> {
120    let mut ranges = Vec::new();
121    for sibling in siblings {
122        if let PartitionBound::Range {
123            lower: existing_lower,
124            upper: existing_upper,
125        } = &sibling.bound
126        {
127            ranges.push((existing_lower, existing_upper, &sibling.name));
128        }
129    }
130    let mut ordering_error = None;
131    ranges.sort_by(|left, right| {
132        compare_range_points(left.0, right.0).unwrap_or_else(|error| {
133            ordering_error.get_or_insert(error);
134            Ordering::Equal
135        })
136    });
137    if let Some(error) = ordering_error {
138        return Err(error);
139    }
140    for (existing_lower, existing_upper, name) in ranges {
141        if compare_range_points(lower, existing_upper)? == Ordering::Less
142            && compare_range_points(existing_lower, upper)? == Ordering::Less
143        {
144            return Ok(Some(name.clone()));
145        }
146    }
147    Ok(None)
148}
149
150/// Compare two range bound points datum by datum, where `MINVALUE` precedes and `MAXVALUE` follows every value.
151pub(super) fn compare_range_points(
152    left: &[PartitionRangeDatum],
153    right: &[PartitionRangeDatum],
154) -> Result<Ordering, SQLError> {
155    if left.len() != right.len() {
156        return Err(SQLError::Internal(
157            "partition range points have different widths".into(),
158        ));
159    }
160    for (left, right) in left.iter().zip(right) {
161        let ordering = match (left, right) {
162            (PartitionRangeDatum::MinValue, PartitionRangeDatum::MinValue)
163            | (PartitionRangeDatum::MaxValue, PartitionRangeDatum::MaxValue) => Ordering::Equal,
164            (PartitionRangeDatum::MinValue, _) | (_, PartitionRangeDatum::MaxValue) => {
165                Ordering::Less
166            }
167            (PartitionRangeDatum::MaxValue, _) | (_, PartitionRangeDatum::MinValue) => {
168                Ordering::Greater
169            }
170            (PartitionRangeDatum::Value(left), PartitionRangeDatum::Value(right)) => {
171                stored_datum(left)?.cmp(stored_datum(right)?)
172            }
173        };
174        if ordering != Ordering::Equal {
175            return Ok(ordering);
176        }
177    }
178    Ok(Ordering::Equal)
179}
180
181/// Check the modulus chain against the neighboring existing moduli, then report the first existing partition that covers a remainder the new partition would own.
182fn hash_overlap(
183    modulus: i32,
184    remainder: i32,
185    siblings: &[Sibling],
186) -> Result<Option<String>, SQLError> {
187    let mut existing = siblings
188        .iter()
189        .filter_map(|sibling| match sibling.bound {
190            PartitionBound::Hash { modulus, remainder } => {
191                Some((modulus, remainder, &sibling.name))
192            }
193            _ => None,
194        })
195        .collect::<Vec<_>>();
196    if existing.is_empty() {
197        return Ok(None);
198    }
199    existing.sort_by_key(|(modulus, remainder, _)| (*modulus, *remainder));
200    let not_a_factor = |next: i32, name: &str| {
201        modulus_error(format!(
202            "The new modulus {modulus} is not a factor of {next}, the modulus of existing partition \"{name}\"."
203        ))
204    };
205    match existing
206        .iter()
207        .rposition(|(existing_modulus, existing_remainder, _)| {
208            (*existing_modulus, *existing_remainder) <= (modulus, remainder)
209        }) {
210        None => {
211            let (next, _, name) = existing[0];
212            if next % modulus != 0 {
213                return Err(not_a_factor(next, name));
214            }
215        }
216        Some(offset) => {
217            let (previous, _, name) = existing[offset];
218            if modulus % previous != 0 {
219                return Err(modulus_error(format!(
220                    "The new modulus {modulus} is not divisible by {previous}, the modulus of existing partition \"{name}\"."
221                )));
222            }
223            if let Some((next, _, name)) = existing.get(offset + 1) {
224                if next % modulus != 0 {
225                    return Err(not_a_factor(*next, name));
226                }
227            }
228        }
229    }
230    let greatest = existing
231        .iter()
232        .map(|(modulus, _, _)| *modulus)
233        .max()
234        .unwrap_or(modulus);
235    let mut covered = if remainder >= greatest {
236        remainder % greatest
237    } else {
238        remainder
239    };
240    loop {
241        if let Some((_, _, name)) = existing
242            .iter()
243            .find(|(modulus, remainder, _)| covered % modulus == *remainder)
244        {
245            return Ok(Some((*name).clone()));
246        }
247        covered += modulus;
248        if covered >= greatest {
249            return Ok(None);
250        }
251    }
252}
253
254fn modulus_error(detail: String) -> SQLError {
255    SQLError::Diagnostic {
256        sqlstate: "42P17".into(),
257        message: "every hash partition modulus must be a factor of the next larger modulus".into(),
258        detail: Some(detail),
259        hint: None,
260    }
261}
262
263/// Relation names in partition diagnostics are unqualified, as `RelationGetRelationName` reports them.
264pub(super) fn local_relation_name(table: &str) -> Result<String, SQLError> {
265    uqa_core::RelationIdentity::from_legacy_name(table)
266        .map(|identity| identity.name)
267        .map_err(SQLError::Internal)
268}
269
270fn invalid_object_definition(message: String) -> SQLError {
271    SQLError::Routine {
272        sqlstate: "42P17".into(),
273        message,
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::{hash_overlap, Sibling};
280    use crate::ast::PartitionBound;
281
282    fn hash(name: &str, modulus: i32, remainder: i32) -> Sibling {
283        Sibling {
284            name: name.into(),
285            bound: PartitionBound::Hash { modulus, remainder },
286        }
287    }
288
289    #[test]
290    fn hash_admission_follows_check_new_partition_bound() {
291        let existing = [hash("mod4_r0", 4, 0), hash("mod8_r2", 8, 2)];
292        let not_a_factor = hash_overlap(3, 1, &existing).unwrap_err();
293        assert_eq!(
294            not_a_factor.detail(),
295            Some("The new modulus 3 is not a factor of 4, the modulus of existing partition \"mod4_r0\".")
296        );
297        let not_divisible = hash_overlap(12, 1, &existing).unwrap_err();
298        assert_eq!(
299            not_divisible.detail(),
300            Some("The new modulus 12 is not divisible by 8, the modulus of existing partition \"mod8_r2\".")
301        );
302        // Modulus 2, remainder 0 covers remainders 0, 2, 4 and 6 of the greatest modulus 8; the first, 0, belongs to mod4_r0.
303        assert_eq!(
304            hash_overlap(2, 0, &existing).unwrap(),
305            Some("mod4_r0".into())
306        );
307        assert_eq!(hash_overlap(8, 6, &existing).unwrap(), None);
308        assert_eq!(
309            hash_overlap(16, 10, &existing).unwrap(),
310            Some("mod8_r2".into())
311        );
312    }
313}