Skip to main content

spg_engine/
session.rs

1//! Session-parameter handling split out of `lib.rs` (lib.rs split 16):
2//! `set_session_param` records a `SET <name> = <value>` (folding the
3//! MySQL/PG FK-check + string-dialect toggles into engine state),
4//! `session_param` reads one back (the FTS dispatcher consults
5//! `default_text_search_config`), and `ev_ctx` builds an `EvalContext`
6//! pre-chained with that config. Whole `impl Engine` methods; the
7//! execute dispatcher drives `set_session_param`, `select.rs` drives
8//! `ev_ctx`, and `dml.rs` / `plpgsql.rs` read via `session_param`.
9
10use alloc::string::String;
11
12use spg_storage::ColumnSchema;
13
14use crate::Engine;
15use crate::eval::EvalContext;
16
17impl Engine {
18    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
19    /// are case-folded to lowercase to match PG; values keep their
20    /// caller-supplied form so observability paths see what was
21    /// requested. Only `default_text_search_config` is consulted by
22    /// the engine today.
23    pub(crate) fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
24        let normalised = match value {
25            spg_sql::ast::SetValue::String(s) => s,
26            spg_sql::ast::SetValue::Ident(s) => s,
27            spg_sql::ast::SetValue::Number(s) => s,
28            spg_sql::ast::SetValue::Default => String::new(),
29        };
30        let key = name.to_ascii_lowercase();
31        // v7.14.0 — mysqldump preamble emits
32        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
33        // order despite cross-table FK references; the closing
34        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
35        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
36        // session-variable-aware path). Match both shapes.
37        // Also accept PG's `session_replication_role = 'replica'`
38        // which suppresses trigger + FK enforcement during a
39        // logical replication apply (pg_dump preserves this for
40        // schema-only mode but it shows up in some restores).
41        let value_off = matches!(
42            normalised.to_ascii_lowercase().as_str(),
43            "0" | "off" | "false"
44        );
45        let value_on = matches!(
46            normalised.to_ascii_lowercase().as_str(),
47            "1" | "on" | "true"
48        );
49        if key == "foreign_key_checks"
50            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
51        {
52            if value_off || key == "session_replication_role" {
53                self.foreign_key_checks = false;
54            } else if value_on
55                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
56            {
57                self.foreign_key_checks = true;
58                // Drain pending FK queue against the now-complete
59                // catalog. Errors here surface as the SET reply —
60                // caller knows enabling checks revealed orphans.
61                let _ = self.drain_pending_foreign_keys();
62            }
63        }
64        // v7.22 (round-13 T3) — string-literal dialect signals.
65        // `SET sql_mode = …` is something only MySQL clients and
66        // mysqldump preambles emit → MySQL escape semantics.
67        // `SET standard_conforming_strings = on|off` is PG's own
68        // switch for exactly this behaviour (every pg_dump preamble
69        // sets it to on). The same SQL text lexes differently per
70        // dialect, so a flip invalidates the plan cache.
71        let new_escapes = if key == "sql_mode" {
72            Some(true)
73        } else if key == "standard_conforming_strings" {
74            Some(value_off)
75        } else {
76            None
77        };
78        if let Some(flag) = new_escapes
79            && flag != self.backslash_escapes
80        {
81            self.backslash_escapes = flag;
82            self.plan_cache.clear();
83        }
84        self.session_params.insert(key, normalised);
85    }
86
87    /// v7.12.1 — read a session parameter set via `SET`. Used by
88    /// the FTS function dispatcher to resolve the default config
89    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
90    #[must_use]
91    pub fn session_param(&self, name: &str) -> Option<&str> {
92        self.session_params
93            .get(&name.to_ascii_lowercase())
94            .map(String::as_str)
95    }
96
97    /// v7.37.7 — PG `statement_timeout` GUC read accessor. Returns the
98    /// session-set value in **milliseconds**, parsed from the raw
99    /// `SET statement_timeout = N` string. Returns `None` when:
100    /// - the GUC is unset,
101    /// - the value is `0` (PG semantics: 0 = no timeout),
102    /// - the value fails to parse.
103    ///
104    /// Accepted input shapes mirror PG's `GUC_UNIT_MS` parser:
105    /// - bare digits: `100` → 100 ms (PG default unit when GUC is in ms)
106    /// - explicit ms: `100ms`, `100 ms`
107    /// - seconds:     `1s`, `30s` → 1000 / 30000 ms
108    /// - minutes:     `5min` → 300000 ms
109    ///
110    /// The host (`spg-server` per-query watchdog) consults this when
111    /// constructing the `CancelToken` deadline so a SQL-set
112    /// `SET statement_timeout = 1000` is honoured per-session — the
113    /// effective deadline becomes `min(SPG_QUERY_TIMEOUT_MS, session)`.
114    /// Returning `None` from this fn means "no session override, use
115    /// the host-level timeout only".
116    #[must_use]
117    pub fn session_statement_timeout_ms(&self) -> Option<u64> {
118        let raw = self.session_param("statement_timeout")?;
119        parse_pg_duration_ms(raw).filter(|ms| *ms > 0)
120    }
121
122    /// v7.12.1 — build an `EvalContext` chained with the session's
123    /// `default_text_search_config`. Engine-internal callers use
124    /// this instead of `EvalContext::new` so the FTS function
125    /// dispatcher sees the SET configuration.
126    pub(crate) fn ev_ctx<'a>(
127        &'a self,
128        columns: &'a [ColumnSchema],
129        alias: Option<&'a str>,
130    ) -> EvalContext<'a> {
131        EvalContext::new(columns, alias)
132            .with_default_text_search_config(self.session_param("default_text_search_config"))
133    }
134}
135
136/// v7.37.7 — parse a PG-style `GUC_UNIT_MS` duration string into
137/// milliseconds. Accepts the same shapes PG itself accepts for
138/// `statement_timeout` and related ms-based GUCs.
139///
140/// Returns `None` on parse failure (callers treat None as "GUC not
141/// set / default applies").
142fn parse_pg_duration_ms(raw: &str) -> Option<u64> {
143    let s = raw.trim();
144    if s.is_empty() {
145        return None;
146    }
147    // PG accepts trailing unit suffix: ms / s / min / h / d. Strip in
148    // priority order (longer first so `min` doesn't match as `m`).
149    let lowered = s.to_ascii_lowercase();
150    let (num_part, multiplier_ms): (&str, u64) = if let Some(p) = lowered.strip_suffix("ms") {
151        (p, 1)
152    } else if let Some(p) = lowered.strip_suffix("min") {
153        (p, 60_000)
154    } else if let Some(p) = lowered.strip_suffix('s') {
155        (p, 1_000)
156    } else if let Some(p) = lowered.strip_suffix('h') {
157        (p, 3_600_000)
158    } else if let Some(p) = lowered.strip_suffix('d') {
159        (p, 86_400_000)
160    } else {
161        // No unit suffix — bare digits in the GUC's native unit (ms
162        // for `statement_timeout`).
163        (lowered.as_str(), 1)
164    };
165    let n: u64 = num_part.trim().parse().ok()?;
166    n.checked_mul(multiplier_ms)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::parse_pg_duration_ms;
172    use alloc::format;
173
174    #[test]
175    fn parse_bare_digits_treats_as_ms() {
176        assert_eq!(parse_pg_duration_ms("100"), Some(100));
177        assert_eq!(parse_pg_duration_ms("0"), Some(0));
178        assert_eq!(parse_pg_duration_ms("60000"), Some(60_000));
179    }
180
181    #[test]
182    fn parse_ms_suffix() {
183        assert_eq!(parse_pg_duration_ms("100ms"), Some(100));
184        assert_eq!(parse_pg_duration_ms("100 ms"), Some(100));
185    }
186
187    #[test]
188    fn parse_seconds() {
189        assert_eq!(parse_pg_duration_ms("1s"), Some(1_000));
190        assert_eq!(parse_pg_duration_ms("30s"), Some(30_000));
191    }
192
193    #[test]
194    fn parse_minutes_uses_three_letter_suffix() {
195        assert_eq!(parse_pg_duration_ms("5min"), Some(300_000));
196        // `5m` is NOT valid PG (PG requires `min`); confirm we mirror.
197        assert_eq!(parse_pg_duration_ms("5m"), None);
198    }
199
200    #[test]
201    fn parse_invalid_returns_none() {
202        assert_eq!(parse_pg_duration_ms(""), None);
203        assert_eq!(parse_pg_duration_ms("abc"), None);
204        assert_eq!(parse_pg_duration_ms("100x"), None);
205    }
206
207    #[test]
208    fn parse_handles_whitespace() {
209        assert_eq!(parse_pg_duration_ms("  100  "), Some(100));
210    }
211
212    #[test]
213    fn parse_overflow_returns_none() {
214        // u64::MAX seconds overflows when multiplied by 1000 ms/s.
215        assert_eq!(parse_pg_duration_ms(&format!("{}s", u64::MAX)), None);
216    }
217
218    #[cfg(test)]
219    mod session_integration {
220        use crate::Engine;
221        use spg_sql::ast::SetValue;
222
223        #[test]
224        fn set_statement_timeout_round_trips_ms() {
225            let mut e = Engine::new();
226            e.set_session_param("statement_timeout".into(), SetValue::Number("250".into()));
227            assert_eq!(e.session_statement_timeout_ms(), Some(250));
228        }
229
230        #[test]
231        fn set_statement_timeout_zero_is_none() {
232            // PG semantics: 0 means "no timeout".
233            let mut e = Engine::new();
234            e.set_session_param("statement_timeout".into(), SetValue::Number("0".into()));
235            assert_eq!(e.session_statement_timeout_ms(), None);
236        }
237
238        #[test]
239        fn statement_timeout_unset_is_none() {
240            let e = Engine::new();
241            assert_eq!(e.session_statement_timeout_ms(), None);
242        }
243
244        #[test]
245        fn statement_timeout_accepts_ms_suffix_via_string_set() {
246            let mut e = Engine::new();
247            e.set_session_param("statement_timeout".into(), SetValue::String("1500ms".into()));
248            assert_eq!(e.session_statement_timeout_ms(), Some(1500));
249        }
250    }
251}