Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions docs/SYSTEM_TABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions examples/system_environment.sql
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/sql/generators/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn TableGenerator>) {
Expand Down
115 changes: 113 additions & 2 deletions src/sql/generators/system.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<DataColumn> {
vec![
DataColumn::new("name").with_type(DataType::String),
DataColumn::new("value").with_type(DataType::String),
]
}

fn generate(&self, _args: Vec<DataValue>) -> Result<Arc<DataTable>> {
// `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::*;
Expand Down Expand Up @@ -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<String> = (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");
}
}
Loading