use std::path::Path;
use std::sync::Arc;
use arc_swap::ArcSwap;
use hex;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::config::Config;
use crate::error::MiniAppError;
use crate::filter::ListFilter;
use crate::registry::TableRegistry;
use crate::schema::SchemaConfig;
use crate::store::RowRecord;
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RowSelector {
ById {
id: String,
},
ByFilter {
filter: ListFilter,
limit: Option<u32>,
offset: Option<u32>,
},
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum FieldSelector {
All,
List {
fields: Vec<String>,
},
}
impl FieldSelector {
pub fn validate(&self, schema: &SchemaConfig) -> Result<(), MiniAppError> {
if let FieldSelector::List { fields } = self {
let schema_names: std::collections::HashSet<&str> =
schema.fields.iter().map(|f| f.name.as_str()).collect();
for f in fields {
if !schema_names.contains(f.as_str()) {
return Err(MiniAppError::Validation {
field: f.clone(),
reason: format!(
"unknown field '{}' — only schema-registered fields are allowed in field projection",
f
),
});
}
}
}
Ok(())
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum MaterializeFormat {
Raw,
Markdown,
Json,
Yaml,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum WriteMode {
Overwrite,
Error,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct MaterializeParams {
pub table: Option<String>,
pub selector: RowSelector,
pub fields: FieldSelector,
pub format: MaterializeFormat,
pub dest: String,
pub concat: Option<bool>,
pub write_mode: Option<WriteMode>,
pub dry_run: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct MaterializeFile {
pub path: String,
pub bytes: u64,
pub sha256: String,
pub row_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct MaterializeResult {
pub count: usize,
pub files: Vec<MaterializeFile>,
}
fn ext_for(format: &MaterializeFormat) -> &'static str {
match format {
MaterializeFormat::Raw => "txt",
MaterializeFormat::Markdown => "md",
MaterializeFormat::Json => "json",
MaterializeFormat::Yaml => "yaml",
}
}
fn project_row(
data: &serde_json::Value,
field_names: &[String],
) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new();
for name in field_names {
let v = data.get(name).cloned().unwrap_or(serde_json::Value::Null);
map.insert(name.clone(), v);
}
map
}
pub fn apply_projection(
records: Vec<RowRecord>,
fields: &Option<FieldSelector>,
schema: &SchemaConfig,
) -> Result<Vec<RowRecord>, MiniAppError> {
let field_selector = match fields {
None => return Ok(records),
Some(fs) => fs,
};
match field_selector {
FieldSelector::All => Ok(records),
FieldSelector::List {
fields: field_names,
} => {
field_selector.validate(schema)?;
let projected = records
.into_iter()
.map(|row| {
let projected_map = project_row(&row.data, field_names);
RowRecord {
data: serde_json::Value::Object(projected_map),
..row
}
})
.collect();
Ok(projected)
}
}
}
fn serialize_row(
format: &MaterializeFormat,
projected: &serde_json::Map<String, serde_json::Value>,
row_id: &str,
) -> Result<Vec<u8>, MiniAppError> {
match format {
MaterializeFormat::Raw => {
let lines: Vec<String> = projected
.values()
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect();
Ok(lines.join("\n").into_bytes())
}
MaterializeFormat::Markdown => {
let mut md = format!("# {}\n", row_id);
for (field, value) in projected {
let text = match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
md.push_str(&format!("\n## {}\n\n{}\n", field, text));
}
Ok(md.into_bytes())
}
MaterializeFormat::Json => {
let val = serde_json::Value::Object(projected.clone());
serde_json::to_vec_pretty(&val)
.map_err(|e| MiniAppError::MaterializeFormatError(format!("json: {e}")))
}
MaterializeFormat::Yaml => serde_yaml_bw::to_string(projected)
.map(|s| s.into_bytes())
.map_err(|e| MiniAppError::MaterializeFormatError(format!("yaml: {e}"))),
}
}
fn concat_rows(
format: &MaterializeFormat,
rows: &[serde_json::Map<String, serde_json::Value>],
ids: &[String],
) -> Result<Vec<u8>, MiniAppError> {
match format {
MaterializeFormat::Raw => {
let parts: Result<Vec<String>, _> = rows
.iter()
.zip(ids.iter())
.map(|(projected, id)| {
serialize_row(&MaterializeFormat::Raw, projected, id)
.map(|b| String::from_utf8_lossy(&b).into_owned())
})
.collect();
let parts = parts?;
Ok(parts.join("\n\n").into_bytes())
}
MaterializeFormat::Markdown => {
let parts: Result<Vec<String>, _> = rows
.iter()
.zip(ids.iter())
.map(|(projected, id)| {
serialize_row(&MaterializeFormat::Markdown, projected, id)
.map(|b| String::from_utf8_lossy(&b).into_owned())
})
.collect();
let parts = parts?;
Ok(parts.join("\n---\n\n").into_bytes())
}
MaterializeFormat::Json => {
let arr: Vec<serde_json::Value> = rows
.iter()
.map(|m| serde_json::Value::Object(m.clone()))
.collect();
serde_json::to_vec_pretty(&arr)
.map_err(|e| MiniAppError::MaterializeFormatError(format!("json array: {e}")))
}
MaterializeFormat::Yaml => {
let mut out = String::new();
for projected in rows {
let doc = serde_yaml_bw::to_string(projected)
.map_err(|e| MiniAppError::MaterializeFormatError(format!("yaml: {e}")))?;
out.push_str("---\n");
out.push_str(&doc);
}
Ok(out.into_bytes())
}
}
}
fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
pub async fn do_materialize(
_config: &Config,
tables: &Arc<ArcSwap<TableRegistry>>,
params: MaterializeParams,
) -> Result<MaterializeResult, MiniAppError> {
if !Path::new(¶ms.dest).is_absolute() {
tracing::warn!(dest = %params.dest, "row_materialize: dest is not absolute");
return Err(MiniAppError::MaterializeDestRelative {
path: params.dest.clone(),
});
}
let dest = params.dest.clone();
let concat = params.concat.unwrap_or(false);
let dry_run = params.dry_run.unwrap_or(false);
let write_mode_is_error = matches!(params.write_mode, Some(WriteMode::Error));
let (store, schema) = {
let registry = tables.load_full();
let entry = registry.resolve(params.table.as_deref())?;
(Arc::clone(&entry.store), Arc::clone(&entry.schema))
};
let field_names: Vec<String> = match ¶ms.fields {
FieldSelector::All => schema.fields.iter().map(|f| f.name.clone()).collect(),
FieldSelector::List { fields } => {
let schema_names: std::collections::HashSet<&str> =
schema.fields.iter().map(|f| f.name.as_str()).collect();
for f in fields {
if !schema_names.contains(f.as_str()) {
tracing::warn!(field = %f, "row_materialize: unknown projection field");
return Err(MiniAppError::MaterializeFieldUnknown { field: f.clone() });
}
}
fields.clone()
}
};
if let RowSelector::ById { .. } = ¶ms.selector {
if concat {
tracing::warn!("row_materialize: concat=true with selector=by_id is invalid");
return Err(MiniAppError::MaterializeInvalidParam {
field: "concat".to_string(),
reason: "concat=true requires selector=by_filter (ById always yields a single row)"
.to_string(),
});
}
}
let rows = match params.selector {
RowSelector::ById { ref id } => {
let row = store.get(id).await.map_err(|e| match e {
MiniAppError::NotFound { .. } => {
tracing::warn!(id = %id, "row_materialize: row not found");
MiniAppError::MaterializeRowNotFound { id: id.clone() }
}
other => other,
})?;
vec![row]
}
RowSelector::ByFilter {
filter,
limit,
offset,
} => {
let rows = store.list(limit, offset, Some(filter)).await?;
if rows.is_empty() {
tracing::warn!("row_materialize: by_filter selector matched zero rows");
return Err(MiniAppError::MaterializeEmptyResult);
}
rows
}
};
let projected_rows: Vec<serde_json::Map<String, serde_json::Value>> = rows
.iter()
.map(|row| project_row(&row.data, &field_names))
.collect();
let row_ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
let format = ¶ms.format;
let ext = ext_for(format);
let mut files: Vec<MaterializeFile> = Vec::new();
if concat {
let bytes = concat_rows(format, &projected_rows, &row_ids)?;
let sha256 = sha256_hex(&bytes);
let byte_len = bytes.len() as u64;
let dest_path = dest.clone();
if write_mode_is_error && Path::new(&dest_path).exists() {
tracing::warn!(path = %dest_path, "row_materialize: dest already exists with write_mode=error");
return Err(MiniAppError::MaterializeDestInvalid {
path: dest_path.clone(),
reason: "file already exists with write_mode=error".to_string(),
});
}
if !dry_run {
let dest_clone = dest_path.clone();
let bytes_clone = bytes.clone();
tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
if let Some(parent) = Path::new(&dest_clone).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| {
MiniAppError::MaterializeIo(format!(
"create_dir_all '{}': {e}",
parent.display()
))
})?;
}
}
std::fs::write(&dest_clone, &bytes_clone).map_err(|e| {
MiniAppError::MaterializeIo(format!("write '{}': {e}", dest_clone))
})
})
.await
.map_err(|e| MiniAppError::MaterializeIo(format!("blocking task panic: {e}")))??;
}
files.push(MaterializeFile {
path: dest_path,
bytes: byte_len,
sha256,
row_id: None,
});
} else {
if !dry_run {
let dest_dir = dest.clone();
tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
std::fs::create_dir_all(&dest_dir).map_err(|e| {
MiniAppError::MaterializeIo(format!("create_dir_all '{}': {e}", dest_dir))
})
})
.await
.map_err(|e| MiniAppError::MaterializeIo(format!("blocking task panic: {e}")))??;
}
for (row, projected) in rows.iter().zip(projected_rows.iter()) {
let out_path = format!("{}/{}.{}", dest, row.id, ext);
if write_mode_is_error && Path::new(&out_path).exists() {
tracing::warn!(path = %out_path, "row_materialize: output file already exists with write_mode=error");
return Err(MiniAppError::MaterializeDestInvalid {
path: out_path.clone(),
reason: "file already exists with write_mode=error".to_string(),
});
}
let bytes = serialize_row(format, projected, &row.id)?;
let sha256 = sha256_hex(&bytes);
let byte_len = bytes.len() as u64;
if !dry_run {
let out_path_clone = out_path.clone();
let bytes_clone = bytes.clone();
tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
std::fs::write(&out_path_clone, &bytes_clone).map_err(|e| {
MiniAppError::MaterializeIo(format!("write '{}': {e}", out_path_clone))
})
})
.await
.map_err(|e| MiniAppError::MaterializeIo(format!("blocking task panic: {e}")))??;
}
files.push(MaterializeFile {
path: out_path,
bytes: byte_len,
sha256,
row_id: Some(row.id.clone()),
});
}
}
let count = files.len();
Ok(MaterializeResult { count, files })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::registry::TableRegistry;
use crate::schema::{FieldDef, FieldType, SchemaConfig};
use crate::store::Store;
use std::path::PathBuf;
use std::sync::Arc;
async fn make_test_env() -> (Arc<ArcSwap<TableRegistry>>, String, Arc<Config>) {
let schema = SchemaConfig {
table: "test".to_string(),
title: None,
description: None,
fields: vec![
FieldDef {
name: "title".to_string(),
ty: FieldType::String,
required: true,
description: None,
},
FieldDef {
name: "body".to_string(),
ty: FieldType::String,
required: false,
description: None,
},
],
dump: None,
};
let store = Store::open(std::path::Path::new(":memory:"), schema.clone())
.await
.expect("in-memory store must open");
let data = serde_json::json!({"title": "hello", "body": "world"});
let row = store.create(data).await.expect("create must succeed");
let row_id = row.id.clone();
let registry = TableRegistry::from_single(
store,
schema,
PathBuf::from("/fake/schema.yaml"),
"test".to_string(),
);
let config = Arc::new(Config {
schema_path: None,
db_path: None,
user_dir: None,
project_dir: None,
backup_retention: None,
snapshot_retention: None,
});
(Arc::new(ArcSwap::from_pointee(registry)), row_id, config)
}
async fn add_second_row(tables: &Arc<ArcSwap<TableRegistry>>) -> String {
let registry = tables.load_full();
let entry = registry.resolve(None).expect("resolve must succeed");
let data = serde_json::json!({"title": "second", "body": "entry"});
let row = entry.store.create(data).await.expect("create must succeed");
row.id
}
#[tokio::test]
async fn materialize_grid_raw_by_id_no_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = dest.path().to_str().unwrap().to_string();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id.clone() },
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: dest_path.clone(),
concat: Some(false),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, Some(row_id.clone()));
assert_eq!(f.sha256.len(), 64);
assert!(f.bytes > 0);
let written = std::fs::read_to_string(&f.path).unwrap();
assert!(written.contains("hello"));
}
#[tokio::test]
async fn materialize_grid_markdown_by_id_no_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = dest.path().to_str().unwrap().to_string();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id.clone() },
fields: FieldSelector::All,
format: MaterializeFormat::Markdown,
dest: dest_path,
concat: Some(false),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, Some(row_id.clone()));
assert_eq!(f.sha256.len(), 64);
assert!(f.path.ends_with(".md"));
let written = std::fs::read_to_string(&f.path).unwrap();
assert!(written.contains(&format!("# {}", row_id)));
assert!(written.contains("## title"));
}
#[tokio::test]
async fn materialize_grid_json_by_id_no_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = dest.path().to_str().unwrap().to_string();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id.clone() },
fields: FieldSelector::All,
format: MaterializeFormat::Json,
dest: dest_path,
concat: Some(false),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, Some(row_id));
assert_eq!(f.sha256.len(), 64);
assert!(f.path.ends_with(".json"));
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
assert_eq!(parsed["title"], "hello");
}
#[tokio::test]
async fn materialize_grid_yaml_by_id_no_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = dest.path().to_str().unwrap().to_string();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id.clone() },
fields: FieldSelector::All,
format: MaterializeFormat::Yaml,
dest: dest_path,
concat: Some(false),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, Some(row_id));
assert_eq!(f.sha256.len(), 64);
assert!(f.path.ends_with(".yaml"));
let content = std::fs::read_to_string(&f.path).unwrap();
assert!(content.contains("title"));
}
#[tokio::test]
async fn materialize_grid_raw_by_id_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = format!("{}/out.txt", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: dest_path,
concat: Some(true),
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
));
}
#[tokio::test]
async fn materialize_grid_markdown_by_id_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = format!("{}/out.md", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::All,
format: MaterializeFormat::Markdown,
dest: dest_path,
concat: Some(true),
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
));
}
#[tokio::test]
async fn materialize_grid_json_by_id_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = format!("{}/out.json", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::All,
format: MaterializeFormat::Json,
dest: dest_path,
concat: Some(true),
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
));
}
#[tokio::test]
async fn materialize_grid_yaml_by_id_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = format!("{}/out.yaml", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::All,
format: MaterializeFormat::Yaml,
dest: dest_path,
concat: Some(true),
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
));
}
#[tokio::test]
async fn materialize_grid_raw_by_filter_no_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = dest.path().to_str().unwrap().to_string();
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: dest_path,
concat: Some(false),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, Some(row_id));
assert_eq!(f.sha256.len(), 64);
let written = std::fs::read_to_string(&f.path).unwrap();
assert!(written.contains("hello"));
}
#[tokio::test]
async fn materialize_grid_markdown_by_filter_no_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = dest.path().to_str().unwrap().to_string();
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Markdown,
dest: dest_path,
concat: Some(false),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, Some(row_id.clone()));
assert_eq!(f.sha256.len(), 64);
let written = std::fs::read_to_string(&f.path).unwrap();
assert!(written.contains(&format!("# {}", row_id)));
}
#[tokio::test]
async fn materialize_grid_json_by_filter_no_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = dest.path().to_str().unwrap().to_string();
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Json,
dest: dest_path,
concat: Some(false),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, Some(row_id));
assert_eq!(f.sha256.len(), 64);
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
assert_eq!(parsed["title"], "hello");
}
#[tokio::test]
async fn materialize_grid_yaml_by_filter_no_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = dest.path().to_str().unwrap().to_string();
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Yaml,
dest: dest_path,
concat: Some(false),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, Some(row_id));
assert_eq!(f.sha256.len(), 64);
let content = std::fs::read_to_string(&f.path).unwrap();
assert!(content.contains("hello"));
}
#[tokio::test]
async fn materialize_grid_raw_by_filter_concat() {
let (tables, _row_id, config) = make_test_env().await;
add_second_row(&tables).await;
let dest = tempfile::tempdir().unwrap();
let out_file = format!("{}/all.txt", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: out_file.clone(),
concat: Some(true),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, None);
assert_eq!(f.sha256.len(), 64);
assert_eq!(f.path, out_file);
let content = std::fs::read_to_string(&f.path).unwrap();
assert!(content.contains("hello"));
}
#[tokio::test]
async fn materialize_grid_markdown_by_filter_concat() {
let (tables, _row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let out_file = format!("{}/all.md", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Markdown,
dest: out_file.clone(),
concat: Some(true),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, None);
assert_eq!(f.sha256.len(), 64);
let content = std::fs::read_to_string(&f.path).unwrap();
assert!(content.contains("## title"));
}
#[tokio::test]
async fn materialize_grid_json_by_filter_concat() {
let (tables, _row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let out_file = format!("{}/all.json", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Json,
dest: out_file.clone(),
concat: Some(true),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, None);
assert_eq!(f.sha256.len(), 64);
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
assert!(parsed.is_array());
assert_eq!(parsed[0]["title"], "hello");
}
#[tokio::test]
async fn materialize_grid_yaml_by_filter_concat() {
let (tables, _row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let out_file = format!("{}/all.yaml", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Yaml,
dest: out_file.clone(),
concat: Some(true),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.row_id, None);
assert_eq!(f.sha256.len(), 64);
let content = std::fs::read_to_string(&f.path).unwrap();
assert!(content.starts_with("---\n"));
assert!(content.contains("hello"));
}
#[tokio::test]
async fn path_validation_relative_dest() {
let (tables, row_id, config) = make_test_env().await;
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: "relative/path".to_string(), concat: None,
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeDestRelative { ref path } if path == "relative/path"
));
}
#[tokio::test]
async fn path_validation_create_dir_all_success() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let nested = format!("{}/subdir/nested", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id.clone() },
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: nested.clone(),
concat: Some(false),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
assert!(std::path::Path::new(&nested).is_dir());
}
#[tokio::test]
async fn path_validation_concat_true_file_dest() {
let (tables, _row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let out_file = format!("{}/out.txt", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: out_file.clone(),
concat: Some(true),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
assert_eq!(result.files[0].path, out_file);
assert!(std::path::Path::new(&out_file).exists());
}
#[tokio::test]
async fn projection_all_fields_in_schema_order() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::All,
format: MaterializeFormat::Json,
dest: dest.path().to_str().unwrap().to_string(),
concat: None,
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&result.files[0].path).unwrap()).unwrap();
assert!(parsed.get("title").is_some());
assert!(parsed.get("body").is_some());
}
#[tokio::test]
async fn projection_list_specified_order() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::List {
fields: vec!["body".to_string()],
},
format: MaterializeFormat::Json,
dest: dest.path().to_str().unwrap().to_string(),
concat: None,
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&result.files[0].path).unwrap()).unwrap();
assert_eq!(parsed["body"], "world");
assert!(parsed.get("title").is_none());
}
#[tokio::test]
async fn projection_unknown_field_returns_error() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::List {
fields: vec!["nonexistent_field".to_string()],
},
format: MaterializeFormat::Json,
dest: dest.path().to_str().unwrap().to_string(),
concat: None,
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeFieldUnknown { ref field } if field == "nonexistent_field"
));
}
#[tokio::test]
async fn error_dest_invalid_write_mode_error_existing_file() {
let (tables, _row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let out_file = format!("{}/out.txt", dest.path().display());
std::fs::write(&out_file, b"existing").unwrap();
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: out_file.clone(),
concat: Some(true),
write_mode: Some(WriteMode::Error),
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeDestInvalid { ref path, .. } if path == &out_file
));
}
#[tokio::test]
async fn error_row_not_found() {
let (tables, _row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById {
id: "00000000-0000-0000-0000-000000000000".to_string(),
},
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: dest.path().to_str().unwrap().to_string(),
concat: None,
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(err, MiniAppError::MaterializeRowNotFound { .. }));
}
#[tokio::test]
async fn error_empty_result() {
let (tables, _row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("no_such_title"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: dest.path().to_str().unwrap().to_string(),
concat: None,
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(err, MiniAppError::MaterializeEmptyResult));
}
#[tokio::test]
async fn error_invalid_param_concat_by_id() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let out_file = format!("{}/out.txt", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: out_file,
concat: Some(true),
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
));
}
#[tokio::test]
async fn error_field_unknown() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::List {
fields: vec!["unknown".to_string()],
},
format: MaterializeFormat::Raw,
dest: dest.path().to_str().unwrap().to_string(),
concat: None,
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeFieldUnknown { ref field } if field == "unknown"
));
}
#[tokio::test]
async fn error_dest_relative_is_rejected_at_validation() {
let (tables, row_id, config) = make_test_env().await;
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id },
fields: FieldSelector::All,
format: MaterializeFormat::Json,
dest: "not/absolute".to_string(),
concat: None,
write_mode: None,
dry_run: None,
};
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeDestRelative { ref path } if path == "not/absolute"
));
}
#[tokio::test]
async fn dry_run_no_write_but_sha256_and_bytes_present() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let dest_path = dest.path().to_str().unwrap().to_string();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id.clone() },
fields: FieldSelector::All,
format: MaterializeFormat::Json,
dest: dest_path.clone(),
concat: Some(false),
write_mode: None,
dry_run: Some(true),
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.count, 1);
let f = &result.files[0];
assert_eq!(f.sha256.len(), 64);
assert!(f.bytes > 0);
let out_path = format!("{}/{}.json", dest_path, row_id);
assert!(!std::path::Path::new(&out_path).exists());
}
#[tokio::test]
async fn dry_run_write_mode_error_existing_file_still_errors() {
let (tables, _row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let out_file = format!("{}/out.txt", dest.path().display());
std::fs::write(&out_file, b"existing").unwrap();
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: out_file.clone(),
concat: Some(true),
write_mode: Some(WriteMode::Error),
dry_run: Some(true), };
let err = do_materialize(&config, &tables, params).await.unwrap_err();
assert!(matches!(
err,
MiniAppError::MaterializeDestInvalid { ref path, .. } if path == &out_file
));
}
#[tokio::test]
async fn row_id_set_for_each_file_when_no_concat() {
let (tables, row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let params = MaterializeParams {
table: None,
selector: RowSelector::ById { id: row_id.clone() },
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: dest.path().to_str().unwrap().to_string(),
concat: Some(false),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.files[0].row_id, Some(row_id));
}
#[tokio::test]
async fn row_id_is_none_when_concat() {
let (tables, _row_id, config) = make_test_env().await;
let dest = tempfile::tempdir().unwrap();
let out_file = format!("{}/out.txt", dest.path().display());
let params = MaterializeParams {
table: None,
selector: RowSelector::ByFilter {
filter: crate::filter::ListFilter::Eq {
field: "title".to_string(),
value: serde_json::json!("hello"),
},
limit: None,
offset: None,
},
fields: FieldSelector::All,
format: MaterializeFormat::Raw,
dest: out_file,
concat: Some(true),
write_mode: None,
dry_run: None,
};
let result = do_materialize(&config, &tables, params).await.unwrap();
assert_eq!(result.files[0].row_id, None);
}
fn make_schema() -> SchemaConfig {
SchemaConfig {
table: "test".to_string(),
title: None,
description: None,
fields: vec![
FieldDef {
name: "title".to_string(),
ty: FieldType::String,
required: true,
description: None,
},
FieldDef {
name: "body".to_string(),
ty: FieldType::String,
required: false,
description: None,
},
],
dump: None,
}
}
fn make_row(data: serde_json::Value) -> RowRecord {
RowRecord {
id: "test-id".to_string(),
data,
created_at: 0,
updated_at: 0,
}
}
#[test]
fn validate_field_selector_all_is_ok() {
let schema = make_schema();
let fs = FieldSelector::All;
assert!(fs.validate(&schema).is_ok());
}
#[test]
fn validate_field_selector_list_known_fields_ok() {
let schema = make_schema();
let fs = FieldSelector::List {
fields: vec!["title".to_string(), "body".to_string()],
};
assert!(fs.validate(&schema).is_ok());
}
#[test]
fn validate_field_selector_list_single_known_field_ok() {
let schema = make_schema();
let fs = FieldSelector::List {
fields: vec!["title".to_string()],
};
assert!(fs.validate(&schema).is_ok());
}
#[test]
fn validate_field_selector_list_unknown_field_returns_validation_error() {
let schema = make_schema();
let fs = FieldSelector::List {
fields: vec!["title".to_string(), "nonexistent".to_string()],
};
let err = fs.validate(&schema).unwrap_err();
match err {
MiniAppError::Validation { field, reason } => {
assert_eq!(field, "nonexistent");
assert!(reason.contains("nonexistent"));
assert!(reason.contains("schema-registered"));
}
other => panic!("expected Validation error, got {other:?}"),
}
}
#[test]
fn validate_field_selector_list_empty_fields_ok() {
let schema = make_schema();
let fs = FieldSelector::List { fields: vec![] };
assert!(fs.validate(&schema).is_ok());
}
#[test]
fn apply_projection_none_returns_unchanged() {
let schema = make_schema();
let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
let records = vec![row];
let result = apply_projection(records.clone(), &None, &schema).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, records[0].id);
assert_eq!(
result[0].data,
serde_json::json!({"title": "hello", "body": "world"})
);
}
#[test]
fn apply_projection_all_returns_unchanged() {
let schema = make_schema();
let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
let records = vec![row];
let fields = Some(FieldSelector::All);
let result = apply_projection(records.clone(), &fields, &schema).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result[0].data,
serde_json::json!({"title": "hello", "body": "world"})
);
}
#[test]
fn apply_projection_list_projects_data() {
let schema = make_schema();
let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
let original_id = row.id.clone();
let original_created_at = row.created_at;
let records = vec![row];
let fields = Some(FieldSelector::List {
fields: vec!["title".to_string()],
});
let result = apply_projection(records, &fields, &schema).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].data, serde_json::json!({"title": "hello"}));
assert_eq!(result[0].id, original_id);
assert_eq!(result[0].created_at, original_created_at);
}
#[test]
fn apply_projection_list_projects_multiple_rows() {
let schema = make_schema();
let row1 = make_row(serde_json::json!({"title": "first", "body": "one"}));
let row2 = make_row(serde_json::json!({"title": "second", "body": "two"}));
let fields = Some(FieldSelector::List {
fields: vec!["body".to_string()],
});
let result = apply_projection(vec![row1, row2], &fields, &schema).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].data, serde_json::json!({"body": "one"}));
assert_eq!(result[1].data, serde_json::json!({"body": "two"}));
}
#[test]
fn apply_projection_unknown_field_returns_error() {
let schema = make_schema();
let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
let fields = Some(FieldSelector::List {
fields: vec!["nonexistent".to_string()],
});
let err = apply_projection(vec![row], &fields, &schema).unwrap_err();
match err {
MiniAppError::Validation { field, .. } => {
assert_eq!(field, "nonexistent");
}
other => panic!("expected Validation error, got {other:?}"),
}
}
#[test]
fn apply_projection_missing_field_in_data_returns_null() {
let schema = make_schema();
let row = make_row(serde_json::json!({"title": "hello"}));
let fields = Some(FieldSelector::List {
fields: vec!["title".to_string(), "body".to_string()],
});
let result = apply_projection(vec![row], &fields, &schema).unwrap();
assert_eq!(result[0].data["title"], "hello");
assert_eq!(result[0].data["body"], serde_json::Value::Null);
}
}