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
17/// v7.39 (RLS) — reserved `session_params` key holding the effective session
18/// role set by `SET ROLE` (absent = the default Admin superuser login).
19/// `current_user` / RLS enforcement read it via `EvalContext.session_gucs`.
20/// The `__spg_` prefix keeps it out of the user-visible GUC namespace.
21pub(crate) const CURRENT_ROLE_KEY: &str = "__spg_current_role";
22
23/// v7.39 (read01 round 51) — reserved `session_params` key holding the LOGIN
24/// identity: the `user` the client sent in the startup packet. `session_user`
25/// reports it, and `current_user` falls back to it when no `SET ROLE` is in
26/// effect. Absent (embedded engine, or a wire that never set it) = LOGIN_ROLE.
27pub(crate) const SESSION_USER_KEY: &str = "__spg_session_user";
28
29/// v7.37 (round 830) — reserved `session_params` key marking the login
30/// identity as VERIFIED: the connection presented a credential the server
31/// checked (SCRAM or cleartext), rather than merely naming itself in the
32/// startup packet. Absent = unverified, which is the embedded engine and
33/// the server's open mode.
34///
35/// The distinction is what lets a login name carry privilege. Open mode
36/// accepts any startup as the admin role, so a name there is a label and
37/// nothing more — keying privilege on it would let anyone pick their own.
38/// A checked credential is a different thing, and it is the only
39/// configuration where roles and their policies mean anything at all.
40pub(crate) const SESSION_AUTHENTICATED_KEY: &str = "__spg_session_authenticated";
41
42/// v7.39 (RLS) — the login identity (superuser). SPG's embedded engine and
43/// its default server session both authenticate as this.
44pub(crate) const LOGIN_ROLE: &str = "admin";
45
46/// v7.39 (read01 round 58) — PG's bootstrap superuser. `synth_pg_roles` has
47/// always reported a `postgres` row (admin tools probe for it), so the name has
48/// to BE a role: refusing `SET ROLE postgres` while advertising it in pg_roles
49/// would be the same self-contradiction the ACL work went and fixed. It is a
50/// superuser, like the login role.
51pub(crate) const BOOTSTRAP_ROLE: &str = "postgres";
52
53/// The parameter names a session may name at all, beyond PG18's own
54/// inventory.
55///
56/// v7.39 — one list, asked by every surface. They used to decide
57/// separately and gave FOUR answers to one question: measured on the
58/// same name, `SET nosuch_guc` refused it in PG's words,
59/// `SHOW nosuch_guc` refused it in SPG's own words,
60/// `current_setting('nosuch_guc')` returned an empty string and
61/// `set_config('nosuch_guc', 'x', false)` returned `x`. Only the first
62/// was PG's answer.
63///
64/// Three families are accepted past PG's list because refusing them
65/// would break callers that are not wrong:
66///
67/// * anything containing a dot — PG treats `myapp.thing` as a
68///   customised option and accepts it, and extensions rely on that;
69/// * the MySQL-dialect names SPG honours (`sql_mode`,
70///   `foreign_key_checks`, …), which PG has no concept of and which
71///   `mysqldump` preambles emit;
72/// * SPG's own internal keys.
73pub(crate) fn guc_name_accepted_beyond_pg(key: &str) -> bool {
74    key.contains('.')
75        || key.starts_with("__spg")
76        || matches!(
77            key,
78            "sql_mode"
79                | "foreign_key_checks"
80                | "unique_checks"
81                | "autocommit"
82                | "names"
83                | "character_set_client"
84                | "character_set_connection"
85                | "character_set_results"
86                | "collation_connection"
87                | "sql_quote_show_create"
88                | "sql_notes"
89                | "time_zone"
90                | "sql_safe_updates"
91                | "innodb_strict_mode"
92                | "net_write_timeout"
93                | "net_read_timeout"
94                | "wait_timeout"
95                | "interactive_timeout"
96                | "max_allowed_packet"
97                | "group_concat_max_len"
98                | "old_alter_table"
99                | "sql_log_bin"
100                | "session_replication_role"
101        )
102}
103
104/// Is this a name PG18 knows, or one of the three families above?
105///
106/// The read path (`current_setting`, `SHOW`) asks this; the write path
107/// asks [`Engine::reject_unsettable_guc`], which adds PG's per-context
108/// refusals on top of the same list.
109pub(crate) fn guc_name_known(key: &str) -> bool {
110    guc_name_accepted_beyond_pg(key) || crate::guc_catalog::guc_context(key).is_some()
111}
112
113impl Engine {
114    /// v7.39 (read01 round 51) — the login identity: the startup packet's
115    /// `user`, else the Admin default. Drives `session_user`.
116    #[must_use]
117    pub(crate) fn session_user(&self) -> &str {
118        self.session_params
119            .get(SESSION_USER_KEY)
120            .map_or(LOGIN_ROLE, String::as_str)
121    }
122
123    /// v7.39 (read01 round 51) — record the connection's login identity.
124    /// The server calls this once per connection from the startup packet.
125    pub fn set_session_user(&mut self, user: &str) {
126        self.session_params
127            .insert(String::from(SESSION_USER_KEY), String::from(user));
128    }
129
130    /// v7.37 (round 830) — record that this connection's login identity was
131    /// verified against a stored credential. The server calls it once per
132    /// connection, right after `set_session_user`, when it demanded a
133    /// password; open-mode connections never do.
134    pub fn set_session_authenticated(&mut self) {
135        self.session_params
136            .insert(String::from(SESSION_AUTHENTICATED_KEY), String::from("1"));
137    }
138
139    /// Was this session's login identity checked against a credential?
140    #[must_use]
141    pub(crate) fn session_is_authenticated(&self) -> bool {
142        self.session_params.contains_key(SESSION_AUTHENTICATED_KEY)
143    }
144
145    /// v7.39 (RLS) — the effective session role: the `SET ROLE` override, or
146    /// the login identity. Drives `current_user` and RLS role matching.
147    #[must_use]
148    pub(crate) fn current_role(&self) -> &str {
149        self.session_params
150            .get(CURRENT_ROLE_KEY)
151            .map_or_else(|| self.session_user(), String::as_str)
152    }
153
154    /// v7.39 (RLS) — whether the session bypasses RLS. PG: superusers always
155    /// bypass. SPG maps this to "no non-Admin `SET ROLE` is in effect": the
156    /// default login and an explicit `SET ROLE admin` are superuser; any other
157    /// role is policy-subject.
158    #[must_use]
159    pub(crate) fn is_superuser(&self) -> bool {
160        // v7.39 (read01 round 51) — keyed on whether an explicit `SET ROLE` to
161        // a non-superuser role is in effect, NOT on the login NAME. The wire
162        // reports the startup packet's `user` as current_user / session_user;
163        // if superuser-ness followed that name, every connection as e.g.
164        // "unmei" would silently become RLS-subject. Reported identity and
165        // privilege semantics stay decoupled.
166        //
167        // v7.39 (read01 round 58) — the role's own SUPERUSER attribute decides
168        // now. `SET ROLE admin` still is one (the built-in login role), and so
169        // is any role created SUPERUSER. PG never inherits the attribute
170        // through membership, so this reads the role itself, not its set.
171        match self.session_params.get(CURRENT_ROLE_KEY) {
172            Some(r) => self.role_is_superuser(r),
173            // v7.37 (round 830) — with no SET ROLE in effect, the login
174            // identity decides IF it was verified. Measured before this:
175            // psql authenticated as a role with rolsuper = f, over a table
176            // with row security enabled and a USING policy in place, read
177            // every row including another owner's — for every projection
178            // shape, because the predicate was never injected at all.
179            //
180            // The unconditional `true` this replaces was deliberate and is
181            // still right for the case it was written for: in open mode the
182            // startup `user` is unverified, so letting it carry privilege
183            // would mean anyone could name themselves into a role. What
184            // changed is that the name is no longer always unverified — once
185            // a credentialed LOGIN role exists the server demands SCRAM, and
186            // that is exactly the configuration where policies and grants
187            // are supposed to bind.
188            //
189            // `role_is_superuser` still exempts the admin and bootstrap
190            // logins and any role created SUPERUSER, so authenticating as an
191            // administrator changes nothing.
192            None if self.session_is_authenticated() => self.role_is_superuser(self.session_user()),
193            None => true,
194        }
195    }
196
197    /// v7.39 (round 334, V55) — is THAT role a superuser? Split out of
198    /// [`Self::is_superuser`] so a `SECURITY DEFINER` body can be
199    /// authorised as the function's owner rather than the session's role.
200    pub(crate) fn role_is_superuser(&self, role: &str) -> bool {
201        role.eq_ignore_ascii_case(LOGIN_ROLE)
202            || role.eq_ignore_ascii_case(BOOTSTRAP_ROLE)
203            || self.users.get(role).is_some_and(|rec| rec.superuser)
204    }
205
206    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
207    /// are case-folded to lowercase to match PG; values keep their
208    /// caller-supplied form so observability paths see what was
209    /// requested. Only `default_text_search_config` is consulted by
210    /// the engine today.
211    /// v7.39 (round 501) — is `name` a parameter this session may set?
212    ///
213    /// PG18 answers `ERROR: unrecognized configuration parameter "x"` for
214    /// a name it does not know, and refuses the ones a session cannot
215    /// change with a wording that says why. SPG accepted anything —
216    /// round 500 measured `SET nonexistent_knob = 3` answering `SET` — so
217    /// a typo'd parameter name was taken silently and the setting the
218    /// caller believed they had made was never made.
219    ///
220    /// Three kinds of name are accepted beyond PG18's own list, because
221    /// rejecting them would break callers that are not wrong:
222    ///
223    /// * anything containing a dot — PG treats `myapp.thing` as a
224    ///   customised option and accepts it, and extensions rely on that;
225    /// * the MySQL-dialect names SPG honours (`sql_mode`,
226    ///   `foreign_key_checks`, …), which PG has no concept of and which
227    ///   `mysqldump` preambles emit;
228    /// * SPG's own internal keys.
229    ///
230    /// Returns the PG error text, or `None` when the SET may proceed.
231    pub(crate) fn reject_unsettable_guc(&self, name: &str) -> Option<alloc::string::String> {
232        let key = name.to_ascii_lowercase();
233        if guc_name_accepted_beyond_pg(&key) {
234            return None;
235        }
236        match crate::guc_catalog::guc_context(&key) {
237            None => Some(alloc::format!(
238                "unrecognized configuration parameter \"{key}\""
239            )),
240            // PG's own wording per context, so a client that matches on
241            // the message keeps working.
242            Some("internal") => Some(alloc::format!("parameter \"{key}\" cannot be changed")),
243            Some("postmaster") => Some(alloc::format!(
244                "parameter \"{key}\" cannot be changed without restarting the server"
245            )),
246            Some("sighup") => Some(alloc::format!("parameter \"{key}\" cannot be changed now")),
247            Some(_) => None,
248        }
249    }
250
251    /// Clear a session parameter, PG's way.
252    ///
253    /// v7.39 — a CUSTOM (dotted) parameter that this session has set
254    /// once stays defined for the rest of the session and reads back an
255    /// EMPTY STRING, not nothing: measured on PG 18.6, `SET app.z='1';
256    /// RESET app.z; current_setting('app.z', true)` answers `''`, and so
257    /// does the same read after a `SET LOCAL app.y` transaction commits.
258    /// SPG removed the key, so both answered NULL and `SHOW app.z`
259    /// errored — an application that branches on `IS NULL` versus `= ''`
260    /// branches the other way. A name this session never set still
261    /// answers NULL, which PG agrees with.
262    pub(crate) fn clear_session_param(&mut self, name: &str) {
263        let key = name.to_ascii_lowercase();
264        if key.contains('.') {
265            self.session_params
266                .insert(key, alloc::string::String::new());
267        } else {
268            self.session_params.remove(&key);
269        }
270        self.refresh_render_style();
271    }
272
273    pub(crate) fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
274        let normalised = match value {
275            spg_sql::ast::SetValue::String(s) => s,
276            spg_sql::ast::SetValue::Ident(s) => s,
277            spg_sql::ast::SetValue::Number(s) => s,
278            // v7.39 (GUC) — `SET name = DEFAULT` / `SET TIME ZONE
279            // LOCAL` restore the default, i.e. drop the session
280            // override (storing "" would make SHOW render an empty
281            // string instead of the default).
282            spg_sql::ast::SetValue::Default => {
283                self.clear_session_param(&name);
284                return;
285            }
286        };
287        let key = name.to_ascii_lowercase();
288        // v7.14.0 — mysqldump preamble emits
289        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
290        // order despite cross-table FK references; the closing
291        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
292        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
293        // session-variable-aware path). Match both shapes.
294        // Also accept PG's `session_replication_role = 'replica'`
295        // which suppresses trigger + FK enforcement during a
296        // logical replication apply (pg_dump preserves this for
297        // schema-only mode but it shows up in some restores).
298        let value_off = matches!(
299            normalised.to_ascii_lowercase().as_str(),
300            "0" | "off" | "false"
301        );
302        let value_on = matches!(
303            normalised.to_ascii_lowercase().as_str(),
304            "1" | "on" | "true"
305        );
306        // v7.39 — `SET NAMES <charset>` sets the three character_set_*
307        // session variables and, unless a `COLLATE` clause follows,
308        // the charset's DEFAULT collation. The parser emits the charset
309        // under `names` and the expansion lives here, beside the rest of
310        // the MySQL session semantics.
311        //
312        // An unknown charset sets the character_set_* trio and leaves
313        // `collation_connection` alone rather than guessing: a session
314        // that pads differently from what it was told is worse than one
315        // that did not change.
316        if key == "names" {
317            for k in [
318                "character_set_client",
319                "character_set_connection",
320                "character_set_results",
321            ] {
322                self.session_params
323                    .insert(String::from(k), normalised.clone());
324            }
325            if let Some(coll) = crate::collate::charset_default_collation(&normalised) {
326                self.session_params
327                    .insert(String::from("collation_connection"), String::from(coll));
328            }
329            self.refresh_render_style();
330            return;
331        }
332        if key == "foreign_key_checks"
333            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
334        {
335            if value_off || key == "session_replication_role" {
336                self.foreign_key_checks = false;
337            } else if value_on
338                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
339            {
340                self.foreign_key_checks = true;
341                // Drain pending FK queue against the now-complete
342                // catalog. Errors here surface as the SET reply —
343                // caller knows enabling checks revealed orphans.
344                let _ = self.drain_pending_foreign_keys();
345            }
346        }
347        // v7.22 (round-13 T3) — string-literal dialect signals.
348        // `SET sql_mode = …` is something only MySQL clients and
349        // mysqldump preambles emit → MySQL escape semantics.
350        // `SET standard_conforming_strings = on|off` is PG's own
351        // switch for exactly this behaviour (every pg_dump preamble
352        // sets it to on). The same SQL text lexes differently per
353        // dialect, so a flip invalidates the plan cache.
354        let new_escapes = if key == "sql_mode" {
355            // MySQL/MariaDB turn backslash escapes OFF only when the
356            // sql_mode list contains NO_BACKSLASH_ESCAPES; any other
357            // value (including an empty list) leaves them ON. Verified
358            // vs MariaDB: `SET sql_mode='STRICT_TRANS_TABLES'` → `'\n'`
359            // is a newline, `='NO_BACKSLASH_ESCAPES,STRICT_TRANS_TABLES'`
360            // → two bytes. sql_mode is a full replacement, so evaluate
361            // the whole new value rather than tracking a delta.
362            Some(
363                !normalised
364                    .to_ascii_uppercase()
365                    .contains("NO_BACKSLASH_ESCAPES"),
366            )
367        } else if key == "standard_conforming_strings" {
368            Some(value_off)
369        } else {
370            None
371        };
372        if let Some(flag) = new_escapes
373            && flag != self.backslash_escapes
374        {
375            self.backslash_escapes = flag;
376            self.plan_cache.clear();
377        }
378        // v7.39 (round 470) — the OTHER thing sql_mode carries: strictness.
379        // MariaDB's default list has STRICT_TRANS_TABLES, and a list
380        // without any STRICT_ flag makes a value that would raise get bent
381        // to fit instead. Measured on MariaDB 11 with `SET sql_mode=''`:
382        // INT <- 99999999999999 stores 2147483647, TINYINT <- 999 stores
383        // 127, INT UNSIGNED <- -5 stores 0, VARCHAR(3) <- 'toolong' stores
384        // 'too', INT <- 'abc' stores 0 and <- '12xy' stores 12.
385        if key == "sql_mode" {
386            let upper = normalised.to_ascii_uppercase();
387            self.mysql_strict =
388                upper.contains("STRICT_TRANS_TABLES") || upper.contains("STRICT_ALL_TABLES");
389            // v7.39 — and the third thing sql_mode carries: whether
390            // `"…"` is an identifier. Measured on MySQL 9.7.2,
391            // `SET sql_mode='ANSI_QUOTES'; SELECT "abc"` gives
392            // `Unknown column 'abc'` where the default list gives
393            // `abc`. The same text lexes differently either way, so a
394            // flip invalidates the plan cache exactly as
395            // `backslash_escapes` does.
396            // Only a MySQL client or a mysqldump preamble sends this.
397            self.speaks_mysql = true;
398            self.refresh_name_folding();
399            // v7.39.2 — MySQL's default list carries this one, so a
400            // session that never sets sql_mode has it; a list without
401            // it restores the loose behaviour, which is MySQL's too.
402            self.mysql_only_full_group_by = upper.contains("ONLY_FULL_GROUP_BY");
403            let ansi = upper.contains("ANSI_QUOTES");
404            if ansi != self.mysql_ansi_quotes {
405                self.mysql_ansi_quotes = ansi;
406                self.plan_cache.clear();
407            }
408        }
409        // v7.39 (GUC) — PG stores ms-unit time GUCs as an integer and
410        // renders SHOW/current_setting in the largest whole unit
411        // ("250" → "250ms", "5000" → "5s"). Normalise at store time so
412        // every read surface agrees.
413        // v7.39 (round 204) — memory GUCs canonicalize to the largest
414        // binary unit at store time, so `SET work_mem = '65536'` and
415        // `= '64MB'` both SHOW `64MB`, matching PG.
416        // v7.39 (round 522) — which parameters those are now comes from
417        // `guc_unit`, the same table `pg_settings` reads.
418        let normalised = match guc_unit(key.as_str()) {
419            Some("ms") => match parse_pg_duration_ms(&normalised) {
420                Some(ms) => render_pg_duration_ms(ms),
421                None => normalised,
422            },
423            Some(_) => match parse_pg_mem_kb(&normalised) {
424                Some(kb) => render_pg_mem_kb(kb),
425                None => normalised,
426            },
427            None => normalised,
428        };
429        // v7.39 (GUC knife 3) — datestyle is sticky per category (a bare
430        // 'DMY' keeps the current style; 'German' forces DMY); PG stores
431        // and SHOWs the RESOLVED canonical pair. intervalstyle /
432        // extra_float_digits just refresh the cached RenderStyle.
433        let normalised = if key == "datestyle" {
434            match parse_datestyle_parts(&normalised, self.render_style) {
435                Some((st, ord)) => String::from(datestyle_canonical(st, ord)),
436                // Invalid values are rejected earlier (validate_known_guc);
437                // an unvalidated caller keeps the raw text.
438                None => normalised,
439            }
440        } else {
441            normalised
442        };
443        let is_render_guc = matches!(
444            key.as_str(),
445            // v7.39 (round 524) — `bytea_output` joins them: it was
446            // accepted and never read.
447            "datestyle" | "intervalstyle" | "extra_float_digits" | "bytea_output"
448        );
449        self.session_params.insert(key, normalised);
450        if is_render_guc {
451            self.refresh_render_style();
452        }
453    }
454
455    /// v7.39 (GUC knife 3) — recompute the cached `RenderStyle` from the
456    /// session store. Called after any write/removal of a render GUC.
457    pub(crate) fn refresh_render_style(&mut self) {
458        let mut style = crate::eval::RenderStyle::default();
459        if let Some(ds) = self.session_param("datestyle")
460            && let Some((st, ord)) = parse_datestyle_parts(ds, style)
461        {
462            style.date_style = st;
463            style.date_order = ord;
464        }
465        if let Some(is) = self.session_param("intervalstyle")
466            && let Some(k) = parse_intervalstyle(is)
467        {
468            style.interval_style = k;
469        }
470        if let Some(efd) = self.session_param("extra_float_digits")
471            && let Ok(n) = efd.trim().parse::<i32>()
472        {
473            style.extra_float_digits = n;
474        }
475        if let Some(bo) = self.session_param("bytea_output") {
476            style.bytea_escape = bo.trim().eq_ignore_ascii_case("escape");
477        }
478        self.render_style = style;
479    }
480
481    /// v7.12.1 — read a session parameter set via `SET`. Used by
482    /// the FTS function dispatcher to resolve the default config
483    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
484    /// v7.39 (tz epic) — validate + canonicalise a `SET timezone`
485    /// value: 'utc' -> 'UTC'; fixed offsets / abbreviations keep their
486    /// spelling; IANA names resolve through the host tzdb to their
487    /// canonical case. Unknown -> PG's invalid-parameter error.
488    pub(crate) fn canonicalize_timezone(&self, value: &str) -> Result<String, crate::EngineError> {
489        let v = value.trim();
490        if v.eq_ignore_ascii_case("utc") || v.eq_ignore_ascii_case("gmt") {
491            return Ok(v.to_ascii_uppercase());
492        }
493        if crate::eval::datetime_resolve_zone_offset(v).is_some() {
494            return Ok(String::from(v));
495        }
496        match self.tz_canon_fn {
497            Some(f) => match f(v) {
498                Some(canon) => Ok(canon),
499                None => Err(crate::EngineError::Unsupported(alloc::format!(
500                    "invalid value for parameter \"TimeZone\": \"{v}\""
501                ))),
502            },
503            // No host tzdb (bare no_std embedding): keep the pre-epic
504            // accept-and-store behaviour — rendering degrades to UTC
505            // rather than rejecting a name we cannot verify.
506            None => Ok(String::from(v)),
507        }
508    }
509
510    /// v7.39 (GUC knife 3) — the parsed session render style (wire /
511    /// COPY renderers snapshot it once per statement).
512    #[must_use]
513    pub fn render_style(&self) -> crate::eval::RenderStyle {
514        self.render_style
515    }
516
517    /// v7.39 (round 547) — apply the GUC defaults `ALTER ROLE … SET` /
518    /// `ALTER DATABASE … SET` recorded, in PG's order of specificity.
519    ///
520    /// Measured on PG18: with all four scopes set, a new session got the
521    /// role-in-database value. So the least specific is applied first and
522    /// the most specific last, each overwriting.
523    pub fn apply_db_role_settings(&mut self, database: &str, role: &str) {
524        let scopes: alloc::vec::Vec<(alloc::string::String, alloc::string::String)> = alloc::vec![
525            (alloc::string::String::new(), alloc::string::String::new()),
526            (
527                alloc::string::String::from(database),
528                alloc::string::String::new()
529            ),
530            (
531                alloc::string::String::new(),
532                alloc::string::String::from(role)
533            ),
534            (
535                alloc::string::String::from(database),
536                alloc::string::String::from(role)
537            ),
538        ];
539        let mut apply: alloc::vec::Vec<(alloc::string::String, alloc::string::String)> =
540            alloc::vec::Vec::new();
541        for key in &scopes {
542            if let Some(params) = self.active_catalog().db_role_settings().get(key) {
543                for (k, v) in params {
544                    apply.push((k.clone(), v.clone()));
545                }
546            }
547        }
548        for (k, v) in apply {
549            let _ = self.execute(&alloc::format!("SET {k} = '{v}'"));
550        }
551    }
552
553    /// v7.39 (tz epic) — per-statement session TimeZone snapshot for
554    /// the timestamptz renderers. SET already validated the value, so
555    /// an unresolvable name here (host lost its tzdb) degrades to UTC.
556    #[must_use]
557    pub fn session_tz(&self) -> crate::SessionTz {
558        let Some(z) = self.session_param("timezone") else {
559            return crate::SessionTz::Utc;
560        };
561        if z.eq_ignore_ascii_case("utc") || z.eq_ignore_ascii_case("gmt") {
562            return crate::SessionTz::Utc;
563        }
564        if let Some(off) = crate::eval::datetime_resolve_zone_offset(z) {
565            return if off == 0 {
566                crate::SessionTz::Utc
567            } else {
568                crate::SessionTz::Fixed(off)
569            };
570        }
571        match (self.tz_offset_fn, self.tz_abbrev_fn) {
572            (Some(of), Some(af)) => crate::SessionTz::Named(String::from(z), of, af),
573            _ => crate::SessionTz::Utc,
574        }
575    }
576
577    #[must_use]
578    pub fn session_param(&self, name: &str) -> Option<&str> {
579        let lower = name.to_ascii_lowercase();
580        // v7.39 (read01 round 118, B3) — `transaction_isolation` is not a plain
581        // session GUC in the params map; it tracks the live per-transaction
582        // level (`BEGIN ISOLATION LEVEL …`, reset at COMMIT/ROLLBACK). The wire
583        // `SHOW` handler reads this, so it must report the live value rather
584        // than a seeded "read committed".
585        if lower == "transaction_isolation" {
586            return Some(self.current_isolation_level.as_pg_str());
587        }
588        self.session_params.get(&lower).map(String::as_str)
589    }
590
591    /// v7.39 (read01 round 46) — raise a PG-style NOTICE for the statement
592    /// now executing. The text is PG's exact wording minus the "NOTICE:  "
593    /// banner (the wire layer adds that); e.g. `table "t" does not exist,
594    /// skipping`.
595    pub(crate) fn notice(&mut self, text: alloc::string::String) {
596        self.pending_notices.push(crate::Notice {
597            severity: crate::NoticeSeverity::Notice,
598            message: text,
599        });
600    }
601
602    /// v7.39 (round 320, V53) — `RESET ALL` / the reset half of
603    /// `DISCARD ALL`: drop every GUC override, keeping the internal keys
604    /// that are not GUCs at all (the connection's login identity and its
605    /// database). Clearing the whole map took those with it.
606    pub(crate) fn reset_all_gucs(&mut self) {
607        let keep: alloc::vec::Vec<(String, String)> = [SESSION_USER_KEY, "spg.database"]
608            .iter()
609            .filter_map(|k| {
610                self.session_params
611                    .get(*k)
612                    .map(|v| (String::from(*k), v.clone()))
613            })
614            .collect();
615        self.session_params.clear();
616        for (k, v) in keep {
617            self.session_params.insert(k, v);
618        }
619    }
620
621    /// v7.39 (round 318, V41) — raise a PG-style WARNING. Same channel as
622    /// [`Self::notice`], one level louder: PG uses it for "the command
623    /// succeeded but did nothing useful" cases such as `SET CONSTRAINTS`
624    /// outside a transaction block.
625    pub(crate) fn warning(&mut self, text: alloc::string::String) {
626        self.pending_notices.push(crate::Notice {
627            severity: crate::NoticeSeverity::Warning,
628            message: text,
629        });
630    }
631
632    /// v7.39 (round 757, F31-B3) — deliver a plpgsql body's RAISE
633    /// messages into the pending-notice queue, honouring
634    /// `client_min_messages` (INFO passes unconditionally, as in PG).
635    pub(crate) fn drain_raise_sink(&mut self, sink: crate::triggers::NoticeSink) {
636        self.queue_raised(sink.into_inner());
637    }
638
639    /// The vec-shaped half: body walkers that cannot hold `&mut self`
640    /// collect into a plain Vec and the owning method queues it here.
641    pub(crate) fn queue_raised(
642        &mut self,
643        raised: alloc::vec::Vec<(crate::NoticeSeverity, alloc::string::String)>,
644    ) {
645        for (severity, message) in raised {
646            if self.notice_severity_reaches_client(severity) {
647                self.pending_notices
648                    .push(crate::Notice { severity, message });
649            }
650        }
651    }
652
653    /// v7.39 (read01 round 46) — drain the NOTICEs the last statement
654    /// raised. pgwire emits one NoticeResponse per entry ahead of the
655    /// statement's CommandComplete; embedded callers may ignore them.
656    #[must_use]
657    pub fn take_notices(&mut self) -> alloc::vec::Vec<crate::Notice> {
658        core::mem::take(&mut self.pending_notices)
659    }
660
661    /// v7.37.7 — PG `statement_timeout` GUC read accessor. Returns the
662    /// session-set value in **milliseconds**, parsed from the raw
663    /// `SET statement_timeout = N` string. Returns `None` when:
664    /// - the GUC is unset,
665    /// - the value is `0` (PG semantics: 0 = no timeout),
666    /// - the value fails to parse.
667    ///
668    /// Accepted input shapes mirror PG's `GUC_UNIT_MS` parser:
669    /// - bare digits: `100` → 100 ms (PG default unit when GUC is in ms)
670    /// - explicit ms: `100ms`, `100 ms`
671    /// - seconds:     `1s`, `30s` → 1000 / 30000 ms
672    /// - minutes:     `5min` → 300000 ms
673    ///
674    /// The host (`spg-server` per-query watchdog) consults this when
675    /// constructing the `CancelToken` deadline so a SQL-set
676    /// `SET statement_timeout = 1000` is honoured per-session — the
677    /// effective deadline becomes `min(SPG_QUERY_TIMEOUT_MS, session)`.
678    /// Returning `None` from this fn means "no session override, use
679    /// the host-level timeout only".
680    #[must_use]
681    pub fn session_statement_timeout_ms(&self) -> Option<u64> {
682        let raw = self.session_param("statement_timeout")?;
683        parse_pg_duration_ms(raw).filter(|ms| *ms > 0)
684    }
685
686    /// `work_mem` in BYTES, which is what a sort has to compare against.
687    ///
688    /// The GUC has been accepted, unit-normalised and rendered since
689    /// round 204, and never read: nothing in the engine turned it into a
690    /// budget, so a sort's memory was bounded by the row count and not
691    /// by the setting. Round 863 added this so the external sort has a
692    /// ceiling to spill at.
693    ///
694    /// PG's default is 4 MB, and the same default applies when the
695    /// session has not set it or the stored value will not parse.
696    #[must_use]
697    pub fn session_work_mem_bytes(&self) -> usize {
698        const DEFAULT_KB: usize = 4 * 1024;
699        let kb = self
700            .session_param("work_mem")
701            .and_then(parse_pg_mem_kb)
702            .and_then(|kb| usize::try_from(kb).ok())
703            .filter(|kb| *kb > 0)
704            .unwrap_or(DEFAULT_KB);
705        kb.saturating_mul(1024)
706    }
707
708    /// v7.40.4 — how many EXTRA threads a large sort may use.
709    ///
710    /// `max_parallel_workers_per_gather` has been in the GUC catalogue
711    /// since the compatibility surface was built and nothing has ever
712    /// read it. A customer could set it, `SHOW` it back, and get one
713    /// thread. PG's default is 2, meaning two workers alongside the
714    /// process that already has the query — three sorting processes —
715    /// which is what the sort panel's PostgreSQL leg was doing while
716    /// SPG's leg used one core.
717    ///
718    /// Clamped by the machine: asking for more threads than there are
719    /// cores makes the merge deeper for nothing.
720    #[must_use]
721    pub(crate) fn session_parallel_workers(&self) -> crate::parsort::Workers {
722        let cores = available_cores();
723        crate::parsort::Workers {
724            per_sort: self.guc_count("max_parallel_workers_per_gather", 2, cores),
725            per_process: self.guc_count("max_parallel_workers", 8, cores),
726        }
727    }
728
729    /// A GUC that counts worker processes: PG's own default when it is
730    /// unset or unreadable, and never more than the machine can run.
731    fn guc_count(&self, name: &str, default: usize, cores: usize) -> usize {
732        self.session_param(name)
733            .map_or(default, |v| v.trim().parse::<usize>().unwrap_or(default))
734            .min(cores.saturating_sub(1))
735    }
736
737    /// v7.39 (round 621) — does a message of this severity reach the client?
738    ///
739    /// `client_min_messages` was validated on the way in and then never read,
740    /// so `SET client_min_messages = warning` — and even `= error` — left the
741    /// NOTICEs coming. Every `DROP … IF EXISTS` on a name that is not there
742    /// said so, which is why the standing differential corpus could not use
743    /// the GUC to quieten its own setup and carried the asymmetry in eighteen
744    /// of its files.
745    ///
746    /// PG's order, ascending: debug5 < debug4 < debug3 < debug2 < debug1 <
747    /// log < notice < warning < error < fatal < panic. A message is sent when
748    /// its own severity is at least the setting. Anything above `warning`
749    /// suppresses both of the severities SPG raises.
750    #[must_use]
751    pub fn notice_severity_reaches_client(&self, severity: crate::NoticeSeverity) -> bool {
752        fn rank(s: &str) -> u8 {
753            match s {
754                "debug5" => 0,
755                "debug4" => 1,
756                "debug3" => 2,
757                "debug2" => 3,
758                "debug1" => 4,
759                "log" => 5,
760                "notice" => 6,
761                "warning" => 7,
762                "error" => 8,
763                "fatal" => 9,
764                "panic" => 10,
765                // Not one of PG's levels — SET would have refused it, so this
766                // is the default rather than a silent drop.
767                _ => 6,
768            }
769        }
770        let setting = self
771            .session_param("client_min_messages")
772            .map_or(6, |v| rank(&v.trim().to_ascii_lowercase()));
773        let own = match severity {
774            crate::NoticeSeverity::Notice => 6,
775            crate::NoticeSeverity::Warning => 7,
776            // PG sends INFO to the client unconditionally.
777            crate::NoticeSeverity::Info => return true,
778        };
779        own >= setting
780    }
781
782    /// v7.12.1 — build an `EvalContext` chained with the session's
783    /// `default_text_search_config`. Engine-internal callers use
784    /// this instead of `EvalContext::new` so the FTS function
785    /// dispatcher sees the SET configuration.
786    /// v7.39 (round 523) — the session zone's offset at a UTC instant,
787    /// or 0 when the session is on UTC.
788    ///
789    /// The clock rewrite needs it: `current_date` and the local-clock
790    /// family read the session's wall clock, and SPG's unified clock
791    /// reads UTC, so `SET TimeZone = 'Asia/Tokyo'` left `current_date`
792    /// naming yesterday for nine hours of every day.
793    pub(crate) fn session_tz_offset_at(&self, utc_micros: i64) -> i64 {
794        let Some(zone) = self.session_params.get("timezone") else {
795            return 0;
796        };
797        if zone.eq_ignore_ascii_case("utc") || zone.eq_ignore_ascii_case("gmt") {
798            return 0;
799        }
800        if let Some(off) = crate::eval::resolve_zone_offset_pub(zone) {
801            return off;
802        }
803        self.tz_offset_fn
804            .and_then(|f| f(zone, utc_micros))
805            .unwrap_or(0)
806    }
807
808    /// v7.39 (round 524) — the session, cloned for a write path's
809    /// evaluation context. See [`crate::eval::DmlSession`].
810    pub(crate) fn dml_session(&self) -> crate::eval::DmlSession {
811        crate::eval::DmlSession {
812            gucs: self.session_params.clone(),
813            users: self.users.clone(),
814            render_style: self.render_style,
815            tz_offset_fn: self.tz_offset_fn,
816            tz_localize_fn: self.tz_localize_fn,
817            tz_abbrev_fn: self.tz_abbrev_fn,
818        }
819    }
820
821    /// v7.39 (round 523) — the session facts an assignment into a
822    /// column is read under: the zone a naive timestamp names a
823    /// wall-clock reading in, and the order an ambiguous date is read
824    /// with. `None` when both are the defaults.
825    ///
826    /// The INSERT path evaluates VALUES through a context-free literal
827    /// walker with no `EvalContext`, so it takes these as an argument
828    /// the way it already takes the dialect.
829    /// v7.39 (round 524) — the date order joined it: the same
830    /// context-free walker read every written date as MDY.
831    pub(crate) fn session_coercion(&self) -> Option<crate::eval::SessionCoercion> {
832        let zone = self
833            .session_params
834            .get("timezone")
835            .filter(|z| !z.eq_ignore_ascii_case("utc") && !z.eq_ignore_ascii_case("gmt"))
836            .cloned();
837        let order = self.render_style.date_order;
838        if zone.is_none() && order == crate::eval::DateOrder::Mdy {
839            return None;
840        }
841        Some(crate::eval::SessionCoercion {
842            zone,
843            localize: self.tz_localize_fn,
844            offset: self.tz_offset_fn,
845            order,
846        })
847    }
848
849    pub(crate) fn ev_ctx<'a>(
850        &'a self,
851        columns: &'a [ColumnSchema],
852        alias: Option<&'a str>,
853    ) -> EvalContext<'a> {
854        EvalContext::new(columns, alias)
855            .with_render_style(self.render_style)
856            .with_tz_fns(self.tz_offset_fn, self.tz_localize_fn, self.tz_abbrev_fn)
857            .with_default_text_search_config(self.session_param("default_text_search_config"))
858            // Thread the session GUC map so current_setting resolves
859            // custom `SET app.foo = …` settings (request-context / RLS).
860            .with_session_gucs(&self.session_params)
861            // v7.39 (read01 round 58) — and the role store, so the privilege
862            // builtins can expand role membership.
863            .with_users(&self.users)
864            // v7.39 (read01 round 63) — and the engine itself, so a user
865            // function whose body has its own FROM can run that body through
866            // the real executor (visibility filter and all).
867            .with_engine(self)
868            // v7.37.16 (16.12) — thread the read-only catalog so
869            // builtins like pg_partition_root can walk partition
870            // roles. Other EvalContext call sites (scan paths,
871            // joinfold, aggregate) continue to construct without
872            // catalog access; catalog-aware builtins return NULL
873            // there per documented contract.
874            //
875            // v7.38.19 — the ACTIVE catalog, not the committed one.
876            //
877            // A multi-statement simple query is an implicit transaction,
878            // so a function created earlier in the string lives in the
879            // transaction's shadow catalog. Reading the committed one
880            // meant `CREATE FUNCTION f() …; SELECT f()` answered
881            // `function f() does not exist` while the CREATE in that same
882            // string had just succeeded, and PostgreSQL 18.4 answers `1`.
883            //
884            // `CREATE TABLE t(…); SELECT count(*) FROM t` in the same
885            // position was already right, which is why this looked like
886            // an array-return defect when it surfaced: it was found while
887            // re-verifying a customer's ledger entry that said
888            // `RETURNS bigint[]` had been fixed in v7.37.25. Run on its
889            // own it is fixed; run beside its own CREATE it was not, and
890            // the ledger's probe had been the two-statement form.
891            //
892            // Ninth member of the family v7.38.18 documented for
893            // `ANALYZE` -- eight statement kinds read the active catalog
894            // there and one did not.
895            .with_catalog(self.active_catalog())
896            // v7.38 (read01 P5.24) — thread the host CSPRNG so gen_random_bytes
897            // / gen_salt use real entropy instead of the predictable PRNG.
898            .with_salt_fn(self.salt_fn)
899            // v7.39 (read01 pgstatfuncs.c) — calling-connection identity.
900            .with_backend_pid_fn(self.backend_pid_fn)
901            .with_wal_lsn_fn(self.wal_lsn_fn)
902            // v7.39 (round 318, V51) — and the connection-control hook, so
903            // pg_cancel_backend / pg_terminate_backend really signal.
904            .with_backend_signal_fn(self.backend_signal_fn)
905            // v7.38 (read01 P6.08) — thread the host wall clock so uuidv7 gets
906            // a real time-ordered prefix.
907            .with_clock(self.clock)
908            // v7.38 (T24) — thread the transaction-version state so the txid_*
909            // builtins report real ids instead of a constant stub.
910            .with_xact(self.xact_view())
911    }
912
913    /// v7.38 (T24) — read-only snapshot of the transaction-version state the
914    /// `txid_*` / `pg_xact_status` builtins read. A transaction's id is
915    /// allocated at BEGIN (`transaction.rs`), so it is stable across the
916    /// statements of that transaction, as in PG. In autocommit the id exists
917    /// only once the statement has written.
918    pub(crate) fn xact_view(&self) -> crate::eval::XactView<'_> {
919        crate::eval::XactView {
920            current: self
921                .current_tx
922                .and_then(|t| self.tx_writer_versions.get(&t).copied())
923                .or(self.stmt_writer_version),
924            active: &self.active_writer_versions,
925            aborted: &self.aborted_versions,
926        }
927    }
928}
929
930/// v7.37.7 — parse a PG-style `GUC_UNIT_MS` duration string into
931/// milliseconds. Accepts the same shapes PG itself accepts for
932/// `statement_timeout` and related ms-based GUCs.
933///
934/// Returns `None` on parse failure (callers treat None as "GUC not
935/// set / default applies").
936/// v7.39 (GUC knife 3) — parse a DateStyle value ('ISO, MDY' / 'German'
937/// / 'DMY' / …) against the current style: keywords apply in order,
938/// each updating its own category (PG semantics; German implies DMY).
939/// Returns None on any unrecognised keyword.
940pub(crate) fn parse_datestyle_parts(
941    value: &str,
942    current: crate::eval::RenderStyle,
943) -> Option<(crate::eval::DateStyleKind, crate::eval::DateOrder)> {
944    use crate::eval::{DateOrder, DateStyleKind};
945    let mut st = current.date_style;
946    let mut ord = current.date_order;
947    let mut any = false;
948    for part in value.split(',') {
949        let p = part.trim().to_ascii_lowercase();
950        match p.as_str() {
951            "iso" => st = DateStyleKind::Iso,
952            "german" => {
953                st = DateStyleKind::German;
954                ord = DateOrder::Dmy;
955            }
956            "sql" => st = DateStyleKind::Sql,
957            "postgres" => st = DateStyleKind::Postgres,
958            "mdy" | "us" | "noneuro" | "noneuropean" => ord = DateOrder::Mdy,
959            "dmy" | "euro" | "european" => ord = DateOrder::Dmy,
960            "ymd" => ord = DateOrder::Ymd,
961            _ => return None,
962        }
963        any = true;
964    }
965    if any { Some((st, ord)) } else { None }
966}
967
968/// The canonical `SHOW datestyle` text for a resolved pair.
969pub(crate) fn datestyle_canonical(
970    st: crate::eval::DateStyleKind,
971    ord: crate::eval::DateOrder,
972) -> &'static str {
973    use crate::eval::{DateOrder, DateStyleKind};
974    match (st, ord) {
975        (DateStyleKind::Iso, DateOrder::Mdy) => "ISO, MDY",
976        (DateStyleKind::Iso, DateOrder::Dmy) => "ISO, DMY",
977        (DateStyleKind::Iso, DateOrder::Ymd) => "ISO, YMD",
978        (DateStyleKind::German, DateOrder::Mdy) => "German, MDY",
979        (DateStyleKind::German, DateOrder::Dmy) => "German, DMY",
980        (DateStyleKind::German, DateOrder::Ymd) => "German, YMD",
981        (DateStyleKind::Sql, DateOrder::Mdy) => "SQL, MDY",
982        (DateStyleKind::Sql, DateOrder::Dmy) => "SQL, DMY",
983        (DateStyleKind::Sql, DateOrder::Ymd) => "SQL, YMD",
984        (DateStyleKind::Postgres, DateOrder::Mdy) => "Postgres, MDY",
985        (DateStyleKind::Postgres, DateOrder::Dmy) => "Postgres, DMY",
986        (DateStyleKind::Postgres, DateOrder::Ymd) => "Postgres, YMD",
987    }
988}
989
990/// v7.39 (GUC knife 3) — IntervalStyle keyword → kind.
991pub(crate) fn parse_intervalstyle(value: &str) -> Option<crate::eval::IntervalStyleKind> {
992    use crate::eval::IntervalStyleKind as K;
993    match value.trim().to_ascii_lowercase().as_str() {
994        "postgres" => Some(K::Postgres),
995        "sql_standard" => Some(K::SqlStandard),
996        "iso_8601" => Some(K::Iso8601),
997        "postgres_verbose" => Some(K::PostgresVerbose),
998        _ => None,
999    }
1000}
1001
1002pub(crate) fn parse_pg_duration_ms(raw: &str) -> Option<u64> {
1003    let s = raw.trim();
1004    if s.is_empty() {
1005        return None;
1006    }
1007    // PG accepts trailing unit suffix: ms / s / min / h / d. Strip in
1008    // priority order (longer first so `min` doesn't match as `m`).
1009    let lowered = s.to_ascii_lowercase();
1010    let (num_part, multiplier_ms): (&str, u64) = if let Some(p) = lowered.strip_suffix("ms") {
1011        (p, 1)
1012    } else if let Some(p) = lowered.strip_suffix("min") {
1013        (p, 60_000)
1014    } else if let Some(p) = lowered.strip_suffix('s') {
1015        (p, 1_000)
1016    } else if let Some(p) = lowered.strip_suffix('h') {
1017        (p, 3_600_000)
1018    } else if let Some(p) = lowered.strip_suffix('d') {
1019        (p, 86_400_000)
1020    } else {
1021        // No unit suffix — bare digits in the GUC's native unit (ms
1022        // for `statement_timeout`).
1023        (lowered.as_str(), 1)
1024    };
1025    let n: u64 = num_part.trim().parse().ok()?;
1026    n.checked_mul(multiplier_ms)
1027}
1028
1029/// v7.39 (GUC) — render a millisecond count the way PG's SHOW does:
1030/// the largest unit that divides it evenly; zero is unit-less.
1031fn render_pg_duration_ms(ms: u64) -> String {
1032    use alloc::format;
1033    if ms == 0 {
1034        return String::from("0");
1035    }
1036    if ms % 86_400_000 == 0 {
1037        format!("{}d", ms / 86_400_000)
1038    } else if ms % 3_600_000 == 0 {
1039        format!("{}h", ms / 3_600_000)
1040    } else if ms % 60_000 == 0 {
1041        format!("{}min", ms / 60_000)
1042    } else if ms % 1_000 == 0 {
1043        format!("{}s", ms / 1_000)
1044    } else {
1045        format!("{ms}ms")
1046    }
1047}
1048
1049/// v7.39 (round 522) — the unit PG counts a GUC in.
1050///
1051/// PG keeps a parameter's value in TWO forms and they are not the same
1052/// string: `pg_settings.setting` is a bare number counting `unit`s
1053/// (`work_mem` → `4096`, unit `kB`), while SHOW / `current_setting`
1054/// render the human form (`4MB`). Measured on PG18: a value with no
1055/// suffix is already in the GUC's unit, so `SET work_mem = 8192` and
1056/// `= '8MB'` are the same setting.
1057///
1058/// One table so the SET-time normaliser and `pg_settings` cannot drift
1059/// apart on which parameters carry a unit — round 515 spent a round
1060/// re-syncing two copies of a list like this one.
1061pub(crate) fn guc_unit(name: &str) -> Option<&'static str> {
1062    match name {
1063        "statement_timeout"
1064        | "lock_timeout"
1065        | "idle_in_transaction_session_timeout"
1066        | "idle_session_timeout"
1067        | "transaction_timeout" => Some("ms"),
1068        "work_mem" | "maintenance_work_mem" => Some("kB"),
1069        // Counted in BLOCKS, and PG names the block size as the unit.
1070        "shared_buffers" | "temp_buffers" | "effective_cache_size" | "wal_buffers" => Some("8kB"),
1071        _ => None,
1072    }
1073}
1074
1075/// The bare count `pg_settings.setting` reports for a stored value —
1076/// the inverse of the human form SHOW renders. `None` when the
1077/// parameter has no unit or the value does not parse, and the caller
1078/// keeps the string it already had.
1079pub(crate) fn guc_raw_setting(name: &str, stored: &str) -> Option<String> {
1080    match guc_unit(name)? {
1081        "ms" => parse_pg_duration_ms(stored).map(|ms| alloc::format!("{ms}")),
1082        "kB" => parse_pg_mem_kb(stored).map(|kb| alloc::format!("{kb}")),
1083        // A block count, so the kB reading divides by the block size.
1084        "8kB" => parse_pg_mem_kb(stored).map(|kb| alloc::format!("{}", kb / 8)),
1085        _ => None,
1086    }
1087}
1088
1089/// v7.39 (round 204) — parse a PG memory-size GUC value to a count of
1090/// KILOBYTES (work_mem's base unit). Accepts a bare integer (already
1091/// kB) or a `<n><unit>` with unit B/kB/MB/GB/TB. `None` on malformed
1092/// input so the caller keeps the raw string.
1093pub(crate) fn parse_pg_mem_kb(raw: &str) -> Option<u64> {
1094    let s = raw.trim();
1095    if s.is_empty() {
1096        return None;
1097    }
1098    let lowered = s.to_ascii_lowercase();
1099    let (num_part, mult_kb): (&str, u64) = if let Some(p) = lowered.strip_suffix("tb") {
1100        (p, 1024 * 1024 * 1024)
1101    } else if let Some(p) = lowered.strip_suffix("gb") {
1102        (p, 1024 * 1024)
1103    } else if let Some(p) = lowered.strip_suffix("mb") {
1104        (p, 1024)
1105    } else if let Some(p) = lowered.strip_suffix("kb") {
1106        (p, 1)
1107    } else if let Some(p) = lowered.strip_suffix('b') {
1108        // bytes → kB only when a whole multiple of 1024.
1109        let n: u64 = p.trim().parse().ok()?;
1110        return if n % 1024 == 0 { Some(n / 1024) } else { None };
1111    } else {
1112        (lowered.as_str(), 1)
1113    };
1114    let n: u64 = num_part.trim().parse().ok()?;
1115    n.checked_mul(mult_kb)
1116}
1117
1118/// v7.39 (round 204) — render a kB count the way PG's SHOW does: the
1119/// largest binary unit that divides it evenly.
1120fn render_pg_mem_kb(kb: u64) -> String {
1121    use alloc::format;
1122    if kb == 0 {
1123        return String::from("0");
1124    }
1125    if kb % (1024 * 1024) == 0 {
1126        format!("{}GB", kb / (1024 * 1024))
1127    } else if kb % 1024 == 0 {
1128        format!("{}MB", kb / 1024)
1129    } else {
1130        format!("{kb}kB")
1131    }
1132}
1133
1134/// v7.40.4 — the machine's core count, or 1 where it cannot be asked.
1135///
1136/// A `no_std` build has no way to ask, and a build that cannot ask must
1137/// not guess high: one core is the answer that makes `session_parallel_workers`
1138/// return the serial path.
1139#[cfg(feature = "std")]
1140fn available_cores() -> usize {
1141    extern crate std;
1142    std::thread::available_parallelism().map_or(1, core::num::NonZeroUsize::get)
1143}
1144
1145#[cfg(not(feature = "std"))]
1146fn available_cores() -> usize {
1147    1
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152    use super::parse_pg_duration_ms;
1153    use alloc::format;
1154
1155    #[test]
1156    fn parse_bare_digits_treats_as_ms() {
1157        assert_eq!(parse_pg_duration_ms("100"), Some(100));
1158        assert_eq!(parse_pg_duration_ms("0"), Some(0));
1159        assert_eq!(parse_pg_duration_ms("60000"), Some(60_000));
1160    }
1161
1162    #[test]
1163    fn parse_ms_suffix() {
1164        assert_eq!(parse_pg_duration_ms("100ms"), Some(100));
1165        assert_eq!(parse_pg_duration_ms("100 ms"), Some(100));
1166    }
1167
1168    #[test]
1169    fn parse_seconds() {
1170        assert_eq!(parse_pg_duration_ms("1s"), Some(1_000));
1171        assert_eq!(parse_pg_duration_ms("30s"), Some(30_000));
1172    }
1173
1174    #[test]
1175    fn parse_minutes_uses_three_letter_suffix() {
1176        assert_eq!(parse_pg_duration_ms("5min"), Some(300_000));
1177        // `5m` is NOT valid PG (PG requires `min`); confirm we mirror.
1178        assert_eq!(parse_pg_duration_ms("5m"), None);
1179    }
1180
1181    #[test]
1182    fn parse_invalid_returns_none() {
1183        assert_eq!(parse_pg_duration_ms(""), None);
1184        assert_eq!(parse_pg_duration_ms("abc"), None);
1185        assert_eq!(parse_pg_duration_ms("100x"), None);
1186    }
1187
1188    #[test]
1189    fn parse_handles_whitespace() {
1190        assert_eq!(parse_pg_duration_ms("  100  "), Some(100));
1191    }
1192
1193    #[test]
1194    fn parse_overflow_returns_none() {
1195        // u64::MAX seconds overflows when multiplied by 1000 ms/s.
1196        assert_eq!(parse_pg_duration_ms(&format!("{}s", u64::MAX)), None);
1197    }
1198
1199    #[cfg(test)]
1200    mod session_integration {
1201        use crate::Engine;
1202        use spg_sql::ast::SetValue;
1203
1204        #[test]
1205        fn set_statement_timeout_round_trips_ms() {
1206            let mut e = Engine::new();
1207            e.set_session_param("statement_timeout".into(), SetValue::Number("250".into()));
1208            assert_eq!(e.session_statement_timeout_ms(), Some(250));
1209        }
1210
1211        #[test]
1212        fn set_statement_timeout_zero_is_none() {
1213            // PG semantics: 0 means "no timeout".
1214            let mut e = Engine::new();
1215            e.set_session_param("statement_timeout".into(), SetValue::Number("0".into()));
1216            assert_eq!(e.session_statement_timeout_ms(), None);
1217        }
1218
1219        #[test]
1220        fn statement_timeout_unset_is_none() {
1221            let e = Engine::new();
1222            assert_eq!(e.session_statement_timeout_ms(), None);
1223        }
1224
1225        #[test]
1226        fn statement_timeout_accepts_ms_suffix_via_string_set() {
1227            let mut e = Engine::new();
1228            e.set_session_param(
1229                "statement_timeout".into(),
1230                SetValue::String("1500ms".into()),
1231            );
1232            assert_eq!(e.session_statement_timeout_ms(), Some(1500));
1233        }
1234
1235        /// v7.39 (round 621) — `client_min_messages` decided nothing: the GUC
1236        /// was validated on the way in (round 204) and then never read, so
1237        /// `SET client_min_messages = warning` — and even `= error` — left
1238        /// every `DROP … IF EXISTS` notice coming.
1239        ///
1240        /// The wire half of this was checked against live PG18 over seven
1241        /// shapes and matches byte for byte; what is pinned here is the
1242        /// decision itself, which is the part that can drift.
1243        #[test]
1244        fn client_min_messages_gates_by_pg_severity_order() {
1245            use crate::NoticeSeverity::{Notice, Warning};
1246            let mut e = Engine::new();
1247            // The default is `notice`: both severities reach the client.
1248            assert!(e.notice_severity_reaches_client(Notice));
1249            assert!(e.notice_severity_reaches_client(Warning));
1250
1251            e.execute("SET client_min_messages = warning").unwrap();
1252            assert!(!e.notice_severity_reaches_client(Notice));
1253            assert!(e.notice_severity_reaches_client(Warning));
1254
1255            for above in ["error", "fatal", "panic"] {
1256                e.execute(&alloc::format!("SET client_min_messages = {above}"))
1257                    .unwrap();
1258                assert!(!e.notice_severity_reaches_client(Notice), "{above}");
1259                assert!(!e.notice_severity_reaches_client(Warning), "{above}");
1260            }
1261
1262            // Everything at or below `notice` lets both through — PG's order
1263            // is debug5 < … < log < notice < warning < error < fatal < panic.
1264            for below in ["notice", "log", "debug1", "debug5"] {
1265                e.execute(&alloc::format!("SET client_min_messages = {below}"))
1266                    .unwrap();
1267                assert!(e.notice_severity_reaches_client(Notice), "{below}");
1268                assert!(e.notice_severity_reaches_client(Warning), "{below}");
1269            }
1270
1271            // Case is not the caller's problem, and RESET is the road back.
1272            e.execute("SET client_min_messages = WARNING").unwrap();
1273            assert!(!e.notice_severity_reaches_client(Notice));
1274            e.execute("RESET client_min_messages").unwrap();
1275            assert!(e.notice_severity_reaches_client(Notice));
1276
1277            // And an out-of-domain value is still refused rather than
1278            // silently taken as some default.
1279            assert!(e.execute("SET client_min_messages = bogus_zz").is_err());
1280        }
1281    }
1282
1283    /// `work_mem` had been accepted, normalised and rendered since round
1284    /// 204 without anything reading it, so a sort's memory answered to
1285    /// the row count and not to the setting. These pin the read that
1286    /// round 863 added, including that it is the SAME number whichever
1287    /// spelling the session used — PG canonicalises at store time and
1288    /// the byte value has to follow.
1289    ///
1290    /// Round 863 checked these bite: with the accessor made to ignore
1291    /// the session, the `64MB` case drops to the 4MB default and this
1292    /// goes red. The fallback test below stays green either way, which
1293    /// is why it is not the one carrying the claim.
1294    #[test]
1295    fn work_mem_reads_back_as_bytes() {
1296        let mut e = crate::Engine::new();
1297        assert_eq!(
1298            e.session_work_mem_bytes(),
1299            4 * 1024 * 1024,
1300            "an untouched session gets PG's 4MB default"
1301        );
1302
1303        e.execute("SET work_mem = '64MB'").unwrap();
1304        assert_eq!(e.session_work_mem_bytes(), 64 * 1024 * 1024);
1305
1306        // Bare integers are kB, PG's base unit for this GUC.
1307        e.execute("SET work_mem = '65536'").unwrap();
1308        assert_eq!(
1309            e.session_work_mem_bytes(),
1310            64 * 1024 * 1024,
1311            "'65536' and '64MB' are the same setting and must be the same bytes"
1312        );
1313
1314        e.execute("SET work_mem = '1024kB'").unwrap();
1315        assert_eq!(e.session_work_mem_bytes(), 1024 * 1024);
1316    }
1317
1318    #[test]
1319    fn work_mem_that_cannot_be_read_falls_back_to_the_default() {
1320        let mut e = crate::Engine::new();
1321        // A rejected SET leaves the previous value in place; the point
1322        // here is that the accessor never hands back 0, which would
1323        // make a sort spill on its first row.
1324        let _ = e.execute("SET work_mem = 'not_a_size'");
1325        assert_eq!(e.session_work_mem_bytes(), 4 * 1024 * 1024);
1326        assert!(e.session_work_mem_bytes() > 0);
1327    }
1328
1329    /// v7.40.4 — the GUC that was accepted and never read.
1330    #[test]
1331    fn parallel_workers_comes_from_the_guc() {
1332        let mut e = crate::Engine::new();
1333        let cores = super::available_cores();
1334        let w = e.session_parallel_workers();
1335        assert_eq!(
1336            w.per_sort,
1337            2usize.min(cores.saturating_sub(1)),
1338            "PG's own default is two per gather"
1339        );
1340        assert_eq!(
1341            w.per_process,
1342            8usize.min(cores.saturating_sub(1)),
1343            "and eight for the cluster"
1344        );
1345        e.execute("SET max_parallel_workers_per_gather = 0")
1346            .unwrap();
1347        assert_eq!(
1348            e.session_parallel_workers().per_sort,
1349            0,
1350            "zero must reach the serial path"
1351        );
1352        e.execute("SET max_parallel_workers_per_gather = 1")
1353            .unwrap();
1354        assert_eq!(
1355            e.session_parallel_workers().per_sort,
1356            1usize.min(cores.saturating_sub(1))
1357        );
1358        // Never more threads than the machine has cores to run them on.
1359        e.execute("SET max_parallel_workers_per_gather = 1024")
1360            .unwrap();
1361        assert!(e.session_parallel_workers().per_sort < cores.max(1));
1362    }
1363}