use fathomdb_engine::{
Engine, EngineError, InitialState, LifecycleState, ProjectionFts, ProjectionRole,
ProjectionSpec, ReadView, SearchFilter, SoftFallbackBranch, SourceId,
};
use fathomdb_schema::SQLITE_SUFFIX;
use proptest::prelude::*;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn db_path(dir: &TempDir, name: &str) -> PathBuf {
dir.path().join(format!("{name}{SQLITE_SUFFIX}"))
}
fn roles(items: &[ProjectionRole]) -> BTreeSet<ProjectionRole> {
items.iter().copied().collect()
}
fn nested_spec(
name: &str,
source: &[&str],
roles_: &[ProjectionRole],
fts: bool,
) -> ProjectionSpec {
ProjectionSpec {
name: name.to_string(),
roles: roles(roles_),
fts: fts.then_some(ProjectionFts { tokenizer: None }),
vector: None,
source: Some(source.iter().map(|segment| (*segment).to_string()).collect()),
}
}
fn node(logical_id: &str, source_id: &str, body: &str) -> fathomdb_engine::PreparedWrite {
fathomdb_engine::PreparedWrite::Node {
kind: "doc".to_string(),
body: body.to_string(),
source_id: SourceId::new(source_id).expect("source id"),
logical_id: Some(logical_id.to_string()),
state: InitialState::Active,
reason: None,
valid_from: None,
valid_until: None,
}
}
fn pending_node(logical_id: &str, source_id: &str, body: &str) -> fathomdb_engine::PreparedWrite {
fathomdb_engine::PreparedWrite::Node {
kind: "doc".to_string(),
body: body.to_string(),
source_id: SourceId::new(source_id).expect("source id"),
logical_id: Some(logical_id.to_string()),
state: InitialState::Pending,
reason: None,
valid_from: None,
valid_until: None,
}
}
fn eav_values(path: &Path, name: &str) -> Vec<String> {
let conn = rusqlite::Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
)
.expect("open read only");
let values = conn
.prepare(
"SELECT attr_value FROM canonical_attributes WHERE attr_name = ?1 ORDER BY attr_value",
)
.expect("prepare")
.query_map([name], |row| row.get::<_, String>(0))
.expect("query")
.map(|row| row.expect("row"))
.collect();
values
}
fn property_fts_match(path: &Path, name: &str, query: &str) -> Vec<i64> {
let conn = rusqlite::Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
)
.expect("open read only");
let rows = conn
.prepare(
"SELECT write_cursor FROM property_search_index
WHERE attr_name = ?1 AND property_search_index MATCH ?2
ORDER BY write_cursor",
)
.expect("prepare")
.query_map([name, query], |row| row.get::<_, i64>(0))
.expect("query")
.map(|row| row.expect("row"))
.collect();
rows
}
#[test]
fn nested_literal_path_round_trips_and_projects_scalar_values() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "nested");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
let spec = nested_spec(
"core:deadline",
&["attributes", "core:deadline", "value.with[punctuation]"],
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
);
engine.configure_projections(std::slice::from_ref(&spec), &[]).unwrap();
engine
.write(&[node(
"N1",
"slice45:nested",
r#"{"attributes":{"core:deadline":{"value.with[punctuation]":"2026-10-01"}}}"#,
)])
.unwrap();
assert_eq!(engine.read_projections().unwrap(), vec![spec]);
opened.engine.close().unwrap();
assert_eq!(eav_values(&path, "core:deadline"), vec!["2026-10-01"]);
}
#[test]
fn nested_scalars_use_canonical_text_and_missing_or_null_do_not_project() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "scalars");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
engine
.configure_projections(
&[nested_spec(
"value",
&["attributes", "literal:key", "value"],
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
)],
&[],
)
.unwrap();
engine
.write(&[
node("text", "slice45:text", r#"{"attributes":{"literal:key":{"value":"1"}}}"#),
node("int", "slice45:int", r#"{"attributes":{"literal:key":{"value":1}}}"#),
node(
"different",
"slice45:different",
r#"{"note":"1","attributes":{"literal:key":{"value":"2"}}}"#,
),
node("real", "slice45:real", r#"{"attributes":{"literal:key":{"value":2.5}}}"#),
node("bool", "slice45:bool", r#"{"attributes":{"literal:key":{"value":true}}}"#),
node("null", "slice45:null", r#"{"attributes":{"literal:key":{"value":null}}}"#),
node("missing", "slice45:missing", r#"{"attributes":{"literal:key":{}}}"#),
])
.unwrap();
let mut filter = SearchFilter::default();
filter.attributes = vec![("value".to_string(), "1".to_string())];
let result =
engine.search_projected_text("1", "value", Some(filter), &ReadView::default()).unwrap();
assert_eq!(
result.results.iter().map(|hit| hit.body.as_str()).collect::<Vec<_>>(),
vec![
r#"{"attributes":{"literal:key":{"value":"1"}}}"#,
r#"{"attributes":{"literal:key":{"value":1}}}"#,
],
"canonical text equality deliberately collapses string \"1\" and number 1"
);
let unfiltered_hybrid = engine.search_filtered("1", None).unwrap();
assert!(
unfiltered_hybrid.results.iter().any(|hit| hit.body.contains(r#""value":"2""#)),
"the different projected value is a body-search candidate without an attribute filter"
);
let mut hybrid_filter = SearchFilter::default();
hybrid_filter.attributes = vec![("value".to_string(), "1".to_string())];
let hybrid = engine.search_filtered("1", Some(hybrid_filter)).unwrap();
assert!(
hybrid
.results
.iter()
.all(|hit| hit.body.contains(r#""value":"1""#) || hit.body.contains(r#""value":1"#)),
"normal hybrid search must retain public projected-attribute filters"
);
assert!(
hybrid.results.iter().all(|hit| !hit.body.contains(r#""value":"2""#)),
"the different projected value must be excluded by the hybrid attribute filter"
);
opened.engine.close().unwrap();
assert_eq!(eav_values(&path, "value"), vec!["1", "1", "2", "2.5", "true"]);
}
#[test]
fn nested_composite_terminal_rejects_write_and_backfill_atomically() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "composite");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
let spec = nested_spec(
"value",
&["attributes", "core:deadline", "value"],
&[ProjectionRole::Filterable],
false,
);
engine.configure_projections(std::slice::from_ref(&spec), &[]).unwrap();
assert_eq!(
engine.write(&[node(
"object",
"slice45:object",
r#"{"attributes":{"core:deadline":{"value":{"not":"scalar"}}}}"#,
)]),
Err(EngineError::WriteValidation)
);
assert!(eav_values(&path, "value").is_empty());
let backfill_path = db_path(&dir, "backfill_composite");
let backfill = Engine::open(backfill_path.clone()).unwrap();
backfill
.engine
.write(&[node(
"object",
"slice45:backfill",
r#"{"attributes":{"core:deadline":{"value":[]}}}"#,
)])
.unwrap();
assert_eq!(
backfill.engine.configure_projections(&[spec], &[]),
Err(EngineError::WriteValidation)
);
assert!(backfill.engine.read_projections().unwrap().is_empty());
let searchable_backfill_path = db_path(&dir, "searchable_backfill_composite");
let searchable_backfill = Engine::open(searchable_backfill_path).unwrap();
searchable_backfill
.engine
.write(&[node(
"object",
"slice45:searchable-backfill",
r#"{"attributes":{"core:deadline":{"value":{"not":"scalar"}}}}"#,
)])
.unwrap();
let searchable_only = nested_spec(
"value",
&["attributes", "core:deadline", "value"],
&[ProjectionRole::Searchable],
true,
);
assert_eq!(
searchable_backfill
.engine
.configure_projections(&[searchable_only], &[]),
Err(EngineError::WriteValidation),
"a nested composite terminal is invalid regardless of whether the projection also wants EAV"
);
assert!(searchable_backfill.engine.read_projections().unwrap().is_empty());
}
#[test]
fn source_change_requires_drop_before_nested_backfill_validation() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "source_change_precedence")).unwrap();
let engine = &opened.engine;
let original = nested_spec(
"value",
&["attributes", "core:deadline", "value"],
&[ProjectionRole::Filterable],
false,
);
engine.configure_projections(std::slice::from_ref(&original), &[]).unwrap();
engine
.write(&[node(
"node",
"slice45:source-change",
r#"{"attributes":{"core:deadline":{"value":"scalar","replacement":{"nested":"object"}}}}"#,
)])
.unwrap();
let changed = nested_spec(
"value",
&["attributes", "core:deadline", "replacement"],
&[ProjectionRole::Filterable],
false,
);
assert!(matches!(
engine.configure_projections(&[changed], &[]),
Err(EngineError::ProjectionDestructive { .. })
));
}
#[test]
fn nested_source_rewrite_and_explicit_source_change_clear_old_derived_rows() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "nested_cleanup_rewrite_source_change");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
let old = nested_spec(
"field",
&["attributes", "old", "value"],
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
);
engine.configure_projections(std::slice::from_ref(&old), &[]).unwrap();
engine
.write(&[node(
"rewrite",
"slice45:rewrite",
r#"{"attributes":{"old":{"value":"stale-rewrite"},"new":{"value":"new-source"}}}"#,
)])
.unwrap();
engine
.write(&[node(
"rewrite",
"slice45:rewrite",
r#"{"attributes":{"old":{"value":"fresh-rewrite"},"new":{"value":"new-source"}}}"#,
)])
.unwrap();
assert_eq!(eav_values(&path, "field"), vec!["fresh-rewrite"]);
assert!(property_fts_match(&path, "field", "stale").is_empty());
assert_eq!(property_fts_match(&path, "field", "fresh").len(), 1);
let changed = nested_spec(
"field",
&["attributes", "new", "value"],
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
);
engine.configure_projections(&[changed], &["field".to_string()]).unwrap();
assert_eq!(eav_values(&path, "field"), vec!["new-source"]);
assert!(property_fts_match(&path, "field", "fresh").is_empty());
assert_eq!(property_fts_match(&path, "field", "new").len(), 1);
}
#[test]
fn nested_source_delete_purge_and_erasure_remove_derived_rows() {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "nested_cleanup_lifecycle_erasure");
let opened = Engine::open(path.clone()).unwrap();
let engine = &opened.engine;
let spec = nested_spec(
"field",
&["attributes", "core:field", "value"],
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
);
engine.configure_projections(&[spec], &[]).unwrap();
engine
.write(&[
node(
"delete",
"slice45:delete",
r#"{"attributes":{"core:field":{"value":"delete-me"}}}"#,
),
node("erase", "slice45:erase", r#"{"attributes":{"core:field":{"value":"erase-me"}}}"#),
])
.unwrap();
engine.transition("delete", LifecycleState::Deleted, Some("test delete".to_string())).unwrap();
assert_eq!(eav_values(&path, "field"), vec!["erase-me"]);
assert!(property_fts_match(&path, "field", "delete").is_empty());
engine.purge("delete").unwrap();
assert_eq!(eav_values(&path, "field"), vec!["erase-me"]);
engine.erase_source("slice45:erase").unwrap();
assert!(eav_values(&path, "field").is_empty());
assert!(property_fts_match(&path, "field", "erase").is_empty());
}
#[test]
fn projected_text_search_is_field_scoped_filtered_and_text_only() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "search")).unwrap();
let engine = &opened.engine;
engine
.configure_projections(
&[
nested_spec(
"title",
&["attributes", "core:title", "value"],
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
),
nested_spec(
"status",
&["attributes", "core:status", "value"],
&[ProjectionRole::Filterable],
false,
),
],
&[],
)
.unwrap();
engine
.write(&[
node(
"A",
"slice45:a",
r#"{"body_only":"needle","attributes":{"core:title":{"value":"needle alpha"},"core:status":{"value":"open"}}}"#,
),
node(
"B",
"slice45:b",
r#"{"attributes":{"core:title":{"value":"needle beta"},"core:status":{"value":"closed"}}}"#,
),
node(
"C",
"slice45:c",
r#"{"body_only":"needle","attributes":{"core:title":{"value":"not a match"},"core:status":{"value":"open"}}}"#,
),
])
.unwrap();
let mut filter = SearchFilter::default();
filter.attributes = vec![("status".to_string(), "open".to_string())];
let result = engine
.search_projected_text("needle", "title", Some(filter), &ReadView::default())
.unwrap();
assert_eq!(result.soft_fallback, None);
assert_eq!(result.results.len(), 1);
assert_eq!(
result.results[0].body,
r#"{"body_only":"needle","attributes":{"core:title":{"value":"needle alpha"},"core:status":{"value":"open"}}}"#
);
assert_eq!(result.results[0].branch, SoftFallbackBranch::Text);
}
#[test]
fn projected_text_search_filters_before_applying_its_result_limit() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "filtered_limit")).unwrap();
let engine = &opened.engine;
engine
.configure_projections(
&[
nested_spec(
"title",
&["title"],
&[ProjectionRole::Filterable, ProjectionRole::Searchable],
true,
),
nested_spec("state", &["state"], &[ProjectionRole::Filterable], false),
],
&[],
)
.unwrap();
let mut writes = Vec::new();
for i in 0..11 {
writes.push(node(
&format!("N{i}"),
&format!("slice45:limit:{i}"),
&format!(r#"{{"title":"needle","state":"{}"}}"#, if i == 10 { "keep" } else { "drop" }),
));
}
engine.write(&writes).unwrap();
let mut filter = SearchFilter::default();
filter.attributes = vec![("state".to_string(), "keep".to_string())];
let hits = engine
.search_projected_text("needle", "title", Some(filter), &ReadView::default())
.unwrap();
assert_eq!(hits.results.len(), 1);
assert_eq!(hits.results[0].id.value, "N10");
}
#[test]
fn promoting_a_preexisting_composite_nested_source_is_rejected() {
let dir = TempDir::new().unwrap();
let opened = Engine::open(db_path(&dir, "pending_composite")).unwrap();
let engine = &opened.engine;
engine
.write(&[pending_node(
"pending",
"slice45:pending-composite",
r#"{"attributes":{"core:value":{"value":{"not":"scalar"}}}}"#,
)])
.unwrap();
engine
.configure_projections(
&[nested_spec(
"value",
&["attributes", "core:value", "value"],
&[ProjectionRole::Filterable],
false,
)],
&[],
)
.unwrap();
assert_eq!(
engine.transition("pending", LifecycleState::Active, None),
Err(EngineError::WriteValidation)
);
}
proptest! {
#[test]
fn literal_path_segments_round_trip_through_the_registry(
first in "[a-z0-9:.\\[\\]]{1,12}",
second in "[a-z0-9:.\\[\\]]{1,12}",
value in "[a-zA-Z0-9]{1,12}",
) {
let dir = TempDir::new().unwrap();
let path = db_path(&dir, "property");
let opened = Engine::open(path.clone()).unwrap();
let spec = nested_spec("field", &[&first, &second], &[ProjectionRole::Filterable], false);
opened.engine.configure_projections(std::slice::from_ref(&spec), &[]).unwrap();
let body = format!(r#"{{"{first}":{{"{second}":"{value}"}}}}"#);
opened.engine.write(&[node("property", "slice45:property", &body)]).unwrap();
prop_assert_eq!(opened.engine.read_projections().unwrap(), vec![spec]);
opened.engine.close().unwrap();
prop_assert_eq!(eav_values(&path, "field"), vec![value]);
}
}