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}