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, Serialize, 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), None).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 history: Default::default(),
656 };
657
658 let store = Store::open(std::path::Path::new(":memory:"), schema.clone())
661 .await
662 .expect("in-memory store must open");
663
664 let data = serde_json::json!({"title": "hello", "body": "world"});
666 let row = store.create(data).await.expect("create must succeed");
668 let row_id = row.id.clone();
669
670 let registry = TableRegistry::from_single(
671 store,
672 schema,
673 PathBuf::from("/fake/schema.yaml"),
674 "test".to_string(),
675 );
676
677 let config = Arc::new(Config {
678 schema_path: None,
679 db_path: None,
680 user_dir: None,
681 project_dir: None,
682 backup_retention: None,
683 snapshot_retention: None,
684 });
685
686 (Arc::new(ArcSwap::from_pointee(registry)), row_id, config)
687 }
688
689 async fn add_second_row(tables: &Arc<ArcSwap<TableRegistry>>) -> String {
691 let registry = tables.load_full();
692 let entry = registry.resolve(None).expect("resolve must succeed");
694 let data = serde_json::json!({"title": "second", "body": "entry"});
695 let row = entry.store.create(data).await.expect("create must succeed");
697 row.id
698 }
699
700 #[tokio::test]
714 async fn materialize_grid_raw_by_id_no_concat() {
715 let (tables, row_id, config) = make_test_env().await;
716 let dest = tempfile::tempdir().unwrap();
717 let dest_path = dest.path().to_str().unwrap().to_string();
718
719 let params = MaterializeParams {
720 table: None,
721 selector: RowSelector::ById { id: row_id.clone() },
722 fields: FieldSelector::All,
723 format: MaterializeFormat::Raw,
724 dest: dest_path.clone(),
725 concat: Some(false),
726 write_mode: None,
727 dry_run: None,
728 };
729
730 let result = do_materialize(&config, &tables, params).await.unwrap();
731 assert_eq!(result.count, 1);
732 let f = &result.files[0];
733 assert_eq!(f.row_id, Some(row_id.clone()));
734 assert_eq!(f.sha256.len(), 64);
735 assert!(f.bytes > 0);
736 let written = std::fs::read_to_string(&f.path).unwrap();
738 assert!(written.contains("hello"));
739 }
740
741 #[tokio::test]
744 async fn materialize_grid_markdown_by_id_no_concat() {
745 let (tables, row_id, config) = make_test_env().await;
746 let dest = tempfile::tempdir().unwrap();
747 let dest_path = dest.path().to_str().unwrap().to_string();
748
749 let params = MaterializeParams {
750 table: None,
751 selector: RowSelector::ById { id: row_id.clone() },
752 fields: FieldSelector::All,
753 format: MaterializeFormat::Markdown,
754 dest: dest_path,
755 concat: Some(false),
756 write_mode: None,
757 dry_run: None,
758 };
759
760 let result = do_materialize(&config, &tables, params).await.unwrap();
761 assert_eq!(result.count, 1);
762 let f = &result.files[0];
763 assert_eq!(f.row_id, Some(row_id.clone()));
764 assert_eq!(f.sha256.len(), 64);
765 assert!(f.path.ends_with(".md"));
766 let written = std::fs::read_to_string(&f.path).unwrap();
767 assert!(written.contains(&format!("# {}", row_id)));
768 assert!(written.contains("## title"));
769 }
770
771 #[tokio::test]
774 async fn materialize_grid_json_by_id_no_concat() {
775 let (tables, row_id, config) = make_test_env().await;
776 let dest = tempfile::tempdir().unwrap();
777 let dest_path = dest.path().to_str().unwrap().to_string();
778
779 let params = MaterializeParams {
780 table: None,
781 selector: RowSelector::ById { id: row_id.clone() },
782 fields: FieldSelector::All,
783 format: MaterializeFormat::Json,
784 dest: dest_path,
785 concat: Some(false),
786 write_mode: None,
787 dry_run: None,
788 };
789
790 let result = do_materialize(&config, &tables, params).await.unwrap();
791 assert_eq!(result.count, 1);
792 let f = &result.files[0];
793 assert_eq!(f.row_id, Some(row_id));
794 assert_eq!(f.sha256.len(), 64);
795 assert!(f.path.ends_with(".json"));
796 let parsed: serde_json::Value =
797 serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
798 assert_eq!(parsed["title"], "hello");
799 }
800
801 #[tokio::test]
804 async fn materialize_grid_yaml_by_id_no_concat() {
805 let (tables, row_id, config) = make_test_env().await;
806 let dest = tempfile::tempdir().unwrap();
807 let dest_path = dest.path().to_str().unwrap().to_string();
808
809 let params = MaterializeParams {
810 table: None,
811 selector: RowSelector::ById { id: row_id.clone() },
812 fields: FieldSelector::All,
813 format: MaterializeFormat::Yaml,
814 dest: dest_path,
815 concat: Some(false),
816 write_mode: None,
817 dry_run: None,
818 };
819
820 let result = do_materialize(&config, &tables, params).await.unwrap();
821 assert_eq!(result.count, 1);
822 let f = &result.files[0];
823 assert_eq!(f.row_id, Some(row_id));
824 assert_eq!(f.sha256.len(), 64);
825 assert!(f.path.ends_with(".yaml"));
826 let content = std::fs::read_to_string(&f.path).unwrap();
827 assert!(content.contains("title"));
828 }
829
830 #[tokio::test]
833 async fn materialize_grid_raw_by_id_concat() {
834 let (tables, row_id, config) = make_test_env().await;
835 let dest = tempfile::tempdir().unwrap();
836 let dest_path = format!("{}/out.txt", dest.path().display());
837
838 let params = MaterializeParams {
839 table: None,
840 selector: RowSelector::ById { id: row_id },
841 fields: FieldSelector::All,
842 format: MaterializeFormat::Raw,
843 dest: dest_path,
844 concat: Some(true),
845 write_mode: None,
846 dry_run: None,
847 };
848
849 let err = do_materialize(&config, &tables, params).await.unwrap_err();
850 assert!(matches!(
851 err,
852 MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
853 ));
854 }
855
856 #[tokio::test]
859 async fn materialize_grid_markdown_by_id_concat() {
860 let (tables, row_id, config) = make_test_env().await;
861 let dest = tempfile::tempdir().unwrap();
862 let dest_path = format!("{}/out.md", dest.path().display());
863
864 let params = MaterializeParams {
865 table: None,
866 selector: RowSelector::ById { id: row_id },
867 fields: FieldSelector::All,
868 format: MaterializeFormat::Markdown,
869 dest: dest_path,
870 concat: Some(true),
871 write_mode: None,
872 dry_run: None,
873 };
874
875 let err = do_materialize(&config, &tables, params).await.unwrap_err();
876 assert!(matches!(
877 err,
878 MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
879 ));
880 }
881
882 #[tokio::test]
885 async fn materialize_grid_json_by_id_concat() {
886 let (tables, row_id, config) = make_test_env().await;
887 let dest = tempfile::tempdir().unwrap();
888 let dest_path = format!("{}/out.json", dest.path().display());
889
890 let params = MaterializeParams {
891 table: None,
892 selector: RowSelector::ById { id: row_id },
893 fields: FieldSelector::All,
894 format: MaterializeFormat::Json,
895 dest: dest_path,
896 concat: Some(true),
897 write_mode: None,
898 dry_run: None,
899 };
900
901 let err = do_materialize(&config, &tables, params).await.unwrap_err();
902 assert!(matches!(
903 err,
904 MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
905 ));
906 }
907
908 #[tokio::test]
911 async fn materialize_grid_yaml_by_id_concat() {
912 let (tables, row_id, config) = make_test_env().await;
913 let dest = tempfile::tempdir().unwrap();
914 let dest_path = format!("{}/out.yaml", dest.path().display());
915
916 let params = MaterializeParams {
917 table: None,
918 selector: RowSelector::ById { id: row_id },
919 fields: FieldSelector::All,
920 format: MaterializeFormat::Yaml,
921 dest: dest_path,
922 concat: Some(true),
923 write_mode: None,
924 dry_run: None,
925 };
926
927 let err = do_materialize(&config, &tables, params).await.unwrap_err();
928 assert!(matches!(
929 err,
930 MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
931 ));
932 }
933
934 #[tokio::test]
937 async fn materialize_grid_raw_by_filter_no_concat() {
938 let (tables, row_id, config) = make_test_env().await;
939 let dest = tempfile::tempdir().unwrap();
940 let dest_path = dest.path().to_str().unwrap().to_string();
941
942 let params = MaterializeParams {
943 table: None,
944 selector: RowSelector::ByFilter {
945 filter: crate::filter::ListFilter::Eq {
946 field: "title".to_string(),
947 value: serde_json::json!("hello"),
948 },
949 limit: None,
950 offset: None,
951 },
952 fields: FieldSelector::All,
953 format: MaterializeFormat::Raw,
954 dest: dest_path,
955 concat: Some(false),
956 write_mode: None,
957 dry_run: None,
958 };
959
960 let result = do_materialize(&config, &tables, params).await.unwrap();
961 assert_eq!(result.count, 1);
962 let f = &result.files[0];
963 assert_eq!(f.row_id, Some(row_id));
964 assert_eq!(f.sha256.len(), 64);
965 let written = std::fs::read_to_string(&f.path).unwrap();
966 assert!(written.contains("hello"));
967 }
968
969 #[tokio::test]
972 async fn materialize_grid_markdown_by_filter_no_concat() {
973 let (tables, row_id, config) = make_test_env().await;
974 let dest = tempfile::tempdir().unwrap();
975 let dest_path = dest.path().to_str().unwrap().to_string();
976
977 let params = MaterializeParams {
978 table: None,
979 selector: RowSelector::ByFilter {
980 filter: crate::filter::ListFilter::Eq {
981 field: "title".to_string(),
982 value: serde_json::json!("hello"),
983 },
984 limit: None,
985 offset: None,
986 },
987 fields: FieldSelector::All,
988 format: MaterializeFormat::Markdown,
989 dest: dest_path,
990 concat: Some(false),
991 write_mode: None,
992 dry_run: None,
993 };
994
995 let result = do_materialize(&config, &tables, params).await.unwrap();
996 assert_eq!(result.count, 1);
997 let f = &result.files[0];
998 assert_eq!(f.row_id, Some(row_id.clone()));
999 assert_eq!(f.sha256.len(), 64);
1000 let written = std::fs::read_to_string(&f.path).unwrap();
1001 assert!(written.contains(&format!("# {}", row_id)));
1002 }
1003
1004 #[tokio::test]
1007 async fn materialize_grid_json_by_filter_no_concat() {
1008 let (tables, row_id, config) = make_test_env().await;
1009 let dest = tempfile::tempdir().unwrap();
1010 let dest_path = dest.path().to_str().unwrap().to_string();
1011
1012 let params = MaterializeParams {
1013 table: None,
1014 selector: RowSelector::ByFilter {
1015 filter: crate::filter::ListFilter::Eq {
1016 field: "title".to_string(),
1017 value: serde_json::json!("hello"),
1018 },
1019 limit: None,
1020 offset: None,
1021 },
1022 fields: FieldSelector::All,
1023 format: MaterializeFormat::Json,
1024 dest: dest_path,
1025 concat: Some(false),
1026 write_mode: None,
1027 dry_run: None,
1028 };
1029
1030 let result = do_materialize(&config, &tables, params).await.unwrap();
1031 assert_eq!(result.count, 1);
1032 let f = &result.files[0];
1033 assert_eq!(f.row_id, Some(row_id));
1034 assert_eq!(f.sha256.len(), 64);
1035 let parsed: serde_json::Value =
1036 serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
1037 assert_eq!(parsed["title"], "hello");
1038 }
1039
1040 #[tokio::test]
1043 async fn materialize_grid_yaml_by_filter_no_concat() {
1044 let (tables, row_id, config) = make_test_env().await;
1045 let dest = tempfile::tempdir().unwrap();
1046 let dest_path = dest.path().to_str().unwrap().to_string();
1047
1048 let params = MaterializeParams {
1049 table: None,
1050 selector: RowSelector::ByFilter {
1051 filter: crate::filter::ListFilter::Eq {
1052 field: "title".to_string(),
1053 value: serde_json::json!("hello"),
1054 },
1055 limit: None,
1056 offset: None,
1057 },
1058 fields: FieldSelector::All,
1059 format: MaterializeFormat::Yaml,
1060 dest: dest_path,
1061 concat: Some(false),
1062 write_mode: None,
1063 dry_run: None,
1064 };
1065
1066 let result = do_materialize(&config, &tables, params).await.unwrap();
1067 assert_eq!(result.count, 1);
1068 let f = &result.files[0];
1069 assert_eq!(f.row_id, Some(row_id));
1070 assert_eq!(f.sha256.len(), 64);
1071 let content = std::fs::read_to_string(&f.path).unwrap();
1072 assert!(content.contains("hello"));
1073 }
1074
1075 #[tokio::test]
1078 async fn materialize_grid_raw_by_filter_concat() {
1079 let (tables, _row_id, config) = make_test_env().await;
1080 add_second_row(&tables).await;
1081 let dest = tempfile::tempdir().unwrap();
1082 let out_file = format!("{}/all.txt", dest.path().display());
1083
1084 let params = MaterializeParams {
1085 table: None,
1086 selector: RowSelector::ByFilter {
1087 filter: crate::filter::ListFilter::Eq {
1088 field: "title".to_string(),
1089 value: serde_json::json!("hello"),
1090 },
1091 limit: None,
1092 offset: None,
1093 },
1094 fields: FieldSelector::All,
1095 format: MaterializeFormat::Raw,
1096 dest: out_file.clone(),
1097 concat: Some(true),
1098 write_mode: None,
1099 dry_run: None,
1100 };
1101
1102 let result = do_materialize(&config, &tables, params).await.unwrap();
1103 assert_eq!(result.count, 1);
1104 let f = &result.files[0];
1105 assert_eq!(f.row_id, None);
1107 assert_eq!(f.sha256.len(), 64);
1108 assert_eq!(f.path, out_file);
1109 let content = std::fs::read_to_string(&f.path).unwrap();
1110 assert!(content.contains("hello"));
1111 }
1112
1113 #[tokio::test]
1116 async fn materialize_grid_markdown_by_filter_concat() {
1117 let (tables, _row_id, config) = make_test_env().await;
1118 let dest = tempfile::tempdir().unwrap();
1119 let out_file = format!("{}/all.md", dest.path().display());
1120
1121 let params = MaterializeParams {
1122 table: None,
1123 selector: RowSelector::ByFilter {
1124 filter: crate::filter::ListFilter::Eq {
1125 field: "title".to_string(),
1126 value: serde_json::json!("hello"),
1127 },
1128 limit: None,
1129 offset: None,
1130 },
1131 fields: FieldSelector::All,
1132 format: MaterializeFormat::Markdown,
1133 dest: out_file.clone(),
1134 concat: Some(true),
1135 write_mode: None,
1136 dry_run: None,
1137 };
1138
1139 let result = do_materialize(&config, &tables, params).await.unwrap();
1140 assert_eq!(result.count, 1);
1141 let f = &result.files[0];
1142 assert_eq!(f.row_id, None);
1143 assert_eq!(f.sha256.len(), 64);
1144 let content = std::fs::read_to_string(&f.path).unwrap();
1145 assert!(content.contains("## title"));
1146 }
1147
1148 #[tokio::test]
1151 async fn materialize_grid_json_by_filter_concat() {
1152 let (tables, _row_id, config) = make_test_env().await;
1153 let dest = tempfile::tempdir().unwrap();
1154 let out_file = format!("{}/all.json", dest.path().display());
1155
1156 let params = MaterializeParams {
1157 table: None,
1158 selector: RowSelector::ByFilter {
1159 filter: crate::filter::ListFilter::Eq {
1160 field: "title".to_string(),
1161 value: serde_json::json!("hello"),
1162 },
1163 limit: None,
1164 offset: None,
1165 },
1166 fields: FieldSelector::All,
1167 format: MaterializeFormat::Json,
1168 dest: out_file.clone(),
1169 concat: Some(true),
1170 write_mode: None,
1171 dry_run: None,
1172 };
1173
1174 let result = do_materialize(&config, &tables, params).await.unwrap();
1175 assert_eq!(result.count, 1);
1176 let f = &result.files[0];
1177 assert_eq!(f.row_id, None);
1178 assert_eq!(f.sha256.len(), 64);
1179 let parsed: serde_json::Value =
1180 serde_json::from_str(&std::fs::read_to_string(&f.path).unwrap()).unwrap();
1181 assert!(parsed.is_array());
1182 assert_eq!(parsed[0]["title"], "hello");
1183 }
1184
1185 #[tokio::test]
1188 async fn materialize_grid_yaml_by_filter_concat() {
1189 let (tables, _row_id, config) = make_test_env().await;
1190 let dest = tempfile::tempdir().unwrap();
1191 let out_file = format!("{}/all.yaml", dest.path().display());
1192
1193 let params = MaterializeParams {
1194 table: None,
1195 selector: RowSelector::ByFilter {
1196 filter: crate::filter::ListFilter::Eq {
1197 field: "title".to_string(),
1198 value: serde_json::json!("hello"),
1199 },
1200 limit: None,
1201 offset: None,
1202 },
1203 fields: FieldSelector::All,
1204 format: MaterializeFormat::Yaml,
1205 dest: out_file.clone(),
1206 concat: Some(true),
1207 write_mode: None,
1208 dry_run: None,
1209 };
1210
1211 let result = do_materialize(&config, &tables, params).await.unwrap();
1212 assert_eq!(result.count, 1);
1213 let f = &result.files[0];
1214 assert_eq!(f.row_id, None);
1215 assert_eq!(f.sha256.len(), 64);
1216 let content = std::fs::read_to_string(&f.path).unwrap();
1217 assert!(content.starts_with("---\n"));
1218 assert!(content.contains("hello"));
1219 }
1220
1221 #[tokio::test]
1226 async fn path_validation_relative_dest() {
1227 let (tables, row_id, config) = make_test_env().await;
1228
1229 let params = MaterializeParams {
1230 table: None,
1231 selector: RowSelector::ById { id: row_id },
1232 fields: FieldSelector::All,
1233 format: MaterializeFormat::Raw,
1234 dest: "relative/path".to_string(), concat: None,
1236 write_mode: None,
1237 dry_run: None,
1238 };
1239
1240 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1241 assert!(matches!(
1242 err,
1243 MiniAppError::MaterializeDestRelative { ref path } if path == "relative/path"
1244 ));
1245 }
1246
1247 #[tokio::test]
1248 async fn path_validation_create_dir_all_success() {
1249 let (tables, row_id, config) = make_test_env().await;
1250 let dest = tempfile::tempdir().unwrap();
1251 let nested = format!("{}/subdir/nested", dest.path().display());
1253
1254 let params = MaterializeParams {
1255 table: None,
1256 selector: RowSelector::ById { id: row_id.clone() },
1257 fields: FieldSelector::All,
1258 format: MaterializeFormat::Raw,
1259 dest: nested.clone(),
1260 concat: Some(false),
1261 write_mode: None,
1262 dry_run: None,
1263 };
1264
1265 let result = do_materialize(&config, &tables, params).await.unwrap();
1266 assert_eq!(result.count, 1);
1267 assert!(std::path::Path::new(&nested).is_dir());
1268 }
1269
1270 #[tokio::test]
1271 async fn path_validation_concat_true_file_dest() {
1272 let (tables, _row_id, config) = make_test_env().await;
1273 let dest = tempfile::tempdir().unwrap();
1274 let out_file = format!("{}/out.txt", dest.path().display());
1275
1276 let params = MaterializeParams {
1277 table: None,
1278 selector: RowSelector::ByFilter {
1279 filter: crate::filter::ListFilter::Eq {
1280 field: "title".to_string(),
1281 value: serde_json::json!("hello"),
1282 },
1283 limit: None,
1284 offset: None,
1285 },
1286 fields: FieldSelector::All,
1287 format: MaterializeFormat::Raw,
1288 dest: out_file.clone(),
1289 concat: Some(true),
1290 write_mode: None,
1291 dry_run: None,
1292 };
1293
1294 let result = do_materialize(&config, &tables, params).await.unwrap();
1295 assert_eq!(result.count, 1);
1296 assert_eq!(result.files[0].path, out_file);
1297 assert!(std::path::Path::new(&out_file).exists());
1298 }
1299
1300 #[tokio::test]
1305 async fn projection_all_fields_in_schema_order() {
1306 let (tables, row_id, config) = make_test_env().await;
1307 let dest = tempfile::tempdir().unwrap();
1308
1309 let params = MaterializeParams {
1310 table: None,
1311 selector: RowSelector::ById { id: row_id },
1312 fields: FieldSelector::All,
1313 format: MaterializeFormat::Json,
1314 dest: dest.path().to_str().unwrap().to_string(),
1315 concat: None,
1316 write_mode: None,
1317 dry_run: None,
1318 };
1319
1320 let result = do_materialize(&config, &tables, params).await.unwrap();
1321 let parsed: serde_json::Value =
1322 serde_json::from_str(&std::fs::read_to_string(&result.files[0].path).unwrap()).unwrap();
1323 assert!(parsed.get("title").is_some());
1325 assert!(parsed.get("body").is_some());
1326 }
1327
1328 #[tokio::test]
1329 async fn projection_list_specified_order() {
1330 let (tables, row_id, config) = make_test_env().await;
1331 let dest = tempfile::tempdir().unwrap();
1332
1333 let params = MaterializeParams {
1334 table: None,
1335 selector: RowSelector::ById { id: row_id },
1336 fields: FieldSelector::List {
1337 fields: vec!["body".to_string()],
1338 },
1339 format: MaterializeFormat::Json,
1340 dest: dest.path().to_str().unwrap().to_string(),
1341 concat: None,
1342 write_mode: None,
1343 dry_run: None,
1344 };
1345
1346 let result = do_materialize(&config, &tables, params).await.unwrap();
1347 let parsed: serde_json::Value =
1348 serde_json::from_str(&std::fs::read_to_string(&result.files[0].path).unwrap()).unwrap();
1349 assert_eq!(parsed["body"], "world");
1350 assert!(parsed.get("title").is_none());
1352 }
1353
1354 #[tokio::test]
1355 async fn projection_unknown_field_returns_error() {
1356 let (tables, row_id, config) = make_test_env().await;
1357 let dest = tempfile::tempdir().unwrap();
1358
1359 let params = MaterializeParams {
1360 table: None,
1361 selector: RowSelector::ById { id: row_id },
1362 fields: FieldSelector::List {
1363 fields: vec!["nonexistent_field".to_string()],
1364 },
1365 format: MaterializeFormat::Json,
1366 dest: dest.path().to_str().unwrap().to_string(),
1367 concat: None,
1368 write_mode: None,
1369 dry_run: None,
1370 };
1371
1372 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1373 assert!(matches!(
1374 err,
1375 MiniAppError::MaterializeFieldUnknown { ref field } if field == "nonexistent_field"
1376 ));
1377 }
1378
1379 #[tokio::test]
1384 async fn error_dest_invalid_write_mode_error_existing_file() {
1385 let (tables, _row_id, config) = make_test_env().await;
1386 let dest = tempfile::tempdir().unwrap();
1387 let out_file = format!("{}/out.txt", dest.path().display());
1388 std::fs::write(&out_file, b"existing").unwrap();
1390
1391 let params = MaterializeParams {
1392 table: None,
1393 selector: RowSelector::ByFilter {
1394 filter: crate::filter::ListFilter::Eq {
1395 field: "title".to_string(),
1396 value: serde_json::json!("hello"),
1397 },
1398 limit: None,
1399 offset: None,
1400 },
1401 fields: FieldSelector::All,
1402 format: MaterializeFormat::Raw,
1403 dest: out_file.clone(),
1404 concat: Some(true),
1405 write_mode: Some(WriteMode::Error),
1406 dry_run: None,
1407 };
1408
1409 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1410 assert!(matches!(
1411 err,
1412 MiniAppError::MaterializeDestInvalid { ref path, .. } if path == &out_file
1413 ));
1414 }
1415
1416 #[tokio::test]
1417 async fn error_row_not_found() {
1418 let (tables, _row_id, config) = make_test_env().await;
1419 let dest = tempfile::tempdir().unwrap();
1420
1421 let params = MaterializeParams {
1422 table: None,
1423 selector: RowSelector::ById {
1424 id: "00000000-0000-0000-0000-000000000000".to_string(),
1425 },
1426 fields: FieldSelector::All,
1427 format: MaterializeFormat::Raw,
1428 dest: dest.path().to_str().unwrap().to_string(),
1429 concat: None,
1430 write_mode: None,
1431 dry_run: None,
1432 };
1433
1434 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1435 assert!(matches!(err, MiniAppError::MaterializeRowNotFound { .. }));
1436 }
1437
1438 #[tokio::test]
1439 async fn error_empty_result() {
1440 let (tables, _row_id, config) = make_test_env().await;
1441 let dest = tempfile::tempdir().unwrap();
1442
1443 let params = MaterializeParams {
1444 table: None,
1445 selector: RowSelector::ByFilter {
1446 filter: crate::filter::ListFilter::Eq {
1447 field: "title".to_string(),
1448 value: serde_json::json!("no_such_title"),
1449 },
1450 limit: None,
1451 offset: None,
1452 },
1453 fields: FieldSelector::All,
1454 format: MaterializeFormat::Raw,
1455 dest: dest.path().to_str().unwrap().to_string(),
1456 concat: None,
1457 write_mode: None,
1458 dry_run: None,
1459 };
1460
1461 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1462 assert!(matches!(err, MiniAppError::MaterializeEmptyResult));
1463 }
1464
1465 #[tokio::test]
1466 async fn error_invalid_param_concat_by_id() {
1467 let (tables, row_id, config) = make_test_env().await;
1468 let dest = tempfile::tempdir().unwrap();
1469 let out_file = format!("{}/out.txt", dest.path().display());
1470
1471 let params = MaterializeParams {
1472 table: None,
1473 selector: RowSelector::ById { id: row_id },
1474 fields: FieldSelector::All,
1475 format: MaterializeFormat::Raw,
1476 dest: out_file,
1477 concat: Some(true),
1478 write_mode: None,
1479 dry_run: None,
1480 };
1481
1482 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1483 assert!(matches!(
1484 err,
1485 MiniAppError::MaterializeInvalidParam { ref field, .. } if field == "concat"
1486 ));
1487 }
1488
1489 #[tokio::test]
1490 async fn error_field_unknown() {
1491 let (tables, row_id, config) = make_test_env().await;
1492 let dest = tempfile::tempdir().unwrap();
1493
1494 let params = MaterializeParams {
1495 table: None,
1496 selector: RowSelector::ById { id: row_id },
1497 fields: FieldSelector::List {
1498 fields: vec!["unknown".to_string()],
1499 },
1500 format: MaterializeFormat::Raw,
1501 dest: dest.path().to_str().unwrap().to_string(),
1502 concat: None,
1503 write_mode: None,
1504 dry_run: None,
1505 };
1506
1507 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1508 assert!(matches!(
1509 err,
1510 MiniAppError::MaterializeFieldUnknown { ref field } if field == "unknown"
1511 ));
1512 }
1513
1514 #[tokio::test]
1515 async fn error_dest_relative_is_rejected_at_validation() {
1516 let (tables, row_id, config) = make_test_env().await;
1519
1520 let params = MaterializeParams {
1521 table: None,
1522 selector: RowSelector::ById { id: row_id },
1523 fields: FieldSelector::All,
1524 format: MaterializeFormat::Json,
1525 dest: "not/absolute".to_string(),
1526 concat: None,
1527 write_mode: None,
1528 dry_run: None,
1529 };
1530
1531 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1532 assert!(matches!(
1533 err,
1534 MiniAppError::MaterializeDestRelative { ref path } if path == "not/absolute"
1535 ));
1536 }
1537
1538 #[tokio::test]
1543 async fn dry_run_no_write_but_sha256_and_bytes_present() {
1544 let (tables, row_id, config) = make_test_env().await;
1545 let dest = tempfile::tempdir().unwrap();
1546 let dest_path = dest.path().to_str().unwrap().to_string();
1547
1548 let params = MaterializeParams {
1549 table: None,
1550 selector: RowSelector::ById { id: row_id.clone() },
1551 fields: FieldSelector::All,
1552 format: MaterializeFormat::Json,
1553 dest: dest_path.clone(),
1554 concat: Some(false),
1555 write_mode: None,
1556 dry_run: Some(true),
1557 };
1558
1559 let result = do_materialize(&config, &tables, params).await.unwrap();
1560 assert_eq!(result.count, 1);
1561 let f = &result.files[0];
1562 assert_eq!(f.sha256.len(), 64);
1564 assert!(f.bytes > 0);
1565 let out_path = format!("{}/{}.json", dest_path, row_id);
1567 assert!(!std::path::Path::new(&out_path).exists());
1568 }
1569
1570 #[tokio::test]
1571 async fn dry_run_write_mode_error_existing_file_still_errors() {
1572 let (tables, _row_id, config) = make_test_env().await;
1573 let dest = tempfile::tempdir().unwrap();
1574 let out_file = format!("{}/out.txt", dest.path().display());
1575 std::fs::write(&out_file, b"existing").unwrap();
1577
1578 let params = MaterializeParams {
1579 table: None,
1580 selector: RowSelector::ByFilter {
1581 filter: crate::filter::ListFilter::Eq {
1582 field: "title".to_string(),
1583 value: serde_json::json!("hello"),
1584 },
1585 limit: None,
1586 offset: None,
1587 },
1588 fields: FieldSelector::All,
1589 format: MaterializeFormat::Raw,
1590 dest: out_file.clone(),
1591 concat: Some(true),
1592 write_mode: Some(WriteMode::Error),
1593 dry_run: Some(true), };
1595
1596 let err = do_materialize(&config, &tables, params).await.unwrap_err();
1597 assert!(matches!(
1598 err,
1599 MiniAppError::MaterializeDestInvalid { ref path, .. } if path == &out_file
1600 ));
1601 }
1602
1603 #[tokio::test]
1608 async fn row_id_set_for_each_file_when_no_concat() {
1609 let (tables, row_id, config) = make_test_env().await;
1610 let dest = tempfile::tempdir().unwrap();
1611
1612 let params = MaterializeParams {
1613 table: None,
1614 selector: RowSelector::ById { id: row_id.clone() },
1615 fields: FieldSelector::All,
1616 format: MaterializeFormat::Raw,
1617 dest: dest.path().to_str().unwrap().to_string(),
1618 concat: Some(false),
1619 write_mode: None,
1620 dry_run: None,
1621 };
1622
1623 let result = do_materialize(&config, &tables, params).await.unwrap();
1624 assert_eq!(result.files[0].row_id, Some(row_id));
1625 }
1626
1627 #[tokio::test]
1628 async fn row_id_is_none_when_concat() {
1629 let (tables, _row_id, config) = make_test_env().await;
1630 let dest = tempfile::tempdir().unwrap();
1631 let out_file = format!("{}/out.txt", dest.path().display());
1632
1633 let params = MaterializeParams {
1634 table: None,
1635 selector: RowSelector::ByFilter {
1636 filter: crate::filter::ListFilter::Eq {
1637 field: "title".to_string(),
1638 value: serde_json::json!("hello"),
1639 },
1640 limit: None,
1641 offset: None,
1642 },
1643 fields: FieldSelector::All,
1644 format: MaterializeFormat::Raw,
1645 dest: out_file,
1646 concat: Some(true),
1647 write_mode: None,
1648 dry_run: None,
1649 };
1650
1651 let result = do_materialize(&config, &tables, params).await.unwrap();
1652 assert_eq!(result.files[0].row_id, None);
1653 }
1654
1655 fn make_schema() -> SchemaConfig {
1660 SchemaConfig {
1661 table: "test".to_string(),
1662 title: None,
1663 description: None,
1664 fields: vec![
1665 FieldDef {
1666 name: "title".to_string(),
1667 ty: FieldType::String,
1668 required: true,
1669 description: None,
1670 },
1671 FieldDef {
1672 name: "body".to_string(),
1673 ty: FieldType::String,
1674 required: false,
1675 description: None,
1676 },
1677 ],
1678 dump: None,
1679 history: Default::default(),
1680 }
1681 }
1682
1683 fn make_row(data: serde_json::Value) -> RowRecord {
1684 RowRecord {
1685 id: "test-id".to_string(),
1686 data,
1687 created_at: 0,
1688 updated_at: 0,
1689 }
1690 }
1691
1692 #[test]
1693 fn validate_field_selector_all_is_ok() {
1694 let schema = make_schema();
1695 let fs = FieldSelector::All;
1696 assert!(fs.validate(&schema).is_ok());
1697 }
1698
1699 #[test]
1700 fn validate_field_selector_list_known_fields_ok() {
1701 let schema = make_schema();
1702 let fs = FieldSelector::List {
1703 fields: vec!["title".to_string(), "body".to_string()],
1704 };
1705 assert!(fs.validate(&schema).is_ok());
1706 }
1707
1708 #[test]
1709 fn validate_field_selector_list_single_known_field_ok() {
1710 let schema = make_schema();
1711 let fs = FieldSelector::List {
1712 fields: vec!["title".to_string()],
1713 };
1714 assert!(fs.validate(&schema).is_ok());
1715 }
1716
1717 #[test]
1718 fn validate_field_selector_list_unknown_field_returns_validation_error() {
1719 let schema = make_schema();
1720 let fs = FieldSelector::List {
1721 fields: vec!["title".to_string(), "nonexistent".to_string()],
1722 };
1723 let err = fs.validate(&schema).unwrap_err();
1724 match err {
1725 MiniAppError::Validation { field, reason } => {
1726 assert_eq!(field, "nonexistent");
1727 assert!(reason.contains("nonexistent"));
1728 assert!(reason.contains("schema-registered"));
1729 }
1730 other => panic!("expected Validation error, got {other:?}"),
1731 }
1732 }
1733
1734 #[test]
1735 fn validate_field_selector_list_empty_fields_ok() {
1736 let schema = make_schema();
1738 let fs = FieldSelector::List { fields: vec![] };
1739 assert!(fs.validate(&schema).is_ok());
1740 }
1741
1742 #[test]
1747 fn apply_projection_none_returns_unchanged() {
1748 let schema = make_schema();
1749 let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1750 let records = vec![row];
1751 let result = apply_projection(records.clone(), &None, &schema).unwrap();
1752 assert_eq!(result.len(), 1);
1753 assert_eq!(result[0].id, records[0].id);
1754 assert_eq!(
1755 result[0].data,
1756 serde_json::json!({"title": "hello", "body": "world"})
1757 );
1758 }
1759
1760 #[test]
1761 fn apply_projection_all_returns_unchanged() {
1762 let schema = make_schema();
1763 let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1764 let records = vec![row];
1765 let fields = Some(FieldSelector::All);
1766 let result = apply_projection(records.clone(), &fields, &schema).unwrap();
1767 assert_eq!(result.len(), 1);
1768 assert_eq!(
1769 result[0].data,
1770 serde_json::json!({"title": "hello", "body": "world"})
1771 );
1772 }
1773
1774 #[test]
1775 fn apply_projection_list_projects_data() {
1776 let schema = make_schema();
1777 let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1778 let original_id = row.id.clone();
1779 let original_created_at = row.created_at;
1780 let records = vec![row];
1781 let fields = Some(FieldSelector::List {
1782 fields: vec!["title".to_string()],
1783 });
1784 let result = apply_projection(records, &fields, &schema).unwrap();
1785 assert_eq!(result.len(), 1);
1786 assert_eq!(result[0].data, serde_json::json!({"title": "hello"}));
1788 assert_eq!(result[0].id, original_id);
1790 assert_eq!(result[0].created_at, original_created_at);
1791 }
1792
1793 #[test]
1794 fn apply_projection_list_projects_multiple_rows() {
1795 let schema = make_schema();
1796 let row1 = make_row(serde_json::json!({"title": "first", "body": "one"}));
1797 let row2 = make_row(serde_json::json!({"title": "second", "body": "two"}));
1798 let fields = Some(FieldSelector::List {
1799 fields: vec!["body".to_string()],
1800 });
1801 let result = apply_projection(vec![row1, row2], &fields, &schema).unwrap();
1802 assert_eq!(result.len(), 2);
1803 assert_eq!(result[0].data, serde_json::json!({"body": "one"}));
1804 assert_eq!(result[1].data, serde_json::json!({"body": "two"}));
1805 }
1806
1807 #[test]
1808 fn apply_projection_unknown_field_returns_error() {
1809 let schema = make_schema();
1810 let row = make_row(serde_json::json!({"title": "hello", "body": "world"}));
1811 let fields = Some(FieldSelector::List {
1812 fields: vec!["nonexistent".to_string()],
1813 });
1814 let err = apply_projection(vec![row], &fields, &schema).unwrap_err();
1815 match err {
1816 MiniAppError::Validation { field, .. } => {
1817 assert_eq!(field, "nonexistent");
1818 }
1819 other => panic!("expected Validation error, got {other:?}"),
1820 }
1821 }
1822
1823 #[test]
1824 fn apply_projection_missing_field_in_data_returns_null() {
1825 let schema = make_schema();
1829 let row = make_row(serde_json::json!({"title": "hello"}));
1830 let fields = Some(FieldSelector::List {
1831 fields: vec!["title".to_string(), "body".to_string()],
1832 });
1833 let result = apply_projection(vec![row], &fields, &schema).unwrap();
1834 assert_eq!(result[0].data["title"], "hello");
1835 assert_eq!(result[0].data["body"], serde_json::Value::Null);
1836 }
1837}