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