Queries and Reports: script query
A script "query" runs one or more SELECT statements against a database and prints what they return. You control
what that output looks like: each query serializes as CSV or an aligned table, an
output block composes lines of your own from the results, and a
mask block redacts columns before they are printed. It is the read-only counterpart to
script "exec": a query script reads and prints, and does not open a write transaction.
Use a query script for reads that should be kept as code: recurring reports, health checks, exports with masked columns, and reads that capture a set of keys for a later query. Because the script is code, the same report is versioned, reviewed, and runs identically in every environment.
A query script wraps one or more inner query blocks. Each block runs in the order it is written and prints its
result as a section of the output. A block that declares rows prints nothing and feeds its
result to a later query.
Examples
Common reads as code. Each tab is a complete script, and each Example execution block shows an end-to-end run on
SQLite.
- Revenue report
- Health check
- Parameterized report
This prints an aggregate as a table, then details for the top spenders, feeding one query's result into the next:
script "query" "revenue_report" {
# Section 1: an aggregate, printed as an aligned table.
query "by_plan" {
sql = <<-SQL
SELECT u.plan, count(DISTINCT u.id) AS users, coalesce(sum(o.total), 0) AS revenue
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.plan
ORDER BY revenue DESC
SQL
format = TABLE
}
# A bound query: prints nothing, captures the top-spender ids for the next block.
query "top_ids" {
sql = "SELECT user_id FROM orders GROUP BY user_id ORDER BY sum(total) DESC LIMIT 2"
rows {
user_id = int
}
}
# Section 2: details for exactly those ids, as CSV.
query "top_detail" {
sql = <<-SQL
SELECT u.email, sum(o.total) AS spent
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id IN (SELECT value FROM json_each(?))
GROUP BY u.id
ORDER BY spent DESC
SQL
args = [jsonencode(query.top_ids.rows[*].user_id)]
format = CSV
}
output {
message = "top ${length(query.top_ids.rows)} spenders shown above"
}
}
Example execution
The example is self-contained on SQLite. Set up a database with sqlite3 report.db < setup.sql:
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, plan TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, total INTEGER);
INSERT INTO users VALUES
(1,'ada@example.com','pro'),(2,'bob@example.com','free'),
(3,'cara@example.com','pro'),(4,'dan@example.com','free'),(5,'eve@example.com','pro');
INSERT INTO orders VALUES
(10,1,50),(11,1,70),(12,3,200),(13,5,30),(14,2,10),(15,3,20);
Run the script:
atlas script query --url "sqlite://report.db" --file "file://report.hcl" --run revenue_report --quiet
plan | users | revenue
------+-------+---------
pro | 3 | 370
free | 2 | 10
cara@example.com,220
ada@example.com,120
top 2 spenders shown above
Each printing query writes its result as a section of the output, in source order, separated by blank lines.
by_plan renders as an aligned table, top_ids is a bound query that prints nothing and captures the two
top-spender ids, and top_detail binds those ids (JSON-encoded) and prints their emails and totals as CSV.
This prints the counts of two data invariants as a single table row, for a scheduled monitor:
script "query" "data_health" {
query "counts" {
sql = <<-SQL
SELECT
(SELECT count(*) FROM orders WHERE user_id NOT IN (SELECT id FROM users)) AS orphan_orders,
(SELECT count(*) FROM users WHERE plan IS NULL) AS users_without_plan
SQL
format = TABLE
}
}
Example execution
The example is self-contained on SQLite. Set up a database with sqlite3 health.db < setup.sql:
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, plan TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, total INTEGER);
INSERT INTO users VALUES (1,'ada@example.com','pro'),(2,'bob@example.com',NULL);
INSERT INTO orders VALUES (10,1,50),(11,99,20);
Run the script:
atlas script query --url "sqlite://health.db" --file "file://health.hcl" --run data_health --quiet
Order 11 references a user that no longer exists, and bob has no plan, so both counts are 1:
orphan_orders | users_without_plan
---------------+--------------------
1 | 1
Run on a schedule, the same script reports both counts as 0 once the data is clean.
This binds a variable into the query, so one script serves every plan:
variable "plan" {
type = string
}
script "query" "plan_revenue" {
query "totals" {
sql = <<-SQL
SELECT u.email, coalesce(sum(o.total), 0) AS revenue
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.plan = ?
GROUP BY u.id
ORDER BY revenue DESC
SQL
args = [var.plan]
format = CSV
}
output {
message = "revenue for plan ${var.plan}"
}
}
Example execution
The example runs against the same report.db as the revenue report. Pass the plan with --var:
atlas script query --url "sqlite://report.db" --file "file://plan_report.hcl" --run plan_revenue --var plan=pro --quiet
cara@example.com,220
ada@example.com,120
eve@example.com,30
revenue for plan pro
Running it with --var plan=free reports the two free-plan users instead.
Output Format, Arguments, and Sections
Each printing query serializes its result in its own format. TABLE is the default and renders an aligned,
human-readable grid. CSV emits one row per line, comma-separated, with no header, and a single-scalar CSV result
prints the bare value. The inner query label is optional for a printing query, and required for a bound one, since
later blocks address it as query.<name>.rows.
The args attribute is a positional list bound to the placeholders in sql, in order. Values can be literals or
var.<name> references, and the placeholder marker is driver-native, ? on MySQL for example. See
SQL placeholders. Always bind through args, and never
string-interpolate untrusted values into the SQL:
script "query" "user_email" {
query "by_id" {
sql = "SELECT email FROM users WHERE id = ?"
args = [2]
format = CSV
}
}
Against the report.db from the revenue report, this prints the matching email:
atlas script query --url "sqlite://report.db" --file "file://script.hcl" --run user_email --quiet
bob@example.com
A script may hold several inner query blocks. Each runs in source order and writes its result as a section of the
output, with sections separated by a single blank line.
The commands above pass --quiet to print the result on its own. Without it, Atlas wraps the same result in the
streaming report, adding a header per block and a closing
summary.
The rows Block
A rows block turns a query into a bound query: it reads only the declared columns, emits nothing to the output,
and binds the result into scope for the blocks after it.
Use it when a query produces a set of keys for a later query to filter on. For example, the script below captures the ids of the pro-plan users, then reports only their orders:
script "query" "pro_orders" {
query "pro_ids" {
sql = "SELECT id FROM users WHERE plan = 'pro'"
rows {
id = int
}
}
query "orders" {
sql = <<-SQL
SELECT user_id, count(*) AS orders
FROM orders
WHERE user_id IN (SELECT value FROM json_each(?))
GROUP BY user_id
ORDER BY user_id
SQL
args = [jsonencode(query.pro_ids.rows[*].id)]
format = CSV
}
}
Later blocks reference the bound result in three forms:
query.<name>.rows[*].<col>- the column as a listquery.<name>.rows[N].<col>- one row's columnlength(query.<name>.rows)- the row count
A list cannot be bound as a placeholder value directly. Encode it with jsonencode(...) and unpack it in SQL, as
the example above does. Because a bound query emits nothing, rows is mutually exclusive with format and mask,
which only apply to emitted output.
A query script where every query declares rows and no output block is defined has nothing to emit and is
rejected. A bound query must feed something that produces output.
Masking Result Columns
A mask {} block on a query (or a script-level default) redacts result columns before the result is serialized,
applied to the bytes that actually leave Atlas. Use it when a query returns columns such as email, ssn, phone,
or a *_enc column whose values should not appear verbatim in a report or an export. For example, the script below
redacts the email column in a CSV report:
script "query" "audit" {
query "users" {
sql = "SELECT id, email FROM users ORDER BY id"
format = CSV
mask {
columns = ["email"]
method = REDACT
}
}
}
Masking has its own methods (REDACT, PARTIAL, HASH, REPLACE), glob column matching, scopes, and reusable named
masks. See Masking Sensitive Output for the full reference.
Masking runs on already-fetched rows, not as a query-time filter: the full value is read from the database before it is redacted. It protects the serialized output, not the data at rest.
Testing
Query scripts are tested with the Atlas testing framework: a script "query" command inside a
test block runs the scripts matching run and compares what they print with output or match. An as block runs
the same script under a reduced role or login to test privileges. See
Testing Data Scripts for the full reference.