use std::path::{Path, PathBuf};
use crate::edit::{
PathPart, SpecEdit, ValidatedSpec, apply_edits, sequence_item_indent, write_atomic,
};
use crate::error::{Error, Result};
use crate::manifest::{MANIFEST_FILENAME, Manifest};
pub const GENERATED_MARKER: &str = "-- generated:";
#[must_use]
pub fn sql_is_generated(sql: &str) -> bool {
sql.lines()
.next()
.is_some_and(|line| line.trim_start().starts_with(GENERATED_MARKER))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordedStep {
pub name: String,
pub sql: String,
pub provenance: String,
pub description: Option<String>,
}
pub fn record_step(dir: &Path, step: &RecordedStep) -> Result<(PathBuf, ValidatedSpec)> {
valid_step_name(&step.name)?;
one_line(&step.provenance)?;
if let Some(description) = &step.description {
valid_description(description)?;
}
record_gated(dir, step)
}
fn record_gated(dir: &Path, step: &RecordedStep) -> Result<(PathBuf, ValidatedSpec)> {
write_recorded(dir, step, &model_contents(&step.provenance, &step.sql))
}
fn write_recorded(
dir: &Path,
step: &RecordedStep,
contents: &str,
) -> Result<(PathBuf, ValidatedSpec)> {
let manifest_path = dir.join(MANIFEST_FILENAME);
if !manifest_path.exists() {
return Err(Error::ManifestNotFound);
}
let original = std::fs::read_to_string(&manifest_path).map_err(|e| Error::FileRead {
path: manifest_path.clone(),
source: e,
})?;
let steps_before = Manifest::from_yaml_str(&original)?.steps.len();
let models_abs = dir.join("models");
let filename = format!(
"{:02}_{}.sql",
next_model_number(&models_abs),
filename_slug(&step.name)
);
let sql_cited = format!("models/{filename}");
let sql_rel = Path::new("models").join(&filename);
let sql_abs = models_abs.join(&filename);
if sql_abs.exists() {
return Err(Error::GeneratedSqlExists(sql_abs));
}
let steps_path = vec![PathPart::Key("steps".to_string())];
let item = |indent: &str| {
let mut item = format!(
"{indent}- name: {}\n{indent} sql: {sql_cited}\n",
step.name
);
if let Some(description) = &step.description {
item.push_str(&format!(
"{indent} description: {}\n",
description_scalar(description)
));
}
item
};
let edit = if steps_before == 0 {
SpecEdit::Replace {
path: steps_path,
value: format!("\n{}", item(" ").trim_end_matches('\n')),
}
} else {
SpecEdit::Append {
item: item(&sequence_item_indent(&original, &steps_path)?),
path: steps_path,
}
};
let validated = apply_edits(&original, &[edit])?;
reads_back(validated.manifest(), steps_before, step, &sql_cited)?;
checkpoint(dir);
let models_dir_created = !models_abs.exists();
std::fs::create_dir_all(&models_abs)?;
if let Err(e) = write_atomic(&sql_abs, contents.as_bytes()) {
remove_orphan_model(&sql_abs, &models_abs, models_dir_created);
return Err(e);
}
if let Err(e) = validated.write_to(dir) {
remove_orphan_model(&sql_abs, &models_abs, models_dir_created);
return Err(e);
}
Ok((sql_rel, validated))
}
pub fn amend_step_sql(dir: &Path, step_name: &str, sql: &str, provenance: &str) -> Result<PathBuf> {
one_line(provenance)?;
let manifest = Manifest::load(dir)?;
let Some(step) = manifest.steps.iter().find(|s| s.name == step_name) else {
return Err(Error::EditTarget {
path: "steps".to_string(),
detail: format!("no step named '{step_name}'"),
});
};
let Some(sql_rel) = step.sql.as_deref() else {
return Err(Error::EditTarget {
path: format!("steps.{step_name}"),
detail: format!(
"step '{step_name}' has no sql: file — its recipe was not machine-generated; \
record a new step downstream of it instead"
),
});
};
let sql_abs = dir.join(sql_rel);
let current = std::fs::read_to_string(&sql_abs).map_err(|_| Error::SqlFileNotFound {
step: step_name.to_string(),
path: sql_abs.clone(),
})?;
if !sql_is_generated(¤t) {
return Err(Error::HandAuthoredSql {
step: step_name.to_string(),
path: sql_abs,
});
}
checkpoint(dir);
write_atomic(&sql_abs, model_contents(provenance, sql).as_bytes())?;
Ok(PathBuf::from(sql_rel))
}
fn checkpoint(_dir: &Path) {}
fn model_contents(provenance: &str, sql: &str) -> String {
let mut contents = format!("{GENERATED_MARKER} {provenance}\n{sql}");
if !contents.ends_with('\n') {
contents.push('\n');
}
contents
}
const YAML_INDICATORS: &str = "-?:,[]{}#&*!|>'\"%@`";
fn valid_step_name(name: &str) -> Result<()> {
let refuse = |detail: String| {
Err(Error::EditTarget {
path: "(name)".to_string(),
detail,
})
};
if name.is_empty() {
return refuse("the step name is empty".to_string());
}
if name.chars().any(char::is_control) {
return refuse(format!(
"the step name {name:?} contains a control character — a newline here would \
inject fields or steps into the manifest"
));
}
if name != name.trim() {
return refuse(format!(
"the step name {name:?} has leading or trailing whitespace, which YAML would \
silently drop"
));
}
if name.contains('#') {
return refuse(format!(
"the step name {name:?} contains '#', which YAML reads as a comment — the \
recorded name would be silently truncated"
));
}
if name.contains(':') {
return refuse(format!(
"the step name {name:?} contains ':', which YAML reads as a mapping"
));
}
if name.starts_with(|c: char| YAML_INDICATORS.contains(c)) {
return refuse(format!(
"the step name {name:?} opens with a YAML indicator character, so it would \
not read back as itself"
));
}
Ok(())
}
fn remove_orphan_model(sql_abs: &Path, models_abs: &Path, models_dir_created: bool) {
let _ = std::fs::remove_file(sql_abs);
if models_dir_created {
let _ = std::fs::remove_dir(models_abs);
}
}
fn one_line(provenance: &str) -> Result<()> {
if provenance.contains('\n') || provenance.contains('\r') {
return Err(Error::EditTarget {
path: "(provenance)".to_string(),
detail: "the provenance note must be a single line — it becomes the generated \
file's marker header"
.to_string(),
});
}
Ok(())
}
fn reads_back(
manifest: &Manifest,
steps_before: usize,
step: &RecordedStep,
sql_cited: &str,
) -> Result<()> {
let last = manifest.steps.last();
let faithful = manifest.steps.len() == steps_before + 1
&& last
.is_some_and(|last| last.name == step.name && last.sql.as_deref() == Some(sql_cited));
if !faithful {
return Err(Error::EditTarget {
path: "(name)".to_string(),
detail: format!(
"the step name {:?} does not record faithfully — spliced into the manifest \
it reads back as something other than itself; use a plain single-line name",
step.name
),
});
}
if last.and_then(|last| last.description.as_deref()) != step.description.as_deref() {
return Err(Error::EditTarget {
path: "(description)".to_string(),
detail: format!(
"the description {:?} does not record faithfully — spliced into the manifest \
it reads back as something other than itself",
step.description.as_deref().unwrap_or_default()
),
});
}
Ok(())
}
fn valid_description(description: &str) -> Result<()> {
let refuse = |detail: String| {
Err(Error::EditTarget {
path: "(description)".to_string(),
detail,
})
};
if description.trim().is_empty() {
return refuse(
"the description is empty — give one line saying what the step does and why, \
or leave it out"
.to_string(),
);
}
if description
.chars()
.any(|c| matches!(c, '\n' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}'))
{
return refuse(format!(
"the description {description:?} spans lines — a step's description is one line"
));
}
if description.chars().any(char::is_control) {
return refuse(format!(
"the description {description:?} contains a control character — a step's \
description is one line of text"
));
}
Ok(())
}
fn description_scalar(description: &str) -> String {
let plain = serde_yaml::from_str::<serde_yaml::Value>(&format!("d: {description}\n"))
.ok()
.and_then(|doc| doc.get("d").cloned());
if plain == Some(serde_yaml::Value::String(description.to_string())) {
description.to_string()
} else {
format!("'{}'", description.replace('\'', "''"))
}
}
fn filename_slug(name: &str) -> String {
let slug: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
if slug.chars().all(|c| c == '_') {
"step".to_string()
} else {
slug
}
}
fn next_model_number(models_dir: &Path) -> u32 {
let mut max = 0;
if let Ok(entries) = std::fs::read_dir(models_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
let digits: String = name.chars().take_while(char::is_ascii_digit).collect();
if !digits.is_empty()
&& name[digits.len()..].starts_with('_')
&& let Ok(n) = digits.parse::<u32>()
{
max = max.max(n);
}
}
}
max + 1
}
const FILTER_ROWS: &str = "filter-rows";
const SORT_ROWS: &str = "sort-rows";
pub(crate) struct Operation {
pub(crate) long_name: &'static str,
pub(crate) summary: &'static str,
reads: &'static [Role],
writes: &'static [Role],
parameters: fn() -> serde_json::Value,
sql: fn(&Recording) -> String,
recognised: Option<fn(&Statement) -> Option<Reading>>,
}
type Reading = (String, serde_json::Map<String, serde_json::Value>);
pub(crate) struct Recording<'a> {
name: &'a str,
on: &'a str,
arguments: &'a serde_json::Map<String, serde_json::Value>,
}
impl Recording<'_> {
fn text(&self, key: &str) -> &str {
self.arguments
.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
}
}
pub(crate) struct Role {
pub(crate) name: &'static str,
pub(crate) kind: &'static str,
pub(crate) list: bool,
pub(crate) required: bool,
pub(crate) of: Option<&'static str>,
pub(crate) min_items: Option<u64>,
pub(crate) description: &'static str,
}
struct Comparison {
word: &'static str,
sign: &'static str,
}
const COMPARISONS: &[Comparison] = &[
Comparison {
word: "is",
sign: "=",
},
Comparison {
word: "is not",
sign: "!=",
},
Comparison {
word: "over",
sign: ">",
},
Comparison {
word: "under",
sign: "<",
},
Comparison {
word: "between",
sign: "between",
},
Comparison {
word: "is null",
sign: "is null",
},
];
const DIRECTIONS: &[Comparison] = &[
Comparison {
word: "ascending",
sign: "asc",
},
Comparison {
word: "descending",
sign: "desc",
},
];
const STEP_TABLE: Role = Role {
name: "out",
kind: "table",
list: false,
required: true,
of: None,
min_items: None,
description: "The table the step makes, which the step's name names.",
};
const CATALOGUE: &[Operation] = &[
Operation {
long_name: FILTER_ROWS,
summary: "Keeps the rows of a table for which a SQL condition holds.",
reads: &[
Role {
name: "table",
kind: "table",
list: false,
required: true,
of: None,
min_items: None,
description: "The table whose rows are kept.",
},
Role {
name: "column",
kind: "column",
list: false,
required: false,
of: Some("table"),
min_items: None,
description: "The column the condition is on; the condition may name others.",
},
],
writes: &[STEP_TABLE],
parameters: filter_rows_parameters,
sql: filter_rows_sql,
recognised: Some(filter_rows_recognised),
},
Operation {
long_name: SORT_ROWS,
summary: "Orders the rows of a table by a SQL `ORDER BY` clause.",
reads: &[
Role {
name: "table",
kind: "table",
list: false,
required: true,
of: None,
min_items: None,
description: "The table whose rows are ordered.",
},
Role {
name: "column",
kind: "column",
list: false,
required: false,
of: Some("table"),
min_items: None,
description: "The column the order is by; the order may name others.",
},
],
writes: &[STEP_TABLE],
parameters: sort_rows_parameters,
sql: sort_rows_sql,
recognised: None,
},
];
fn filter_rows_parameters() -> serde_json::Value {
let comparisons: Vec<serde_json::Value> = COMPARISONS
.iter()
.map(|c| serde_json::json!({ "word": c.word, "sign": c.sign }))
.collect();
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"where": {
"type": "string",
"description": "Rows for which the condition holds are kept. \
The condition is any SQL condition on the table, recorded as written; \
the comparisons listed are the ones offered by word, not a limit.",
"x-kind": "condition",
"x-comparisons": comparisons,
}
},
"required": ["where"],
})
}
fn filter_rows_sql(recording: &Recording) -> String {
format!(
"CREATE OR REPLACE TABLE {} AS\n{};\n",
quote_ident(recording.name),
filter_rows_query(&from_ident(recording.on), recording.text("where")),
)
}
fn filter_rows_query(from: &str, condition: &str) -> String {
format!("SELECT *\nFROM {from}\nWHERE {condition}")
}
fn filter_rows_recognised(statement: &Statement) -> Option<Reading> {
let from = &statement.tree["statements"][0]["node"]["from_table"];
let written = from["query_location"]
.as_u64()
.and_then(|at| statement.text.as_bytes().get(at as usize));
if written == Some(&b'\'') {
return None;
}
from["table_name"].as_str().and_then(|on| {
let from = from_ident_on(&statement.con, on);
conditions(&statement.text)
.find(|condition| {
let enclosed = format!("(\n{condition}\n)");
statement.reads_as(&filter_rows_query(&from, &enclosed))
&& statement.reads_as(&filter_rows_query(&from, condition))
})
.map(|condition| {
let arguments = [("where".to_string(), condition.into())];
(on.to_string(), arguments.into_iter().collect())
})
})
}
fn conditions(text: &str) -> impl Iterator<Item = &str> {
let starts: Vec<usize> = text
.to_ascii_lowercase()
.match_indices("where")
.map(|(at, keyword)| at + keyword.len())
.collect();
starts.into_iter().flat_map(move |start| {
let after = &text[start..];
after
.match_indices([';', '-', '/'])
.map(|(cut, _)| cut)
.chain([after.len()])
.map(move |cut| after[..cut].trim())
})
}
fn sort_rows_parameters() -> serde_json::Value {
let directions: Vec<serde_json::Value> = DIRECTIONS
.iter()
.map(|d| serde_json::json!({ "word": d.word, "sign": d.sign }))
.collect();
serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"order_by": {
"type": "string",
"description": "The rows are put in the order this SQL `ORDER BY` clause gives, \
recorded as written; the order may name several columns, each with a direction. \
The directions listed are the ones offered by word, not a limit.",
"x-kind": "order",
"x-directions": directions,
}
},
"required": ["order_by"],
})
}
fn sort_rows_sql(recording: &Recording) -> String {
format!(
"CREATE OR REPLACE TABLE {} AS\nSELECT *\nFROM {}\nORDER BY {};\n",
quote_ident(recording.name),
from_ident(recording.on),
recording.text("order_by"),
)
}
fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn from_ident(name: &str) -> String {
thread_local! {
static READER: Option<std::mem::ManuallyDrop<duckdb::Connection>> =
duckdb::Connection::open_in_memory().ok().map(std::mem::ManuallyDrop::new);
}
READER.with(|con| match con {
Some(con) => from_ident_on(con, name),
None => quote_ident(name),
})
}
fn from_ident_on(con: &duckdb::Connection, name: &str) -> String {
let plain = !name.is_empty()
&& !name.as_bytes()[0].is_ascii_digit()
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
if plain && reads_bare_as_table(con, name) {
name.to_string()
} else {
quote_ident(name)
}
}
fn reads_bare_as_table(con: &duckdb::Connection, name: &str) -> bool {
tree(con, &format!("SELECT * FROM {name}")).is_some_and(|tree| {
let from = &tree["statements"][0]["node"]["from_table"];
from["type"] == "BASE_TABLE" && from["table_name"] == name
})
}
pub(crate) fn operations() -> &'static [Operation] {
CATALOGUE
}
pub(crate) fn operation(long_name: &str) -> Option<&'static Operation> {
CATALOGUE.iter().find(|op| op.long_name == long_name)
}
impl Operation {
pub(crate) fn listing(&self) -> serde_json::Value {
serde_json::json!({
"long_name": self.long_name,
"summary": self.summary,
})
}
pub(crate) fn description(&self) -> serde_json::Value {
description(
self.long_name,
self.summary,
self.reads,
self.writes,
(self.parameters)(),
)
}
}
impl Role {
fn entry(&self) -> serde_json::Value {
let mut entry = serde_json::json!({
"name": self.name,
"kind": self.kind,
"list": self.list,
"required": self.required,
"description": self.description,
});
if let Some(of) = self.of {
entry["of"] = of.into();
}
if let Some(min_items) = self.min_items {
entry["min_items"] = min_items.into();
}
entry
}
}
pub(crate) fn description(
long_name: &str,
summary: &str,
reads: &[Role],
writes: &[Role],
parameters: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({
"long_name": long_name,
"summary": summary,
"reads": reads.iter().map(Role::entry).collect::<Vec<_>>(),
"writes": writes.iter().map(Role::entry).collect::<Vec<_>>(),
"parameters": parameters,
})
}
pub(crate) fn describe(name: &str) -> Option<serde_json::Value> {
operation(name)
.map(Operation::description)
.or_else(|| crate::operator::description(name))
}
pub(crate) fn recognise(sql: &str) -> Result<serde_json::Value> {
Ok(match read_as_operation(sql)? {
Some((operation, (on, arguments))) => serde_json::json!({
"operation": operation,
"on": on,
"arguments": arguments,
}),
None => serde_json::json!({ "operation": null }),
})
}
fn read_as_operation(sql: &str) -> Result<Option<(&'static str, Reading)>> {
match duckdb_statement_count(sql) {
1 => {}
0 => {
return Err(refused(
"DuckDB cannot parse the text as a SQL statement, so arc cannot read it as one"
.to_string(),
));
}
count => {
return Err(refused(format!(
"the text holds more than one statement: DuckDB splits it into {count}, and \
arc reads one statement at a time"
)));
}
}
let statement = Statement::parse(sql)?;
for op in CATALOGUE {
let Some(recognised) = op.recognised else {
continue;
};
if let Some(reading) = recognised(&statement) {
return Ok(Some((op.long_name, reading)));
}
}
Ok(None)
}
struct Statement {
text: String,
tree: serde_json::Value,
shape: serde_json::Value,
con: duckdb::Connection,
}
impl Statement {
fn parse(text: &str) -> Result<Statement> {
let refused_by = |reason: String| {
Error::Io(std::io::Error::other(format!(
"the DuckDB library arc is linked with {reason}, so arc cannot read the statement"
)))
};
let con = duckdb::Connection::open_in_memory()
.map_err(|e| refused_by(format!("opened no in-memory database ({e})")))?;
let Some(tree) = tree(&con, text) else {
return Err(refused_by(
"gave no tree from `json_serialize_sql`".to_string(),
));
};
Ok(Statement {
text: text.to_string(),
shape: unlocated(&tree),
tree,
con,
})
}
fn reads_as(&self, text: &str) -> bool {
tree(&self.con, text).is_some_and(|tree| unlocated(&tree) == self.shape)
}
}
fn tree(con: &duckdb::Connection, text: &str) -> Option<serde_json::Value> {
let query = "SELECT json_serialize_sql(?::VARCHAR)::VARCHAR";
con.query_row(query, [text], |row| row.get::<_, String>(0))
.ok()
.and_then(|tree| serde_json::from_str(&tree).ok())
}
fn unlocated(tree: &serde_json::Value) -> serde_json::Value {
match tree {
serde_json::Value::Object(node) => node
.iter()
.filter(|(key, _)| !matches!(key.as_str(), "query_location" | "query_location_length"))
.map(|(key, value)| (key.clone(), unlocated(value)))
.collect(),
serde_json::Value::Array(items) => items.iter().map(unlocated).collect(),
other => other.clone(),
}
}
#[derive(Clone, Copy)]
pub(crate) struct OperationRequest<'a> {
pub(crate) long_name: &'a str,
pub(crate) on: &'a str,
pub(crate) name: &'a str,
pub(crate) arguments: &'a serde_json::Map<String, serde_json::Value>,
pub(crate) description: Option<&'a str>,
}
pub(crate) fn record_operation(
dir: &Path,
request: &OperationRequest,
history: &crate::history::LocalHistory,
) -> Result<PathBuf> {
let OperationRequest {
long_name,
on,
name,
arguments,
description,
} = *request;
let Some(op) = operation(long_name) else {
let held: Vec<&str> = CATALOGUE.iter().map(|op| op.long_name).collect();
return Err(refused(format!(
"no operation called `{long_name}`; the operations arc holds are {}",
held.join(", ")
)));
};
op.admit(arguments)?;
made_by_a_step(dir, on)?;
let step = RecordedStep {
name: name.to_string(),
sql: (op.sql)(&Recording {
name,
on,
arguments,
}),
provenance: format!("{} on {on}", op.long_name),
description: description.map(str::to_string),
};
let (sql_rel, _) = crate::history::record_step_with_history(dir, &step, history)?;
Ok(sql_rel)
}
#[derive(Clone, Copy)]
pub(crate) struct SqlRequest<'a> {
pub(crate) sql: &'a str,
pub(crate) name: &'a str,
pub(crate) description: Option<&'a str>,
}
pub(crate) struct SqlRecorded {
pub(crate) model: PathBuf,
pub(crate) operation: Option<(&'static str, String)>,
}
#[derive(Clone, Copy)]
pub(crate) struct SqlStep<'a> {
pub(crate) name: &'a str,
pub(crate) description: Option<&'a str>,
pub(crate) contents: &'a str,
}
pub(crate) fn record_sql(
dir: &Path,
request: &SqlRequest,
history: &crate::history::LocalHistory,
) -> Result<SqlRecorded> {
let SqlRequest {
sql,
name,
description,
} = *request;
if let Some((long_name, (on, arguments))) = read_as_operation(sql)? {
let operation = OperationRequest {
long_name,
on: &on,
name,
arguments: &arguments,
description,
};
let model = record_operation(dir, &operation, history)?;
return Ok(SqlRecorded {
model,
operation: Some((long_name, on)),
});
}
let contents = sql_step_contents(name, sql)?;
let step = SqlStep {
name,
description,
contents: &contents,
};
let (model, _) = crate::history::record_sql_step_with_history(dir, &step, history)?;
Ok(SqlRecorded {
model,
operation: None,
})
}
pub(crate) fn record_sql_step(dir: &Path, step: &SqlStep) -> Result<(PathBuf, ValidatedSpec)> {
valid_step_name(step.name)?;
if let Some(description) = step.description {
valid_description(description)?;
}
let recorded = RecordedStep {
name: step.name.to_string(),
sql: String::new(),
provenance: String::new(),
description: step.description.map(str::to_string),
};
write_recorded(dir, &recorded, step.contents)
}
fn sql_step_contents(name: &str, sql: &str) -> Result<String> {
let tabled = format!("CREATE OR REPLACE TABLE {} AS\n{sql}", quote_ident(name));
let mut contents = if duckdb_statement_count(&tabled) == 1 {
tabled
} else {
sql.to_string()
};
if !contents.ends_with('\n') {
contents.push('\n');
}
if sql_is_generated(&contents) {
return Err(refused(format!(
"the statement opens with `{GENERATED_MARKER}`, the line that marks a file arc may \
rewrite, and arc does not write the analyst's own text under it; remove the line"
)));
}
Ok(contents)
}
impl Operation {
fn admit(&self, arguments: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
let schema = (self.parameters)();
let empty = serde_json::Map::new();
let properties = schema["properties"].as_object().unwrap_or(&empty);
let taken = || {
properties
.keys()
.map(|key| format!("`{key}`"))
.collect::<Vec<_>>()
.join(", ")
};
if schema["additionalProperties"] == serde_json::Value::Bool(false) {
for key in arguments.keys() {
if !properties.contains_key(key) {
return Err(refused(format!(
"`{}` takes no argument `{key}`; it takes {}",
self.long_name,
taken()
)));
}
}
}
let required = schema["required"].as_array().into_iter().flatten();
for required in required.filter_map(serde_json::Value::as_str) {
if !arguments.contains_key(required) {
return Err(refused(format!(
"`{}` needs the argument `{required}`, and none was given",
self.long_name
)));
}
}
for (key, value) in arguments {
let Some(property) = properties.get(key) else {
continue;
};
if let Some(ty) = property["type"].as_str()
&& !is_of_type(value, ty)
{
return Err(refused(format!(
"the argument `{key}` of `{}` is a {ty}, not {value}",
self.long_name
)));
}
if let Some(clause) = Clause::of(&property["x-kind"])
&& let Some(text) = value.as_str()
{
one_statement(key, clause, text)?;
}
}
Ok(())
}
}
fn is_of_type(value: &serde_json::Value, ty: &str) -> bool {
match ty {
"string" => value.is_string(),
"boolean" => value.is_boolean(),
"integer" => value.is_i64() || value.is_u64(),
"number" => value.is_number(),
"array" => value.is_array(),
"object" => value.is_object(),
"null" => value.is_null(),
_ => true,
}
}
struct Clause {
keyword: &'static str,
noun: &'static str,
}
impl Clause {
fn of(x_kind: &serde_json::Value) -> Option<Clause> {
match x_kind.as_str() {
Some("condition") => Some(Clause {
keyword: "WHERE",
noun: "condition",
}),
Some("order") => Some(Clause {
keyword: "ORDER BY",
noun: "order",
}),
_ => None,
}
}
}
fn one_statement(key: &str, clause: Clause, value: &str) -> Result<()> {
let probe = format!("SELECT 1 {} {value}", clause.keyword);
match duckdb_statement_count(&probe) {
1 => Ok(()),
0 => Err(refused(format!(
"the {} `{key}` is not one SQL statement DuckDB can parse, so arc cannot \
record it as one: {value}",
clause.noun
))),
_ => Err(refused(format!(
"the {} `{key}` holds a `;` outside a string or a comment, which would end \
the step's statement and begin another: {value}",
clause.noun
))),
}
}
fn duckdb_statement_count(sql: &str) -> usize {
use duckdb::ffi;
let Ok(query) = std::ffi::CString::new(sql) else {
return 0;
};
unsafe {
let mut db: ffi::duckdb_database = std::ptr::null_mut();
if ffi::duckdb_open(std::ptr::null(), &mut db) != ffi::DuckDBSuccess {
ffi::duckdb_close(&mut db);
return 0;
}
let mut con: ffi::duckdb_connection = std::ptr::null_mut();
if ffi::duckdb_connect(db, &mut con) != ffi::DuckDBSuccess {
ffi::duckdb_disconnect(&mut con);
ffi::duckdb_close(&mut db);
return 0;
}
let mut extracted: ffi::duckdb_extracted_statements = std::ptr::null_mut();
let count = ffi::duckdb_extract_statements(con, query.as_ptr(), &mut extracted);
ffi::duckdb_destroy_extracted(&mut extracted);
ffi::duckdb_disconnect(&mut con);
ffi::duckdb_close(&mut db);
count as usize
}
}
fn made_by_a_step(dir: &Path, on: &str) -> Result<()> {
let manifest = Manifest::load(dir)?;
let graph = crate::asset::AssetGraph::build(&manifest, dir);
for unreadable in &graph.unreadable {
eprintln!("warning: {}", unreadable.warning());
}
let wanted = on.to_lowercase();
let made = graph.steps.values().any(|assets| {
assets.produces.contains(&wanted)
&& assets.declared_kind.get(&wanted) == Some(&crate::asset_kind::AssetKind::Table)
});
if !made {
return Err(refused(format!(
"no step of the protocol makes a table called `{on}`; an operation is applied to \
a table a step makes"
)));
}
Ok(())
}
fn refused(message: String) -> Error {
Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
message,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_marker_is_read_from_the_first_line_only() {
assert!(sql_is_generated(
"-- generated: grid filter on tides\nSELECT 1"
));
assert!(sql_is_generated(
" -- generated: indented marker\nSELECT 1"
));
assert!(!sql_is_generated("SELECT 1\n-- generated: too late"));
assert!(!sql_is_generated("-- a hand comment\nSELECT 1"));
assert!(!sql_is_generated(""));
}
#[test]
fn slugs_keep_safe_characters_and_degrade_to_step() {
assert_eq!(filename_slug("filter_tides"), "filter_tides");
assert_eq!(filename_slug("top-10 ports!"), "top-10_ports_");
assert_eq!(filename_slug("///"), "step");
assert_eq!(filename_slug(""), "step");
}
#[test]
fn numbering_continues_from_the_highest_numbered_model() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
next_model_number(dir.path()),
1,
"an absent dir starts at 1"
);
std::fs::create_dir_all(dir.path().join("models")).unwrap();
let models = dir.path().join("models");
assert_eq!(next_model_number(&models), 1, "an empty dir starts at 1");
std::fs::write(models.join("load_data.sql"), "SELECT 1").unwrap();
assert_eq!(
next_model_number(&models),
1,
"unnumbered models don't count"
);
std::fs::write(models.join("03_filter.sql"), "SELECT 1").unwrap();
std::fs::write(models.join("01_seed.sql"), "SELECT 1").unwrap();
assert_eq!(next_model_number(&models), 4, "continues past the highest");
}
#[test]
fn model_contents_normalises_only_the_final_newline() {
assert_eq!(
model_contents("grid filter", "SELECT 1"),
"-- generated: grid filter\nSELECT 1\n"
);
let terminated = model_contents("v", "SELECT 1\n");
assert_eq!(terminated, "-- generated: v\nSELECT 1\n");
}
#[test]
fn reads_back_refuses_a_manifest_that_differs_from_the_request_in_any_part() {
let manifest = |steps: &str| {
Manifest::from_yaml_str(&format!("name: shop\nsteps:\n{steps}"))
.expect("the manifest loads")
};
let first = " - name: orders\n sql: models/01_orders.sql\n";
let step = RecordedStep {
name: "big".to_string(),
sql: "SELECT 1;".to_string(),
provenance: "test".to_string(),
description: Some("Keep the big ones".to_string()),
};
let sql = "models/02_big.sql";
let recorded =
" - name: big\n sql: models/02_big.sql\n description: Keep the big ones\n";
assert!(
reads_back(&manifest(&format!("{first}{recorded}")), 1, &step, sql).is_ok(),
"the manifest that carries exactly the request is refused"
);
for (what, steps, expected) in [
(
"another name",
format!(
"{first} - name: small\n sql: models/02_big.sql\n description: Keep the big ones\n"
),
"(name)",
),
(
"another file",
format!(
"{first} - name: big\n sql: models/03_big.sql\n description: Keep the big ones\n"
),
"(name)",
),
(
"two new steps",
format!("{first} - name: extra\n sql: models/09_extra.sql\n{recorded}"),
"(name)",
),
("no new step", first.to_string(), "(name)"),
(
"another description",
format!(
"{first} - name: big\n sql: models/02_big.sql\n description: Keep the small ones\n"
),
"(description)",
),
(
"no description",
format!("{first} - name: big\n sql: models/02_big.sql\n"),
"(description)",
),
] {
match reads_back(&manifest(&steps), 1, &step, sql) {
Err(Error::EditTarget { path, .. }) => assert_eq!(path, expected, "{what}"),
other => panic!("{what}: expected the {expected} refusal, got {other:?}"),
}
}
let undescribed = RecordedStep {
description: None,
..step.clone()
};
assert!(
reads_back(
&manifest(&format!(
"{first} - name: big\n sql: models/02_big.sql\n"
)),
1,
&undescribed,
sql
)
.is_ok(),
"a step asked for with no description is refused when it reads back with none"
);
match reads_back(
&manifest(&format!("{first}{recorded}")),
1,
&undescribed,
sql,
) {
Err(Error::EditTarget { path, .. }) => assert_eq!(path, "(description)"),
other => panic!("a description nobody asked for: got {other:?}"),
}
}
#[test]
fn past_the_gates_a_step_that_does_not_read_back_is_refused_with_the_directory_untouched() {
for (what, name, description, expected) in [
("a name holding ` #`", "big # draft", None, "(name)"),
(
"a description that spans lines",
"big",
Some("Keep the big ones\n and no others"),
"(description)",
),
] {
let dir = tempfile::tempdir().expect("tempdir");
let manifest = dir.path().join(MANIFEST_FILENAME);
std::fs::write(
&manifest,
"name: shop\nsteps:\n - name: orders\n sql: models/01_orders.sql\n",
)
.unwrap();
let step = RecordedStep {
name: name.to_string(),
sql: "SELECT 1;".to_string(),
provenance: "test".to_string(),
description: description.map(str::to_string),
};
match record_gated(dir.path(), &step) {
Err(Error::EditTarget { path, .. }) => assert_eq!(path, expected, "{what}"),
other => panic!("{what}: expected the {expected} refusal, got {other:?}"),
}
assert_eq!(
std::fs::read_to_string(&manifest).unwrap(),
"name: shop\nsteps:\n - name: orders\n sql: models/01_orders.sql\n",
"{what}: the manifest changed"
);
assert!(
!dir.path().join("models").exists(),
"{what}: a model was written"
);
}
}
#[test]
fn a_sql_step_gets_the_line_where_the_line_and_the_statement_parse() {
for (sql, expected) in [
("SELECT 1", "CREATE OR REPLACE TABLE \"t\" AS\nSELECT 1\n"),
("SELECT 1;", "CREATE OR REPLACE TABLE \"t\" AS\nSELECT 1;\n"),
(
"SELECT 1 -- one",
"CREATE OR REPLACE TABLE \"t\" AS\nSELECT 1 -- one\n",
),
(
"WITH x AS (SELECT 1) SELECT * FROM x",
"CREATE OR REPLACE TABLE \"t\" AS\nWITH x AS (SELECT 1) SELECT * FROM x\n",
),
(
"FROM orders SELECT id",
"CREATE OR REPLACE TABLE \"t\" AS\nFROM orders SELECT id\n",
),
("SELECT 1\n", "CREATE OR REPLACE TABLE \"t\" AS\nSELECT 1\n"),
(
"DESCRIBE orders",
"CREATE OR REPLACE TABLE \"t\" AS\nDESCRIBE orders\n",
),
(
"SHOW TABLES\n",
"CREATE OR REPLACE TABLE \"t\" AS\nSHOW TABLES\n",
),
(
"CREATE TABLE t2 AS SELECT 1",
"CREATE TABLE t2 AS SELECT 1\n",
),
(
"UPDATE orders SET amount = 0",
"UPDATE orders SET amount = 0\n",
),
(
"INSERT INTO orders VALUES (1)\n",
"INSERT INTO orders VALUES (1)\n",
),
] {
assert_eq!(
sql_step_contents("t", sql).expect("the statement is recorded"),
expected,
"{sql:?}"
);
}
}
#[test]
fn the_line_quotes_a_name_that_would_read_as_sql() {
assert_eq!(
sql_step_contents("a\"b c", "SELECT 1").unwrap(),
"CREATE OR REPLACE TABLE \"a\"\"b c\" AS\nSELECT 1\n"
);
}
#[test]
fn a_statement_written_as_typed_never_opens_with_the_generated_line() {
for sql in [
"-- generated: mine\nUPDATE orders SET amount = 0",
" -- generated: mine\nINSERT INTO orders VALUES (1)",
] {
let refusal = sql_step_contents("t", sql)
.expect_err("refused")
.to_string();
assert!(refusal.contains("-- generated:"), "{sql:?}: {refusal}");
}
for sql in [
"-- generated: mine\nSELECT 1",
"-- generated: mine\nDESCRIBE orders",
] {
let contents = sql_step_contents("t", sql).unwrap();
assert!(!sql_is_generated(&contents), "{contents:?}");
}
}
#[test]
fn a_multi_line_provenance_is_refused() {
match one_line("line one\nline two") {
Err(Error::EditTarget { path, .. }) => assert_eq!(path, "(provenance)"),
other => panic!("expected EditTarget, got {other:?}"),
}
}
#[test]
fn names_that_would_not_read_back_as_themselves_are_refused() {
for hostile in [
"", "x\n timeout_sec: 1", "x\n - name: injected", "x\ry", "x\ty", "top10 # draft", "a: b", " padded", "padded ", "- item", "[list]", "*anchor", ] {
match valid_step_name(hostile) {
Err(Error::EditTarget { path, .. }) => assert_eq!(path, "(name)", "{hostile:?}"),
other => panic!("expected {hostile:?} refused, got {other:?}"),
}
}
}
#[test]
fn plain_names_pass_the_gate() {
for plain in ["filter_tides", "top-10 ports", "dover", "Reprise 2", "café"] {
assert!(valid_step_name(plain).is_ok(), "{plain:?} should pass");
}
}
#[test]
fn rollback_removes_the_model_and_a_directory_this_promotion_created() {
let dir = tempfile::tempdir().unwrap();
let models = dir.path().join("models");
std::fs::create_dir_all(&models).unwrap();
let model = models.join("01_x.sql");
std::fs::write(&model, "SELECT 1;").unwrap();
remove_orphan_model(&model, &models, true);
assert!(
!models.exists(),
"a created dir is taken back out with the model"
);
}
#[test]
fn rollback_leaves_a_pre_existing_models_directory_in_place() {
let dir = tempfile::tempdir().unwrap();
let models = dir.path().join("models");
std::fs::create_dir_all(&models).unwrap();
let model = models.join("01_x.sql");
std::fs::write(&model, "SELECT 1;").unwrap();
remove_orphan_model(&model, &models, false);
assert!(!model.exists(), "the orphan model is removed");
assert!(
models.exists(),
"a directory the promotion found is not its to remove"
);
}
#[test]
fn rollback_never_takes_out_a_directory_something_else_now_occupies() {
let dir = tempfile::tempdir().unwrap();
let models = dir.path().join("models");
std::fs::create_dir_all(&models).unwrap();
let model = models.join("01_x.sql");
std::fs::write(&model, "SELECT 1;").unwrap();
let bystander = models.join("theirs.sql");
std::fs::write(&bystander, "SELECT 2;").unwrap();
remove_orphan_model(&model, &models, true);
assert!(!model.exists(), "the orphan model is removed");
assert!(bystander.exists(), "the bystander survives");
assert!(models.exists(), "a non-empty directory is left standing");
}
fn is_one_sentence(text: &str) -> bool {
let Some(body) = text.strip_suffix('.') else {
return false;
};
text.chars().next().is_some_and(char::is_uppercase)
&& !text.contains('\n')
&& !body.contains(['.', '?', '!'])
}
#[test]
fn the_sentence_check_refuses_what_is_not_one_sentence() {
assert!(is_one_sentence("The table whose rows are kept."));
for not_one in [
"",
"the table.",
"The table",
"The table. The rows.",
"The table\nwhose rows.",
"Is it? Yes.",
] {
assert!(
!is_one_sentence(not_one),
"{not_one:?} was read as one sentence"
);
}
}
#[test]
fn every_long_name_is_lower_case_hyphenated_and_appears_once() {
let mut seen = std::collections::BTreeSet::new();
for op in operations() {
assert!(
!op.long_name.is_empty()
&& op
.long_name
.chars()
.all(|c| c.is_ascii_lowercase() || c == '-'),
"`{}` is not a lower case, hyphenated long name",
op.long_name
);
assert!(
seen.insert(op.long_name),
"`{}` is held twice, so a lookup could not tell them apart",
op.long_name
);
assert!(
is_one_sentence(op.summary),
"`{}` has no one-sentence summary: {:?}",
op.long_name,
op.summary
);
}
}
fn described_names() -> Vec<&'static str> {
operations()
.iter()
.map(|op| op.long_name)
.chain(crate::operator::catalog_names())
.collect()
}
const ROLE_KINDS: [&str; 6] = ["table", "file", "directory", "pattern", "column", "any"];
#[test]
fn every_description_names_each_role_once_in_the_one_shape() {
for name in described_names() {
let description =
describe(name).unwrap_or_else(|| panic!("`{name}` is held but has no description"));
let mut keys: Vec<&str> = description
.as_object()
.expect("a description is an object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
["long_name", "parameters", "reads", "summary", "writes"],
"`{name}`: the description holds a key the shape does not name:\n{description}"
);
assert_eq!(description["long_name"], name, "`{name}`: {description}");
assert!(
description["summary"].as_str().is_some_and(is_one_sentence),
"`{name}` has no one-sentence summary: {}",
description["summary"]
);
assert_eq!(
description["parameters"]["type"], "object",
"`{name}`: `parameters` is not an object schema"
);
let reads = description["reads"].as_array().expect("`reads` is a list");
let writes = description["writes"]
.as_array()
.expect("`writes` is a list");
if operation(name).is_some() {
assert_eq!(
reads.first().map(|r| &r["kind"]),
Some(&serde_json::json!("table")),
"`{name}` is an operation and does not read a table first"
);
}
for (direction, roles) in [("reads", reads), ("writes", writes)] {
let mut seen: Vec<&str> = Vec::new();
for role in roles {
let role_name = role["name"].as_str().expect("a role has a name");
let kind = role["kind"].as_str().expect("a role has a kind");
let list = role["list"].as_bool().expect("`list` is a boolean");
assert!(
role["required"].is_boolean(),
"`{name}` {direction} `{role_name}`: `required` is not a boolean"
);
assert!(
ROLE_KINDS.contains(&kind),
"`{name}` {direction} `{role_name}` has kind `{kind}`, which arc does not know"
);
assert!(
!seen.contains(&role_name),
"`{name}` {direction} `{role_name}` twice"
);
assert!(
role["description"].as_str().is_some_and(is_one_sentence),
"`{name}` {direction} `{role_name}`, which has no one-sentence description: {}",
role["description"]
);
let mut expected = vec!["description", "kind", "list", "name", "required"];
if kind == "column" {
expected.push("of");
}
if list {
expected.push("min_items");
}
expected.sort_unstable();
let mut held: Vec<&str> = role
.as_object()
.expect("a role is an object")
.keys()
.map(String::as_str)
.collect();
held.sort_unstable();
assert_eq!(
held, expected,
"`{name}` {direction} `{role_name}`: `of` belongs on a column alone and \
`min_items` on a list alone:\n{role}"
);
if kind == "column" {
let of = role["of"].as_str().expect("`of` is a string");
assert!(
reads
.iter()
.take_while(|r| *r != role)
.any(|r| r["name"] == of
&& ["table", "file"]
.contains(&r["kind"].as_str().unwrap_or(""))),
"`{name}` {direction} the column `{role_name}` `of` `{of}`, which is no \
earlier read of a table or a file"
);
}
seen.push(role_name);
}
}
}
}
#[test]
fn no_operation_is_called_what_an_operator_is_called() {
for op in operations() {
assert!(
!crate::operator::catalog_names().contains(&op.long_name)
&& crate::operator::with_schema(op.long_name).is_none(),
"`{}` is the long name of an operation and the name of an operator, so \
describing it can answer for only one of them",
op.long_name
);
}
}
#[test]
fn an_operation_is_found_by_its_exact_long_name_and_no_other_spelling() {
assert_eq!(
operation(FILTER_ROWS).map(|op| op.long_name),
Some(FILTER_ROWS)
);
for near_miss in ["", "Filter-Rows", "filter", "filter-rows ", "filter_rows"] {
assert!(
operation(near_miss).is_none(),
"`{near_miss}` was found as if it were `{FILTER_ROWS}`"
);
}
}
#[test]
fn a_description_extends_the_listing_with_what_it_reads_and_writes_and_its_schema() {
let op = operation(FILTER_ROWS).expect("the filter is held");
let listing = op.listing();
let description = op.description();
assert_eq!(listing["long_name"], FILTER_ROWS);
assert_eq!(description["long_name"], listing["long_name"]);
assert_eq!(description["summary"], listing["summary"]);
assert_eq!(listing["summary"], op.summary);
assert_eq!(
description["reads"],
serde_json::json!([
{
"name": "table",
"kind": "table",
"list": false,
"required": true,
"description": op.reads[0].description,
},
{
"name": "column",
"kind": "column",
"list": false,
"required": false,
"of": "table",
"description": op.reads[1].description,
},
]),
"the filter reads a table, then a column of that table"
);
assert_eq!(
description["writes"],
serde_json::json!([
{
"name": "out",
"kind": "table",
"list": false,
"required": true,
"description": STEP_TABLE.description,
},
]),
"the filter writes the one table its step makes"
);
assert_eq!(description["parameters"], filter_rows_parameters());
assert_eq!(describe(FILTER_ROWS), Some(description));
}
#[test]
fn the_filter_takes_one_required_condition_and_no_other_key() {
let schema = filter_rows_parameters();
let where_ = &schema["properties"]["where"];
assert_eq!(
schema["required"],
serde_json::json!(["where"]),
"`where` is the one required parameter"
);
assert_eq!(
schema["additionalProperties"],
serde_json::json!(false),
"the schema is closed to any other key"
);
assert_eq!(
schema["properties"].as_object().map(|p| p.len()),
Some(1),
"`where` is the one parameter; the column is not an argument"
);
assert_eq!(where_["type"], "string", "`where` is a string");
assert_eq!(
where_["x-kind"], "condition",
"`where` does not say its value is a condition"
);
assert!(
where_["description"]
.as_str()
.is_some_and(|d| is_one_sentence(d.split_inclusive(". ").next().unwrap().trim())),
"`where` has no description whose first sentence the panel can print: {where_}"
);
for constraint in ["enum", "pattern"] {
assert!(
where_.get(constraint).is_none(),
"`where` holds `{constraint}`, which would hold the condition to it: {where_}"
);
}
}
#[test]
fn the_condition_offers_six_comparisons_by_word_and_sign_in_order() {
let schema = filter_rows_parameters();
assert_eq!(
schema["properties"]["where"]["x-comparisons"],
serde_json::json!([
{ "word": "is", "sign": "=" },
{ "word": "is not", "sign": "!=" },
{ "word": "over", "sign": ">" },
{ "word": "under", "sign": "<" },
{ "word": "between", "sign": "between" },
{ "word": "is null", "sign": "is null" },
]),
"the comparisons offered, in order, as a word and a sign"
);
}
#[test]
fn the_sort_takes_one_required_order_and_no_other_key() {
let schema = sort_rows_parameters();
let order_by = &schema["properties"]["order_by"];
assert_eq!(
schema["required"],
serde_json::json!(["order_by"]),
"`order_by` is the one required parameter"
);
assert_eq!(
schema["additionalProperties"],
serde_json::json!(false),
"the schema is closed to any other key"
);
assert_eq!(
schema["properties"].as_object().map(|p| p.len()),
Some(1),
"`order_by` is the one parameter; the column is not an argument"
);
assert_eq!(order_by["type"], "string", "`order_by` is a string");
assert_eq!(
order_by["x-kind"], "order",
"`order_by` does not say its value is an order"
);
assert!(
order_by["description"]
.as_str()
.is_some_and(|d| !d.trim().is_empty()),
"`order_by` has no description: {order_by}"
);
for absent in ["title", "enum", "pattern", "x-comparisons"] {
assert!(
order_by.get(absent).is_none(),
"`order_by` holds `{absent}`, which it must not: {order_by}"
);
}
}
#[test]
fn the_order_offers_two_directions_by_word_and_sign_in_order() {
let schema = sort_rows_parameters();
assert_eq!(
schema["properties"]["order_by"]["x-directions"],
serde_json::json!([
{ "word": "ascending", "sign": "asc" },
{ "word": "descending", "sign": "desc" },
]),
"the directions offered, in order, as a word and a sign"
);
}
fn operation_taking(parameters: fn() -> serde_json::Value) -> Operation {
Operation {
long_name: "test-op",
summary: "A test operation.",
reads: &[],
writes: &[],
parameters,
sql: |recording| recording.text("where").to_string(),
recognised: None,
}
}
fn arguments(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
value.as_object().expect("an object").clone()
}
const WHERE: Clause = Clause {
keyword: "WHERE",
noun: "condition",
};
const ORDER_BY: Clause = Clause {
keyword: "ORDER BY",
noun: "order",
};
#[test]
fn a_clause_is_chosen_by_x_kind_and_only_for_the_two_arc_checks() {
let condition = Clause::of(&serde_json::json!("condition")).expect("condition is a clause");
assert_eq!((condition.keyword, condition.noun), ("WHERE", "condition"));
let order = Clause::of(&serde_json::json!("order")).expect("order is a clause");
assert_eq!((order.keyword, order.noun), ("ORDER BY", "order"));
for other in [
serde_json::json!("sort"),
serde_json::json!(""),
serde_json::json!(null),
serde_json::json!(1),
] {
assert!(Clause::of(&other).is_none(), "{other} was read as a clause");
}
}
#[test]
fn an_open_schema_admits_an_argument_it_does_not_name() {
let op = operation_taking(
|| serde_json::json!({ "type": "object", "properties": { "a": { "type": "string" } } }),
);
assert!(op.admit(&arguments(serde_json::json!({ "b": 1 }))).is_ok());
}
#[test]
fn a_value_of_the_wrong_type_is_refused_naming_the_argument_and_its_type() {
let op = operation(FILTER_ROWS).unwrap();
let err = op
.admit(&arguments(serde_json::json!({ "where": 100 })))
.unwrap_err()
.to_string();
assert!(err.contains("`where`") && err.contains("string"), "{err}");
}
#[test]
fn only_a_value_annotated_as_a_condition_is_held_to_one_statement() {
let op = operation_taking(|| {
serde_json::json!({
"type": "object",
"properties": {
"label": { "type": "string" },
"where": { "type": "string", "x-kind": "condition" },
},
})
});
assert!(
op.admit(&arguments(
serde_json::json!({ "label": "a > 1; DROP TABLE t" })
))
.is_ok()
);
assert!(
op.admit(&arguments(
serde_json::json!({ "where": "a > 1; DROP TABLE t" })
))
.is_err()
);
}
#[test]
fn a_terminator_is_found_where_duckdb_ends_a_statement() {
for condition in [
"a > 1; DROP TABLE orders",
"a > 1;\nDROP TABLE orders", "1 = 1; SELECT 42",
] {
assert!(
one_statement("where", WHERE, condition).is_err(),
"{condition:?} was admitted"
);
}
for condition in [
"a = ';'",
"a > 1 -- ;",
"a > 1 /* ; */",
"\"a;b\" > 1",
"$$;$$ = a",
"a > 1;", "a > 1;;", "amount > 100 UNION ALL SELECT * FROM orders", ] {
assert!(
one_statement("where", WHERE, condition).is_ok(),
"{condition:?} was refused"
);
}
}
#[test]
fn a_probe_duckdb_cannot_parse_at_all_is_refused() {
for condition in [
"amount > 100; DROP TABLE orders; zzz", "amount > 100; DROP TABLE orders;\n.print done", "a = 'b;", "a > 1; /*", ] {
let refusal = one_statement("where", WHERE, condition);
assert!(refusal.is_err(), "{condition:?} was admitted");
let message = refusal.unwrap_err().to_string();
assert!(
message.contains("`where`"),
"{condition:?}: the refusal does not name the argument"
);
assert!(
message.contains("is not one SQL statement DuckDB can parse"),
"{condition:?}: the refusal is not the cannot-parse one: {message}"
);
}
}
#[test]
fn quote_ident_doubles_an_embedded_quote() {
assert_eq!(quote_ident("big_orders"), "\"big_orders\"");
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
}
#[test]
fn the_from_table_is_quoted_only_when_a_bare_name_would_not_carry_it() {
assert_eq!(from_ident("orders"), "orders");
assert_eq!(from_ident("big_orders"), "big_orders");
assert_eq!(from_ident("weird; drop"), "\"weird; drop\"");
assert_eq!(from_ident("a\"b"), "\"a\"\"b\"");
assert_eq!(from_ident("has space"), "\"has space\"");
assert_eq!(from_ident("1st"), "\"1st\"");
assert_eq!(from_ident("order"), "\"order\"");
assert_eq!(from_ident("ORDER"), "\"ORDER\"");
assert_eq!(from_ident("data"), "data");
assert_eq!(from_ident("Data"), "Data");
}
#[test]
fn a_filter_and_a_sort_on_a_table_named_with_each_keyword_duckdb_lists_read_that_table() {
let con = duckdb::Connection::open_in_memory().unwrap();
let listed: Vec<String> = con
.prepare("SELECT keyword_name FROM duckdb_keywords()")
.unwrap()
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.map(|word| word.unwrap())
.collect();
for named in ["order", "data"] {
assert!(
listed.iter().any(|word| word == named),
"duckdb_keywords() lists no `{named}`, so this test reads no reserved word and no \
word DuckDB reads bare"
);
}
let arguments = serde_json::json!({ "where": "amount > 1", "order_by": "amount" })
.as_object()
.unwrap()
.clone();
type Writes = fn(&Recording) -> String;
let operations: [(&str, Writes); 2] = [
("filter-rows", filter_rows_sql),
("sort-rows", sort_rows_sql),
];
for word in listed
.iter()
.flat_map(|word| [word.clone(), word.to_uppercase()])
{
con.execute_batch(&format!(
"CREATE OR REPLACE TABLE \"{word}\" AS SELECT 7 AS amount, '{word}' AS marker"
))
.unwrap_or_else(|e| panic!("DuckDB makes no table called {word:?}: {e}"));
for (operation, sql) in operations {
let step = sql(&Recording {
name: "kept",
on: &word,
arguments: &arguments,
});
con.execute_batch(&step).unwrap_or_else(|e| {
panic!("{operation} on a table named {word:?} does not run:\n{step}\n{e}")
});
let marker: String = con
.query_row("SELECT marker FROM kept", [], |row| row.get(0))
.unwrap();
assert_eq!(
marker, word,
"{operation} on a table named {word:?} read another table:\n{step}"
);
con.execute_batch("DROP TABLE kept").unwrap();
}
}
}
#[test]
fn the_forms_a_hand_written_splitter_missed_are_refused() {
for condition in [
r"note = E'\'' ; DROP TABLE orders ; --'",
r"note = e'\'' ; DROP TABLE orders ; --'",
"amount > 100 --\r; DROP TABLE orders",
] {
assert!(
one_statement("where", WHERE, condition).is_err(),
"{condition:?} was admitted"
);
}
}
#[test]
fn an_order_is_held_to_one_statement_by_its_own_probe_and_named_an_order() {
for order in [
"amount desc",
"region, amount desc",
"amount desc LIMIT 1",
"amount desc /* ; */",
"note = 'a;b', amount desc",
] {
assert!(
one_statement("order_by", ORDER_BY, order).is_ok(),
"{order:?} was refused"
);
}
let two = one_statement("order_by", ORDER_BY, "amount desc; DROP TABLE orders")
.unwrap_err()
.to_string();
assert!(
two.contains("the order `order_by`") && two.contains("`;`"),
"a terminator refusal names the order and the `;`: {two}"
);
let zero = one_statement("order_by", ORDER_BY, "amount desc; DROP TABLE orders; zzz")
.unwrap_err()
.to_string();
assert!(
zero.contains("the order `order_by`")
&& zero.contains("is not one SQL statement DuckDB can parse"),
"a cannot-parse refusal names the order and says it cannot parse: {zero}"
);
}
#[test]
fn each_json_schema_type_admits_its_own_values_only() {
let values = [
("string", serde_json::json!("a")),
("boolean", serde_json::json!(true)),
("integer", serde_json::json!(3)),
("number", serde_json::json!(2.5)),
("array", serde_json::json!([])),
("object", serde_json::json!({})),
("null", serde_json::Value::Null),
];
for (ty, _) in &values {
for (other, value) in &values {
let admitted = is_of_type(value, ty);
let expected = ty == other || (*ty == "number" && *other == "integer");
assert_eq!(admitted, expected, "{value} as a {ty}");
}
}
assert!(is_of_type(&serde_json::json!(u64::MAX), "integer"));
assert!(is_of_type(&serde_json::json!("a"), "no-such-type"));
}
#[test]
fn the_table_is_one_a_step_makes_in_any_case_and_not_a_file_it_writes() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("models")).unwrap();
std::fs::write(
dir.path().join(MANIFEST_FILENAME),
"name: shop\nsteps:\n - name: orders\n sql: models/orders.sql\n",
)
.unwrap();
std::fs::write(
dir.path().join("models/orders.sql"),
"CREATE TABLE orders AS SELECT 1 AS id;\nCOPY orders TO 'build/orders.csv';\n",
)
.unwrap();
assert!(made_by_a_step(dir.path(), "orders").is_ok());
assert!(made_by_a_step(dir.path(), "ORDERS").is_ok());
let err = made_by_a_step(dir.path(), "build/orders.csv").unwrap_err();
assert!(err.to_string().contains("`build/orders.csv`"), "{err}");
}
}