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    targets: impl IntoIterator<Item = &'a crate::ast::AssignmentTarget<ScalarExpr>>,
45    duplicate: fn(&str) -> SQLError,
46) -> Result<(), SQLError> {
47    let mut seen = std::collections::BTreeMap::new();
48    for target in targets {
49        if layer_column(layer, &target.column).is_none() {
50            return Err(unknown_view_column(layer, &target.column));
51        }
52        let whole = target.is_whole_column();
53        if seen
54            .insert(&target.column, whole)
55            .is_some_and(|previous| previous || whole)
56        {
57            return Err(duplicate(&target.column));
58        }
59    }
60    Ok(())
61}
62
63pub(super) fn validate_mapped_columns(
64    targets: &[crate::ast::AssignmentTarget<ScalarExpr>],
65    duplicate: fn(&str) -> SQLError,
66) -> Result<(), SQLError> {
67    let mut seen = std::collections::BTreeMap::new();
68    for target in targets {
69        let whole = target.is_whole_column();
70        if seen
71            .insert(&target.column, whole)
72            .is_some_and(|previous| previous || whole)
73        {
74            return Err(duplicate(&target.column));
75        }
76    }
77    Ok(())
78}
79
80pub(super) fn validate_insert_targets(
81    layer: &AutomaticViewLayer,
82    plan: &InsertPlan,
83) -> Result<(), SQLError> {
84    validate_view_target_columns(layer, plan.columns.iter(), duplicate_insert_column)?;
85    let Some(conflict) = &plan.on_conflict else {
86        return Ok(());
87    };
88    for column in &conflict.conflict_columns {
89        if layer_column(layer, column).is_none() {
90            return Err(unknown_view_column(layer, column));
91        }
92    }
93    if let ConflictActionPlan::Update { assignments, .. } = &conflict.action {
94        validate_view_target_columns(
95            layer,
96            assignments.iter().map(|assignment| &assignment.target),
97            duplicate_assignment,
98        )?;
99    }
100    Ok(())
101}
102
103pub(super) fn validate_update_targets(
104    layer: &AutomaticViewLayer,
105    plan: &UpdatePlan,
106) -> Result<(), SQLError> {
107    validate_view_target_columns(
108        layer,
109        plan.assignments.iter().map(|assignment| &assignment.target),
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        for expression in assignment.expressions() {
266            validate_public_view_expression(expression, &columns, ordinary_scope)?;
267        }
268    }
269    if let Some(predicate) = plan.predicate.as_ref() {
270        validate_public_view_expression(predicate, &columns, ordinary_scope)?;
271    }
272    let returning_scope = ExpressionScope {
273        returning_aliases: Some(&plan.returning_aliases),
274        ..ordinary_scope
275    };
276    for projection in &plan.returning {
277        validate_public_view_expression(&projection.expr, &columns, returning_scope)?;
278    }
279    Ok(())
280}
281
282pub(super) fn validate_public_delete_contract(
283    services: ViewRewriteContext<'_>,
284    plan: &DeletePlan,
285    source: Option<&RowSchema>,
286) -> Result<(), SQLError> {
287    let columns = public_view_columns(services, &plan.table)?;
288    let ordinary_scope = ExpressionScope {
289        target_qualifier: &plan.target_qualifier,
290        returning_aliases: None,
291        source,
292        include_excluded: false,
293    };
294    if let Some(predicate) = plan.predicate.as_ref() {
295        validate_public_view_expression(predicate, &columns, ordinary_scope)?;
296    }
297    let returning_scope = ExpressionScope {
298        returning_aliases: Some(&plan.returning_aliases),
299        ..ordinary_scope
300    };
301    for projection in &plan.returning {
302        validate_public_view_expression(&projection.expr, &columns, returning_scope)?;
303    }
304    Ok(())
305}
306
307pub(super) fn validate_public_insert_contract(
308    services: ViewRewriteContext<'_>,
309    plan: &InsertPlan,
310) -> Result<(), SQLError> {
311    let columns = public_view_columns(services, &plan.table)?;
312    for predicate in plan.on_conflict.iter().flat_map(|conflict| {
313        conflict
314            .expressions
315            .iter()
316            .chain(conflict.predicate.iter().map(Box::as_ref))
317    }) {
318        validate_public_view_expression(
319            predicate,
320            &columns,
321            ExpressionScope {
322                target_qualifier: &plan.target_qualifier,
323                returning_aliases: None,
324                source: None,
325                include_excluded: false,
326            },
327        )?;
328    }
329    if let Some(ConflictPlan {
330        action:
331            ConflictActionPlan::Update {
332                assignments,
333                predicate,
334            },
335        ..
336    }) = &plan.on_conflict
337    {
338        let scope = ExpressionScope {
339            target_qualifier: &plan.target_qualifier,
340            returning_aliases: None,
341            source: None,
342            include_excluded: true,
343        };
344        for assignment in assignments {
345            for expression in assignment.expressions() {
346                validate_public_view_expression(expression, &columns, scope)?;
347            }
348        }
349        if let Some(predicate) = predicate {
350            validate_public_view_expression(predicate, &columns, scope)?;
351        }
352    }
353    let scope = ExpressionScope {
354        target_qualifier: &plan.target_qualifier,
355        returning_aliases: Some(&plan.returning_aliases),
356        source: None,
357        include_excluded: false,
358    };
359    for projection in &plan.returning {
360        validate_public_view_expression(&projection.expr, &columns, scope)?;
361    }
362    Ok(())
363}
364
365pub fn validate_public_merge_contract(
366    services: ViewRewriteContext<'_>,
367    plan: &MergePlan,
368    source: &RowSchema,
369) -> Result<(), SQLError> {
370    let columns = public_view_columns(services, &plan.target)?;
371    let matched_scope = ExpressionScope {
372        target_qualifier: &plan.target_qualifier,
373        returning_aliases: None,
374        source: Some(source),
375        include_excluded: false,
376    };
377    let target_only_scope = ExpressionScope {
378        source: None,
379        ..matched_scope
380    };
381    validate_public_view_expression(&plan.join_condition, &columns, matched_scope)?;
382    for clause in &plan.when_clauses {
383        match clause {
384            MergeWhenPlan::UpdateMatched {
385                condition,
386                assignments,
387            } => {
388                if let Some(condition) = condition {
389                    validate_public_view_expression(condition, &columns, matched_scope)?;
390                }
391                for assignment in assignments {
392                    for expression in assignment.expressions() {
393                        validate_public_view_expression(expression, &columns, matched_scope)?;
394                    }
395                }
396            }
397            MergeWhenPlan::DeleteMatched { condition }
398            | MergeWhenPlan::NothingMatched { condition } => {
399                if let Some(condition) = condition {
400                    validate_public_view_expression(condition, &columns, matched_scope)?;
401                }
402            }
403            MergeWhenPlan::UpdateNotMatchedBySource {
404                condition,
405                assignments,
406            } => {
407                if let Some(condition) = condition {
408                    validate_public_view_expression(condition, &columns, target_only_scope)?;
409                }
410                for assignment in assignments {
411                    validate_public_view_expression(
412                        &assignment.value,
413                        &columns,
414                        target_only_scope,
415                    )?;
416                }
417            }
418            MergeWhenPlan::DeleteNotMatchedBySource { condition }
419            | MergeWhenPlan::NothingNotMatchedBySource { condition } => {
420                if let Some(condition) = condition {
421                    validate_public_view_expression(condition, &columns, target_only_scope)?;
422                }
423            }
424            MergeWhenPlan::InsertNotMatched { .. } | MergeWhenPlan::NothingNotMatched { .. } => {}
425        }
426    }
427    let returning_scope = ExpressionScope {
428        returning_aliases: Some(&plan.returning_aliases),
429        ..matched_scope
430    };
431    for projection in &plan.returning {
432        validate_public_view_expression(&projection.expr, &columns, returning_scope)?;
433    }
434    Ok(())
435}
436
437pub(super) fn validate_merge_targets(
438    layer: &AutomaticViewLayer,
439    plan: &MergePlan,
440) -> Result<(), SQLError> {
441    for clause in &plan.when_clauses {
442        match clause {
443            MergeWhenPlan::UpdateMatched { assignments, .. }
444            | MergeWhenPlan::UpdateNotMatchedBySource { assignments, .. } => {
445                validate_view_target_columns(
446                    layer,
447                    assignments.iter().map(|assignment| &assignment.target),
448                    duplicate_assignment,
449                )?;
450            }
451            MergeWhenPlan::InsertNotMatched { columns, .. } if !columns.is_empty() => {
452                validate_view_target_columns(layer, columns.iter(), duplicate_insert_column)?;
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                validate_public_view_targets(
469                    services,
470                    &plan.target,
471                    assignments
472                        .iter()
473                        .map(|assignment| assignment.target.column.as_str()),
474                )?;
475                validate_mapped_columns(
476                    &assignments
477                        .iter()
478                        .map(|assignment| assignment.target.clone())
479                        .collect::<Vec<_>>(),
480                    duplicate_assignment,
481                )?;
482            }
483            MergeWhenPlan::InsertNotMatched { columns, .. } if !columns.is_empty() => {
484                validate_public_view_targets(
485                    services,
486                    &plan.target,
487                    columns.iter().map(|target| target.column.as_str()),
488                )?;
489                validate_mapped_columns(columns, duplicate_insert_column)?;
490            }
491            _ => {}
492        }
493    }
494    Ok(())
495}
496
497pub(super) fn merge_action_capability_error(
498    view: &str,
499    clauses: &[MergeWhenPlan],
500    capabilities: ViewMutationCapabilities,
501) -> Option<SQLError> {
502    clauses.iter().find_map(|clause| match clause {
503        MergeWhenPlan::UpdateMatched { .. } | MergeWhenPlan::UpdateNotMatchedBySource { .. }
504            if !capabilities.updatable =>
505        {
506            Some(not_automatically_updatable(view, "UPDATE"))
507        }
508        MergeWhenPlan::DeleteMatched { .. } | MergeWhenPlan::DeleteNotMatchedBySource { .. }
509            if !capabilities.deletable =>
510        {
511            Some(not_automatically_updatable(view, "DELETE FROM"))
512        }
513        MergeWhenPlan::InsertNotMatched { .. } if !capabilities.insertable => {
514            Some(not_automatically_updatable(view, "INSERT INTO"))
515        }
516        _ => None,
517    })
518}
519
520fn validate_merge_rule_free(
521    services: ViewRewriteContext<'_>,
522    relation: &str,
523) -> Result<(), SQLError> {
524    let has_rules = [
525        crate::ast::RuleEvent::Insert,
526        crate::ast::RuleEvent::Update,
527        crate::ast::RuleEvent::Delete,
528    ]
529    .into_iter()
530    .map(|event| services.catalog.rules_for(relation, event))
531    .collect::<Result<Vec<_>, SQLError>>()?
532    .iter()
533    .any(|rules| !rules.is_empty());
534    if !has_rules {
535        return Ok(());
536    }
537    Err(SQLError::Routine {
538        sqlstate: "0A000".into(),
539        message: format!(
540            "cannot execute MERGE on relation \"{}\"",
541            display_relation(relation)
542        ),
543    })
544}
545
546fn merge_uses_event(plan: &MergePlan, event: TriggerEvent) -> bool {
547    plan.when_clauses.iter().any(|clause| match event {
548        TriggerEvent::Insert => matches!(clause, MergeWhenPlan::InsertNotMatched { .. }),
549        TriggerEvent::Update => matches!(
550            clause,
551            MergeWhenPlan::UpdateMatched { .. } | MergeWhenPlan::UpdateNotMatchedBySource { .. }
552        ),
553        TriggerEvent::Delete => matches!(
554            clause,
555            MergeWhenPlan::DeleteMatched { .. } | MergeWhenPlan::DeleteNotMatchedBySource { .. }
556        ),
557        TriggerEvent::Truncate => false,
558    })
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562pub enum MergeViewTargetPath {
563    AutomaticRewrite,
564    ViewTriggers,
565}
566
567pub fn merge_view_target_path(
568    services: ViewRewriteContext<'_>,
569    plan: &MergePlan,
570) -> Result<MergeViewTargetPath, SQLError> {
571    let canonical = services
572        .catalog
573        .try_resolve_view_name(&plan.target)
574        .map_err(|error| SQLError::Internal(format!("resolve MERGE view: {error}")))?
575        .ok_or_else(|| SQLError::UnknownTable(plan.target.clone()))?;
576    let definition = services
577        .catalog
578        .view_definition(&canonical)?
579        .ok_or_else(|| SQLError::UnknownTable(plan.target.clone()))?;
580    if definition.kind == crate::catalog::view::StoredViewKind::Materialized {
581        return Err(SQLError::Routine {
582            sqlstate: "0A000".into(),
583            message: format!(
584                "cannot execute MERGE on relation \"{}\"",
585                display_relation(&canonical)
586            ),
587        });
588    }
589    validate_merge_rule_free(services, &canonical)?;
590    let automatic = view_updatability(services, &canonical)?.automatic;
591    let insert_trigger = instead_of_trigger_definition(services, &canonical, TriggerEvent::Insert)?;
592    let update_trigger = instead_of_trigger_definition(services, &canonical, TriggerEvent::Update)?;
593    let delete_trigger = instead_of_trigger_definition(services, &canonical, TriggerEvent::Delete)?;
594    let supported = ViewMutationCapabilities {
595        insertable: automatic.insertable || insert_trigger,
596        updatable: automatic.updatable || update_trigger,
597        deletable: automatic.deletable || delete_trigger,
598    };
599    if let Some(error) = merge_action_capability_error(&canonical, &plan.when_clauses, supported) {
600        return Err(error);
601    }
602    let mut uses_automatic = false;
603    let mut uses_trigger = false;
604    let mut has_action = false;
605    for (event, trigger) in [
606        (TriggerEvent::Insert, insert_trigger),
607        (TriggerEvent::Update, update_trigger),
608        (TriggerEvent::Delete, delete_trigger),
609    ] {
610        if !merge_uses_event(plan, event) {
611            continue;
612        }
613        has_action = true;
614        uses_trigger |= trigger;
615        uses_automatic |= !trigger;
616    }
617    if uses_trigger && uses_automatic {
618        return Err(SQLError::Routine {
619            sqlstate: "0A000".into(),
620            message: format!(
621                "cannot merge into view \"{}\"",
622                display_relation(&canonical)
623            ),
624        });
625    }
626    if uses_trigger || !has_action {
627        Ok(MergeViewTargetPath::ViewTriggers)
628    } else {
629        Ok(MergeViewTargetPath::AutomaticRewrite)
630    }
631}
632
633pub(super) fn validate_public_view_targets<'a>(
634    services: ViewRewriteContext<'_>,
635    view: &str,
636    columns: impl IntoIterator<Item = &'a str>,
637) -> Result<(), SQLError> {
638    let definition = services
639        .catalog
640        .view_definition(view)?
641        .ok_or_else(|| SQLError::UnknownTable(view.to_string()))?;
642    let schema = stored_view_schema(services, &definition)?;
643    for column in columns {
644        if !schema.has_unqualified_column(column) {
645            return Err(SQLError::UnknownColumn(format!("{view}.{column}")));
646        }
647    }
648    Ok(())
649}
650
651pub(super) fn validate_direct_view_rule_path(
652    services: ViewRewriteContext<'_>,
653    view: &str,
654    event: crate::ast::RuleEvent,
655    operation: &str,
656) -> Result<(), SQLError> {
657    let rules = services.catalog.rules_for(view, event)?;
658    let has_conditional_instead = rules
659        .iter()
660        .any(|rule| rule.definition.instead && rule.definition.condition.is_some());
661    let has_unconditional_instead = rules
662        .iter()
663        .any(|rule| rule.definition.instead && rule.definition.condition.is_none());
664    if has_conditional_instead && !has_unconditional_instead {
665        return Err(not_automatically_updatable(view, operation));
666    }
667    Ok(())
668}
669
670use super::stored_view_schema;