use crate::{
atoms::IndexAtom, errors::AtomicResult, storelike::Query, utils::truncate_string, Atom, Db,
Resource, Storelike, Subject, Value,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use super::trees::{self, Operation, Transaction, Tree};
pub type IndexIterator = Box<dyn Iterator<Item = AtomicResult<IndexAtom>> + Send>;
pub use crate::storelike::{FilterOperator, PropVal};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryFilter {
pub filters: Vec<PropVal>,
pub sort_by: Option<String>,
pub drive: Subject,
}
impl QueryFilter {
#[tracing::instrument(skip_all)]
pub fn watch(&self, store: &Db) -> AtomicResult<()> {
let has_constraint = self
.filters
.iter()
.any(|c| c.property.is_some() || c.value.is_some());
if !has_constraint {
return Err("Cannot watch a query without a property or value. These types of queries are not implemented. See https://github.com/atomicdata-dev/atomic-server/issues/548 ".into());
};
store.register_watched_query(self.clone())
}
pub fn is_watched(&self, store: &Db) -> bool {
let query_filter_bin = self.encode().expect("Failed to encode QueryFilter");
store
.kv
.contains_key(Tree::WatchedQueries, &query_filter_bin)
.unwrap_or(false)
}
}
impl QueryFilter {
pub fn single(
property: Option<String>,
value: Option<Value>,
sort_by: Option<String>,
drive: Subject,
) -> Self {
QueryFilter {
filters: vec![PropVal {
property,
value,
..Default::default()
}],
sort_by,
drive,
}
}
pub fn try_from_query(q: &Query) -> AtomicResult<Self> {
let drive = q.drive.clone().ok_or(
"Indexed queries require a drive scope. Set Query::drive to the drive Subject.",
)?;
let mut filters: Vec<PropVal> = Vec::new();
if q.property.is_some() || q.value.is_some() {
filters.push(PropVal {
property: q.property.clone(),
value: q.value.clone(),
operator: crate::storelike::FilterOperator::Equal,
});
}
filters.extend(q.filters.iter().cloned());
Ok(QueryFilter {
filters,
sort_by: q.sort_by.clone(),
drive,
})
}
}
pub const SEPARATION_BIT: u8 = 0xff;
pub const QUERY_ID_LEN: usize = 16;
pub fn query_id(query_filter: &QueryFilter) -> AtomicResult<[u8; QUERY_ID_LEN]> {
let encoded = query_filter.encode()?;
Ok(query_id_from_filter_bytes(&encoded))
}
pub fn query_id_from_filter_bytes(encoded_filter: &[u8]) -> [u8; QUERY_ID_LEN] {
let hash = blake3::hash(encoded_filter);
let mut id = [0u8; QUERY_ID_LEN];
id.copy_from_slice(&hash.as_bytes()[..QUERY_ID_LEN]);
id
}
const TAG_NONE: u8 = 0x05;
const TAG_BOOL: u8 = 0x10;
const TAG_NUMBER: u8 = 0x20;
const TAG_STRING: u8 = 0x30;
const SORT_TERMINATOR: [u8; 2] = [0x00, 0x00];
pub const MAX_LEN: usize = 120;
pub fn encode_sort_value(value: Option<&Value>) -> Vec<u8> {
match value {
None => vec![TAG_NONE],
Some(Value::Boolean(b)) => vec![TAG_BOOL, u8::from(*b)],
Some(Value::Integer(i)) | Some(Value::Timestamp(i)) => encode_number(*i as f64),
Some(Value::Float(f)) => encode_number(*f),
Some(other) => encode_sort_string(&other.to_sortable_string()),
}
}
fn encode_number(f: f64) -> Vec<u8> {
let bits = f.to_bits();
let ordered = if bits >> 63 == 1 {
!bits
} else {
bits ^ (1 << 63)
};
let mut out = Vec::with_capacity(9);
out.push(TAG_NUMBER);
out.extend_from_slice(&ordered.to_be_bytes());
out
}
fn encode_sort_string(s: &str) -> Vec<u8> {
let shorter = truncate_string(s, MAX_LEN);
let lowercase = shorter.to_lowercase();
let bytes = lowercase.as_bytes();
let mut out = Vec::with_capacity(1 + bytes.len());
out.push(TAG_STRING);
for b in bytes {
if *b == 0x00 {
out.push(0x00);
out.push(0xFF);
} else {
out.push(*b);
}
}
out
}
pub fn sort_key_for(resource: &Resource, prop: &str) -> Vec<u8> {
let value = resource.get(prop).ok().cloned().or_else(|| {
if prop == crate::urls::SORT_ORDER {
resource.get(crate::urls::CREATED_AT).ok().cloned()
} else {
None
}
});
encode_sort_value(value.as_ref())
}
#[tracing::instrument(skip_all)]
pub async fn query_sorted_indexed(
store: &Db,
q: &Query,
q_filter: &QueryFilter,
) -> AtomicResult<(Vec<Subject>, Vec<Resource>, usize)> {
let id = query_id(q_filter)?;
let start_key: Vec<u8> = match &q.start_val {
Some(val) => [id.as_slice(), &encode_sort_value(Some(val))].concat(),
None => id.to_vec(),
};
let end_key: Vec<u8> = match &q.end_val {
Some(val) => [id.as_slice(), &encode_sort_value(Some(val)), &[0xFF]].concat(),
None => [id.as_slice(), &[0xFF]].concat(),
};
let iter = store
.kv
.range(Tree::QueryMembers, start_key, end_key, q.sort_desc);
let mut subjects: Vec<Subject> = vec![];
let mut resources: Vec<Resource> = vec![];
let mut count = 0;
let base_domain = store.get_base_domain();
let rights_cache = std::sync::Mutex::new(crate::hierarchy::RightsCache::default());
let limit = q.limit.unwrap_or(usize::MAX);
for (i, kv) in iter.enumerate() {
let kv = kv?;
let in_selection = subjects.len() < limit && i >= q.offset;
let mut should_count = true;
if in_selection {
let (k, _v) = &kv;
let (_id, _sort, subject_str) = parse_members_key(k)?;
let subject = Subject::from_raw(subject_str, base_domain.as_deref());
if !q.include_external && !subject.is_local() {
should_count = false;
} else if q.for_agent != crate::agents::ForAgent::Sudo || q.include_nested {
match store.resolve_query_member(&subject, q, &rights_cache).await {
Some(body) => {
subjects.push(subject);
if let Some(resource) = body {
resources.push(resource);
}
}
None => {
should_count = false;
}
}
} else {
subjects.push(subject);
}
}
if should_count {
count += 1;
}
}
Ok((subjects, resources, count))
}
fn compare_values(actual: &Value, query: &Value) -> std::cmp::Ordering {
let a = actual.to_string();
let b = query.to_string();
match (a.parse::<f64>(), b.parse::<f64>()) {
(Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
_ => a.cmp(&b),
}
}
fn value_matches(actual: &Value, query: &Value, operator: FilterOperator) -> bool {
use std::cmp::Ordering;
use FilterOperator::*;
match operator {
Equal => actual.contains_value(query),
StartsWith => actual.to_string().starts_with(&query.to_string()),
Contains => actual.to_string().contains(&query.to_string()),
GreaterThan => compare_values(actual, query) == Ordering::Greater,
GreaterThanOrEqual => {
matches!(
compare_values(actual, query),
Ordering::Greater | Ordering::Equal
)
}
LessThan => compare_values(actual, query) == Ordering::Less,
LessThanOrEqual => {
matches!(
compare_values(actual, query),
Ordering::Less | Ordering::Equal
)
}
}
}
fn constraint_matches(resource: &Resource, c: &PropVal) -> bool {
match (&c.property, &c.value) {
(Some(property), Some(value)) => {
matches!(resource.get(property), Ok(v) if value_matches(v, value, c.operator))
}
(Some(property), None) => resource.get(property).is_ok(),
(None, Some(value)) => resource
.get_propvals()
.iter()
.any(|(_p, v)| value_matches(v, value, c.operator)),
(None, None) => true,
}
}
pub fn resource_matches_filter(resource: &Resource, q_filter: &QueryFilter) -> bool {
q_filter
.filters
.iter()
.all(|c| constraint_matches(resource, c))
}
fn atom_touches_constraint(q_filter: &QueryFilter, index_atom: &IndexAtom) -> bool {
q_filter
.filters
.iter()
.any(|c| match (&c.property, &c.value) {
(Some(property), _) => property == &index_atom.property,
(None, Some(value)) => value.to_string() == index_atom.ref_value,
(None, None) => false,
})
}
pub(crate) fn index_key_property<'a>(
q_filter: &'a QueryFilter,
index_atom: &'a IndexAtom,
) -> &'a String {
if let Some(sort_by) = &q_filter.sort_by {
return sort_by;
}
for c in &q_filter.filters {
if let Some(property) = &c.property {
return property;
}
}
&index_atom.property
}
pub fn should_update_property<'a>(
q_filter: &'a QueryFilter,
index_atom: &'a IndexAtom,
resource: &Resource,
) -> Option<&'a String> {
if !resource_matches_filter(resource, q_filter) {
return None;
}
let touches_sort = q_filter
.sort_by
.as_ref()
.is_some_and(|s| s == &index_atom.property);
if !touches_sort && !atom_touches_constraint(q_filter, index_atom) {
return None;
}
Some(index_key_property(q_filter, index_atom))
}
#[tracing::instrument(level = "info", skip_all)]
pub fn check_if_atom_matches_watched_query_filters(
store: &Db,
index_atom: &IndexAtom,
_atom: &Atom,
delete: bool,
resource: &Resource,
transaction: &mut Transaction,
) -> AtomicResult<()> {
let subject_str = index_atom.subject.as_str();
let filters: Vec<Arc<QueryFilter>> = if subject_str.starts_with("did:") {
store.all_watched_queries_for_property(&index_atom.property)
} else {
let drive_prefix = drive_prefix_from_subject(&index_atom.subject);
store.watched_queries_for_atom(drive_prefix.as_str(), &index_atom.property)
};
tracing::trace!(
"check_if_atom_matches_watched_query_filters: subject={}, atom_prop={}, filters_count={}",
subject_str,
index_atom.property,
filters.len()
);
for q_filter in &filters {
if let Some(prop) = should_update_property(q_filter, index_atom, resource) {
let sort_key = sort_key_for(resource, prop);
update_indexed_member(
q_filter,
index_atom.subject.as_str(),
&sort_key,
delete,
transaction,
)?;
}
}
Ok(())
}
#[tracing::instrument(skip_all)]
pub fn update_indexed_member(
collection: &QueryFilter,
subject: &str,
sort_key: &[u8],
delete: bool,
transaction: &mut Transaction,
) -> AtomicResult<()> {
tracing::debug!(
"update_indexed_member: subject={}, delete={}, filter={:?}",
subject,
delete,
collection
);
let key = create_query_index_key(collection, Some(sort_key), Some(subject))?;
if delete {
transaction.push(Operation {
tree: Tree::QueryMembers,
method: trees::Method::Delete,
key,
val: None,
})
} else {
transaction.push(Operation {
tree: Tree::QueryMembers,
method: trees::Method::Insert,
key,
val: Some(b"".into()),
});
}
Ok(())
}
#[tracing::instrument(skip_all)]
pub fn create_query_index_key(
query_filter: &QueryFilter,
sort_key: Option<&[u8]>,
subject: Option<&str>,
) -> AtomicResult<Vec<u8>> {
let id = query_id(query_filter)?;
let mut key: Vec<u8> = Vec::with_capacity(
QUERY_ID_LEN + sort_key.map_or(1, <[u8]>::len) + 2 + subject.map_or(0, str::len),
);
key.extend_from_slice(&id);
key.extend_from_slice(sort_key.unwrap_or(&[TAG_NONE]));
if let Some(sub) = subject {
key.extend_from_slice(&SORT_TERMINATOR);
key.extend_from_slice(sub.as_bytes());
}
Ok(key)
}
#[tracing::instrument(skip_all)]
pub fn parse_members_key(bytes: &[u8]) -> AtomicResult<(&[u8], &[u8], &str)> {
if bytes.len() < QUERY_ID_LEN + 1 {
return Err("QueryMembers key too short".into());
}
let (id, rest) = bytes.split_at(QUERY_ID_LEN);
let sort_len = match rest[0] {
TAG_NONE => 1,
TAG_BOOL => 2,
TAG_NUMBER => 9,
TAG_STRING => {
let mut i = 1;
loop {
if i + 1 >= rest.len() {
return Err("QueryMembers key: unterminated string sort segment".into());
}
if rest[i] == 0x00 {
if rest[i + 1] == 0x00 {
break;
}
i += 2;
} else {
i += 1;
}
}
i
}
other => return Err(format!("QueryMembers key: unknown sort tag {other:#x}").into()),
};
if rest.len() < sort_len + 2 || rest[sort_len] != 0x00 || rest[sort_len + 1] != 0x00 {
return Err("QueryMembers key: missing sort terminator".into());
}
let sort = &rest[..sort_len];
let subject = std::str::from_utf8(&rest[sort_len + 2..])
.map_err(|e| format!("Can't parse subject in members_key: {}", e))?;
Ok((id, sort, subject))
}
pub(crate) fn parse_members_key_id_subject(bytes: &[u8]) -> Option<(Vec<u8>, String)> {
let (id, _sort, subject) = parse_members_key(bytes).ok()?;
Some((id.to_vec(), subject.to_string()))
}
pub fn requires_query_index(query: &Query) -> bool {
query.sort_by.is_some()
|| query.start_val.is_some()
|| query.end_val.is_some()
|| !query.filters.is_empty()
}
pub fn drive_prefix_from_subject(subject: &Subject) -> Subject {
let s = subject.as_str();
let prefix = if s.starts_with("internal:") {
s.find(":/").map(|pos| s[..pos + 2].to_string())
} else if s.starts_with("http://") || s.starts_with("https://") {
url::Url::parse(s).ok().map(|url| {
let host = url.host_str().unwrap_or("");
match url.port() {
Some(port) => format!("{}://{}:{}", url.scheme(), host, port),
None => format!("{}://{}", url.scheme(), host),
}
})
} else {
None
};
Subject::from(prefix.unwrap_or_else(|| s.to_string()))
}
#[cfg(test)]
pub mod test {
use super::*;
use crate::{urls, values::SubResource};
#[test]
fn encode_decode_folder_table_filter() {
use crate::Value;
let filter = QueryFilter::single(
Some("https://atomicdata.dev/properties/parent".to_string()),
Some(Value::AtomicUrl(Subject::from(
"did:ad:C1PsEdNI7K1D4N2dMVaaHwxwevsl/6pL8rSdejvD+ori3rZb6eafyTgeEVKCHPG0Po3SBQyT7Ea/7pB/Fl8PCg==",
))),
Some("https://atomicdata.dev/properties/createdAt".to_string()),
Subject::from("http://localhost:9883"),
);
let bytes = filter.encode().expect("encode");
let decoded = QueryFilter::from_bytes(&bytes).expect("decode");
assert_eq!(decoded.filters[0].property, filter.filters[0].property);
assert_eq!(decoded.sort_by, filter.sort_by);
assert_eq!(decoded.drive.as_str(), filter.drive.as_str());
let sort_key = encode_sort_value(Some(&Value::String("2026-04-21".into())));
let key = create_query_index_key(
&filter,
Some(&sort_key),
Some("https://localhost/members/foo"),
)
.expect("create_query_index_key");
let (id, sort, sub) = parse_members_key(&key).expect("parse_members_key");
assert_eq!(id, query_id(&filter).unwrap());
assert_eq!(sort, sort_key.as_slice());
assert_eq!(sub, "https://localhost/members/foo");
}
#[tokio::test]
async fn create_and_parse_key() {
round_trip(Value::String("\n".into()));
round_trip(Value::String("short".into()));
round_trip(Value::String("with \0 embedded NUL".into()));
round_trip(Value::Float(1.142));
round_trip(Value::Float(-1.142));
round_trip(Value::Integer(-42));
round_trip(Value::Boolean(true));
round_trip(Value::String("UPPERCASE".into()));
round_trip(Value::String("29NA(E*Tn3028nt87n_#T&*NF_AE*&#N@_T*&!#B_&*TN&*AEBT&*#B&TB@#!#@BB29NA(E*Tn3028nt87n_#T&*NF_AE*&#N@_T*&!#B_&*TN&*AEBT&*#B&TB@#!#@BB29NA(E*Tn3028nt87n_#T&*NF_AE*&#N@_T*&!#B_&*TN&*AEBT&*#B&TB@#!#@BB29NA(E*Tn3028nt87n_#T&*NF_AE*&#N@_T*&!#B_&*TN&*AEBT&*#B&TB@#!#@BB".into()));
fn round_trip(val: Value) {
let collection = QueryFilter::single(
Some("http://example.org/prop".to_string()),
Some(Value::AtomicUrl("http://example.org/value".into())),
None,
Subject::from("https://example.com"),
);
let subject = "https://example.com/subject";
let sort_key = encode_sort_value(Some(&val));
let key = create_query_index_key(&collection, Some(&sort_key), Some(subject)).unwrap();
let (id, sort, sub_out) = parse_members_key(&key).unwrap();
assert_eq!(id, query_id(&collection).unwrap());
assert_eq!(sort, sort_key.as_slice());
assert_eq!(sub_out, subject);
}
}
#[test]
fn typed_sort_key_ordering() {
let q = QueryFilter::single(
Some("http://example.org/prop".to_string()),
Some(Value::AtomicUrl("http://example.org/value".into())),
None,
Subject::from("https://example.com"),
);
let key = |val: Option<&Value>| {
create_query_index_key(
&q,
Some(&encode_sort_value(val)),
Some("https://example.com/subject"),
)
.unwrap()
};
let no_value = key(None);
let bool_false = key(Some(&Value::Boolean(false)));
let bool_true = key(Some(&Value::Boolean(true)));
let num_neg = key(Some(&Value::Float(-1.5)));
let num_1 = key(Some(&Value::Integer(1)));
let num_2 = key(Some(&Value::Float(2.0)));
let num_10 = key(Some(&Value::Integer(10)));
let num_1000 = key(Some(&Value::Float(1000.0)));
let str_1 = key(Some(&Value::String("1".into())));
let a_downcase = key(Some(&Value::String("a".into())));
let ab = key(Some(&Value::String("ab".into())));
let b_upcase = key(Some(&Value::String("B".into())));
let mid3 = key(Some(&Value::String("hi there".into())));
let expected = vec![
&no_value,
&bool_false,
&bool_true,
&num_neg,
&num_1,
&num_2,
&num_10,
&num_1000,
&str_1,
&a_downcase,
&ab,
&b_upcase,
&mid3,
];
let mut sorted = expected.clone();
sorted.reverse();
sorted.sort();
assert_eq!(sorted, expected);
assert_eq!(
encode_sort_value(Some(&Value::Integer(2))),
encode_sort_value(Some(&Value::Float(2.0)))
);
}
#[test]
fn range_bounds_bracket_member_keys() {
let q = QueryFilter::single(
Some("http://example.org/prop".to_string()),
None,
None,
Subject::from("https://example.com"),
);
let id = query_id(&q).unwrap();
let entry = |val: &Value, subject: &str| {
create_query_index_key(&q, Some(&encode_sort_value(Some(val))), Some(subject)).unwrap()
};
let low = entry(&Value::Integer(1), "https://example.com/a");
let high = entry(&Value::String("zzz".into()), "https://example.com/z");
let start: Vec<u8> = id.to_vec();
let end: Vec<u8> = [id.as_slice(), &[0xFF]].concat();
assert!(start < low && low < high && high < end);
let val = Value::String("middle".into());
let bound: Vec<u8> = [id.as_slice(), &encode_sort_value(Some(&val)), &[0xFF]].concat();
let member_a = entry(&val, "https://example.com/a");
let member_z = entry(&val, "https://example.com/zzzzzz");
assert!(member_a < bound && member_z < bound);
let extension = entry(&Value::String("middle0".into()), "https://example.com/a");
assert!(extension < bound);
let next = entry(&Value::String("middlf".into()), "https://example.com/a");
assert!(bound < next);
}
#[tokio::test]
async fn should_update_or_not() {
let store = &Db::init_temp("should_update_or_not").await.unwrap();
let prop = urls::IS_A.to_string();
let class = urls::AGENT;
let qf_prop_val = QueryFilter::single(
Some(prop.clone()),
Some(Value::AtomicUrl(class.to_string().into())),
None,
Subject::from("https://example.com"),
);
let qf_prop = QueryFilter::single(
Some(prop.clone()),
None,
None,
Subject::from("https://example.com"),
);
let qf_val = QueryFilter::single(
None,
Some(Value::AtomicUrl(class.to_string().into())),
None,
Subject::from("https://example.com"),
);
let mut resource_correct_class = Resource::new_instance(class, store).await.unwrap();
resource_correct_class
.set(
urls::IS_A.into(),
Value::ResourceArray(vec![
SubResource::Subject(class.to_string().into()),
SubResource::Subject(urls::PARAGRAPH.to_string().into()),
]),
store,
)
.await
.unwrap();
resource_correct_class
.set(
urls::PUBLIC_KEY.into(),
Value::String("This is not a public key but it should be fine".into()),
store,
)
.await
.unwrap();
resource_correct_class
.set(
urls::DESCRIPTION.into(),
Value::Markdown("random description".into()),
store,
)
.await
.unwrap();
let subject = Subject::from("https://example.com/someAgent");
let index_atom = IndexAtom {
subject,
property: prop.clone(),
ref_value: class.to_string(),
sort_value: class.to_string(),
};
assert!(should_update_property(&qf_val, &index_atom, &resource_correct_class).is_some());
assert!(
should_update_property(&qf_prop_val, &index_atom, &resource_correct_class,).is_some()
);
assert!(should_update_property(&qf_prop, &index_atom, &resource_correct_class).is_some());
let resource_wrong_class = Resource::new_instance(urls::PARAGRAPH, store)
.await
.unwrap();
assert!(should_update_property(&qf_prop, &index_atom, &resource_wrong_class).is_some());
assert!(should_update_property(&qf_val, &index_atom, &resource_wrong_class).is_none());
assert!(should_update_property(&qf_prop_val, &index_atom, &resource_wrong_class).is_none());
let qf_prop_val_sort = QueryFilter::single(
Some(prop.clone()),
Some(Value::AtomicUrl(class.to_string().into())),
Some(urls::DESCRIPTION.to_string()),
Subject::from("https://example.com"),
);
let qf_prop_sort = QueryFilter::single(
Some(prop.clone()),
None,
Some(urls::DESCRIPTION.to_string()),
Subject::from("https://example.com"),
);
let qf_val_sort = QueryFilter::single(
Some(prop),
Some(Value::AtomicUrl(class.to_string().into())),
Some(urls::DESCRIPTION.to_string()),
Subject::from("https://example.com"),
);
assert!(
should_update_property(&qf_prop_val_sort, &index_atom, &resource_correct_class,)
.is_some()
);
assert!(
should_update_property(&qf_prop_sort, &index_atom, &resource_correct_class,).is_some()
);
assert!(
should_update_property(&qf_val_sort, &index_atom, &resource_correct_class,).is_some()
);
}
#[tokio::test]
async fn multi_property_and_filter() {
let store = &Db::init_temp("multi_property_and_filter").await.unwrap();
let class = urls::AGENT;
let drive = Subject::from("https://example.com");
let mut resource = Resource::new_instance(class, store).await.unwrap();
resource
.set(
urls::DESCRIPTION.into(),
Value::Markdown("hello".into()),
store,
)
.await
.unwrap();
let matching = QueryFilter {
filters: vec![
PropVal {
property: Some(urls::IS_A.to_string()),
value: Some(Value::AtomicUrl(class.to_string().into())),
..Default::default()
},
PropVal {
property: Some(urls::DESCRIPTION.to_string()),
value: Some(Value::String("hello".into())),
..Default::default()
},
],
sort_by: None,
drive: drive.clone(),
};
let non_matching = QueryFilter {
filters: vec![
PropVal {
property: Some(urls::IS_A.to_string()),
value: Some(Value::AtomicUrl(class.to_string().into())),
..Default::default()
},
PropVal {
property: Some(urls::DESCRIPTION.to_string()),
value: Some(Value::String("different".into())),
..Default::default()
},
],
sort_by: None,
drive,
};
assert!(resource_matches_filter(&resource, &matching));
assert!(!resource_matches_filter(&resource, &non_matching));
let is_a_atom = IndexAtom {
subject: Subject::from("https://example.com/someAgent"),
property: urls::IS_A.to_string(),
ref_value: class.to_string(),
sort_value: class.to_string(),
};
assert!(should_update_property(&matching, &is_a_atom, &resource).is_some());
assert!(should_update_property(&non_matching, &is_a_atom, &resource).is_none());
let desc_atom = IndexAtom {
subject: Subject::from("https://example.com/someAgent"),
property: urls::DESCRIPTION.to_string(),
ref_value: "hello".to_string(),
sort_value: "hello".to_string(),
};
assert!(should_update_property(&matching, &desc_atom, &resource).is_some());
}
#[tokio::test]
async fn operator_filters() {
let store = &Db::init_temp("operator_filters").await.unwrap();
let drive = Subject::from("https://example.com");
let mut resource = Resource::new_instance(urls::AGENT, store).await.unwrap();
resource
.set(
urls::DESCRIPTION.into(),
Value::Markdown("hello world".into()),
store,
)
.await
.unwrap();
resource
.set_unsafe(urls::COLLECTION_PAGE_SIZE.into(), Value::Integer(42))
.unwrap();
let f = |property: &str, value: Value, operator: FilterOperator| QueryFilter {
filters: vec![PropVal {
property: Some(property.to_string()),
value: Some(value),
operator,
}],
sort_by: None,
drive: drive.clone(),
};
use FilterOperator::*;
let sw = |v: &str| f(urls::DESCRIPTION, Value::String(v.into()), StartsWith);
assert!(resource_matches_filter(&resource, &sw("hello")));
assert!(!resource_matches_filter(&resource, &sw("world")));
let ct = |v: &str| f(urls::DESCRIPTION, Value::String(v.into()), Contains);
assert!(resource_matches_filter(&resource, &ct("lo wo")));
assert!(!resource_matches_filter(&resource, &ct("xyz")));
let num = |v: i64, op: FilterOperator| f(urls::COLLECTION_PAGE_SIZE, Value::Integer(v), op);
assert!(resource_matches_filter(&resource, &num(10, GreaterThan)));
assert!(!resource_matches_filter(&resource, &num(50, GreaterThan)));
assert!(resource_matches_filter(
&resource,
&num(42, GreaterThanOrEqual)
));
assert!(resource_matches_filter(&resource, &num(100, LessThan)));
assert!(!resource_matches_filter(&resource, &num(42, LessThan)));
assert!(resource_matches_filter(
&resource,
&num(42, LessThanOrEqual)
));
}
}