faucet_transform_sql/config.rs
1//! Config types for the SQL transform. No I/O or DuckDB here.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::BTreeMap;
7
8/// Configuration for the `sql` transform.
9#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10pub struct SqlTransformConfig {
11 /// The SQL statement. The page's records are the relation `batch`. Must
12 /// produce a result set; each result row becomes one output record.
13 pub query: String,
14 /// Reference relations loaded once at compile time and joinable by name.
15 #[serde(default, skip_serializing_if = "Vec::is_empty")]
16 pub relations: Vec<RelationSpec>,
17 /// Optional DuckDB `memory_limit` pragma (e.g. "1GB"). Default: DuckDB's own.
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub memory_limit: Option<String>,
20 /// Optional DuckDB `threads` pragma. Default: DuckDB's own. Set to 1–2 for
21 /// high-fan-out matrices to avoid CPU over-subscription across rows.
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub threads: Option<usize>,
24}
25
26/// A reference relation registered before the first page.
27#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
28pub struct RelationSpec {
29 /// Relation name as referenced in the query. Must be a safe SQL identifier
30 /// and must not be `batch` (reserved for the page).
31 pub name: String,
32 /// Where the relation's data comes from.
33 pub source: RelationSource,
34 /// Re-stat the file's mtime before each page; rebuild + atomic swap if it
35 /// changed. Default false. Ignored for `values` and `http` (both loaded
36 /// once for the whole run).
37 #[serde(default)]
38 pub reload_on_change: bool,
39}
40
41/// HTTP method used to fetch an `http` reference relation. Only the two verbs a
42/// small read-only list endpoint needs are supported; `GET` is the default.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
44#[serde(rename_all = "UPPERCASE")]
45pub enum HttpMethod {
46 /// `GET` — the default.
47 #[default]
48 Get,
49 /// `POST` — for POST-search list endpoints (no request body is sent).
50 Post,
51}
52
53// serde `default = "..."` needs a function, not a literal.
54fn default_true() -> bool {
55 true
56}
57
58/// The data source for a reference relation.
59#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
60#[serde(tag = "type", rename_all = "snake_case")]
61pub enum RelationSource {
62 /// Delimited file loaded via DuckDB `read_csv_auto`.
63 Csv {
64 /// Filesystem path to the CSV file (absolute, or relative to the working directory).
65 path: String,
66 /// Whether the first row is a header row. Default: `true`.
67 #[serde(default = "default_true")]
68 has_header: bool,
69 },
70 /// Newline-delimited JSON loaded via DuckDB `read_json_auto`.
71 Jsonl {
72 /// Filesystem path to the JSONL file (absolute, or relative to the working directory).
73 path: String,
74 },
75 /// Inline rows materialized into a table.
76 Values {
77 /// Column names, in declaration order.
78 columns: Vec<String>,
79 /// Rows of cell values; each inner row must have the same length as `columns`.
80 rows: Vec<Vec<Value>>,
81 },
82 /// Rows fetched from a small REST endpoint **once** at compile/first-use and
83 /// cached for the whole run (never re-fetched per page). The response is
84 /// materialized into a DuckDB table joinable by the relation's `name`.
85 Http {
86 /// Endpoint URL to fetch the rows from.
87 url: String,
88 /// HTTP method. Default: `GET`.
89 #[serde(default)]
90 method: HttpMethod,
91 /// Static request headers sent with the fetch (e.g. a bearer token
92 /// injected via `${...}` at the CLI layer). Optional.
93 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
94 headers: BTreeMap<String, String>,
95 /// JSONPath selecting the row array in the response body (e.g.
96 /// `$.items[*]`). If omitted, the whole body is used and must be a JSON
97 /// array. Every selected element must be a JSON object (one table row).
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 records_path: Option<String>,
100 },
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn config_round_trips_and_schema_builds() {
109 let cfg: SqlTransformConfig = serde_json::from_value(serde_json::json!({
110 "query": "SELECT * FROM batch",
111 "relations": [
112 {"name": "countries",
113 "source": {"type": "csv", "path": "c.csv", "has_header": true}}
114 ]
115 }))
116 .unwrap();
117 assert_eq!(cfg.relations.len(), 1);
118 assert!(matches!(
119 cfg.relations[0].source,
120 RelationSource::Csv { .. }
121 ));
122 // schema_for! must succeed (used by `faucet schema transform sql`).
123 let schema = schemars::schema_for!(SqlTransformConfig);
124 let json = serde_json::to_value(&schema).unwrap();
125 assert!(
126 json.get("properties")
127 .and_then(|p| p.get("query"))
128 .is_some()
129 );
130 }
131}