use nodedb_query::scan_filter::{FilterOp, ScanFilter};
use nodedb_types::Value;
use crate::util::fnv1a_hash;
pub fn predicate_class(canonical_filter_sql: &str, collection: &str) -> u64 {
let normalized = normalize_predicate_text(canonical_filter_sql);
let mut buf = Vec::with_capacity(collection.len() + normalized.len() + 1);
buf.extend_from_slice(collection.as_bytes());
buf.push(b'\x00');
buf.extend_from_slice(normalized.as_bytes());
fnv1a_hash(&buf)
}
fn normalize_predicate_text(sql: &str) -> String {
let mut out = String::with_capacity(sql.len());
let chars: Vec<char> = sql.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\'' {
out.push_str("str");
i += 1;
while i < chars.len() {
if chars[i] == '\'' {
i += 1;
if i < chars.len() && chars[i] == '\'' {
i += 1;
} else {
break;
}
} else {
i += 1;
}
}
continue;
}
if c.is_ascii_digit() || (c == '-' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit())
{
let mut is_float = false;
i += 1; while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
if chars[i] == '.' {
is_float = true;
}
i += 1;
}
if is_float {
out.push_str("f64");
} else {
out.push_str("i64");
}
continue;
}
out.push(c);
i += 1;
}
out
}
fn value_type_tag(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Integer(_) => "i64",
Value::Float(_) => "f64",
Value::String(_) => "str",
Value::Bytes(_) => "bytes",
Value::Array(_) => "arr",
Value::Set(_) => "arr",
Value::Object(_) => "obj",
Value::Uuid(_) => "uuid",
Value::Ulid(_) => "ulid",
Value::DateTime(_) => "datetime",
Value::NaiveDateTime(_) => "naive_datetime",
Value::Duration(_) => "duration",
Value::Decimal(_) => "decimal",
Value::Geometry(_) => "geometry",
Value::Regex(_) => "regex",
Value::Range { .. } => "range",
Value::Record { .. } => "record",
Value::ArrayCell(_) => "array_cell",
Value::Vector(_) => "vec",
_ => "val",
}
}
fn render_filter(f: &ScanFilter) -> String {
match f.op {
FilterOp::Or => {
let groups: Vec<String> = f
.clauses
.iter()
.map(|group| {
let parts: Vec<String> = group.iter().map(render_filter).collect();
format!("({})", parts.join(" AND "))
})
.collect();
format!("({})", groups.join(" OR "))
}
FilterOp::Expr => {
let bytes = match &f.expr {
Some(expr) => zerompk::to_msgpack_vec(expr).unwrap_or_default(),
None => Vec::new(),
};
format!("expr:{:016x}", fnv1a_hash(&bytes))
}
FilterOp::IsNull
| FilterOp::IsNotNull
| FilterOp::MatchAll
| FilterOp::Exists
| FilterOp::NotExists => {
format!("{} {}", f.field, f.op.as_str())
}
FilterOp::GtColumn
| FilterOp::GteColumn
| FilterOp::LtColumn
| FilterOp::LteColumn
| FilterOp::EqColumn
| FilterOp::NeColumn => {
let rhs_col = f.value.as_str().unwrap_or("");
format!("{} {} col:{}", f.field, f.op.as_str(), rhs_col)
}
_ => {
format!("{} {} {}", f.field, f.op.as_str(), value_type_tag(&f.value))
}
}
}
fn canonical_filter_text(filters: &[ScanFilter]) -> String {
filters
.iter()
.map(render_filter)
.collect::<Vec<_>>()
.join(" AND ")
}
pub fn predicate_class_for_filters(filter_bytes: &[u8], collection: &str) -> u64 {
match zerompk::from_msgpack::<Vec<ScanFilter>>(filter_bytes) {
Ok(filters) => predicate_class(&canonical_filter_text(&filters), collection),
Err(e) => {
tracing::debug!(
collection,
error = %e,
"predicate_class_for_filters: failed to decode filter bytes; \
falling back to collection-only class (degraded granularity)"
);
predicate_class("", collection)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_shape_different_literal_same_hash() {
let h1 = predicate_class("WHERE balance > 1000", "accounts");
let h2 = predicate_class("WHERE balance > 9999", "accounts");
assert_eq!(h1, h2);
}
#[test]
fn different_field_different_hash() {
let h1 = predicate_class("WHERE balance > 1000", "accounts");
let h2 = predicate_class("WHERE age > 1000", "accounts");
assert_ne!(h1, h2);
}
#[test]
fn different_collection_different_hash() {
let h1 = predicate_class("WHERE x > 1", "col_a");
let h2 = predicate_class("WHERE x > 1", "col_b");
assert_ne!(h1, h2);
}
#[test]
fn string_literals_normalized() {
let h1 = predicate_class("WHERE name = 'alice'", "users");
let h2 = predicate_class("WHERE name = 'bob'", "users");
assert_eq!(h1, h2);
}
#[test]
fn float_literals_normalized() {
let h1 = predicate_class("WHERE score > 1.5", "items");
let h2 = predicate_class("WHERE score > 9.9", "items");
assert_eq!(h1, h2);
}
fn encode_filters(filters: &[ScanFilter]) -> Vec<u8> {
zerompk::to_msgpack_vec(&filters.to_vec()).expect("encode filters")
}
#[test]
fn for_filters_same_shape_different_integer_literals_same_class() {
let f1 = vec![ScanFilter {
field: "balance".into(),
op: FilterOp::Gt,
value: Value::Integer(1000),
..Default::default()
}];
let f2 = vec![ScanFilter {
field: "balance".into(),
op: FilterOp::Gt,
value: Value::Integer(9999),
..Default::default()
}];
let h1 = predicate_class_for_filters(&encode_filters(&f1), "accounts");
let h2 = predicate_class_for_filters(&encode_filters(&f2), "accounts");
assert_eq!(
h1, h2,
"same shape, different integer literals must be same class"
);
}
#[test]
fn for_filters_same_shape_different_string_literals_same_class() {
let f1 = vec![ScanFilter {
field: "name".into(),
op: FilterOp::Eq,
value: Value::String("alice".into()),
..Default::default()
}];
let f2 = vec![ScanFilter {
field: "name".into(),
op: FilterOp::Eq,
value: Value::String("bob".into()),
..Default::default()
}];
let h1 = predicate_class_for_filters(&encode_filters(&f1), "users");
let h2 = predicate_class_for_filters(&encode_filters(&f2), "users");
assert_eq!(
h1, h2,
"same shape, different string literals must be same class"
);
}
#[test]
fn for_filters_different_field_different_class() {
let f1 = vec![ScanFilter {
field: "balance".into(),
op: FilterOp::Gt,
value: Value::Integer(100),
..Default::default()
}];
let f2 = vec![ScanFilter {
field: "age".into(),
op: FilterOp::Gt,
value: Value::Integer(100),
..Default::default()
}];
let h1 = predicate_class_for_filters(&encode_filters(&f1), "accounts");
let h2 = predicate_class_for_filters(&encode_filters(&f2), "accounts");
assert_ne!(h1, h2, "different field must produce different class");
}
#[test]
fn for_filters_different_op_different_class() {
let f1 = vec![ScanFilter {
field: "score".into(),
op: FilterOp::Gt,
value: Value::Integer(5),
..Default::default()
}];
let f2 = vec![ScanFilter {
field: "score".into(),
op: FilterOp::Lt,
value: Value::Integer(5),
..Default::default()
}];
let h1 = predicate_class_for_filters(&encode_filters(&f1), "items");
let h2 = predicate_class_for_filters(&encode_filters(&f2), "items");
assert_ne!(h1, h2, "different op must produce different class");
}
#[test]
fn for_filters_different_collection_different_class() {
let filters = vec![ScanFilter {
field: "x".into(),
op: FilterOp::Eq,
value: Value::Integer(1),
..Default::default()
}];
let bytes = encode_filters(&filters);
let h1 = predicate_class_for_filters(&bytes, "col_a");
let h2 = predicate_class_for_filters(&bytes, "col_b");
assert_ne!(h1, h2, "different collection must produce different class");
}
#[test]
fn for_filters_or_filter_stable_hash() {
let or_filter = vec![ScanFilter {
field: String::new(),
op: FilterOp::Or,
value: Value::Null,
clauses: vec![
vec![ScanFilter {
field: "status".into(),
op: FilterOp::Eq,
value: Value::String("active".into()),
..Default::default()
}],
vec![ScanFilter {
field: "status".into(),
op: FilterOp::Eq,
value: Value::String("pending".into()),
..Default::default()
}],
],
expr: None,
}];
let bytes = encode_filters(&or_filter);
let h1 = predicate_class_for_filters(&bytes, "orders");
let h2 = predicate_class_for_filters(&bytes, "orders");
assert_eq!(
h1, h2,
"OR filter must produce stable hash across two calls"
);
}
#[test]
fn for_filters_empty_bytes_degraded_fallback_stable() {
let h1 = predicate_class_for_filters(b"not valid msgpack", "col");
let h2 = predicate_class_for_filters(b"not valid msgpack", "col");
assert_eq!(h1, h2, "degraded fallback must be deterministic");
}
}