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
28 changes: 28 additions & 0 deletions docs/SYSTEM_TABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions examples/system_environment.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions src/sql/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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"),
}
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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::{
Expand Down
110 changes: 110 additions & 0 deletions src/sql/functions/system.rs
Original file line number Diff line number Diff line change
@@ -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<DataValue> {
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"));
}
}
Loading