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