Skip to main content

uqa_sql/semantics/view_rewrite/
validation.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Public-view and mapped-target validation for automatic DML rewriting.
8
9use super::{
10    display_relation, instead_of_trigger_definition, non_writable_column,
11    not_automatically_updatable, view_updatability, AutomaticViewLayer, BTreeSet,
12    ConflictActionPlan, ConflictPlan, DeletePlan, InsertPlan, MergePlan, MergeWhenPlan,
13    ReturningAliases, RowSchema, SQLError, ScalarExpr, TriggerEvent, UpdatePlan, ViewColumn,
14    ViewMutationCapabilities, ViewRewriteContext,
15};
16
17pub(super) fn layer_column<'a>(
18    layer: &'a AutomaticViewLayer,
19    name: &str,
20) -> Option<&'a ViewColumn> {
21    layer.columns.iter().find(|column| column.name == name)
22}
23
24fn unknown_view_column(layer: &AutomaticViewLayer, column: &str) -> SQLError {
25    SQLError::UnknownColumn(format!("{}.{column}", layer.canonical_name))
26}
27
28pub(super) fn duplicate_insert_column(column: &str) -> SQLError {
29    SQLError::Routine {
30        sqlstate: "42701".into(),
31        message: format!("column \"{column}\" specified more than once"),
32    }
33}
34
35pub(super) fn duplicate_assignment(column: &str) -> SQLError {
36    SQLError::Routine {
37        sqlstate: "42601".into(),
38        message: format!("multiple assignments to same column \"{column}\""),
39    }
40}
41
42fn validate_view_target_columns<'a>(
43    layer: &AutomaticViewLayer,
44    columns: impl IntoIterator<Item = &'a str>,
45    duplicate: fn(&str) -> SQLError,
46) -> Result<(), SQLError> {
47    let mut seen = BTreeSet::new();
48    for column in columns {
49        if layer_column(layer, column).is_none() {
50            return Err(unknown_view_column(layer, column));
51        }
52        if !seen.insert(column) {
53            return Err(duplicate(column));
54        }
55    }
56    Ok(())
57}
58
59pub(super) fn validate_mapped_columns(
60    columns: &[String],
61    duplicate: fn(&str) -> SQLError,
62) -> Result<(), SQLError> {
63    let mut seen = BTreeSet::new();
64    for column in columns {
65        if !seen.insert(column) {
66            return Err(duplicate(column));
67        }
68    }
69    Ok(())
70}
71
72pub(super) fn validate_insert_targets(
73    layer: &AutomaticViewLayer,
74    plan: &InsertPlan,
75) -> Result<(), SQLError> {
76    validate_view_target_columns(
77        layer,
78        plan.columns.iter().map(String::as_str),
79        duplicate_insert_column,
80    )?;
81    let Some(conflict) = &plan.on_conflict else {
82        return Ok(());
83    };
84    for column in &conflict.conflict_columns {
85        if layer_column(layer, column).is_none() {
86            return Err(unknown_view_column(layer, column));
87        }
88    }
89    if let ConflictActionPlan::Update { assignments, .. } = &conflict.action {
90        validate_view_target_columns(
91            layer,
92            assignments
93                .iter()
94                .map(|assignment| assignment.column.as_str()),
95            duplicate_assignment,
96        )?;
97    }
98    Ok(())
99}
100
101pub(super) fn validate_update_targets(
102    layer: &AutomaticViewLayer,
103    plan: &UpdatePlan,
104) -> Result<(), SQLError> {
105    validate_view_target_columns(
106        layer,
107        plan.assignments
108            .iter()
109            .map(|assignment| assignment.column.as_str()),
110        duplicate_assignment,
111    )
112}
113
114pub(super) fn writable_column(
115    layer: &AutomaticViewLayer,
116    name: &str,
117    operation: &str,
118) -> Result<String, SQLError> {
119    let column = layer_column(layer, name)
120        .ok_or_else(|| SQLError::UnknownColumn(format!("{}.{name}", layer.canonical_name)))?;
121    column
122        .writable_source_column
123        .clone()
124        .ok_or_else(|| non_writable_column(&layer.canonical_name, name, operation))
125}
126
127fn ambiguous_column(column: &str) -> SQLError {
128    SQLError::Routine {
129        sqlstate: "42702".into(),
130        message: format!("column reference \"{column}\" is ambiguous"),
131    }
132}
133
134#[derive(Clone, Copy)]
135pub(super) struct ExpressionScope<'a> {
136    pub(super) target_qualifier: &'a str,
137    pub(super) returning_aliases: Option<&'a ReturningAliases>,
138    pub(super) source: Option<&'a RowSchema>,
139    pub(super) include_excluded: bool,
140}
141
142impl ExpressionScope<'_> {
143    pub(super) fn row_image_qualifier(self, qualifier: &str) -> bool {
144        self.returning_aliases.is_some_and(|aliases| {
145            [
146                (aliases.old.as_str(), aliases.old_explicit),
147                (aliases.new.as_str(), aliases.new_explicit),
148            ]
149            .into_iter()
150            .any(|(alias, explicit)| {
151                qualifier == alias
152                    && (explicit
153                        || !self
154                            .source
155                            .is_some_and(|source| source.has_qualifier(alias)))
156            })
157        })
158    }
159
160    pub(super) fn target_qualifier(self, qualifier: &str) -> bool {
161        qualifier == self.target_qualifier
162            || (self.include_excluded && qualifier == "excluded")
163            || self.row_image_qualifier(qualifier)
164    }
165}
166
167pub(super) fn validate_view_expression(
168    expression: &ScalarExpr,
169    layer: &AutomaticViewLayer,
170    scope: ExpressionScope<'_>,
171) -> Result<(), SQLError> {
172    let mut expression = expression.clone();
173    let mut error = None;
174    crate::plan::rewrite_scalar_expression(&mut expression, &mut |node| {
175        if error.is_some() {
176            return;
177        }
178        match node {
179            ScalarExpr::Column(column) => {
180                let target = layer_column(layer, column).is_some();
181                let source = scope
182                    .source
183                    .is_some_and(|source| source.has_unqualified_column(column));
184                if target && (source || scope.include_excluded) {
185                    error = Some(ambiguous_column(column));
186                } else if !target && !source {
187                    error = Some(SQLError::UnknownColumn(column.clone()));
188                }
189            }
190            ScalarExpr::QualifiedColumn { qualifier, column }
191                if scope.target_qualifier(qualifier) && layer_column(layer, column).is_none() =>
192            {
193                error = Some(SQLError::UnknownColumn(format!("{qualifier}.{column}")));
194            }
195            _ => {}
196        }
197    });
198    error.map_or(Ok(()), Err)
199}
200
201fn public_view_columns(
202    services: ViewRewriteContext<'_>,
203    view: &str,
204) -> Result<BTreeSet<String>, SQLError> {
205    let definition = services
206        .catalog
207        .view_definition(view)?
208        .ok_or_else(|| SQLError::UnknownTable(view.to_string()))?;
209    let schema = stored_view_schema(services, &definition)?;
210    Ok(schema
211        .columns()
212        .iter()
213        .enumerate()
214        .map(|(position, column)| schema.public_name(position).unwrap_or(column).to_string())
215        .collect())
216}
217
218fn validate_public_view_expression(
219    expression: &ScalarExpr,
220    public_columns: &BTreeSet<String>,
221    scope: ExpressionScope<'_>,
222) -> Result<(), SQLError> {
223    let mut expression = expression.clone();
224    let mut error = None;
225    crate::plan::rewrite_scalar_expression(&mut expression, &mut |node| {
226        if error.is_some() {
227            return;
228        }
229        match node {
230            ScalarExpr::Column(column) => {
231                let target = public_columns.contains(column);
232                let source = scope
233                    .source
234                    .is_some_and(|source| source.has_unqualified_column(column));
235                if target && (source || scope.include_excluded) {
236                    error = Some(ambiguous_column(column));
237                } else if !target && !source {
238                    error = Some(SQLError::UnknownColumn(column.clone()));
239                }
240            }
241            ScalarExpr::QualifiedColumn { qualifier, column }
242                if scope.target_qualifier(qualifier) && !public_columns.contains(column) =>
243            {
244                error = Some(SQLError::UnknownColumn(format!("{qualifier}.{column}")));
245            }
246            _ => {}
247        }
248    });
249    error.map_or(Ok(()), Err)
250}
251
252pub(super) fn validate_public_update_contract(
253    services: ViewRewriteContext<'_>,
254    plan: &UpdatePlan,
255    source: Option<&RowSchema>,
256) -> Result<(), SQLError> {
257    let columns = public_view_columns(services, &plan.table)?;
258    let ordinary_scope = ExpressionScope {
259        target_qualifier: &plan.target_qualifier,
260        returning_aliases: None,
261        source,
262        include_excluded: false,
263    };
264    for assignment in &plan.assignments {
265        validate_public_view_expression(&assignment.value, &columns, ordinary_scope)?;
266    }
267    if let Some(predicate) = plan.predicate.as_ref() {
268        validate_public_view_expression(predicate, &columns, ordinary_scope)?;
269    }
270    let returning_scope = ExpressionScope {
271        returning_aliases: Some(&plan.returning_aliases),
272        ..ordinary_scope
273    };
274    for projection in &plan.returning {
275        validate_public_view_expression(&projection.expr, &columns, returning_scope)?;
276    }
277    Ok(())
278}
279
280pub(super) fn validate_public_delete_contract(
281    services: ViewRewriteContext<'_>,
282    plan: &DeletePlan,
283    source: Option<&RowSchema>,
284) -> Result<(), SQLError> {
285    let columns = public_view_columns(services, &plan.table)?;
286    let ordinary_scope = ExpressionScope {
287        target_qualifier: &plan.target_qualifier,
288        returning_aliases: None,
289        source,
290        include_excluded: false,
291    };
292    if let Some(predicate) = plan.predicate.as_ref() {
293        validate_public_view_expression(predicate, &columns, ordinary_scope)?;
294    }
295    let returning_scope = ExpressionScope {
296        returning_aliases: Some(&plan.returning_aliases),
297        ..ordinary_scope
298    };
299    for projection in &plan.returning {
300        validate_public_view_expression(&projection.expr, &columns, returning_scope)?;
301    }
302    Ok(())
303}
304
305pub(super) fn validate_public_insert_contract(
306    services: ViewRewriteContext<'_>,
307    plan: &InsertPlan,
308) -> Result<(), SQLError> {
309    let columns = public_view_columns(services, &plan.table)?;
310    for predicate in plan.on_conflict.iter().flat_map(|conflict| {
311        conflict
312            .expressions
313            .iter()
314            .chain(conflict.predicate.iter().map(Box::as_ref))
315    }) {
316        validate_public_view_expression(
317            predicate,
318            &columns,
319            ExpressionScope {
320                target_qualifier: &plan.target_qualifier,
321                returning_aliases: None,
322                source: None,
323                include_excluded: false,
324            },
325        )?;
326    }
327    if let Some(ConflictPlan {
328        action:
329            ConflictActionPlan::Update {
330                assignments,
331                predicate,
332            },
333        ..
334    }) = &plan.on_conflict
335    {
336        let scope = ExpressionScope {
337            target_qualifier: &plan.target_qualifier,
338            returning_aliases: None,
339            source: None,
340            include_excluded: true,
341        };
342        for assignment in assignments {
343            validate_public_view_expression(&assignment.value, &columns, scope)?;
344        }
345        if let Some(predicate) = predicate {
346            validate_public_view_expression(predicate, &columns, scope)?;
347        }
348    }
349    let scope = ExpressionScope {
350        target_qualifier: &plan.target_qualifier,
351        returning_aliases: Some(&plan.returning_aliases),
352        source: None,
353        include_excluded: false,
354    };
355    for projection in &plan.returning {
356        validate_public_view_expression(&projection.expr, &columns, scope)?;
357    }
358    Ok(())
359}
360
361pub fn validate_public_merge_contract(
362    services: ViewRewriteContext<'_>,
363    plan: &MergePlan,
364    source: &RowSchema,
365) -> Result<(), SQLError> {
366    let columns = public_view_columns(services, &plan.target)?;
367    let matched_scope = ExpressionScope {
368        target_qualifier: &plan.target_qualifier,
369        returning_aliases: None,
370        source: Some(source),
371        include_excluded: false,
372    };
373    let target_only_scope = ExpressionScope {
374        source: None,
375        ..matched_scope
376    };
377    validate_public_view_expression(&plan.join_condition, &columns, matched_scope)?;
378    for clause in &plan.when_clauses {
379        match clause {
380            MergeWhenPlan::UpdateMatched {
381                condition,
382                assignments,
383            } => {
384                if let Some(condition) = condition {
385                    validate_public_view_expression(condition, &columns, matched_scope)?;
386                }
387                for assignment in assignments {
388                    validate_public_view_expression(&assignment.value, &columns, matched_scope)?;
389                }
390            }
391            MergeWhenPlan::DeleteMatched { condition }
392            | MergeWhenPlan::NothingMatched { condition } => {
393                if let Some(condition) = condition {
394                    validate_public_view_expression(condition, &columns, matched_scope)?;
395                }
396            }
397            MergeWhenPlan::UpdateNotMatchedBySource {
398                condition,
399                assignments,
400            } => {
401                if let Some(condition) = condition {
402                    validate_public_view_expression(condition, &columns, target_only_scope)?;
403                }
404                for assignment in assignments {
405                    validate_public_view_expression(
406                        &assignment.value,
407                        &columns,
408                        target_only_scope,
409                    )?;
410                }
411            }
412            MergeWhenPlan::DeleteNotMatchedBySource { condition }
413            | MergeWhenPlan::NothingNotMatchedBySource { condition } => {
414                if let Some(condition) = condition {
415                    validate_public_view_expression(condition, &columns, target_only_scope)?;
416                }
417            }
418            MergeWhenPlan::InsertNotMatched { .. } | MergeWhenPlan::NothingNotMatched { .. } => {}
419        }
420    }
421    let returning_scope = ExpressionScope {
422        returning_aliases: Some(&plan.returning_aliases),
423        ..matched_scope
424    };
425    for projection in &plan.returning {
426        validate_public_view_expression(&projection.expr, &columns, returning_scope)?;
427    }
428    Ok(())
429}
430
431pub(super) fn validate_merge_targets(
432    layer: &AutomaticViewLayer,
433    plan: &MergePlan,
434) -> Result<(), SQLError> {
435    for clause in &plan.when_clauses {
436        match clause {
437            MergeWhenPlan::UpdateMatched { assignments, .. }
438            | MergeWhenPlan::UpdateNotMatchedBySource { assignments, .. } => {
439                validate_view_target_columns(
440                    layer,
441                    assignments
442                        .iter()
443                        .map(|assignment| assignment.column.as_str()),
444                    duplicate_assignment,
445                )?;
446            }
447            MergeWhenPlan::InsertNotMatched { columns, .. } if !columns.is_empty() => {
448                validate_view_target_columns(
449                    layer,
450                    columns.iter().map(String::as_str),
451                    duplicate_insert_column,
452                )?;
453            }
454            _ => {}
455        }
456    }
457    Ok(())
458}
459
460pub fn validate_public_merge_targets(
461    services: ViewRewriteContext<'_>,
462    plan: &MergePlan,
463) -> Result<(), SQLError> {
464    for clause in &plan.when_clauses {
465        match clause {
466            MergeWhenPlan::UpdateMatched { assignments, .. }
467            | MergeWhenPlan::UpdateNotMatchedBySource { assignments, .. } => {
468                let columns = assignments
469                    .iter()
470                    .map(|assignment| assignment.column.as_str())
471                    .collect::<Vec<_>>();
472                validate_public_view_targets(services, &plan.target, columns.iter().copied())?;
473                validate_mapped_columns(
474                    &columns
475                        .iter()
476                        .map(|column| (*column).to_string())
477                        .collect::<Vec<_>>(),
478                    duplicate_assignment,
479                )?;
480            }
481            MergeWhenPlan::InsertNotMatched { columns, .. } if !columns.is_empty() => {
482                validate_public_view_targets(
483                    services,
484                    &plan.target,
485                    columns.iter().map(String::as_str),
486                )?;
487                validate_mapped_columns(columns, duplicate_insert_column)?;
488            }
489            _ => {}
490        }
491    }
492    Ok(())
493}
494
495pub(super) fn merge_action_capability_error(
496    view: &str,
497    clauses: &[MergeWhenPlan],
498    capabilities: ViewMutationCapabilities,
499) -> Option<SQLError> {
500    clauses.iter().find_map(|clause| match clause {
501        MergeWhenPlan::UpdateMatched { .. } | MergeWhenPlan::UpdateNotMatchedBySource { .. }
502            if !capabilities.updatable =>
503        {
504            Some(not_automatically_updatable(view, "UPDATE"))
505        }
506        MergeWhenPlan::DeleteMatched { .. } | MergeWhenPlan::DeleteNotMatchedBySource { .. }
507            if !capabilities.deletable =>
508        {
509            Some(not_automatically_updatable(view, "DELETE FROM"))
510        }
511        MergeWhenPlan::InsertNotMatched { .. } if !capabilities.insertable => {
512            Some(not_automatically_updatable(view, "INSERT INTO"))
513        }
514        _ => None,
515    })
516}
517
518fn validate_merge_rule_free(
519    services: ViewRewriteContext<'_>,
520    relation: &str,
521) -> Result<(), SQLError> {
522    let has_rules = [
523        crate::ast::RuleEvent::Insert,
524        crate::ast::RuleEvent::Update,
525        crate::ast::RuleEvent::Delete,
526    ]
527    .into_iter()
528    .map(|event| services.catalog.rules_for(relation, event))
529    .collect::<Result<Vec<_>, SQLError>>()?
530    .iter()
531    .any(|rules| !rules.is_empty());
532    if !has_rules {
533        return Ok(());
534    }
535    Err(SQLError::Routine {
536        sqlstate: "0A000".into(),
537        message: format!(
538            "cannot execute MERGE on relation \"{}\"",
539            display_relation(relation)
540        ),
541    })
542}
543
544fn merge_uses_event(plan: &MergePlan, event: TriggerEvent) -> bool {
545    plan.when_clauses.iter().any(|clause| match event {
546        TriggerEvent::Insert => matches!(clause, MergeWhenPlan::InsertNotMatched { .. }),
547        TriggerEvent::Update => matches!(
548            clause,
549            MergeWhenPlan::UpdateMatched { .. } | MergeWhenPlan::UpdateNotMatchedBySource { .. }
550        ),
551        TriggerEvent::Delete => matches!(
552            clause,
553            MergeWhenPlan::DeleteMatched { .. } | MergeWhenPlan::DeleteNotMatchedBySource { .. }
554        ),
555        TriggerEvent::Truncate => false,
556    })
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
560pub enum MergeViewTargetPath {
561    AutomaticRewrite,
562    ViewTriggers,
563}
564
565pub fn merge_view_target_path(
566    services: ViewRewriteContext<'_>,
567    plan: &MergePlan,
568) -> Result<MergeViewTargetPath, SQLError> {
569    let canonical = services
570        .catalog
571        .try_resolve_view_name(&plan.target)
572        .map_err(|error| SQLError::Internal(format!("resolve MERGE view: {error}")))?
573        .ok_or_else(|| SQLError::UnknownTable(plan.target.clone()))?;
574    let definition = services
575        .catalog
576        .view_definition(&canonical)?
577        .ok_or_else(|| SQLError::UnknownTable(plan.target.clone()))?;
578    if definition.kind == crate::catalog::view::StoredViewKind::Materialized {
579        return Err(SQLError::Routine {
580            sqlstate: "0A000".into(),
581            message: format!(
582                "cannot execute MERGE on relation \"{}\"",
583                display_relation(&canonical)
584            ),
585        });
586    }
587    validate_merge_rule_free(services, &canonical)?;
588    let automatic = view_updatability(services, &canonical)?.automatic;
589    let insert_trigger = instead_of_trigger_definition(services, &canonical, TriggerEvent::Insert)?;
590    let update_trigger = instead_of_trigger_definition(services, &canonical, TriggerEvent::Update)?;
591    let delete_trigger = instead_of_trigger_definition(services, &canonical, TriggerEvent::Delete)?;
592    let supported = ViewMutationCapabilities {
593        insertable: automatic.insertable || insert_trigger,
594        updatable: automatic.updatable || update_trigger,
595        deletable: automatic.deletable || delete_trigger,
596    };
597    if let Some(error) = merge_action_capability_error(&canonical, &plan.when_clauses, supported) {
598        return Err(error);
599    }
600    let mut uses_automatic = false;
601    let mut uses_trigger = false;
602    let mut has_action = false;
603    for (event, trigger) in [
604        (TriggerEvent::Insert, insert_trigger),
605        (TriggerEvent::Update, update_trigger),
606        (TriggerEvent::Delete, delete_trigger),
607    ] {
608        if !merge_uses_event(plan, event) {
609            continue;
610        }
611        has_action = true;
612        uses_trigger |= trigger;
613        uses_automatic |= !trigger;
614    }
615    if uses_trigger && uses_automatic {
616        return Err(SQLError::Routine {
617            sqlstate: "0A000".into(),
618            message: format!(
619                "cannot merge into view \"{}\"",
620                display_relation(&canonical)
621            ),
622        });
623    }
624    if uses_trigger || !has_action {
625        Ok(MergeViewTargetPath::ViewTriggers)
626    } else {
627        Ok(MergeViewTargetPath::AutomaticRewrite)
628    }
629}
630
631pub(super) fn validate_public_view_targets<'a>(
632    services: ViewRewriteContext<'_>,
633    view: &str,
634    columns: impl IntoIterator<Item = &'a str>,
635) -> Result<(), SQLError> {
636    let definition = services
637        .catalog
638        .view_definition(view)?
639        .ok_or_else(|| SQLError::UnknownTable(view.to_string()))?;
640    let schema = stored_view_schema(services, &definition)?;
641    for column in columns {
642        if !schema.has_unqualified_column(column) {
643            return Err(SQLError::UnknownColumn(format!("{view}.{column}")));
644        }
645    }
646    Ok(())
647}
648
649pub(super) fn validate_direct_view_rule_path(
650    services: ViewRewriteContext<'_>,
651    view: &str,
652    event: crate::ast::RuleEvent,
653    operation: &str,
654) -> Result<(), SQLError> {
655    let rules = services.catalog.rules_for(view, event)?;
656    let has_conditional_instead = rules
657        .iter()
658        .any(|rule| rule.definition.instead && rule.definition.condition.is_some());
659    let has_unconditional_instead = rules
660        .iter()
661        .any(|rule| rule.definition.instead && rule.definition.condition.is_none());
662    if has_conditional_instead && !has_unconditional_instead {
663        return Err(not_automatically_updatable(view, operation));
664    }
665    Ok(())
666}
667
668use super::stored_view_schema;