Skip to main content

uqa_sql/semantics/
partition.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Declarative partition validation, value comparison, and routing semantics.
8
9use crate::{
10    ast::{ColumnDef, Expr, TableHierarchy},
11    type_resolution::FunctionTypeResolver,
12    ResultRow, RowSchema, SQLError, SQLParam,
13};
14use std::cmp::Ordering;
15use uqa_core::Value;
16
17/// Hierarchy definitions needed to validate and route a row; no storage writes are exposed.
18pub trait PartitionCatalog {
19    fn try_table_hierarchy(&self, table: &str) -> Result<TableHierarchy, String>;
20    fn direct_hierarchy_children(&self, parent: &str) -> Result<Vec<String>, SQLError>;
21    fn try_resolve_table_name(&self, name: &str) -> Result<Option<String>, String>;
22    fn try_describe_table(&self, table: &str) -> Result<Option<Vec<ColumnDef>>, String>;
23}
24
25/// Evaluate declared partition keys and bounds with the caller's expression scope.
26pub trait PartitionExpressions {
27    fn evaluate_bound(&self, expression: &Expr, params: &[SQLParam]) -> Result<Value, SQLError>;
28    fn evaluate_row(
29        &self,
30        expression: &Expr,
31        row: &ResultRow,
32        schema: &RowSchema,
33        params: &[SQLParam],
34    ) -> Result<Value, SQLError>;
35}
36
37#[derive(Clone, Copy)]
38pub struct PartitionContext<'a> {
39    pub catalog: &'a dyn PartitionCatalog,
40    pub expressions: &'a dyn PartitionExpressions,
41    pub types: &'a dyn FunctionTypeResolver,
42}
43
44mod hash;
45
46pub fn validate_hash_partition_spec(
47    context: &PartitionContext<'_>,
48    spec: &crate::ast::PartitionSpec,
49    columns: &[crate::ast::ColumnDef],
50) -> Result<(), SQLError> {
51    hash::validate_partition_spec(context.types, spec, columns)
52}
53
54pub fn validate_new_partition_bound(
55    context: &PartitionContext<'_>,
56    parent: &str,
57    bound: &crate::ast::PartitionBound,
58) -> Result<(), SQLError> {
59    let hierarchy = context
60        .catalog
61        .try_table_hierarchy(parent)
62        .map_err(|error| SQLError::Internal(format!("read parent partition metadata: {error}")))?;
63    let spec = hierarchy
64        .partition_spec
65        .as_ref()
66        .ok_or_else(|| SQLError::Routine {
67            sqlstate: "42809".into(),
68            message: format!("relation \"{parent}\" is not partitioned"),
69        })?;
70    validate_partition_bound_width(spec, bound)?;
71    if let crate::ast::PartitionBound::Hash { modulus, remainder } = bound {
72        hash::validate_bound(*modulus, *remainder)?;
73        let mut existing_moduli = Vec::new();
74        for sibling in context.catalog.direct_hierarchy_children(parent)? {
75            let sibling_hierarchy = context
76                .catalog
77                .try_table_hierarchy(&sibling)
78                .map_err(|error| SQLError::Internal(format!("read sibling partition: {error}")))?;
79            match sibling_hierarchy.partition_bound.as_ref() {
80                Some(crate::ast::PartitionBound::Hash { modulus, remainder }) => {
81                    hash::validate_bound(*modulus, *remainder)?;
82                    existing_moduli.push(*modulus);
83                }
84                Some(crate::ast::PartitionBound::Default) => {
85                    return Err(SQLError::Internal(format!(
86                        "HASH-partitioned table `{parent}` has a default partition"
87                    )))
88                }
89                Some(_) => {
90                    return Err(SQLError::Internal(
91                        "partition siblings use different bound strategies".into(),
92                    ))
93                }
94                None => {}
95            }
96        }
97        hash::validate_modulus_chain(*modulus, existing_moduli)?;
98    }
99    if let crate::ast::PartitionBound::Range { lower, upper } = bound {
100        if compare_partition_points(context, lower, upper)? != Ordering::Less {
101            return Err(invalid_partition_bound(
102                "empty range bound specified for partition",
103            ));
104        }
105    }
106    for sibling in context.catalog.direct_hierarchy_children(parent)? {
107        let sibling_hierarchy = context
108            .catalog
109            .try_table_hierarchy(&sibling)
110            .map_err(|error| SQLError::Internal(format!("read sibling partition: {error}")))?;
111        let Some(sibling_bound) = sibling_hierarchy.partition_bound.as_ref() else {
112            continue;
113        };
114        if partition_bounds_overlap(context, bound, sibling_bound)? {
115            return Err(invalid_partition_bound(format!(
116                "partition would overlap partition \"{sibling}\""
117            )));
118        }
119    }
120    Ok(())
121}
122
123/// Test one stored row against a prospective direct-child bound before the
124/// hierarchy edge is installed. DEFAULT means no existing non-default sibling
125/// accepts the row, matching the routing decision the new edge will expose.
126pub fn prospective_partition_bound_accepts_document(
127    context: &PartitionContext<'_>,
128    parent: &str,
129    bound: &crate::ast::PartitionBound,
130    document: &ResultRow,
131) -> Result<bool, SQLError> {
132    let hierarchy = context
133        .catalog
134        .try_table_hierarchy(parent)
135        .map_err(|error| SQLError::Internal(format!("read parent partition metadata: {error}")))?;
136    let spec = hierarchy
137        .partition_spec
138        .as_ref()
139        .ok_or_else(|| SQLError::Routine {
140            sqlstate: "42809".into(),
141            message: format!("relation \"{parent}\" is not partitioned"),
142        })?;
143    let (keys, row_hash) = partition_key_values_and_hash(context, parent, spec, document)?;
144    if !matches!(bound, crate::ast::PartitionBound::Default) {
145        return partition_bound_matches(context, bound, &keys, &[], row_hash);
146    }
147    for sibling in context.catalog.direct_hierarchy_children(parent)? {
148        let sibling_hierarchy = context
149            .catalog
150            .try_table_hierarchy(&sibling)
151            .map_err(|error| SQLError::Internal(format!("read child partition: {error}")))?;
152        let Some(sibling_bound) = sibling_hierarchy.partition_bound.as_ref() else {
153            continue;
154        };
155        if matches!(sibling_bound, crate::ast::PartitionBound::Default) {
156            continue;
157        }
158        if partition_bound_matches(context, sibling_bound, &keys, &[], row_hash)? {
159            return Ok(false);
160        }
161    }
162    Ok(true)
163}
164
165/// Evaluate a retained detached-partition CHECK without requiring a live
166/// parent edge. This is also the exact predicate used for prospective ATTACH
167/// row scans.
168pub fn partition_constraint_accepts_document(
169    context: &PartitionContext<'_>,
170    table: &str,
171    spec: &crate::ast::PartitionSpec,
172    bound: &crate::ast::PartitionBound,
173    document: &ResultRow,
174) -> Result<bool, SQLError> {
175    let (keys, row_hash) = partition_key_values_and_hash(context, table, spec, document)?;
176    partition_bound_matches(context, bound, &keys, &[], row_hash)
177}
178
179fn partition_key_values_and_hash(
180    context: &PartitionContext<'_>,
181    table: &str,
182    spec: &crate::ast::PartitionSpec,
183    document: &ResultRow,
184) -> Result<(Vec<Value>, Option<u64>), SQLError> {
185    let (keys, definitions) = evaluate_partition_keys(context, table, &spec.keys, document, &[])?;
186    let row_hash = (spec.strategy == crate::ast::PartitionStrategy::Hash)
187        .then(|| hash::row_hash(context.types, spec, &definitions, &keys))
188        .transpose()?;
189    Ok((keys, row_hash))
190}
191
192fn validate_partition_bound_width(
193    spec: &crate::ast::PartitionSpec,
194    bound: &crate::ast::PartitionBound,
195) -> Result<(), SQLError> {
196    use crate::ast::{PartitionBound, PartitionStrategy};
197    match (spec.strategy, bound) {
198        (_, PartitionBound::Default) => Ok(()),
199        (PartitionStrategy::List, PartitionBound::List(_)) if spec.keys.len() != 1 => {
200            Err(invalid_partition_bound(
201                "cannot use list partition bounds with more than one partition key",
202            ))
203        }
204        (PartitionStrategy::List, PartitionBound::List(_)) => Ok(()),
205        (PartitionStrategy::Range, PartitionBound::Range { lower, upper })
206            if lower.len() != spec.keys.len() || upper.len() != spec.keys.len() =>
207        {
208            Err(invalid_partition_bound(
209                "partition bound has the wrong number of columns",
210            ))
211        }
212        (PartitionStrategy::Range, PartitionBound::Range { .. })
213        | (PartitionStrategy::Hash, PartitionBound::Hash { .. }) => Ok(()),
214        (strategy, _) => Err(invalid_partition_bound(format!(
215            "invalid bound specification for a {} partitioned table",
216            match strategy {
217                PartitionStrategy::List => "list",
218                PartitionStrategy::Range => "range",
219                PartitionStrategy::Hash => "hash",
220            }
221        ))),
222    }
223}
224
225fn partition_bounds_overlap(
226    context: &PartitionContext<'_>,
227    left: &crate::ast::PartitionBound,
228    right: &crate::ast::PartitionBound,
229) -> Result<bool, SQLError> {
230    use crate::ast::PartitionBound;
231    match (left, right) {
232        (PartitionBound::Default, PartitionBound::Default) => Ok(true),
233        (PartitionBound::Default, _) | (_, PartitionBound::Default) => Ok(false),
234        (PartitionBound::List(left), PartitionBound::List(right)) => {
235            let left = evaluate_bound_values(context, left)?;
236            let right = evaluate_bound_values(context, right)?;
237            Ok(left.iter().any(|value| right.contains(value)))
238        }
239        (
240            PartitionBound::Range {
241                lower: left_lower,
242                upper: left_upper,
243            },
244            PartitionBound::Range {
245                lower: right_lower,
246                upper: right_upper,
247            },
248        ) => Ok(
249            compare_partition_points(context, left_lower, right_upper)? == Ordering::Less
250                && compare_partition_points(context, right_lower, left_upper)? == Ordering::Less,
251        ),
252        (
253            PartitionBound::Hash {
254                modulus: left_modulus,
255                remainder: left_remainder,
256            },
257            PartitionBound::Hash {
258                modulus: right_modulus,
259                remainder: right_remainder,
260            },
261        ) => hash::bounds_overlap(
262            *left_modulus,
263            *left_remainder,
264            *right_modulus,
265            *right_remainder,
266        ),
267        _ => Err(SQLError::Internal(
268            "partition siblings use different bound strategies".into(),
269        )),
270    }
271}
272
273fn evaluate_bound_values(
274    context: &PartitionContext<'_>,
275    expressions: &[crate::ast::Expr],
276) -> Result<Vec<Value>, SQLError> {
277    expressions
278        .iter()
279        .map(|expression| context.expressions.evaluate_bound(expression, &[]))
280        .collect()
281}
282
283fn compare_partition_points(
284    context: &PartitionContext<'_>,
285    left: &[crate::ast::PartitionRangeDatum],
286    right: &[crate::ast::PartitionRangeDatum],
287) -> Result<Ordering, SQLError> {
288    if left.len() != right.len() {
289        return Err(invalid_partition_bound(
290            "partition range points have different widths",
291        ));
292    }
293    for (left, right) in left.iter().zip(right) {
294        let ordering = match (left, right) {
295            (
296                crate::ast::PartitionRangeDatum::MinValue,
297                crate::ast::PartitionRangeDatum::MinValue,
298            )
299            | (
300                crate::ast::PartitionRangeDatum::MaxValue,
301                crate::ast::PartitionRangeDatum::MaxValue,
302            ) => Ordering::Equal,
303            (crate::ast::PartitionRangeDatum::MinValue, _)
304            | (_, crate::ast::PartitionRangeDatum::MaxValue) => Ordering::Less,
305            (crate::ast::PartitionRangeDatum::MaxValue, _)
306            | (_, crate::ast::PartitionRangeDatum::MinValue) => Ordering::Greater,
307            (
308                crate::ast::PartitionRangeDatum::Value(left),
309                crate::ast::PartitionRangeDatum::Value(right),
310            ) => context
311                .expressions
312                .evaluate_bound(left, &[])?
313                .cmp(&context.expressions.evaluate_bound(right, &[])?),
314        };
315        if ordering != Ordering::Equal {
316            return Ok(ordering);
317        }
318    }
319    Ok(Ordering::Equal)
320}
321
322fn invalid_partition_bound(message: impl Into<String>) -> SQLError {
323    SQLError::Routine {
324        sqlstate: "42P17".into(),
325        message: message.into(),
326    }
327}
328
329pub fn partition_insert_target(
330    context: &PartitionContext<'_>,
331    requested_table: &str,
332    document: &ResultRow,
333    params: &[SQLParam],
334    include_descendants: bool,
335) -> Result<String, SQLError> {
336    let table = context
337        .catalog
338        .try_resolve_table_name(requested_table)
339        .map_err(|error| SQLError::Internal(format!("resolve INSERT table: {error}")))?
340        .ok_or_else(|| SQLError::UnknownTable(requested_table.to_string()))?;
341    let hierarchy = context
342        .catalog
343        .try_table_hierarchy(&table)
344        .map_err(|error| SQLError::Internal(format!("read partition metadata: {error}")))?;
345    validate_partition_ancestor_path(context, requested_table, &table, document, params)?;
346    if let Some(spec) = hierarchy.partition_spec.as_ref() {
347        if !include_descendants {
348            return Err(SQLError::Routine {
349                sqlstate: "42809".into(),
350                message: format!("cannot insert into partitioned table \"{requested_table}\""),
351            });
352        }
353        let child = select_direct_partition_with_spec(context, &table, spec, document, params)?
354            .ok_or_else(|| no_partition_for_row(requested_table))?;
355        return route_partition_tree(context, &child, document, params);
356    }
357    Ok(table)
358}
359
360fn validate_partition_ancestor_path(
361    context: &PartitionContext<'_>,
362    requested_table: &str,
363    table: &str,
364    document: &ResultRow,
365    params: &[SQLParam],
366) -> Result<(), SQLError> {
367    let mut child = table.to_string();
368    let mut visited = std::collections::BTreeSet::new();
369    loop {
370        if !visited.insert(child.clone()) {
371            return Err(SQLError::Internal(format!(
372                "partition hierarchy cycle reaches `{child}`"
373            )));
374        }
375        let hierarchy = context
376            .catalog
377            .try_table_hierarchy(&child)
378            .map_err(|error| SQLError::Internal(format!("read partition metadata: {error}")))?;
379        if hierarchy.partition_bound.is_none() {
380            return Ok(());
381        }
382        let parent = hierarchy.parents.first().ok_or_else(|| {
383            SQLError::Internal(format!("partition `{child}` has no parent relation"))
384        })?;
385        let selected = select_direct_partition(context, parent, document, params)?;
386        if selected.as_deref() != Some(child.as_str()) {
387            return Err(SQLError::Routine {
388                sqlstate: "23514".into(),
389                message: format!(
390                    "new row for relation \"{requested_table}\" violates partition constraint"
391                ),
392            });
393        }
394        child.clone_from(parent);
395    }
396}
397
398fn route_partition_tree(
399    context: &PartitionContext<'_>,
400    table: &str,
401    document: &ResultRow,
402    params: &[SQLParam],
403) -> Result<String, SQLError> {
404    let hierarchy = context
405        .catalog
406        .try_table_hierarchy(table)
407        .map_err(|error| SQLError::Internal(format!("read partition metadata: {error}")))?;
408    let Some(spec) = hierarchy.partition_spec.as_ref() else {
409        return Ok(table.to_string());
410    };
411    let child = select_direct_partition_with_spec(context, table, spec, document, params)?
412        .ok_or_else(|| no_partition_for_row(table))?;
413    route_partition_tree(context, &child, document, params)
414}
415
416fn select_direct_partition(
417    context: &PartitionContext<'_>,
418    parent: &str,
419    document: &ResultRow,
420    params: &[SQLParam],
421) -> Result<Option<String>, SQLError> {
422    let hierarchy = context
423        .catalog
424        .try_table_hierarchy(parent)
425        .map_err(|error| SQLError::Internal(format!("read parent partition metadata: {error}")))?;
426    let spec = hierarchy.partition_spec.as_ref().ok_or_else(|| {
427        SQLError::Internal(format!("partition parent `{parent}` has no partition key"))
428    })?;
429    select_direct_partition_with_spec(context, parent, spec, document, params)
430}
431
432fn select_direct_partition_with_spec(
433    context: &PartitionContext<'_>,
434    parent: &str,
435    spec: &crate::ast::PartitionSpec,
436    document: &ResultRow,
437    params: &[SQLParam],
438) -> Result<Option<String>, SQLError> {
439    let (keys, definitions) =
440        evaluate_partition_keys(context, parent, &spec.keys, document, params)?;
441    let row_hash = (spec.strategy == crate::ast::PartitionStrategy::Hash)
442        .then(|| hash::row_hash(context.types, spec, &definitions, &keys))
443        .transpose()?;
444    let mut default = None;
445    for child in context.catalog.direct_hierarchy_children(parent)? {
446        let child_hierarchy = context
447            .catalog
448            .try_table_hierarchy(&child)
449            .map_err(|error| SQLError::Internal(format!("read child partition: {error}")))?;
450        let Some(bound) = child_hierarchy.partition_bound.as_ref() else {
451            continue;
452        };
453        if matches!(bound, crate::ast::PartitionBound::Default) {
454            if default.replace(child).is_some() {
455                return Err(SQLError::Internal(format!(
456                    "partitioned table `{parent}` has more than one default partition"
457                )));
458            }
459            continue;
460        }
461        if partition_bound_matches(context, bound, &keys, params, row_hash)? {
462            return Ok(Some(child));
463        }
464    }
465    Ok(default)
466}
467
468fn evaluate_partition_keys(
469    context: &PartitionContext<'_>,
470    table: &str,
471    expressions: &[crate::ast::Expr],
472    document: &ResultRow,
473    params: &[SQLParam],
474) -> Result<(Vec<Value>, Vec<crate::ast::ColumnDef>), SQLError> {
475    let definitions = context
476        .catalog
477        .try_describe_table(table)
478        .map_err(|error| SQLError::Internal(format!("read partition row type: {error}")))?
479        .ok_or_else(|| SQLError::UnknownTable(table.to_string()))?;
480    let schema = crate::RowSchema::with_types(
481        definitions
482            .iter()
483            .map(|definition| definition.name.clone())
484            .collect(),
485        definitions
486            .iter()
487            .map(|definition| Some(definition.ty.clone()))
488            .collect(),
489    );
490    let values = expressions
491        .iter()
492        .map(|expression| {
493            context
494                .expressions
495                .evaluate_row(expression, document, &schema, params)
496        })
497        .collect::<Result<Vec<_>, _>>()?;
498    Ok((values, definitions))
499}
500
501fn partition_bound_matches(
502    context: &PartitionContext<'_>,
503    bound: &crate::ast::PartitionBound,
504    keys: &[Value],
505    params: &[SQLParam],
506    row_hash: Option<u64>,
507) -> Result<bool, SQLError> {
508    use crate::ast::PartitionBound;
509    match bound {
510        PartitionBound::Default => Ok(true),
511        PartitionBound::List(values) => {
512            let [key] = keys else {
513                return Err(SQLError::Internal(
514                    "LIST partition has more than one partition key".into(),
515                ));
516            };
517            for expression in values {
518                if context.expressions.evaluate_bound(expression, params)? == *key {
519                    return Ok(true);
520                }
521            }
522            Ok(false)
523        }
524        PartitionBound::Range { lower, upper } => {
525            if keys.iter().any(|value| matches!(value, Value::Null)) {
526                return Ok(false);
527            }
528            Ok(
529                compare_key_to_bound(context, keys, lower, params)? != Ordering::Less
530                    && compare_key_to_bound(context, keys, upper, params)? == Ordering::Less,
531            )
532        }
533        PartitionBound::Hash { modulus, remainder } => hash::bound_matches(
534            row_hash.ok_or_else(|| {
535                SQLError::Internal("HASH partition bound has no computed row hash".into())
536            })?,
537            *modulus,
538            *remainder,
539        ),
540    }
541}
542
543fn compare_key_to_bound(
544    context: &PartitionContext<'_>,
545    keys: &[Value],
546    bound: &[crate::ast::PartitionRangeDatum],
547    params: &[SQLParam],
548) -> Result<Ordering, SQLError> {
549    if keys.len() != bound.len() {
550        return Err(SQLError::Internal(format!(
551            "partition key width {} differs from bound width {}",
552            keys.len(),
553            bound.len()
554        )));
555    }
556    for (key, datum) in keys.iter().zip(bound) {
557        let ordering = match datum {
558            crate::ast::PartitionRangeDatum::MinValue => Ordering::Greater,
559            crate::ast::PartitionRangeDatum::MaxValue => Ordering::Less,
560            crate::ast::PartitionRangeDatum::Value(expression) => {
561                key.cmp(&context.expressions.evaluate_bound(expression, params)?)
562            }
563        };
564        if ordering != Ordering::Equal {
565            return Ok(ordering);
566        }
567    }
568    Ok(Ordering::Equal)
569}
570
571fn no_partition_for_row(table: &str) -> SQLError {
572    SQLError::Routine {
573        sqlstate: "23514".into(),
574        message: format!("no partition of relation \"{table}\" found for row"),
575    }
576}
577
578mod identity;
579pub use identity::{partition_hierarchy_root, partition_identity_owner};