Skip to main content

uqa_sql/semantics/partition/
bounds.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! `PostgreSQL` partition bound transformation (`transformPartitionBound`). Every declared value is analyzed as a partition bound expression, coerced to its partition key type with assignment semantics, and evaluated once, so a stored bound holds typed constants that routing compares directly and a later catalog change such as `ALTER TYPE ... RENAME VALUE` cannot reinterpret.
8
9use super::key::{key_columns, KeyColumn};
10use super::PartitionContext;
11use crate::ast::{Expr, PartitionBound, PartitionRangeDatum, PartitionSpec, PartitionStrategy};
12use crate::ir::ScalarExpr;
13use crate::schema::SchemaExpressionCatalog;
14use crate::SQLError;
15use uqa_core::Value;
16
17/// Transform a declared bound for a new partition of `parent` into its stored form.
18pub fn transform_partition_bound(
19    context: &PartitionContext<'_>,
20    parent: &str,
21    bound: &PartitionBound,
22) -> Result<PartitionBound, SQLError> {
23    let (spec, keys) = parent_partition_key(context, parent)?;
24    match (spec.strategy, bound) {
25        (PartitionStrategy::Hash, PartitionBound::Default) => Err(invalid_table_definition(
26            "a hash-partitioned table may not have a default partition",
27        )),
28        (_, PartitionBound::Default) => Ok(PartitionBound::Default),
29        (PartitionStrategy::Hash, PartitionBound::Hash { modulus, remainder }) => {
30            if *modulus <= 0 {
31                return Err(invalid_table_definition(
32                    "modulus for hash partition must be an integer value greater than zero",
33                ));
34            }
35            if *remainder < 0 {
36                return Err(invalid_table_definition(
37                    "remainder for hash partition must be an integer value greater than or equal to zero",
38                ));
39            }
40            if remainder >= modulus {
41                return Err(invalid_table_definition(
42                    "remainder for hash partition must be less than modulus",
43                ));
44            }
45            Ok(bound.clone())
46        }
47        (PartitionStrategy::Hash, _) => Err(invalid_table_definition(
48            "invalid bound specification for a hash partition",
49        )),
50        (PartitionStrategy::List, PartitionBound::List(values)) => {
51            let [key] = keys.as_slice() else {
52                return Err(SQLError::Internal(
53                    "LIST partitioned table has more than one partition key".into(),
54                ));
55            };
56            let mut transformed: Vec<Value> = Vec::with_capacity(values.len());
57            for expression in values {
58                let value = transform_value(context, expression, &key.name, key)?;
59                // Equal constants appear once, as `transformPartitionBound` removes duplicates.
60                if !transformed.contains(&value) {
61                    transformed.push(value);
62                }
63            }
64            Ok(PartitionBound::List(
65                transformed.into_iter().map(Expr::Literal).collect(),
66            ))
67        }
68        (PartitionStrategy::List, _) => Err(invalid_table_definition(
69            "invalid bound specification for a list partition",
70        )),
71        (PartitionStrategy::Range, PartitionBound::Range { lower, upper }) => {
72            if lower.len() != keys.len() {
73                return Err(invalid_table_definition(
74                    "FROM must specify exactly one value per partitioning column",
75                ));
76            }
77            if upper.len() != keys.len() {
78                return Err(invalid_table_definition(
79                    "TO must specify exactly one value per partitioning column",
80                ));
81            }
82            Ok(PartitionBound::Range {
83                lower: transform_range_datums(context, lower, &keys)?,
84                upper: transform_range_datums(context, upper, &keys)?,
85            })
86        }
87        (PartitionStrategy::Range, _) => Err(invalid_table_definition(
88            "invalid bound specification for a range partition",
89        )),
90    }
91}
92
93pub(super) fn parent_partition_key(
94    context: &PartitionContext<'_>,
95    parent: &str,
96) -> Result<(PartitionSpec, Vec<KeyColumn>), SQLError> {
97    let hierarchy = context
98        .catalog
99        .try_table_hierarchy(parent)
100        .map_err(|error| SQLError::Internal(format!("read parent partition metadata: {error}")))?;
101    let spec = hierarchy.partition_spec.ok_or_else(|| SQLError::Routine {
102        sqlstate: "42809".into(),
103        message: format!("relation \"{parent}\" is not partitioned"),
104    })?;
105    let columns = context
106        .catalog
107        .try_describe_table(parent)
108        .map_err(|error| SQLError::Internal(format!("read partition row type: {error}")))?
109        .ok_or_else(|| SQLError::UnknownTable(parent.to_string()))?;
110    let keys = key_columns(context.types, context.expressions, &spec, &columns)?;
111    Ok((spec, keys))
112}
113
114/// `transformPartitionRangeBounds`: `MINVALUE` and `MAXVALUE` pass through, NULL is rejected, and infinite bounds must continue to the last column.
115fn transform_range_datums(
116    context: &PartitionContext<'_>,
117    datums: &[PartitionRangeDatum],
118    keys: &[KeyColumn],
119) -> Result<Vec<PartitionRangeDatum>, SQLError> {
120    let expression_names = keys
121        .iter()
122        .filter(|key| key.expression)
123        .map(|key| key.name.as_str())
124        .collect::<Vec<_>>();
125    // PostgreSQL names an expression key by the next unused key expression, which advances only for value datums.
126    let mut next_expression = 0;
127    let mut result = Vec::with_capacity(datums.len());
128    for (position, datum) in datums.iter().enumerate() {
129        let PartitionRangeDatum::Value(expression) = datum else {
130            result.push(datum.clone());
131            continue;
132        };
133        let key = &keys[position];
134        let name = if key.expression {
135            let name = expression_names.get(next_expression).ok_or_else(|| {
136                SQLError::Internal("partition range bound names a missing key expression".into())
137            })?;
138            next_expression += 1;
139            *name
140        } else {
141            key.name.as_str()
142        };
143        let value = transform_value(context, expression, name, key)?;
144        if matches!(value, Value::Null) {
145            return Err(SQLError::Routine {
146                sqlstate: "42P17".into(),
147                message: "cannot specify NULL in range bound".into(),
148            });
149        }
150        result.push(PartitionRangeDatum::Value(Expr::Literal(value)));
151    }
152    validate_infinite_bounds(&result)?;
153    Ok(result)
154}
155
156fn validate_infinite_bounds(datums: &[PartitionRangeDatum]) -> Result<(), SQLError> {
157    let mut infinite = None;
158    for datum in datums {
159        match (infinite, datum) {
160            (None, PartitionRangeDatum::Value(_))
161            | (Some(true), PartitionRangeDatum::MinValue)
162            | (Some(false), PartitionRangeDatum::MaxValue) => {}
163            (None, PartitionRangeDatum::MinValue) => infinite = Some(true),
164            (None, PartitionRangeDatum::MaxValue) => infinite = Some(false),
165            (Some(minimum), _) => {
166                let bound = if minimum { "MINVALUE" } else { "MAXVALUE" };
167                return Err(SQLError::Routine {
168                    sqlstate: "42804".into(),
169                    message: format!("every bound following {bound} must also be {bound}"),
170                });
171            }
172        }
173    }
174    Ok(())
175}
176
177/// `transformPartitionBoundValue`: analyze, coerce to the key type in assignment context, and evaluate once.
178fn transform_value(
179    context: &PartitionContext<'_>,
180    expression: &Expr,
181    column: &str,
182    key: &KeyColumn,
183) -> Result<Value, SQLError> {
184    let plan = crate::plan::ExpressionPlan::lower(expression.clone());
185    validate_bound_expression(context.schema, &plan.scalar)?;
186    let source = crate::type_resolution::common_context_expression_type(
187        &plan.scalar,
188        &crate::RowSchema::default(),
189        &[],
190        Some(context.types),
191    )?;
192    if let Some(source) = source.as_ref() {
193        if !crate::type_resolution::assignment_type_compatible(source, &key.ty) {
194            return Err(SQLError::Routine {
195                sqlstate: "42804".into(),
196                message: format!(
197                    "specified value cannot be cast to type {} for column \"{column}\"",
198                    format_type(context, &key.ty)?
199                ),
200            });
201        }
202    }
203    let value = context.expressions.evaluate_bound(expression, &[])?;
204    crate::assignment::conversion::coerce_assignment_value(
205        context.assignment,
206        value,
207        &key.ty,
208        source.as_ref(),
209    )
210}
211
212/// `format_type_be`: the catalog's visible spelling of a type, without type modifiers.
213pub(super) fn format_type(
214    context: &PartitionContext<'_>,
215    ty: &crate::ast::ColumnType,
216) -> Result<String, SQLError> {
217    let oid = crate::catalog::type_metadata::pg_type_oid(ty);
218    Ok(context
219        .assignment
220        .resolve_regtype_output(&crate::ast::ColumnType::Regtype, oid)
221        .map_err(SQLError::Internal)?
222        .unwrap_or_else(|| ty.regtype_name()))
223}
224
225/// Reject what parse analysis disallows in a partition bound, reporting the first violation in analysis order: a call's arguments before the call itself, and a subquery before its body.
226fn validate_bound_expression(
227    catalog: &dyn SchemaExpressionCatalog,
228    expression: &ScalarExpr,
229) -> Result<(), SQLError> {
230    // A bare `*` only appears as the argument of `count(*)`, which parse analysis reports as an aggregate.
231    match expression {
232        ScalarExpr::Column(_)
233        | ScalarExpr::QualifiedColumn { .. }
234        | ScalarExpr::QualifiedStar(_)
235        | ScalarExpr::Position(_)
236        | ScalarExpr::InternalColumn(_) => {
237            return Err(feature_not_supported(
238                "cannot use column reference in partition bound expression",
239            ))
240        }
241        ScalarExpr::ScalarSubquery(_)
242        | ScalarExpr::Exists { .. }
243        | ScalarExpr::InSubquery { .. } => {
244            return Err(feature_not_supported(
245                "cannot use subquery in partition bound",
246            ))
247        }
248        ScalarExpr::Param(index) => {
249            return Err(SQLError::Routine {
250                sqlstate: "42P02".into(),
251                message: format!("there is no parameter ${index}"),
252            })
253        }
254        _ => {}
255    }
256    let mut root = true;
257    expression.try_visit(&mut |node| {
258        if std::mem::take(&mut root) {
259            return Ok(true);
260        }
261        validate_bound_expression(catalog, node)?;
262        Ok::<_, SQLError>(false)
263    })?;
264    match expression {
265        ScalarExpr::WindowCall { .. } => Err(SQLError::Routine {
266            sqlstate: "42P20".into(),
267            message: "window functions are not allowed in partition bound".into(),
268        }),
269        ScalarExpr::Func { .. }
270            if crate::semantics::aggregates::is_aggregate(catalog, expression) =>
271        {
272            Err(SQLError::Routine {
273                sqlstate: "42803".into(),
274                message: "aggregate functions are not allowed in partition bound".into(),
275            })
276        }
277        ScalarExpr::Func { .. }
278            if crate::semantics::sets::validation::expression_may_return_set(
279                catalog,
280                catalog,
281                expression,
282                &crate::RowSchema::default(),
283                &[],
284            )? =>
285        {
286            Err(feature_not_supported(
287                "set-returning functions are not allowed in partition bound",
288            ))
289        }
290        _ => Ok(()),
291    }
292}
293
294fn feature_not_supported(message: &str) -> SQLError {
295    SQLError::Routine {
296        sqlstate: "0A000".into(),
297        message: message.into(),
298    }
299}
300
301fn invalid_table_definition(message: &str) -> SQLError {
302    SQLError::Routine {
303        sqlstate: "42P16".into(),
304        message: message.into(),
305    }
306}