Skip to main content

mongreldb_protocol/
prepared.rs

1//! Prepared-statement binding record (spec section 10.4, S1D-005).
2//!
3//! A prepared statement's plan is only valid against exactly the catalog and
4//! schema state it was planned against. [`PreparedStatementBinding`] records
5//! everything the plan binds to — SQL text, parameter types, catalog
6//! version, per-table schema versions, and the negotiated feature set — and
7//! [`PreparedStatementBinding::is_compatible`] is the invalidation check the
8//! executor runs before executing: on any incompatible schema change the
9//! statement is invalidated and replanned. A stale plan MUST never execute
10//! silently (S1D-005); executors report incompatibility as
11//! [`mongreldb_types::errors::ErrorCategory::SchemaVersionMismatch`], which
12//! routes the client through re-preparation (spec section 11.7).
13
14use core::fmt;
15use std::collections::{BTreeMap, BTreeSet};
16
17use mongreldb_types::ids::{MetadataVersion, SchemaVersion, TableId};
18
19/// Session-scoped handle of a prepared statement, allocated by
20/// [`crate::services::QueryService::prepare`]. Valid only within the session
21/// that prepared it; the zero value is reserved.
22#[repr(transparent)]
23#[derive(
24    Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
25)]
26pub struct StatementId(pub u64);
27
28impl StatementId {
29    /// The zero value (reserved).
30    pub const ZERO: Self = Self(0);
31
32    /// Wraps a raw value.
33    pub const fn new(value: u64) -> Self {
34        Self(value)
35    }
36
37    /// Returns the raw value.
38    pub const fn get(self) -> u64 {
39        self.0
40    }
41}
42
43impl fmt::Display for StatementId {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        write!(f, "{}", self.0)
46    }
47}
48
49impl fmt::Debug for StatementId {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(f, "StatementId({})", self.0)
52    }
53}
54
55/// Everything a prepared plan binds to (S1D-005).
56///
57/// The binding is data only: the plan itself is server state. Executors call
58/// [`Self::is_compatible`] before every execution and invalidate + replan on
59/// `false`; a stale plan never executes silently.
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61pub struct PreparedStatementBinding {
62    /// Session-scoped handle of this prepared statement.
63    pub statement_id: StatementId,
64    /// The SQL text the plan was built from.
65    pub sql: String,
66    /// Canonical parameter type names in statement order (see
67    /// [`crate::request::ParameterValue::type_name`]); execution with a
68    /// mismatched parameter list fails rather than coercing silently.
69    pub parameter_types: Vec<String>,
70    /// Catalog metadata version the plan was built against.
71    pub catalog_version: MetadataVersion,
72    /// Schema version of every table the plan touches, keyed by table.
73    pub schema_versions: BTreeMap<TableId, SchemaVersion>,
74    /// The negotiated engine feature set the plan relies on. Feature-set
75    /// changes are negotiated at session (re)handshake, not per request, so
76    /// [`Self::is_compatible`] does not re-check them; a session whose
77    /// feature set changed re-prepares all of its statements.
78    pub feature_set: BTreeSet<String>,
79}
80
81impl PreparedStatementBinding {
82    /// Whether the plan is still valid against the current catalog state.
83    ///
84    /// Compatible iff the catalog version is unchanged AND every table the
85    /// plan touches still exists at exactly the schema version it was
86    /// planned against. Tables the plan does not touch may change freely.
87    ///
88    /// On `false` the statement MUST be invalidated and replanned (S1D-005):
89    /// a stale plan never executes silently.
90    pub fn is_compatible(
91        &self,
92        catalog_version: MetadataVersion,
93        schema_versions: &BTreeMap<TableId, SchemaVersion>,
94    ) -> bool {
95        self.catalog_version == catalog_version
96            && self
97                .schema_versions
98                .iter()
99                .all(|(table, version)| schema_versions.get(table) == Some(version))
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::test_support::assert_serde_round_trip;
107
108    fn binding() -> PreparedStatementBinding {
109        let mut schema_versions = BTreeMap::new();
110        schema_versions.insert(TableId::new(1), SchemaVersion::new(10));
111        schema_versions.insert(TableId::new(2), SchemaVersion::new(20));
112        let mut feature_set = BTreeSet::new();
113        feature_set.insert("ann-index".to_owned());
114        feature_set.insert("cdc".to_owned());
115        PreparedStatementBinding {
116            statement_id: StatementId::new(7),
117            sql: "SELECT * FROM events WHERE tenant = ?".to_owned(),
118            parameter_types: vec!["INT64".to_owned()],
119            catalog_version: MetadataVersion::new(100),
120            schema_versions,
121            feature_set,
122        }
123    }
124
125    #[test]
126    fn statement_id_basics_and_serde() {
127        assert_eq!(StatementId::ZERO.get(), 0);
128        let id = StatementId::new(42);
129        assert_eq!(id.get(), 42);
130        assert_eq!(id.to_string(), "42");
131        assert_eq!(format!("{id:?}"), "StatementId(42)");
132        assert_serde_round_trip(&id);
133        assert_serde_round_trip(&StatementId::ZERO);
134    }
135
136    #[test]
137    fn binding_serde_round_trip() {
138        assert_serde_round_trip(&binding());
139        let empty = PreparedStatementBinding {
140            statement_id: StatementId::ZERO,
141            sql: String::new(),
142            parameter_types: vec![],
143            catalog_version: MetadataVersion::ZERO,
144            schema_versions: BTreeMap::new(),
145            feature_set: BTreeSet::new(),
146        };
147        assert_serde_round_trip(&empty);
148    }
149
150    #[test]
151    fn invalidation_matrix() {
152        let binding = binding();
153        let current = binding.schema_versions.clone();
154
155        // Unchanged state is compatible.
156        assert!(binding.is_compatible(MetadataVersion::new(100), &current));
157
158        // Catalog version bump invalidates.
159        assert!(!binding.is_compatible(MetadataVersion::new(101), &current));
160
161        // Schema change on a touched table invalidates.
162        let mut altered = current.clone();
163        altered.insert(TableId::new(1), SchemaVersion::new(11));
164        assert!(!binding.is_compatible(MetadataVersion::new(100), &altered));
165
166        // Dropping a touched table invalidates.
167        let mut dropped = current.clone();
168        dropped.remove(&TableId::new(2));
169        assert!(!binding.is_compatible(MetadataVersion::new(100), &dropped));
170
171        // Changing a table the plan does not touch stays compatible.
172        let mut unrelated = current.clone();
173        unrelated.insert(TableId::new(3), SchemaVersion::new(1));
174        assert!(binding.is_compatible(MetadataVersion::new(100), &unrelated));
175
176        // A binding over no tables only depends on the catalog version.
177        let no_tables = PreparedStatementBinding {
178            schema_versions: BTreeMap::new(),
179            ..binding.clone()
180        };
181        assert!(no_tables.is_compatible(MetadataVersion::new(100), &BTreeMap::new()));
182        assert!(!no_tables.is_compatible(MetadataVersion::new(99), &BTreeMap::new()));
183    }
184}