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