1use std::path::Path;
19use std::sync::Arc;
20
21use arc_swap::ArcSwap;
22use hex;
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26
27use crate::config::Config;
28use crate::error::MiniAppError;
29use crate::filter::ListFilter;
30use crate::registry::TableRegistry;
31use crate::schema::SchemaConfig;
32use crate::store::RowRecord;
33
34#[derive(Debug, Deserialize, JsonSchema)]
44#[serde(tag = "type", rename_all = "snake_case")]
45pub enum RowSelector {
46 ById {
48 id: String,
50 },
51 ByFilter {
53 filter: ListFilter,
55 limit: Option<u32>,
57 offset: Option<u32>,
59 },
60}
61
62#[derive(Debug, Deserialize, JsonSchema)]
68#[serde(tag = "mode", rename_all = "snake_case")]
69pub enum FieldSelector {
70 All,
72 List {
74 fields: Vec<String>,
76 },
77}
78
79impl FieldSelector {
80 pub fn validate(&self, schema: &SchemaConfig) -> Result<(), MiniAppError> {
90 if let FieldSelector::List { fields } = self {
91 let schema_names: std::collections::HashSet<&str> =
92 schema.fields.iter().map(|f| f.name.as_str()).collect();
93 for f in fields {
94 if !schema_names.contains(f.as_str()) {
95 return Err(MiniAppError::Validation {
96 field: f.clone(),
97 reason: format!(
98 "unknown field '{}' — only schema-registered fields are allowed in field projection",
99 f
100 ),
101 });
102 }
103 }
104 }
105 Ok(())
106 }
107}
108
109#[derive(Debug, Deserialize, JsonSchema)]
114#[serde(rename_all = "lowercase")]
115pub enum MaterializeFormat {
116 Raw,
118 Markdown,
120 Json,
122 Yaml,
124}
125
126#[derive(Debug, Deserialize, JsonSchema)]
128#[serde(rename_all = "lowercase")]
129pub enum WriteMode {
130 Overwrite,
132 Error,
134}
135
136#[derive(Debug, Deserialize, JsonSchema)]
145pub struct MaterializeParams {
146 pub table: Option<String>,
148 pub selector: RowSelector,
150 pub fields: FieldSelector,
152 pub format: MaterializeFormat,
154 pub dest: String,
157 pub concat: Option<bool>,
160 pub write_mode: Option<WriteMode>,
162 pub dry_run: Option<bool>,
167}
168
169#[derive(Debug, Serialize)]
180pub struct MaterializeFile {
181 pub path: String,
183 pub bytes: u64,
185 pub sha256: String,
187 pub row_id: Option<String>,
189}
190
191#[derive(Debug, Serialize)]
193pub struct MaterializeResult {
194 pub count: usize,
196 pub files: Vec<MaterializeFile>,
198}
199
200fn ext_for(format: &MaterializeFormat) -> &'static str {
206 match format {
207 MaterializeFormat::Raw => "txt",
208 MaterializeFormat::Markdown => "md",
209 MaterializeFormat::Json => "json",
210 MaterializeFormat::Yaml => "yaml",
211 }
212}
213
214fn project_row(
219 data: &serde_json::Value,
220 field_names: &[String],
221) -> serde_json::Map<String, serde_json::Value> {
222 let mut map = serde_json::Map::new();
223 for name in field_names {
224 let v = data.get(name).cloned().unwrap_or(serde_json::Value::Null);
225 map.insert(name.clone(), v);
226 }
227 map
228}
229
230pub fn apply_projection(
245 records: Vec<RowRecord>,
246 fields: &Option<FieldSelector>,
247 schema: &SchemaConfig,
248) -> Result<Vec<RowRecord>, MiniAppError> {
249 let field_selector = match fields {
250 None => return Ok(records),
251 Some(fs) => fs,
252 };
253 match field_selector {
254 FieldSelector::All => Ok(records),
255 FieldSelector::List {
256 fields: field_names,
257 } => {
258 field_selector.validate(schema)?;
259 let projected = records
260 .into_iter()
261 .map(|row| {
262 let projected_map = project_row(&row.data, field_names);
263 RowRecord {
264 data: serde_json::Value::Object(projected_map),
265 ..row
266 }
267 })
268 .collect();
269 Ok(projected)
270 }
271 }
272}
273
274fn serialize_row(
279 format: &MaterializeFormat,
280 projected: &serde_json::Map<String, serde_json::Value>,
281 row_id: &str,
282) -> Result<Vec<u8>, MiniAppError> {
283 match format {
284 MaterializeFormat::Raw => {
285 let lines: Vec<String> = projected
287 .values()
288 .map(|v| match v {
289 serde_json::Value::String(s) => s.clone(),
290 other => other.to_string(),
291 })
292 .collect();
293 Ok(lines.join("\n").into_bytes())
294 }
295 MaterializeFormat::Markdown => {
296 let mut md = format!("# {}\n", row_id);
298 for (field, value) in projected {
299 let text = match value {
300 serde_json::Value::String(s) => s.clone(),
301 other => other.to_string(),
302 };
303 md.push_str(&format!("\n## {}\n\n{}\n", field, text));
304 }
305 Ok(md.into_bytes())
306 }
307 MaterializeFormat::Json => {
308 let val = serde_json::Value::Object(projected.clone());
309 serde_json::to_vec_pretty(&val)
310 .map_err(|e| MiniAppError::MaterializeFormatError(format!("json: {e}")))
311 }
312 MaterializeFormat::Yaml => serde_yaml_bw::to_string(projected)
313 .map(|s| s.into_bytes())
314 .map_err(|e| MiniAppError::MaterializeFormatError(format!("yaml: {e}"))),
315 }
316}
317
318fn concat_rows(
329 format: &MaterializeFormat,
330 rows: &[serde_json::Map<String, serde_json::Value>],
331 ids: &[String],
332) -> Result<Vec<u8>, MiniAppError> {
333 match format {
334 MaterializeFormat::Raw => {
335 let parts: Result<Vec<String>, _> = rows
337 .iter()
338 .zip(ids.iter())
339 .map(|(projected, id)| {
340 serialize_row(&MaterializeFormat::Raw, projected, id)
341 .map(|b| String::from_utf8_lossy(&b).into_owned())
342 })
343 .collect();
344 let parts = parts?;
345 Ok(parts.join("\n\n").into_bytes())
346 }
347 MaterializeFormat::Markdown => {
348 let parts: Result<Vec<String>, _> = rows
349 .iter()
350 .zip(ids.iter())
351 .map(|(projected, id)| {
352 serialize_row(&MaterializeFormat::Markdown, projected, id)
353 .map(|b| String::from_utf8_lossy(&b).into_owned())
354 })
355 .collect();
356 let parts = parts?;
357 Ok(parts.join("\n---\n\n").into_bytes())
358 }
359 MaterializeFormat::Json => {
360 let arr: Vec<serde_json::Value> = rows
361 .iter()
362 .map(|m| serde_json::Value::Object(m.clone()))
363 .collect();
364 serde_json::to_vec_pretty(&arr)
365 .map_err(|e| MiniAppError::MaterializeFormatError(format!("json array: {e}")))
366 }
367 MaterializeFormat::Yaml => {
368 let mut out = String::new();
370 for projected in rows {
371 let doc = serde_yaml_bw::to_string(projected)
372 .map_err(|e| MiniAppError::MaterializeFormatError(format!("yaml: {e}")))?;
373 out.push_str("---\n");
374 out.push_str(&doc);
375 }
376 Ok(out.into_bytes())
377 }
378 }
379}
380
381fn sha256_hex(bytes: &[u8]) -> String {
383 hex::encode(Sha256::digest(bytes))
384}
385
386pub async fn do_materialize(
421 _config: &Config,
422 tables: &Arc<ArcSwap<TableRegistry>>,
423 params: MaterializeParams,
424) -> Result<MaterializeResult, MiniAppError> {
425 if !Path::new(¶ms.dest).is_absolute() {
427 tracing::warn!(dest = %params.dest, "row_materialize: dest is not absolute");
428 return Err(MiniAppError::MaterializeDestRelative {
429 path: params.dest.clone(),
430 });
431 }
432
433 let dest = params.dest.clone();
434 let concat = params.concat.unwrap_or(false);
435 let dry_run = params.dry_run.unwrap_or(false);
436 let write_mode_is_error = matches!(params.write_mode, Some(WriteMode::Error));
437
438 let (store, schema) = {
440 let registry = tables.load_full();
441 let entry = registry.resolve(params.table.as_deref())?;
442 (Arc::clone(&entry.store), Arc::clone(&entry.schema))
443 };
444
445 let field_names: Vec<String> = match ¶ms.fields {
447 FieldSelector::All => schema.fields.iter().map(|f| f.name.clone()).collect(),
448 FieldSelector::List { fields } => {
449 let schema_names: std::collections::HashSet<&str> =
450 schema.fields.iter().map(|f| f.name.as_str()).collect();
451 for f in fields {
452 if !schema_names.contains(f.as_str()) {
453 tracing::warn!(field = %f, "row_materialize: unknown projection field");
454 return Err(MiniAppError::MaterializeFieldUnknown { field: f.clone() });
455 }
456 }
457 fields.clone()
458 }
459 };
460
461 if let RowSelector::ById { .. } = ¶ms.selector {
463 if concat {
464 tracing::warn!("row_materialize: concat=true with selector=by_id is invalid");
465 return Err(MiniAppError::MaterializeInvalidParam {
466 field: "concat".to_string(),
467 reason: "concat=true requires selector=by_filter (ById always yields a single row)"
468 .to_string(),
469 });
470 }
471 }
472
473 let rows = match params.selector {
475 RowSelector::ById { ref id } => {
476 let row = store.get(id).await.map_err(|e| match e {
477 MiniAppError::NotFound { .. } => {
478 tracing::warn!(id = %id, "row_materialize: row not found");
479 MiniAppError::MaterializeRowNotFound { id: id.clone() }
480 }
481 other => other,
482 })?;
483 vec![row]
484 }
485 RowSelector::ByFilter {
486 filter,
487 limit,
488 offset,
489 } => {
490 let rows = store.list(limit, offset, Some(filter)).await?;
491 if rows.is_empty() {
492 tracing::warn!("row_materialize: by_filter selector matched zero rows");
493 return Err(MiniAppError::MaterializeEmptyResult);
494 }
495 rows
496 }
497 };
498
499 let projected_rows: Vec<serde_json::Map<String, serde_json::Value>> = rows
501 .iter()
502 .map(|row| project_row(&row.data, &field_names))
503 .collect();
504
505 let row_ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
506
507 let format = ¶ms.format;
508 let ext = ext_for(format);
509
510 let mut files: Vec<MaterializeFile> = Vec::new();
511
512 if concat {
513 let bytes = concat_rows(format, &projected_rows, &row_ids)?;
515 let sha256 = sha256_hex(&bytes);
516 let byte_len = bytes.len() as u64;
517 let dest_path = dest.clone();
518
519 if write_mode_is_error && Path::new(&dest_path).exists() {
521 tracing::warn!(path = %dest_path, "row_materialize: dest already exists with write_mode=error");
522 return Err(MiniAppError::MaterializeDestInvalid {
523 path: dest_path.clone(),
524 reason: "file already exists with write_mode=error".to_string(),
525 });
526 }
527
528 if !dry_run {
529 let dest_clone = dest_path.clone();
531 let bytes_clone = bytes.clone();
532 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
533 if let Some(parent) = Path::new(&dest_clone).parent() {
534 if !parent.as_os_str().is_empty() {
535 std::fs::create_dir_all(parent).map_err(|e| {
536 MiniAppError::MaterializeIo(format!(
537 "create_dir_all '{}': {e}",
538 parent.display()
539 ))
540 })?;
541 }
542 }
543 std::fs::write(&dest_clone, &bytes_clone).map_err(|e| {
544 MiniAppError::MaterializeIo(format!("write '{}': {e}", dest_clone))
545 })
546 })
547 .await
548 .map_err(|e| MiniAppError::MaterializeIo(format!("blocking task panic: {e}")))??;
549 }
550
551 files.push(MaterializeFile {
553 path: dest_path,
554 bytes: byte_len,
555 sha256,
556 row_id: None,
557 });
558 } else {
559 if !dry_run {
563 let dest_dir = dest.clone();
564 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
565 std::fs::create_dir_all(&dest_dir).map_err(|e| {
566 MiniAppError::MaterializeIo(format!("create_dir_all '{}': {e}", dest_dir))
567 })
568 })
569 .await
570 .map_err(|e| MiniAppError::MaterializeIo(format!("blocking task panic: {e}")))??;
571 }
572
573 for (row, projected) in rows.iter().zip(projected_rows.iter()) {
574 let out_path = format!("{}/{}.{}", dest, row.id, ext);
575
576 if write_mode_is_error && Path::new(&out_path).exists() {
578 tracing::warn!(path = %out_path, "row_materialize: output file already exists with write_mode=error");
579 return Err(MiniAppError::MaterializeDestInvalid {
580 path: out_path.clone(),
581 reason: "file already exists with write_mode=error".to_string(),
582 });
583 }
584
585 let bytes = serialize_row(format, projected, &row.id)?;
586 let sha256 = sha256_hex(&bytes);
587 let byte_len = bytes.len() as u64;
588
589 if !dry_run {
590 let out_path_clone = out_path.clone();
591 let bytes_clone = bytes.clone();
592 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
593 std::fs::write(&out_path_clone, &bytes_clone).map_err(|e| {
594 MiniAppError::MaterializeIo(format!("write '{}': {e}", out_path_clone))
595 })
596 })
597 .await
598 .map_err(|e| MiniAppError::MaterializeIo(format!("blocking task panic: {e}")))??;
599 }
600
601 files.push(MaterializeFile {
603 path: out_path,
604 bytes: byte_len,
605 sha256,
606 row_id: Some(row.id.clone()),
607 });
608 }
609 }
610
611 let count = files.len();
613 Ok(MaterializeResult { count, files })
614}
615
616#[cfg(test)]
621mod tests {
622 use super::*;
623 use crate::config::Config;
624 use crate::registry::TableRegistry;
625 use crate::schema::{FieldDef, FieldType, SchemaConfig};
626 use crate::store::Store;
627 use std::path::PathBuf;
628 use std::sync::Arc;
629
630 async fn make_test_env() -> (Arc<ArcSwap<TableRegistry>>, String, Arc<Config>) {
636 let schema = SchemaConfig {
637 table: "test".to_string(),
638 title: None,
639 description: None,
640 fields: vec![
641 FieldDef {
642 name: "title".to_string(),
643 ty: FieldType::String,
644 required: true,
645 description: None,
646 },
647 FieldDef {
648 name: "body".to_string(),
649 ty: FieldType::String,
650 required: false,
651 description: None,
652 },
653 ],
654 dump: None,
655 };
656
657 let store = Store::open(std::path::Path::new(":memory:"), schema.clone())
660 .await
661 .expect("in-memory store must open");
662
663 let data = serde_json::json!({"title": "hello", "body": "world"});
665 let row = store.create(data).await.expect("create must succeed");
667 let row_id = row.id.clone();
668
669 let registry = TableRegistry::from_single(
670 store,
671 schema,
672 PathBuf::from("/fake/schema.yaml"),
673 "test".to_string(),
674 );
675
676 let config = Arc::new(Config {
677 schema_path: None,
678 db_path: None,
679 user_dir: None,
680 project_dir: None,
681 backup_retention: None,
682 snapshot_retention: None,
683 });
684
685 (Arc::new(ArcSwap::from_pointee(registry)), row_id, config)
686 }
687
688 async fn add_second_row(tables: &Arc<ArcSwap<TableRegistry>>) -> String {
690 let registry = tables.load_full();
691 let entry = registry.resolve(None).expect("resolve must succeed");
693 let data = serde_json::json!({"title": "second", "body": "entry"});
694 let row = entry.store.create(data).await.expect("create must succeed");
696 row.id
697 }
698
699 #[tokio::test]
713 async fn materialize_grid_raw_by_id_no_concat() {
714 let (tables, row_id, config) = make_test_env().await;
715 let dest = tempfile::tempdir().unwrap();
716 let dest_path = dest.path().to_str().unwrap().to_string();
717
718 let params = MaterializeParams {
719 table: None,
720 selector: RowSelector::ById { id: row_id.clone() },
721 fields: FieldSelector::All,
722 format: MaterializeFormat::Raw,
723 dest: dest_path.clone(),
724 concat: Some(false),
725 write_mode: None,
726 dry_run: None,
727 };
728
729 let result = do_materialize(&config, &tables, params).await.unwrap();
730 assert_eq!(result.count, 1);
731 let f = &result.files[0];
732 assert_eq!(f.row_id, Some(row_id.clone()));
733 assert_eq!(f.sha256.len(), 64);
734 assert!(f.bytes > 0);
735 let written = std::fs::read_to_string(&f.path).unwrap();
737 assert!(written.contains("hello"));
738 }
739
740 #[tokio::test]
743 async fn materialize_grid_markdown_by_id_no_concat() {
744 let (tables, row_id, config) = make_test_env().await;
745 let dest = tempfile::tempdir().unwrap();
746 let dest_path = dest.path().to_str().unwrap().to_string();
747
748 let params = MaterializeParams {
749 table: None,
750 selector: RowSelector::ById { id: row_id.clone() },
751 fields: FieldSelector::All,
752 format: MaterializeFormat::Markdown,
753 dest: dest_path,
754 concat: Some(false),
755 write_mode: None,
756 dry_run: None,
757 };
758
759 let result = do_materialize(&config, &tables, params).await.unwrap();
760 assert_eq!(result.count, 1);
761 let f = &result.files[0];
762 assert_eq!(f.row_id, Some(row_id.clone()));
763 assert_eq!(f.sha256.len(), 64);
764 assert!(f.path.ends_with(".md"));
765 let written = std::fs::read_to_string(&f.path).unwrap();
766 assert!(written.contains(&format!("# {}", row_id)));
767 assert!(written.contains("## title"));
768 }
769
770 #[tokio::test]
773 async fn materialize_grid_json_by_id_no_concat() {
774 let (tables, row_id, config) = make_test_env().await;
775 let dest = tempfile::tempdir().unwrap();
776 let dest_path = dest.path().to_str().unwrap().to_string();
777
778 let params = MaterializeParams {
779 table: None,
780 selector: RowSelector::ById { id: row_id.clone() },
781 fields: FieldSelector::All,
782 format: MaterializeFormat::Json,
783 dest: dest_path,
784 concat: Some(false),
785 write_mode: None,
786 dry_run: None,
787 };
788
789 let result = do_materialize(&config, &tables, params).await.unwrap();
790 assert_eq!(result.count, 1);
791 let f = &result.files[0];
792 assert_eq!(f.row_id, Some(row_id));
793 assert_eq!(f.sha256.len(), 64);
794 assert!(f.path.ends_with(".json"));
795 let parsed: serde_json::Value =
796 serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
797 assert_eq!(parsed["title"], "hello");
798 }
799
800 #[tokio::test]
803 async fn materialize_grid_yaml_by_id_no_concat() {
804 let (tables, row_id, config) = make_test_env().await;
805 let dest = tempfile::tempdir().unwrap();
806 let dest_path = dest.path().to_str().unwrap().to_string();
807
808 let params = MaterializeParams {
809 table: None,
810 selector: RowSelector::ById { id: row_id.clone() },
811 fields: FieldSelector::All,
812 format: MaterializeFormat::Yaml,
813 dest: dest_path,
814 concat: Some(false),
815 write_mode: None,
816 dry_run: None,
817 };
818
819 let result = do_materialize(&config, &tables, params).await.unwrap();
820 assert_eq!(result.count, 1);
821 let f = &result.files[0];
822 assert_eq!(f.row_id, Some(row_id));
823 assert_eq!(f.sha256.len(), 64);
824 assert!(f.path.ends_with(".yaml"));
825 let content = std::fs::read_to_string(&f.path).unwrap();
826 assert!(content.contains("title"));
827 }
828
829 #[tokio::test]
832 async fn materialize_grid_raw_by_id_concat() {
833 let (tables, row_id, config) = make_test_env().await;
834 let dest = tempfile::tempdir().unwrap();
835 let dest_path = format!("{}/out.txt", dest.path().display());
836
837 let params = MaterializeParams {
838 table: None,
839 selector: RowSelector::ById { id: row_id },
840 fields: FieldSelector::All,
841 format: MaterializeFormat::Raw,
842 dest: dest_path,
843 concat: Some(true),
844 write_mode: None,
845 dry_run: None,
846 };
847
848 let err = do_materialize(&config, &tables, params).await.unwrap_err();
849 assert!(matches!(
850 err,
851 MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
852 ));
853 }
854
855 #[tokio::test]
858 async fn materialize_grid_markdown_by_id_concat() {
859 let (tables, row_id, config) = make_test_env().await;
860 let dest = tempfile::tempdir().unwrap();
861 let dest_path = format!("{}/out.md", dest.path().display());
862
863 let params = MaterializeParams {
864 table: None,
865 selector: RowSelector::ById { id: row_id },
866 fields: FieldSelector::All,
867 format: MaterializeFormat::Markdown,
868 dest: dest_path,
869 concat: Some(true),
870 write_mode: None,
871 dry_run: None,
872 };
873
874 let err = do_materialize(&config, &tables, params).await.unwrap_err();
875 assert!(matches!(
876 err,
877 MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
878 ));
879 }
880
881 #[tokio::test]
884 async fn materialize_grid_json_by_id_concat() {
885 let (tables, row_id, config) = make_test_env().await;
886 let dest = tempfile::tempdir().unwrap();
887 let dest_path = format!("{}/out.json", dest.path().display());
888
889 let params = MaterializeParams {
890 table: None,
891 selector: RowSelector::ById { id: row_id },
892 fields: FieldSelector::All,
893 format: MaterializeFormat::Json,
894 dest: dest_path,
895 concat: Some(true),
896 write_mode: None,
897 dry_run: None,
898 };
899
900 let err = do_materialize(&config, &tables, params).await.unwrap_err();
901 assert!(matches!(
902 err,
903 MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
904 ));
905 }
906
907 #[tokio::test]
910 async fn materialize_grid_yaml_by_id_concat() {
911 let (tables, row_id, config) = make_test_env().await;
912 let dest = tempfile::tempdir().unwrap();
913 let dest_path = format!("{}/out.yaml", dest.path().display());
914
915 let params = MaterializeParams {
916 table: None,
917 selector: RowSelector::ById { id: row_id },
918 fields: FieldSelector::All,
919 format: MaterializeFormat::Yaml,
920 dest: dest_path,
921 concat: Some(true),
922 write_mode: None,
923 dry_run: None,
924 };
925
926 let err = do_materialize(&config, &tables, params).await.unwrap_err();
927 assert!(matches!(
928 err,
929 MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
930 ));
931 }
932
933 #[tokio::test]
936 async fn materialize_grid_raw_by_filter_no_concat() {
937 let (tables, row_id, config) = make_test_env().await;
938 let dest = tempfile::tempdir().unwrap();
939 let dest_path = dest.path().to_str().unwrap().to_string();
940
941 let params = MaterializeParams {
942 table: None,
943 selector: RowSelector::ByFilter {
944 filter: crate::filter::ListFilter::Eq {
945 field: "title".to_string(),
946 value: serde_json::json!("hello"),
947 },
948 limit: None,
949 offset: None,
950 },
951 fields: FieldSelector::All,
952 format: MaterializeFormat::Raw,
953 dest: dest_path,
954 concat: Some(false),
955 write_mode: None,
956 dry_run: None,
957 };
958
959 let result = do_materialize(&config, &tables, params).await.unwrap();
960 assert_eq!(result.count, 1);
961 let f = &result.files[0];
962 assert_eq!(f.row_id, Some(row_id));
963 assert_eq!(f.sha256.len(), 64);
964 let written = std::fs::read_to_string(&f.path).unwrap();
965 assert!(written.contains("hello"));
966 }
967
968 #[tokio::test]
971 async fn materialize_grid_markdown_by_filter_no_concat() {
972 let (tables, row_id, config) = make_test_env().await;
973 let dest = tempfile::tempdir().unwrap();
974 let dest_path = dest.path().to_str().unwrap().to_string();
975
976 let params = MaterializeParams {
977 table: None,
978 selector: RowSelector::ByFilter {
979 filter: crate::filter::ListFilter::Eq {
980 field: "title".to_string(),
981 value: serde_json::json!("hello"),
982 },
983 limit: None,
984 offset: None,
985 },
986 fields: FieldSelector::All,
987 format: MaterializeFormat::Markdown,
988 dest: dest_path,
989 concat: Some(false),
990 write_mode: None,
991 dry_run: None,
992 };
993
994 let result = do_materialize(&config, &tables, params).await.unwrap();
995 assert_eq!(result.count, 1);
996 let f = &result.files[0];
997 assert_eq!(f.row_id, Some(row_id.clone()));
998 assert_eq!(f.sha256.len(), 64);
999 let written = std::fs::read_to_string(&f.path).unwrap();
1000 assert!(written.contains(&format!("# {}", row_id)));
1001 }
1002
1003 #[tokio::test]
1006 async fn materialize_grid_json_by_filter_no_concat() {
1007 let (tables, row_id, config) = make_test_env().await;
1008 let dest = tempfile::tempdir().unwrap();
1009 let dest_path = dest.path().to_str().unwrap().to_string();
1010
1011 let params = MaterializeParams {
1012 table: None,
1013 selector: RowSelector::ByFilter {
1014 filter: crate::filter::ListFilter::Eq {
1015 field: "title".to_string(),
1016 value: serde_json::json!("hello"),
1017 },
1018 limit: None,
1019 offset: None,
1020 },
1021 fields: FieldSelector::All,
1022 format: MaterializeFormat::Json,
1023 dest: dest_path,
1024 concat: Some(false),
1025 write_mode: None,
1026 dry_run: None,
1027 };
1028
1029 let result = do_materialize(&config, &tables, params).await.unwrap();
1030 assert_eq!(result.count, 1);
1031 let f = &result.files[0];
1032 assert_eq!(f.row_id, Some(row_id));
1033 assert_eq!(f.sha256.len(), 64);
1034 let parsed: serde_json::Value =
1035 serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
1036 assert_eq!(parsed["title"], "hello");
1037 }
1038
1039 #[tokio::test]
1042 async fn materialize_grid_yaml_by_filter_no_concat() {
1043 let (tables, row_id, config) = make_test_env().await;
1044 let dest = tempfile::tempdir().unwrap();
1045 let dest_path = dest.path().to_str().unwrap().to_string();
1046
1047 let params = MaterializeParams {
1048 table: None,
1049 selector: RowSelector::ByFilter {
1050 filter: crate::filter::ListFilter::Eq {
1051 field: "title".to_string(),
1052 value: serde_json::json!("hello"),
1053 },
1054 limit: None,
1055 offset: None,
1056 },
1057 fields: FieldSelector::All,
1058 format: MaterializeFormat::Yaml,
1059 dest: dest_path,
1060 concat: Some(false),
1061 write_mode: None,
1062 dry_run: None,
1063 };
1064
1065 let result = do_materialize(&config, &tables, params).await.unwrap();
1066 assert_eq!(result.count, 1);
1067 let f = &result.files[0];
1068 assert_eq!(f.row_id, Some(row_id));
1069 assert_eq!(f.sha256.len(), 64);
1070 let content = std::fs::read_to_string(&f.path).unwrap();
1071 assert!(content.contains("hello"));
1072 }
1073
1074 #[tokio::test]
1077 async fn materialize_grid_raw_by_filter_concat() {
1078 let (tables, _row_id, config) = make_test_env().await;
1079 add_second_row(&tables).await;
1080 let dest = tempfile::tempdir().unwrap();
1081 let out_file = format!("{}/all.txt", dest.path().display());
1082
1083 let params = MaterializeParams {
1084 table: None,
1085 selector: RowSelector::ByFilter {
1086 filter: crate::filter::ListFilter::Eq {
1087 field: "title".to_string(),
1088 value: serde_json::json!("hello"),
1089 },
1090 limit: None,
1091 offset: None,
1092 },
1093 fields: FieldSelector::All,
1094 format: MaterializeFormat::Raw,
1095 dest: out_file.clone(),
1096 concat: Some(true),
1097 write_mode: None,
1098 dry_run: None,
1099 };
1100
1101 let result = do_materialize(&config, &tables, params).await.unwrap();
1102 assert_eq!(result.count, 1);
1103 let f = &result.files[0];
1104 assert_eq!(f.row_id, None);
1106 assert_eq!(f.sha256.len(), 64);
1107 assert_eq!(f.path, out_file);
1108 let content = std::fs::read_to_string(&f.path).unwrap();
1109 assert!(content.contains("hello"));
1110 }
1111
1112 #[tokio::test]
1115 async fn materialize_grid_markdown_by_filter_concat() {
1116 let (tables, _row_id, config) = make_test_env().await;
1117 let dest = tempfile::tempdir().unwrap();
1118 let out_file = format!("{}/all.md", dest.path().display());
1119
1120 let params = MaterializeParams {
1121 table: None,
1122 selector: RowSelector::ByFilter {
1123 filter: crate::filter::ListFilter::Eq {
1124 field: "title".to_string(),
1125 value: serde_json::json!("hello"),
1126 },
1127 limit: None,
1128 offset: None,
1129 },
1130 fields: FieldSelector::All,
1131 format: MaterializeFormat::Markdown,
1132 dest: out_file.clone(),
1133 concat: Some(true),
1134 write_mode: None,
1135 dry_run: None,
1136 };
1137
1138 let result = do_materialize(&config, &tables, params).await.unwrap();
1139 assert_eq!(result.count, 1);
1140 let f = &result.files[0];
1141 assert_eq!(f.row_id, None);
1142 assert_eq!(f.sha256.len(), 64);
1143 let content = std::fs::read_to_string(&f.path).unwrap();
1144 assert!(content.contains("## title"));
1145 }
1146
1147 #[tokio::test]
1150 async fn materialize_grid_json_by_filter_concat() {
1151 let (tables, _row_id, config) = make_test_env().await;
1152 let dest = tempfile::tempdir().unwrap();
1153 let out_file = format!("{}/all.json", dest.path().display());
1154
1155 let params = MaterializeParams {
1156 table: None,
1157 selector: RowSelector::ByFilter {
1158 filter: crate::filter::ListFilter::Eq {
1159 field: "title".to_string(),
1160 value: serde_json::json!("hello"),
1161 },
1162 limit: None,
1163 offset: None,
1164 },
1165 fields: FieldSelector::All,
1166 format: MaterializeFormat::Json,
1167 dest: out_file.clone(),
1168 concat: Some(true),
1169 write_mode: None,
1170 dry_run: None,
1171 };
1172
1173 let result = do_materialize(&config, &tables, params).await.unwrap();
1174 assert_eq!(result.count, 1);
1175 let f = &result.files[0];
1176 assert_eq!(f.row_id, None);
1177 assert_eq!(f.sha256.len(), 64);
1178 let parsed: serde_json::Value =
1179 serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
1180 assert!(parsed.is_array());
1181 assert_eq!(parsed[0]["title"], "hello");
1182 }
1183
1184 #[tokio::test]
1187 async fn materialize_grid_yaml_by_filter_concat() {
1188 let (tables, _row_id, config) = make_test_env().await;
1189 let dest = tempfile::tempdir().unwrap();
1190 let out_file = format!("{}/all.yaml", dest.path().display());
1191
1192 let params = MaterializeParams {
1193 table: None,
1194 selector: RowSelector::ByFilter {
1195 filter: crate::filter::ListFilter::Eq {
1196 field: "title".to_string(),
1197 value: serde_json::json!("hello"),
1198 },
1199 limit: None,
1200 offset: None,
1201 },
1202 fields: FieldSelector::All,
1203 format: MaterializeFormat::Yaml,
1204 dest: out_file.clone(),
1205 concat: Some(true),
1206 write_mode: None,
1207 dry_run: None,
1208 };
1209
1210 let result = do_materialize(&config, &tables, params).await.unwrap();
1211 assert_eq!(result.count, 1);
1212 let f = &result.files[0];
1213 assert_eq!(f.row_id, None);
1214 assert_eq!(f.sha256.len(), 64);
1215 let content = std::fs::read_to_string(&f.path).unwrap();
1216 assert!(content.starts_with("---\n"));
1217 assert!(content.contains("hello"));
1218 }
1219
1220 #[tokio::test]
1225 async fn path_validation_relative_dest() {
1226 let (tables, row_id, config) = make_test_env().await;
1227
1228 let params = MaterializeParams {
1229 table: None,
1230 selector: RowSelector::ById { id: row_id },
1231 fields: FieldSelector::All,
1232 format: MaterializeFormat::Raw,
1233 dest: "relative/path".to_string(), concat: None,
1235 write_mode: None,
1236 dry_run: None,
1237 };
1238
1239 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1240 assert!(matches!(
1241 err,
1242 MiniAppError::MaterializeDestRelative { ref path } if path == "relative/path"
1243 ));
1244 }
1245
1246 #[tokio::test]
1247 async fn path_validation_create_dir_all_success() {
1248 let (tables, row_id, config) = make_test_env().await;
1249 let dest = tempfile::tempdir().unwrap();
1250 let nested = format!("{}/subdir/nested", dest.path().display());
1252
1253 let params = MaterializeParams {
1254 table: None,
1255 selector: RowSelector::ById { id: row_id.clone() },
1256 fields: FieldSelector::All,
1257 format: MaterializeFormat::Raw,
1258 dest: nested.clone(),
1259 concat: Some(false),
1260 write_mode: None,
1261 dry_run: None,
1262 };
1263
1264 let result = do_materialize(&config, &tables, params).await.unwrap();
1265 assert_eq!(result.count, 1);
1266 assert!(std::path::Path::new(&nested).is_dir());
1267 }
1268
1269 #[tokio::test]
1270 async fn path_validation_concat_true_file_dest() {
1271 let (tables, _row_id, config) = make_test_env().await;
1272 let dest = tempfile::tempdir().unwrap();
1273 let out_file = format!("{}/out.txt", dest.path().display());
1274
1275 let params = MaterializeParams {
1276 table: None,
1277 selector: RowSelector::ByFilter {
1278 filter: crate::filter::ListFilter::Eq {
1279 field: "title".to_string(),
1280 value: serde_json::json!("hello"),
1281 },
1282 limit: None,
1283 offset: None,
1284 },
1285 fields: FieldSelector::All,
1286 format: MaterializeFormat::Raw,
1287 dest: out_file.clone(),
1288 concat: Some(true),
1289 write_mode: None,
1290 dry_run: None,
1291 };
1292
1293 let result = do_materialize(&config, &tables, params).await.unwrap();
1294 assert_eq!(result.count, 1);
1295 assert_eq!(result.files[0].path, out_file);
1296 assert!(std::path::Path::new(&out_file).exists());
1297 }
1298
1299 #[tokio::test]
1304 async fn projection_all_fields_in_schema_order() {
1305 let (tables, row_id, config) = make_test_env().await;
1306 let dest = tempfile::tempdir().unwrap();
1307
1308 let params = MaterializeParams {
1309 table: None,
1310 selector: RowSelector::ById { id: row_id },
1311 fields: FieldSelector::All,
1312 format: MaterializeFormat::Json,
1313 dest: dest.path().to_str().unwrap().to_string(),
1314 concat: None,
1315 write_mode: None,
1316 dry_run: None,
1317 };
1318
1319 let result = do_materialize(&config, &tables, params).await.unwrap();
1320 let parsed: serde_json::Value =
1321 serde_json::from_str(&std::fs::read_to_string(&result.files[0].path).unwrap()).unwrap();
1322 assert!(parsed.get("title").is_some());
1324 assert!(parsed.get("body").is_some());
1325 }
1326
1327 #[tokio::test]
1328 async fn projection_list_specified_order() {
1329 let (tables, row_id, config) = make_test_env().await;
1330 let dest = tempfile::tempdir().unwrap();
1331
1332 let params = MaterializeParams {
1333 table: None,
1334 selector: RowSelector::ById { id: row_id },
1335 fields: FieldSelector::List {
1336 fields: vec!["body".to_string()],
1337 },
1338 format: MaterializeFormat::Json,
1339 dest: dest.path().to_str().unwrap().to_string(),
1340 concat: None,
1341 write_mode: None,
1342 dry_run: None,
1343 };
1344
1345 let result = do_materialize(&config, &tables, params).await.unwrap();
1346 let parsed: serde_json::Value =
1347 serde_json::from_str(&std::fs::read_to_string(&result.files[0].path).unwrap()).unwrap();
1348 assert_eq!(parsed["body"], "world");
1349 assert!(parsed.get("title").is_none());
1351 }
1352
1353 #[tokio::test]
1354 async fn projection_unknown_field_returns_error() {
1355 let (tables, row_id, config) = make_test_env().await;
1356 let dest = tempfile::tempdir().unwrap();
1357
1358 let params = MaterializeParams {
1359 table: None,
1360 selector: RowSelector::ById { id: row_id },
1361 fields: FieldSelector::List {
1362 fields: vec!["nonexistent_field".to_string()],
1363 },
1364 format: MaterializeFormat::Json,
1365 dest: dest.path().to_str().unwrap().to_string(),
1366 concat: None,
1367 write_mode: None,
1368 dry_run: None,
1369 };
1370
1371 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1372 assert!(matches!(
1373 err,
1374 MiniAppError::MaterializeFieldUnknown { ref field } if field == "nonexistent_field"
1375 ));
1376 }
1377
1378 #[tokio::test]
1383 async fn error_dest_invalid_write_mode_error_existing_file() {
1384 let (tables, _row_id, config) = make_test_env().await;
1385 let dest = tempfile::tempdir().unwrap();
1386 let out_file = format!("{}/out.txt", dest.path().display());
1387 std::fs::write(&out_file, b"existing").unwrap();
1389
1390 let params = MaterializeParams {
1391 table: None,
1392 selector: RowSelector::ByFilter {
1393 filter: crate::filter::ListFilter::Eq {
1394 field: "title".to_string(),
1395 value: serde_json::json!("hello"),
1396 },
1397 limit: None,
1398 offset: None,
1399 },
1400 fields: FieldSelector::All,
1401 format: MaterializeFormat::Raw,
1402 dest: out_file.clone(),
1403 concat: Some(true),
1404 write_mode: Some(WriteMode::Error),
1405 dry_run: None,
1406 };
1407
1408 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1409 assert!(matches!(
1410 err,
1411 MiniAppError::MaterializeDestInvalid { ref path, .. } if path == &out_file
1412 ));
1413 }
1414
1415 #[tokio::test]
1416 async fn error_row_not_found() {
1417 let (tables, _row_id, config) = make_test_env().await;
1418 let dest = tempfile::tempdir().unwrap();
1419
1420 let params = MaterializeParams {
1421 table: None,
1422 selector: RowSelector::ById {
1423 id: "00000000-0000-0000-0000-000000000000".to_string(),
1424 },
1425 fields: FieldSelector::All,
1426 format: MaterializeFormat::Raw,
1427 dest: dest.path().to_str().unwrap().to_string(),
1428 concat: None,
1429 write_mode: None,
1430 dry_run: None,
1431 };
1432
1433 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1434 assert!(matches!(err, MiniAppError::MaterializeRowNotFound { .. }));
1435 }
1436
1437 #[tokio::test]
1438 async fn error_empty_result() {
1439 let (tables, _row_id, config) = make_test_env().await;
1440 let dest = tempfile::tempdir().unwrap();
1441
1442 let params = MaterializeParams {
1443 table: None,
1444 selector: RowSelector::ByFilter {
1445 filter: crate::filter::ListFilter::Eq {
1446 field: "title".to_string(),
1447 value: serde_json::json!("no_such_title"),
1448 },
1449 limit: None,
1450 offset: None,
1451 },
1452 fields: FieldSelector::All,
1453 format: MaterializeFormat::Raw,
1454 dest: dest.path().to_str().unwrap().to_string(),
1455 concat: None,
1456 write_mode: None,
1457 dry_run: None,
1458 };
1459
1460 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1461 assert!(matches!(err, MiniAppError::MaterializeEmptyResult));
1462 }
1463
1464 #[tokio::test]
1465 async fn error_invalid_param_concat_by_id() {
1466 let (tables, row_id, config) = make_test_env().await;
1467 let dest = tempfile::tempdir().unwrap();
1468 let out_file = format!("{}/out.txt", dest.path().display());
1469
1470 let params = MaterializeParams {
1471 table: None,
1472 selector: RowSelector::ById { id: row_id },
1473 fields: FieldSelector::All,
1474 format: MaterializeFormat::Raw,
1475 dest: out_file,
1476 concat: Some(true),
1477 write_mode: None,
1478 dry_run: None,
1479 };
1480
1481 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1482 assert!(matches!(
1483 err,
1484 MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
1485 ));
1486 }
1487
1488 #[tokio::test]
1489 async fn error_field_unknown() {
1490 let (tables, row_id, config) = make_test_env().await;
1491 let dest = tempfile::tempdir().unwrap();
1492
1493 let params = MaterializeParams {
1494 table: None,
1495 selector: RowSelector::ById { id: row_id },
1496 fields: FieldSelector::List {
1497 fields: vec!["unknown".to_string()],
1498 },
1499 format: MaterializeFormat::Raw,
1500 dest: dest.path().to_str().unwrap().to_string(),
1501 concat: None,
1502 write_mode: None,
1503 dry_run: None,
1504 };
1505
1506 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1507 assert!(matches!(
1508 err,
1509 MiniAppError::MaterializeFieldUnknown { ref field } if field == "unknown"
1510 ));
1511 }
1512
1513 #[tokio::test]
1514 async fn error_dest_relative_is_rejected_at_validation() {
1515 let (tables, row_id, config) = make_test_env().await;
1518
1519 let params = MaterializeParams {
1520 table: None,
1521 selector: RowSelector::ById { id: row_id },
1522 fields: FieldSelector::All,
1523 format: MaterializeFormat::Json,
1524 dest: "not/absolute".to_string(),
1525 concat: None,
1526 write_mode: None,
1527 dry_run: None,
1528 };
1529
1530 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1531 assert!(matches!(
1532 err,
1533 MiniAppError::MaterializeDestRelative { ref path } if path == "not/absolute"
1534 ));
1535 }
1536
1537 #[tokio::test]
1542 async fn dry_run_no_write_but_sha256_and_bytes_present() {
1543 let (tables, row_id, config) = make_test_env().await;
1544 let dest = tempfile::tempdir().unwrap();
1545 let dest_path = dest.path().to_str().unwrap().to_string();
1546
1547 let params = MaterializeParams {
1548 table: None,
1549 selector: RowSelector::ById { id: row_id.clone() },
1550 fields: FieldSelector::All,
1551 format: MaterializeFormat::Json,
1552 dest: dest_path.clone(),
1553 concat: Some(false),
1554 write_mode: None,
1555 dry_run: Some(true),
1556 };
1557
1558 let result = do_materialize(&config, &tables, params).await.unwrap();
1559 assert_eq!(result.count, 1);
1560 let f = &result.files[0];
1561 assert_eq!(f.sha256.len(), 64);
1563 assert!(f.bytes > 0);
1564 let out_path = format!("{}/{}.json", dest_path, row_id);
1566 assert!(!std::path::Path::new(&out_path).exists());
1567 }
1568
1569 #[tokio::test]
1570 async fn dry_run_write_mode_error_existing_file_still_errors() {
1571 let (tables, _row_id, config) = make_test_env().await;
1572 let dest = tempfile::tempdir().unwrap();
1573 let out_file = format!("{}/out.txt", dest.path().display());
1574 std::fs::write(&out_file, b"existing").unwrap();
1576
1577 let params = MaterializeParams {
1578 table: None,
1579 selector: RowSelector::ByFilter {
1580 filter: crate::filter::ListFilter::Eq {
1581 field: "title".to_string(),
1582 value: serde_json::json!("hello"),
1583 },
1584 limit: None,
1585 offset: None,
1586 },
1587 fields: FieldSelector::All,
1588 format: MaterializeFormat::Raw,
1589 dest: out_file.clone(),
1590 concat: Some(true),
1591 write_mode: Some(WriteMode::Error),
1592 dry_run: Some(true), };
1594
1595 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1596 assert!(matches!(
1597 err,
1598 MiniAppError::MaterializeDestInvalid { ref path, .. } if path == &out_file
1599 ));
1600 }
1601
1602 #[tokio::test]
1607 async fn row_id_set_for_each_file_when_no_concat() {
1608 let (tables, row_id, config) = make_test_env().await;
1609 let dest = tempfile::tempdir().unwrap();
1610
1611 let params = MaterializeParams {
1612 table: None,
1613 selector: RowSelector::ById { id: row_id.clone() },
1614 fields: FieldSelector::All,
1615 format: MaterializeFormat::Raw,
1616 dest: dest.path().to_str().unwrap().to_string(),
1617 concat: Some(false),
1618 write_mode: None,
1619 dry_run: None,
1620 };
1621
1622 let result = do_materialize(&config, &tables, params).await.unwrap();
1623 assert_eq!(result.files[0].row_id, Some(row_id));
1624 }
1625
1626 #[tokio::test]
1627 async fn row_id_is_none_when_concat() {
1628 let (tables, _row_id, config) = make_test_env().await;
1629 let dest = tempfile::tempdir().unwrap();
1630 let out_file = format!("{}/out.txt", dest.path().display());
1631
1632 let params = MaterializeParams {
1633 table: None,
1634 selector: RowSelector::ByFilter {
1635 filter: crate::filter::ListFilter::Eq {
1636 field: "title".to_string(),
1637 value: serde_json::json!("hello"),
1638 },
1639 limit: None,
1640 offset: None,
1641 },
1642 fields: FieldSelector::All,
1643 format: MaterializeFormat::Raw,
1644 dest: out_file,
1645 concat: Some(true),
1646 write_mode: None,
1647 dry_run: None,
1648 };
1649
1650 let result = do_materialize(&config, &tables, params).await.unwrap();
1651 assert_eq!(result.files[0].row_id, None);
1652 }
1653
1654 fn make_schema() -> SchemaConfig {
1659 SchemaConfig {
1660 table: "test".to_string(),
1661 title: None,
1662 description: None,
1663 fields: vec![
1664 FieldDef {
1665 name: "title".to_string(),
1666 ty: FieldType::String,
1667 required: true,
1668 description: None,
1669 },
1670 FieldDef {
1671 name: "body".to_string(),
1672 ty: FieldType::String,
1673 required: false,
1674 description: None,
1675 },
1676 ],
1677 dump: None,
1678 }
1679 }
1680
1681 fn make_row(data: serde_json::Value) -> RowRecord {
1682 RowRecord {
1683 id: "test-id".to_string(),
1684 data,
1685 created_at: 0,
1686 updated_at: 0,
1687 }
1688 }
1689
1690 #[test]
1691 fn validate_field_selector_all_is_ok() {
1692 let schema = make_schema();
1693 let fs = FieldSelector::All;
1694 assert!(fs.validate(&schema).is_ok());
1695 }
1696
1697 #[test]
1698 fn validate_field_selector_list_known_fields_ok() {
1699 let schema = make_schema();
1700 let fs = FieldSelector::List {
1701 fields: vec!["title".to_string(), "body".to_string()],
1702 };
1703 assert!(fs.validate(&schema).is_ok());
1704 }
1705
1706 #[test]
1707 fn validate_field_selector_list_single_known_field_ok() {
1708 let schema = make_schema();
1709 let fs = FieldSelector::List {
1710 fields: vec!["title".to_string()],
1711 };
1712 assert!(fs.validate(&schema).is_ok());
1713 }
1714
1715 #[test]
1716 fn validate_field_selector_list_unknown_field_returns_validation_error() {
1717 let schema = make_schema();
1718 let fs = FieldSelector::List {
1719 fields: vec!["title".to_string(), "nonexistent".to_string()],
1720 };
1721 let err = fs.validate(&schema).unwrap_err();
1722 match err {
1723 MiniAppError::Validation { field, reason } => {
1724 assert_eq!(field, "nonexistent");
1725 assert!(reason.contains("nonexistent"));
1726 assert!(reason.contains("schema-registered"));
1727 }
1728 other => panic!("expected Validation error, got {other:?}"),
1729 }
1730 }
1731
1732 #[test]
1733 fn validate_field_selector_list_empty_fields_ok() {
1734 let schema = make_schema();
1736 let fs = FieldSelector::List { fields: vec![] };
1737 assert!(fs.validate(&schema).is_ok());
1738 }
1739
1740 #[test]
1745 fn apply_projection_none_returns_unchanged() {
1746 let schema = make_schema();
1747 let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1748 let records = vec![row];
1749 let result = apply_projection(records.clone(), &None, &schema).unwrap();
1750 assert_eq!(result.len(), 1);
1751 assert_eq!(result[0].id, records[0].id);
1752 assert_eq!(
1753 result[0].data,
1754 serde_json::json!({"title": "hello", "body": "world"})
1755 );
1756 }
1757
1758 #[test]
1759 fn apply_projection_all_returns_unchanged() {
1760 let schema = make_schema();
1761 let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1762 let records = vec![row];
1763 let fields = Some(FieldSelector::All);
1764 let result = apply_projection(records.clone(), &fields, &schema).unwrap();
1765 assert_eq!(result.len(), 1);
1766 assert_eq!(
1767 result[0].data,
1768 serde_json::json!({"title": "hello", "body": "world"})
1769 );
1770 }
1771
1772 #[test]
1773 fn apply_projection_list_projects_data() {
1774 let schema = make_schema();
1775 let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1776 let original_id = row.id.clone();
1777 let original_created_at = row.created_at;
1778 let records = vec![row];
1779 let fields = Some(FieldSelector::List {
1780 fields: vec!["title".to_string()],
1781 });
1782 let result = apply_projection(records, &fields, &schema).unwrap();
1783 assert_eq!(result.len(), 1);
1784 assert_eq!(result[0].data, serde_json::json!({"title": "hello"}));
1786 assert_eq!(result[0].id, original_id);
1788 assert_eq!(result[0].created_at, original_created_at);
1789 }
1790
1791 #[test]
1792 fn apply_projection_list_projects_multiple_rows() {
1793 let schema = make_schema();
1794 let row1 = make_row(serde_json::json!({"title": "first", "body": "one"}));
1795 let row2 = make_row(serde_json::json!({"title": "second", "body": "two"}));
1796 let fields = Some(FieldSelector::List {
1797 fields: vec!["body".to_string()],
1798 });
1799 let result = apply_projection(vec![row1, row2], &fields, &schema).unwrap();
1800 assert_eq!(result.len(), 2);
1801 assert_eq!(result[0].data, serde_json::json!({"body": "one"}));
1802 assert_eq!(result[1].data, serde_json::json!({"body": "two"}));
1803 }
1804
1805 #[test]
1806 fn apply_projection_unknown_field_returns_error() {
1807 let schema = make_schema();
1808 let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1809 let fields = Some(FieldSelector::List {
1810 fields: vec!["nonexistent".to_string()],
1811 });
1812 let err = apply_projection(vec![row], &fields, &schema).unwrap_err();
1813 match err {
1814 MiniAppError::Validation { field, .. } => {
1815 assert_eq!(field, "nonexistent");
1816 }
1817 other => panic!("expected Validation error, got {other:?}"),
1818 }
1819 }
1820
1821 #[test]
1822 fn apply_projection_missing_field_in_data_returns_null() {
1823 let schema = make_schema();
1827 let row = make_row(serde_json::json!({"title": "hello"}));
1828 let fields = Some(FieldSelector::List {
1829 fields: vec!["title".to_string(), "body".to_string()],
1830 });
1831 let result = apply_projection(vec![row], &fields, &schema).unwrap();
1832 assert_eq!(result[0].data["title"], "hello");
1833 assert_eq!(result[0].data["body"], serde_json::Value::Null);
1834 }
1835}