diff --git a/docs/SYSTEM_TABLES.md b/docs/SYSTEM_TABLES.md index 36e7ba43..4b40a225 100644 --- a/docs/SYSTEM_TABLES.md +++ b/docs/SYSTEM_TABLES.md @@ -357,13 +357,50 @@ together** (small, same crate, no sampling delay), then the rest by demand. an explicit argument, never a hidden default), and what a permission-denied directory contributes (its own row with NULL metadata, not a query error). -## S9 — `env()` -- **Status:** 🔴 OPEN — tiny -- **Columns:** `name`, `value`. `std::env::vars_os`; values that are not valid - UTF-8 come back lossily converted rather than dropped. -- **Why:** checking `PATH` or configuration from a script, and `SPLIT` already - turns a `PATH` value into rows. Windows names are case-insensitive — document - that `WHERE name = 'Path'` versus `'PATH'` differs by platform. +## S9 — `environment()` +- **Status:** 🟢 DONE 2026-09-27 +- **Where:** `src/sql/generators/system.rs` beside S1 and S2, same feature; + worked queries in `examples/system_environment.sql` +- **Depends on:** nothing — `std::env::vars_os` +- **Named `environment()`, not `env()` as surveyed.** A table function and a + scalar share one name space in a reader's head, and `ENV('HOME')` is the + obvious spelling for a scalar lookup if one is ever wanted. The full word + also reads as the noun it is, like `processes()` and `sockets()`. +- **Columns**, one row per variable, the same everywhere: + + | Column | Type | Notes | + |---|---|---| + | `name` | String | as stored, **case preserved** — see below | + | `value` | String | lossily converted if not valid UTF-8, never dropped | + +- **Whose environment:** the `sql-cli` process's own, inherited from whatever + launched it. It is a snapshot of the shell you ran it from, not the machine's + or the user's registry defaults. +- **Name case differs by the *launching shell*, not only by platform.** + Windows treats names case-insensitively but stores a spelling. On the same + machine, launched from PowerShell the rows are `Path` and `windir`; from Git + Bash they are `PATH` and `WINDIR`, because MSYS rewrites them. So a portable + lookup is `WHERE UPPER(name) = 'PATH'`, and that is what the example uses. + Linux names are case-sensitive and `path` and `PATH` can both exist; the + `UPPER` form would then return both, which is the honest answer. +- **Windows' hidden `=C:` variables are skipped.** Windows keeps one per drive + holding that drive's current directory. `set` and PowerShell's `Env:` both + hide them, Linux has nothing like them, and nobody configured them — the S1 + thread question again (*same kind of row?*), answered the same way. +- **An empty value stays an empty string**, the one place these tables do not + turn `''` into NULL. On Unix `FOO=` is set-but-empty, which is a real answer + and differs from unset; the row existing *is* the fact. Windows cannot hold + an empty variable (setting one to empty deletes it), so it never arises there. +- **Rows are sorted** case-insensitively by name, exact name breaking ties, so + the same shell gives the same result twice. +- **Splitting `PATH` into rows** is `UNNEST(value, ';')` on Windows and + `UNNEST(value, ':')` on Unix — the one query here that is not portable, + because the separator is part of the value. +- **Tests:** one, reading `CARGO_PKG_NAME` (Cargo sets it for every test + binary) rather than setting a variable — the environment is shared by every + test thread, and `set_var` in a parallel test run is a race. It also checks + the fixed columns, the stable order, and that no `=`-prefixed name appears. + Passes on Windows 11 and on Linux (Ubuntu 22.04 under WSL2). ## S10 — `cpus()` - **Status:** 🔴 OPEN — least essential diff --git a/examples/system_environment.sql b/examples/system_environment.sql new file mode 100644 index 00000000..0f25684e --- /dev/null +++ b/examples/system_environment.sql @@ -0,0 +1,47 @@ +-- Querying this process's environment variables (S9, docs/SYSTEM_TABLES.md). +-- +-- environment() returns one row per variable sql-cli inherited from whatever +-- launched it: name, value. Same two columns on Windows, Linux and macOS. +-- +-- Things to know before reading any result: +-- * it is the environment of the shell you ran sql-cli from - a snapshot, +-- not the machine's or the user's stored defaults; +-- * Windows names are case-insensitive but keep a spelling, and the spelling +-- depends on the launching shell: PowerShell gives `Path`, Git Bash `PATH`. +-- Compare with UPPER(name) to be portable; +-- * Windows' hidden per-drive `=C:` variables are not listed. + +-- --------------------------------------------------------------------------- +-- Everything, in name order. +-- --------------------------------------------------------------------------- +SELECT name, value +FROM environment(); +GO + +-- --------------------------------------------------------------------------- +-- A family of related settings. +-- --------------------------------------------------------------------------- +SELECT name, value +FROM environment() +WHERE UPPER(name) LIKE 'PROCESSOR%' + OR UPPER(name) LIKE 'CARGO%' + OR UPPER(name) LIKE 'RUST%'; +GO + +-- --------------------------------------------------------------------------- +-- The longest values - usually PATH and its relatives. +-- --------------------------------------------------------------------------- +SELECT name, LENGTH(value) AS chars +FROM environment() +ORDER BY chars DESC +LIMIT 5; +GO + +-- --------------------------------------------------------------------------- +-- PATH as one row per directory. The separator is ';' on Windows and ':' on +-- Unix, so this is the one query here that is not portable as written. +-- --------------------------------------------------------------------------- +WITH e AS (SELECT value FROM environment() WHERE UPPER(name) = 'PATH') +SELECT UNNEST(value, ';') AS directory +FROM e; +GO diff --git a/src/sql/generators/mod.rs b/src/sql/generators/mod.rs index 0cd7572f..dc5abf94 100644 --- a/src/sql/generators/mod.rs +++ b/src/sql/generators/mod.rs @@ -109,6 +109,8 @@ impl GeneratorRegistry { self.register(Box::new(system::Processes)); #[cfg(feature = "system-tables")] self.register(Box::new(system::Sockets)); + #[cfg(feature = "system-tables")] + self.register(Box::new(system::Environment)); } pub fn register(&mut self, generator: Box) { diff --git a/src/sql/generators/system.rs b/src/sql/generators/system.rs index 1100e4c2..fbd4a8b3 100644 --- a/src/sql/generators/system.rs +++ b/src/sql/generators/system.rs @@ -1,7 +1,8 @@ -//! System tables: the running machine as something to query (S1, S2). +//! System tables: the running machine as something to query (S1, S2, S9). //! //! `SELECT * FROM processes() WHERE name LIKE 'node%'`, -//! `SELECT * FROM sockets() WHERE state = 'LISTEN'`. +//! `SELECT * FROM sockets() WHERE state = 'LISTEN'`, +//! `SELECT * FROM environment() WHERE name LIKE 'CARGO%'`. //! //! These are ordinary [`TableGenerator`]s, the same shape as the file readers //! next door, so nothing in the parser or the executor knows they are special. @@ -361,6 +362,81 @@ impl TableGenerator for Sockets { } } +/// One row per environment variable, as this process inherited it. +pub struct Environment; + +impl Environment { + /// Windows keeps a hidden `=C:`-style variable per drive, holding that + /// drive's current directory. `set` and PowerShell's `Env:` both hide them, + /// Linux has no equivalent, and they are not configuration anyone set - the + /// same kind of question as S1's threads, with the same answer. + fn is_hidden(name: &str) -> bool { + name.starts_with('=') + } + + /// Case-insensitive first, so `Path`, `PROCESSOR_ARCHITECTURE` and + /// `windir` sit where a reader expects them; the exact name breaks ties so + /// Linux's `path` and `PATH` still come out in the same order every time. + fn sort_key(name: &str) -> (String, String) { + (name.to_lowercase(), name.to_string()) + } +} + +impl TableGenerator for Environment { + fn name(&self) -> &str { + "ENVIRONMENT" + } + + fn description(&self) -> &str { + "Every environment variable this process inherited: name, value (UNNEST(value, ';') splits a Windows PATH into rows)" + } + + fn arg_count(&self) -> usize { + 0 + } + + fn columns(&self) -> Vec { + vec![ + DataColumn::new("name").with_type(DataType::String), + DataColumn::new("value").with_type(DataType::String), + ] + } + + fn generate(&self, _args: Vec) -> Result> { + // `vars_os` rather than `vars`: `vars` panics on a variable that is not + // valid UTF-8. Lossy conversion keeps the row; dropping it would be the + // silent shortening principle 5 rules out. + let mut variables: Vec<(String, String)> = std::env::vars_os() + .map(|(name, value)| { + ( + name.to_string_lossy().into_owned(), + value.to_string_lossy().into_owned(), + ) + }) + .filter(|(name, _)| !Self::is_hidden(name)) + .collect(); + variables.sort_by_cached_key(|(name, _)| Self::sort_key(name)); + + let mut table = DataTable::new("environment"); + for column in self.columns() { + table.add_column(column); + } + // An empty value stays an empty string, unlike S1's unreadable cells: + // on Unix `FOO=` is set-but-empty, a real answer that differs from + // unset. Windows cannot hold an empty variable, so it never arises there. + for (name, value) in variables { + table + .add_row(DataRow::new(vec![ + DataValue::String(name), + DataValue::String(value), + ])) + .map_err(|e| anyhow::anyhow!("environment(): {e}"))?; + } + + Ok(Arc::new(table)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -586,4 +662,39 @@ mod tests { ); } } + + /// The environment is shared by every test thread, so this reads a + /// variable Cargo sets for every test binary rather than setting its own. + #[test] + fn environment_lists_what_this_process_inherited() { + let table = Environment.generate(Vec::new()).expect("read environment"); + assert_eq!( + table.column_names(), + vec!["name", "value"], + "the column set is fixed, and identical on every platform" + ); + + let text = |s: &str| DataValue::String(s.to_string()); + let package = (0..table.row_count()) + .find(|&row| table.get_value(row, 0) == Some(&text("CARGO_PKG_NAME"))) + .expect("cargo sets CARGO_PKG_NAME for every test binary"); + assert_eq!( + table.get_value(package, 1), + Some(&text(env!("CARGO_PKG_NAME"))) + ); + + let names: Vec = (0..table.row_count()) + .map(|row| match table.get_value(row, 0) { + Some(DataValue::String(name)) => name.clone(), + other => panic!("row {row} has name {other:?}"), + }) + .collect(); + assert!( + names.iter().all(|name| !Environment::is_hidden(name)), + "Windows' per-drive `=C:` variables are not listed" + ); + let mut sorted = names.clone(); + sorted.sort_by_cached_key(|name| Environment::sort_key(name)); + assert_eq!(names, sorted, "rows come out in a stable order"); + } }