use crate::content::node::{NodeState, PropertyValues};
use crate::content::property::PropertyValue;
use crate::content::provider::SegmentProvider;
use crate::error::Result;
use crate::segment::record::{RecordIdentifier, RecordType};
use crate::store::{ArchiveSet, open_all_archives};
#[derive(Debug, Clone, Default)]
pub struct SearchQuery {
pub has_properties: Vec<String>,
pub has_children: Vec<String>,
pub property_values: Vec<(String, String)>,
}
impl SearchQuery {
#[must_use]
pub fn is_empty(&self) -> bool {
self.has_properties.is_empty()
&& self.has_children.is_empty()
&& self.property_values.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeMatch {
pub record: RecordIdentifier,
pub stable_identifier: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchOutcome {
pub matches: Vec<NodeMatch>,
pub unreadable_nodes: u64,
}
pub fn search_nodes(
directory: &std::path::Path,
query: &SearchQuery,
limit: usize,
) -> Result<SearchOutcome> {
let archives = open_all_archives(directory)?;
let provider = ArchiveSet::new(archives);
let mut matches = Vec::new();
let mut unreadable_nodes = 0u64;
for segment_identifier in provider.segment_identifiers() {
if segment_identifier.is_bulk_segment() {
continue;
}
let Ok(view) = provider.segment(segment_identifier) else {
continue;
};
let mut node_records: Vec<u32> = Vec::new();
for entry in view.structure.record_table() {
match entry.record_type() {
Some(RecordType::Node) => node_records.push(entry.record_number),
Some(_) => {}
None => unreadable_nodes += 1,
}
}
for record_number in node_records {
let record = RecordIdentifier::new(segment_identifier, record_number);
match node_matches(&provider, record, query) {
Ok(false) => {}
Ok(true) => {
let stable_identifier = NodeState::new(&provider, record)
.stable_identifier()
.unwrap_or_else(|_| record.to_string());
matches.push(NodeMatch {
record,
stable_identifier,
});
if limit != 0 && matches.len() >= limit {
return Ok(SearchOutcome {
matches,
unreadable_nodes,
});
}
}
Err(_) => unreadable_nodes += 1,
}
}
}
Ok(SearchOutcome {
matches,
unreadable_nodes,
})
}
fn node_matches(
provider: &dyn SegmentProvider,
record: RecordIdentifier,
query: &SearchQuery,
) -> Result<bool> {
let node = NodeState::new(provider, record);
for child_name in &query.has_children {
if node.child_node(child_name)?.is_none() {
return Ok(false);
}
}
if query.has_properties.is_empty() && query.property_values.is_empty() {
return Ok(true);
}
let properties = node.properties()?;
for property_name in &query.has_properties {
if !properties
.iter()
.any(|property| property.name == *property_name)
{
return Ok(false);
}
}
for (property_name, expected_value) in &query.property_values {
let Some(property) = properties
.iter()
.find(|property| property.name == *property_name)
else {
return Ok(false);
};
if !property_has_string_value(&property.values, expected_value) {
return Ok(false);
}
}
Ok(true)
}
fn property_has_string_value(values: &PropertyValues, expected: &str) -> bool {
let matches_value =
|value: &PropertyValue| value.as_text().is_some_and(|text| text == expected);
match values {
PropertyValues::Single(value) => matches_value(value),
PropertyValues::Multiple(values) => values.iter().any(matches_value),
}
}
#[cfg(test)]
mod tests {
use super::{SearchQuery, search_nodes};
use crate::writer::record_writer::{ChildNodesToWrite, PropertyToWrite, PropertyValuesToWrite};
use crate::writer::store_writer::WritableRepository;
struct TestDirectory {
path: std::path::PathBuf,
}
impl TestDirectory {
fn new(name: &str) -> Self {
let path =
std::env::temp_dir().join(format!("froe-search-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
Self { path }
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn populate(directory: &std::path::Path) {
let store = WritableRepository::open(directory).expect("open");
let generation = store.writing_generation().expect("generation");
let mut writer = store.record_writer(generation);
let marked_value = writer.write_string("target").expect("value");
let marked = writer
.write_node(
Some("nt:unstructured"),
&[],
&ChildNodesToWrite::Zero,
&[PropertyToWrite {
name: "marker".to_owned(),
property_type: crate::content::property::PropertyType::String,
values: PropertyValuesToWrite::Single(marked_value),
}],
)
.expect("marked");
let plain = writer
.write_node(Some("nt:unstructured"), &[], &ChildNodesToWrite::Zero, &[])
.expect("plain");
let content = writer
.write_node(
Some("nt:unstructured"),
&[],
&ChildNodesToWrite::Many(vec![
("marked".to_owned(), marked),
("plain".to_owned(), plain),
]),
&[],
)
.expect("content");
let root = writer
.write_node(
None,
&[],
&ChildNodesToWrite::One {
name: "content".to_owned(),
node: content,
},
&[],
)
.expect("root");
let head = writer
.write_node(
None,
&[],
&ChildNodesToWrite::One {
name: "root".to_owned(),
node: root,
},
&[],
)
.expect("super root");
writer.finish().expect("finish");
let previous = store.head();
assert!(store.set_head(previous, head));
store.close().expect("close");
}
#[test]
fn finds_nodes_by_property_presence() {
let directory = TestDirectory::new("by-property");
populate(&directory.path);
let query = SearchQuery {
has_properties: vec!["marker".to_owned()],
..SearchQuery::default()
};
let outcome = search_nodes(&directory.path, &query, 0).expect("search");
assert_eq!(outcome.matches.len(), 1, "one node has the marker property");
assert_eq!(outcome.unreadable_nodes, 0);
}
#[test]
fn finds_nodes_by_property_value() {
let directory = TestDirectory::new("by-value");
populate(&directory.path);
let query = SearchQuery {
property_values: vec![("marker".to_owned(), "target".to_owned())],
..SearchQuery::default()
};
assert_eq!(
search_nodes(&directory.path, &query, 0)
.expect("search")
.matches
.len(),
1
);
let wrong_value = SearchQuery {
property_values: vec![("marker".to_owned(), "other".to_owned())],
..SearchQuery::default()
};
assert!(
search_nodes(&directory.path, &wrong_value, 0)
.expect("search")
.matches
.is_empty()
);
}
#[test]
fn finds_nodes_by_child_presence() {
let directory = TestDirectory::new("by-child");
populate(&directory.path);
let query = SearchQuery {
has_children: vec!["marked".to_owned()],
..SearchQuery::default()
};
let outcome = search_nodes(&directory.path, &query, 0).expect("search");
assert_eq!(
outcome.matches.len(),
1,
"the content node has the marked child"
);
}
#[test]
fn the_limit_bounds_the_result_count() {
let directory = TestDirectory::new("limit");
populate(&directory.path);
let query = SearchQuery {
has_properties: vec!["jcr:primaryType".to_owned()],
..SearchQuery::default()
};
let limited = search_nodes(&directory.path, &query, 2).expect("search");
assert_eq!(limited.matches.len(), 2, "the limit caps the results");
}
}