Skip to main content

uqa_sql/semantics/effects/
read_only.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! SQL commands forbidden by read-only transactions and snapshot-setting rules.
8
9use super::{command_payload_may_write_database, query_may_write_database, QueryEffectContext};
10use crate::{
11    ast::RelationPersistence,
12    plan::{CommandPlan, UnifiedPlan},
13    result::completion::command_tag_name,
14    SQLError,
15};
16
17pub fn read_only_error(command: &str) -> SQLError {
18    SQLError::Routine {
19        sqlstate: "25006".into(),
20        message: format!("cannot execute {command} in a read-only transaction"),
21    }
22}
23
24fn table_is_temporary(
25    context: &QueryEffectContext<'_>,
26    table: &str,
27) -> Result<Option<bool>, SQLError> {
28    context
29        .catalog
30        .table_persistence(table)
31        .map(|persistence| persistence.map(|value| value == RelationPersistence::Temporary))
32        .map_err(|error| SQLError::Internal(format!("resolve read-only target `{table}`: {error}")))
33}
34
35fn dml_command(
36    context: &QueryEffectContext<'_>,
37    table: &str,
38    command: &CommandPlan,
39) -> Result<Option<&'static str>, SQLError> {
40    match table_is_temporary(context, table)? {
41        Some(false) => Ok(Some(command_tag_name(command))),
42        Some(true) if command_payload_may_write_database(context, command)? => {
43            Ok(Some(command_tag_name(command)))
44        }
45        Some(true) | None => Ok(None),
46    }
47}
48
49/// The tag of a command a read-only transaction rejects, or `None` when the command may run: `ClassifyUtilityCommandAsReadOnly` rejects every utility command that changes persistent state, and `ExecCheckXactReadOnly` rejects a query that writes to a permanent relation.
50pub fn forbidden_command(
51    context: &QueryEffectContext<'_>,
52    plan: &UnifiedPlan,
53) -> Result<Option<&'static str>, SQLError> {
54    let UnifiedPlan::Command(command) = plan else {
55        let UnifiedPlan::Query(query) = plan else {
56            unreachable!();
57        };
58        return query_may_write_database(context, query).map(|mutates| mutates.then_some("SELECT"));
59    };
60    let command = command.as_ref();
61    match command {
62        CommandPlan::Insert(insert) => dml_command(context, &insert.table, command),
63        CommandPlan::Update(update) => dml_command(context, &update.table, command),
64        CommandPlan::Delete(delete) => dml_command(context, &delete.table, command),
65        CommandPlan::Merge(merge) => dml_command(context, &merge.target, command),
66        CommandPlan::DeclareCursor { query, .. } => {
67            query_may_write_database(context, query).map(|mutates| mutates.then_some("SELECT"))
68        }
69        CommandPlan::Execute { name, .. } => context
70            .catalog
71            .lookup_prepared(name)
72            .map_or(Ok(None), |prepared| forbidden_command(context, &prepared)),
73        CommandPlan::Explain {
74            analyze: true,
75            body,
76            ..
77        } => forbidden_command(context, body),
78        // VACUUM's transaction-block prohibition has precedence over read-only validation and is enforced by its executor.
79        CommandPlan::Analyze { .. }
80        | CommandPlan::LockTable(_)
81        | CommandPlan::Vacuum(_)
82        | CommandPlan::SetVariable { .. }
83        | CommandPlan::Notify { .. }
84        | CommandPlan::Listen { .. }
85        | CommandPlan::Unlisten { .. }
86        | CommandPlan::ResetVariable { .. }
87        | CommandPlan::ResetAllVariables
88        | CommandPlan::SetConstraints { .. }
89        | CommandPlan::ShowVariable { .. }
90        | CommandPlan::Discard { .. }
91        | CommandPlan::Load { .. }
92        | CommandPlan::Transaction(_)
93        | CommandPlan::FetchCursor(_)
94        | CommandPlan::CloseCursor { .. }
95        | CommandPlan::Prepare { .. }
96        | CommandPlan::Deallocate { .. }
97        | CommandPlan::Explain { analyze: false, .. }
98        | CommandPlan::DoBlock { .. }
99        | CommandPlan::Call { .. } => Ok(None),
100        CommandPlan::CreateTable(_)
101        | CommandPlan::CreateTableIfNotExists(_)
102        | CommandPlan::CreateTableAs { .. }
103        | CommandPlan::CreateIndex(_)
104        | CommandPlan::RenameIndex(_)
105        | CommandPlan::Drop(_)
106        | CommandPlan::AlterTable(_)
107        | CommandPlan::AlterForeignTable(_)
108        | CommandPlan::AlterView(_)
109        | CommandPlan::CreateView { .. }
110        | CommandPlan::CreateMaterializedView { .. }
111        | CommandPlan::RefreshMaterializedView { .. }
112        | CommandPlan::CreateSchema { .. }
113        | CommandPlan::AlterSchemaOwner { .. }
114        | CommandPlan::RenameSchema { .. }
115        | CommandPlan::Truncate { .. }
116        | CommandPlan::CreateSequence(_)
117        | CommandPlan::AlterSequence(_)
118        | CommandPlan::CreateDomain(_)
119        | CommandPlan::AlterDomain(_)
120        | CommandPlan::CreateEnum(_)
121        | CommandPlan::CreateCompositeType(_)
122        | CommandPlan::AlterEnum(_)
123        | CommandPlan::AlterTypeObject(_)
124        | CommandPlan::GrantType(_)
125        | CommandPlan::CreateForeignWrapper(_)
126        | CommandPlan::CreateForeignServer(_)
127        | CommandPlan::CreateForeignTable(_)
128        | CommandPlan::CreateForeignTableDefinition(_)
129        | CommandPlan::CreateFunction(_)
130        | CommandPlan::DropFunction(_)
131        | CommandPlan::AlterRoutine(_)
132        | CommandPlan::AlterRoutineOwner(_)
133        | CommandPlan::RenameRoutine(_)
134        | CommandPlan::GrantRoutine(_)
135        | CommandPlan::GrantTable(_)
136        | CommandPlan::GrantSequence(_)
137        | CommandPlan::GrantDatabase(_)
138        | CommandPlan::GrantSchema(_)
139        | CommandPlan::GrantRole(_)
140        | CommandPlan::CreateRole(_)
141        | CommandPlan::AlterRole(_)
142        | CommandPlan::RenameRole(_)
143        | CommandPlan::DropRole(_)
144        | CommandPlan::CreateTrigger(_)
145        | CommandPlan::DropTrigger(_)
146        | CommandPlan::CreateRule(_)
147        | CommandPlan::DropRule(_) => Ok(Some(command_tag_name(command))),
148    }
149}
150
151pub fn plan_sets_transaction_snapshot(plan: &UnifiedPlan) -> bool {
152    !matches!(
153        plan,
154        UnifiedPlan::Command(command)
155            if matches!(
156                command.as_ref(),
157                CommandPlan::SetVariable { .. }
158                    | CommandPlan::Notify { .. }
159                    | CommandPlan::Listen { .. }
160                    | CommandPlan::Unlisten { .. }
161                    | CommandPlan::ResetVariable { .. }
162                    | CommandPlan::ResetAllVariables
163                    | CommandPlan::SetConstraints { .. }
164                    | CommandPlan::ShowVariable { .. }
165                    | CommandPlan::Transaction(_)
166                    | CommandPlan::LockTable(_)
167                    | CommandPlan::FetchCursor(_)
168                    | CommandPlan::CloseCursor { .. }
169                    | CommandPlan::Deallocate { .. }
170                    | CommandPlan::Load { .. }
171                    | CommandPlan::Discard { .. }
172            )
173    )
174}