diff --git a/docs/SYSTEM_TABLES.md b/docs/SYSTEM_TABLES.md index 4b40a225..cc4aaf50 100644 --- a/docs/SYSTEM_TABLES.md +++ b/docs/SYSTEM_TABLES.md @@ -410,6 +410,34 @@ together** (small, same crate, no sampling delay), then the rest by demand. Probably only worth it if a real question needs per-core usage; `system()` covers core counts. +## S15 — `ENV(name)`, the scalar lookup +- **Status:** 🟢 DONE 2026-09-28 +- **Where:** `src/sql/functions/system.rs`, registered under a new `System` + function category, behind the same `system-tables` feature (principle 4). + Worked queries appended to `examples/system_environment.sql`. +- **Why a scalar beside S9:** `environment()` answers *list them all*; a single + value wanted inside an expression otherwise needs a scalar subquery, + `(SELECT value FROM environment() WHERE UPPER(name) = 'PATH')`. + `ENV('PATH')` is the name S9 kept free for exactly this. +- **Not a `TableGenerator`, a deliberate exception to principle 1.** That + principle is about *sources*; this is a lookup of one value, and a function + in the registry is the existing mechanism for that. +- **Unset is NULL, set-but-empty is `''`** — S9's distinction, so + `COALESCE(ENV('EDITOR'), 'vi')` works. A name the OS can never hold (empty, + containing `=` or NUL) is NULL too, rather than being handed to `var_os`. + `ENV(NULL)` is NULL; a non-string name is an error. +- **Name case follows the OS, not S9's advice.** `var_os` is case-insensitive + on Windows, so `ENV('path')` finds `Path` whichever shell launched us — the + lookup `environment()` needs `UPPER(name)` for. On Linux it is exact, which + is the correct answer there. +- **Splitting** is `UNNEST(ENV('PATH'), ';')` with no `FROM`. The separator is + still not portable; see the open question below. +- **Tests:** four in the module plus a Windows-only case test, reading + `CARGO_PKG_NAME` rather than setting anything (S9's reason). +- **Open question, not filed:** a portable PATH split — `UNNEST` with the + platform separator, or a `PATH_SEPARATOR()` constant — so the one + non-portable query in the example stops being one. Wait for a real need. + ## Deliberately not doing ## S11 — Open files per process diff --git a/examples/system_environment.sql b/examples/system_environment.sql index 0f25684e..fb8447c1 100644 --- a/examples/system_environment.sql +++ b/examples/system_environment.sql @@ -45,3 +45,15 @@ WITH e AS (SELECT value FROM environment() WHERE UPPER(name) = 'PATH') SELECT UNNEST(value, ';') AS directory FROM e; GO + +-- --------------------------------------------------------------------------- +-- ENV(name): one variable as a scalar (S15), so it can sit in an expression. +-- NULL when unset; on Windows the name is matched case-insensitively. +-- --------------------------------------------------------------------------- +SELECT ENV('PATH') IS NOT NULL AS has_path, + COALESCE(ENV('SQL_CLI_EXAMPLE_UNSET'), 'default') AS fallback; +GO + +-- The PATH split again, without the CTE. +SELECT UNNEST(ENV('PATH'), ';') AS directory; +GO diff --git a/src/sql/functions/mod.rs b/src/sql/functions/mod.rs index 3363a99c..64c41a17 100644 --- a/src/sql/functions/mod.rs +++ b/src/sql/functions/mod.rs @@ -38,6 +38,8 @@ pub mod statistics; pub mod string_fun; pub mod string_methods; pub mod string_utils; +#[cfg(feature = "system-tables")] +pub mod system; pub mod text_processing; pub mod trigonometry; pub mod type_checking; @@ -63,6 +65,7 @@ pub enum FunctionCategory { TableFunction, // Table-generating functions Bitwise, // Bitwise operations and binary visualization Terminal, // Terminal formatting (ANSI colors, styles) + System, // The running process and machine (system-tables feature) } impl fmt::Display for FunctionCategory { @@ -81,6 +84,7 @@ impl fmt::Display for FunctionCategory { FunctionCategory::TableFunction => write!(f, "TableFunction"), FunctionCategory::Bitwise => write!(f, "Bitwise"), FunctionCategory::Terminal => write!(f, "Terminal"), + FunctionCategory::System => write!(f, "System"), } } } @@ -201,6 +205,8 @@ impl FunctionRegistry { registry.register_bitwise_functions(); registry.register_ansi_functions(); registry.register_vector_functions(); + #[cfg(feature = "system-tables")] + registry.register_system_functions(); registry } @@ -894,6 +900,12 @@ impl FunctionRegistry { self.register(Box::new(AnsiStrikethroughFunction)); } + /// Register lookups on the running process (S15) + #[cfg(feature = "system-tables")] + fn register_system_functions(&mut self) { + self.register(Box::new(system::EnvFunction)); + } + /// Register vector mathematics functions fn register_vector_functions(&mut self) { use vector::{ diff --git a/src/sql/functions/system.rs b/src/sql/functions/system.rs new file mode 100644 index 00000000..04591c82 --- /dev/null +++ b/src/sql/functions/system.rs @@ -0,0 +1,110 @@ +//! Scalar lookups on the running process (S15, docs/SYSTEM_TABLES.md). +//! +//! The table generators in `src/sql/generators/system.rs` answer *list them +//! all*; these answer *just this one*, so it can sit inside an expression. + +use crate::data::datatable::DataValue; +use crate::sql::functions::{ArgCount, FunctionCategory, FunctionSignature, SqlFunction}; +use anyhow::{anyhow, Result}; + +/// ENV(name) - one environment variable of this process, or NULL if unset +pub struct EnvFunction; + +impl EnvFunction { + /// A name the OS can never hold. `var_os` makes no promise about these + /// (older toolchains panicked on some), and the honest answer is the same + /// as for any other variable that is not set. + fn is_impossible(name: &str) -> bool { + name.is_empty() || name.contains('=') || name.contains('\0') + } +} + +impl SqlFunction for EnvFunction { + fn signature(&self) -> FunctionSignature { + FunctionSignature { + name: "ENV", + category: FunctionCategory::System, + arg_count: ArgCount::Fixed(1), + description: "Value of one environment variable of this process, NULL if unset (case-insensitive name on Windows)", + returns: "String, or NULL when the variable is not set", + examples: vec![ + "SELECT ENV('PATH')", + "SELECT COALESCE(ENV('EDITOR'), 'vi') AS editor", + "SELECT UNNEST(ENV('PATH'), ';') AS dir -- ':' on Unix", + ], + } + } + + fn evaluate(&self, args: &[DataValue]) -> Result { + self.validate_args(args)?; + + let name = match &args[0] { + DataValue::Null => return Ok(DataValue::Null), + DataValue::String(s) => s.as_str(), + DataValue::InternedString(s) => s.as_str(), + other => { + return Err(anyhow!( + "ENV expects a variable name as a string, got {other:?}" + )) + } + }; + + if Self::is_impossible(name) { + return Ok(DataValue::Null); + } + + // Unset is NULL, set-but-empty is '' - the same distinction + // environment() keeps. Lossy for the same reason as there: a value + // that is not valid UTF-8 is still a value. + Ok(match std::env::var_os(name) { + Some(value) => DataValue::String(value.to_string_lossy().into_owned()), + None => DataValue::Null, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env(name: &str) -> DataValue { + EnvFunction + .evaluate(&[DataValue::String(name.to_string())]) + .unwrap() + } + + /// Reads a variable Cargo sets for every test binary rather than setting + /// one: the environment is shared across test threads (see S9). + #[test] + fn reads_a_set_variable() { + assert_eq!(env("CARGO_PKG_NAME"), DataValue::String("sql-cli".into())); + } + + #[test] + fn unset_and_impossible_names_are_null() { + assert_eq!(env("SQL_CLI_SURELY_NOT_SET_7F3A"), DataValue::Null); + assert_eq!(env(""), DataValue::Null); + assert_eq!(env("A=B"), DataValue::Null); + assert_eq!(env("A\0B"), DataValue::Null); + } + + #[test] + fn null_in_null_out() { + assert_eq!( + EnvFunction.evaluate(&[DataValue::Null]).unwrap(), + DataValue::Null + ); + } + + #[test] + fn non_string_name_is_an_error() { + assert!(EnvFunction.evaluate(&[DataValue::Integer(1)]).is_err()); + } + + /// Windows looks names up case-insensitively; Linux does not. + #[cfg(windows)] + #[test] + fn windows_lookup_ignores_case() { + assert_eq!(env("cargo_pkg_name"), env("CARGO_PKG_NAME")); + } +}