From 4fdc8167b46cc7f48caae7c2f2d5cb06b2f47789 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 3 Sep 2025 17:56:15 +0700 Subject: [PATCH 01/30] init --- packages/rs-drive/src/query/filter.rs | 96 +++++++++++++++++++++++++++ packages/rs-drive/src/query/mod.rs | 1 + 2 files changed, 97 insertions(+) create mode 100644 packages/rs-drive/src/query/filter.rs diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs new file mode 100644 index 00000000000..ce2b2bf964c --- /dev/null +++ b/packages/rs-drive/src/query/filter.rs @@ -0,0 +1,96 @@ +use std::collections::BTreeMap; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::DataContract; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::platform_value::Value; +use dpp::prelude::Identifier; +use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; +use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; +use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; +use dpp::state_transition::{StateTransition, StateTransitionLike}; +use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; +use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; +use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; +use crate::query::{DriveDocumentQuery, InternalClauses}; + +#[cfg(any(feature = "server", feature = "verify"))] +/// Drive query struct +#[derive(Debug, PartialEq, Clone)] +pub struct DriveDocumentQueryFilter<'a> { + ///DataContract + pub contract: &'a DataContract, + /// Document type + pub document_type: DocumentTypeRef<'a>, + /// Internal clauses + pub internal_clauses: InternalClauses, +} + +impl From for DriveDocumentQuery { + fn from(value: DriveDocumentQueryFilter) -> Self { + todo!() + } +} + +impl From for DriveDocumentQueryFilter { + fn from(value: DriveDocumentQuery) -> Self { + todo!() + } +} + +impl<'a> DriveDocumentQueryFilter<'a> { + /// Figures out if a document matches the query + #[cfg(any(feature = "server", feature = "verify"))] + pub fn matches_state_transition(&self, state_transition: &StateTransition) -> bool { + match state_transition { + StateTransition::Batch(batch) => { + for transition in batch.transitions_iter() { + if let BatchedTransitionRef::Document(document_transition) = transition { + if self.matches_document_state_transition(batch.owner_id(), document_transition) { + return true; + } + } + } + false + } + _ => false + } + } + + #[cfg(any(feature = "server", feature = "verify"))] + pub fn matches_document_state_transition(&self, owner_id: Identifier, document_transition: &DocumentTransition) -> bool { + match document_transition { + DocumentTransition::Create(create) => { + self.matches_document(owner_id, create.base(), create.data()) + } + DocumentTransition::Replace(replace) => { + self.matches_document(owner_id, replace.base(), replace.data()) + } + DocumentTransition::Delete(_) => {} + DocumentTransition::Transfer(_) => {} + DocumentTransition::UpdatePrice(_) => {} + DocumentTransition::Purchase(_) => {} + } + } + /// Figures out if a document matches the query + #[cfg(any(feature = "server", feature = "verify"))] + pub fn matches_document(&self, owner_id: Identifier, document_base_transition: &DocumentBaseTransition, document_data: &BTreeMap) -> bool { + if document_base_transition.data_contract_id() != self.contract.id() { + return false; + } + if document_base_transition.document_type_name() != self.document_type.name() { + return false; + } + + if let Some(primary_key_in_clause) = &self.internal_clauses.primary_key_in_clause { + if document_base_transition.id() in primary_key_in_clause.value + } + } + + #[cfg(any(feature = "server", feature = "verify"))] + pub fn validate(&self) -> bool { + + } +} \ No newline at end of file diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 1d67a1f0e29..ebd2935a22f 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -151,6 +151,7 @@ pub mod identity_token_info_drive_query; /// A query to get the token's status #[cfg(any(feature = "server", feature = "verify"))] pub mod token_status_drive_query; +mod filter; #[cfg(any(feature = "server", feature = "verify"))] /// Represents a starting point for a query based on a specific document. From cfeb7677e4c5a382b3ceb34890952b9cf9b84d32 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Thu, 4 Sep 2025 17:18:01 +0700 Subject: [PATCH 02/30] first draft --- packages/rs-drive/src/query/filter.rs | 556 +++++++++++++++++++++++++- packages/rs-drive/src/query/mod.rs | 2 +- 2 files changed, 537 insertions(+), 21 deletions(-) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index ce2b2bf964c..8c721b1ad20 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -14,13 +14,14 @@ use dpp::state_transition::batch_transition::document_base_transition::document_ use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; use dpp::state_transition::batch_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; -use crate::query::{DriveDocumentQuery, InternalClauses}; +use indexmap::IndexMap; +use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause}; #[cfg(any(feature = "server", feature = "verify"))] -/// Drive query struct +/// Drive document query filter #[derive(Debug, PartialEq, Clone)] pub struct DriveDocumentQueryFilter<'a> { - ///DataContract + /// DataContract pub contract: &'a DataContract, /// Document type pub document_type: DocumentTypeRef<'a>, @@ -28,19 +29,33 @@ pub struct DriveDocumentQueryFilter<'a> { pub internal_clauses: InternalClauses, } -impl From for DriveDocumentQuery { - fn from(value: DriveDocumentQueryFilter) -> Self { - todo!() +impl<'a> From> for DriveDocumentQuery<'a> { + fn from(value: DriveDocumentQueryFilter<'a>) -> Self { + DriveDocumentQuery { + contract: value.contract, + document_type: value.document_type, + internal_clauses: value.internal_clauses, + offset: None, + limit: None, + order_by: IndexMap::new(), + start_at: None, + start_at_included: false, + block_time_ms: None, + } } } -impl From for DriveDocumentQueryFilter { - fn from(value: DriveDocumentQuery) -> Self { - todo!() +impl<'a> From> for DriveDocumentQueryFilter<'a> { + fn from(value: DriveDocumentQuery<'a>) -> Self { + DriveDocumentQueryFilter { + contract: value.contract, + document_type: value.document_type, + internal_clauses: value.internal_clauses, + } } } -impl<'a> DriveDocumentQueryFilter<'a> { +impl DriveDocumentQueryFilter<'_> { /// Figures out if a document matches the query #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_state_transition(&self, state_transition: &StateTransition) -> bool { @@ -48,19 +63,26 @@ impl<'a> DriveDocumentQueryFilter<'a> { StateTransition::Batch(batch) => { for transition in batch.transitions_iter() { if let BatchedTransitionRef::Document(document_transition) = transition { - if self.matches_document_state_transition(batch.owner_id(), document_transition) { + if self.matches_document_state_transition( + batch.owner_id(), + document_transition, + ) { return true; } } } false } - _ => false + _ => false, } } #[cfg(any(feature = "server", feature = "verify"))] - pub fn matches_document_state_transition(&self, owner_id: Identifier, document_transition: &DocumentTransition) -> bool { + pub fn matches_document_state_transition( + &self, + owner_id: Identifier, + document_transition: &DocumentTransition, + ) -> bool { match document_transition { DocumentTransition::Create(create) => { self.matches_document(owner_id, create.base(), create.data()) @@ -68,29 +90,523 @@ impl<'a> DriveDocumentQueryFilter<'a> { DocumentTransition::Replace(replace) => { self.matches_document(owner_id, replace.base(), replace.data()) } - DocumentTransition::Delete(_) => {} - DocumentTransition::Transfer(_) => {} - DocumentTransition::UpdatePrice(_) => {} - DocumentTransition::Purchase(_) => {} + DocumentTransition::Delete(_) => { + todo!() + } + DocumentTransition::Transfer(_) => { + todo!() + } + DocumentTransition::UpdatePrice(_) => { + todo!() + } + DocumentTransition::Purchase(_) => { + todo!() + } } } /// Figures out if a document matches the query #[cfg(any(feature = "server", feature = "verify"))] - pub fn matches_document(&self, owner_id: Identifier, document_base_transition: &DocumentBaseTransition, document_data: &BTreeMap) -> bool { + pub fn matches_document( + &self, + _owner_id: Identifier, + document_base_transition: &DocumentBaseTransition, + document_data: &BTreeMap, + ) -> bool { + // Check contract ID if document_base_transition.data_contract_id() != self.contract.id() { return false; } + + // Check document type if document_base_transition.document_type_name() != self.document_type.name() { return false; } + // Check primary key in clause (for document ID) if let Some(primary_key_in_clause) = &self.internal_clauses.primary_key_in_clause { - if document_base_transition.id() in primary_key_in_clause.value + if !self.evaluate_where_clause( + primary_key_in_clause, + &Value::Identifier(document_base_transition.id().to_buffer()), + ) { + return false; + } + } + + // Check primary key equal clause (for document ID) + if let Some(primary_key_equal_clause) = &self.internal_clauses.primary_key_equal_clause { + if !self.evaluate_where_clause( + primary_key_equal_clause, + &Value::Identifier(document_base_transition.id().to_buffer()), + ) { + return false; + } + } + + // Check in clause + if let Some(in_clause) = &self.internal_clauses.in_clause { + let field_value = document_data.get(&in_clause.field); + if let Some(value) = field_value { + if !self.evaluate_where_clause(in_clause, value) { + return false; + } + } else { + // Field doesn't exist in document + return false; + } + } + + // Check range clause + if let Some(range_clause) = &self.internal_clauses.range_clause { + let field_value = document_data.get(&range_clause.field); + if let Some(value) = field_value { + if !self.evaluate_where_clause(range_clause, value) { + return false; + } + } else { + // Field doesn't exist in document + return false; + } + } + + // Check equal clauses + for (field, equal_clause) in &self.internal_clauses.equal_clauses { + let field_value = document_data.get(field); + if let Some(value) = field_value { + if !self.evaluate_where_clause(equal_clause, value) { + return false; + } + } else { + // Field doesn't exist in document + return false; + } + } + + true + } + + /// Helper function to evaluate a where clause against a value + #[cfg(any(feature = "server", feature = "verify"))] + fn evaluate_where_clause(&self, clause: &WhereClause, value: &Value) -> bool { + use crate::query::WhereOperator; + + match &clause.operator { + WhereOperator::Equal => value == &clause.value, + WhereOperator::GreaterThan => value > &clause.value, + WhereOperator::GreaterThanOrEquals => value >= &clause.value, + WhereOperator::LessThan => value < &clause.value, + WhereOperator::LessThanOrEquals => value <= &clause.value, + WhereOperator::In => { + if let Value::Array(ref array) = clause.value { + array.contains(value) + } else { + false + } + } + WhereOperator::Between => { + if let Value::Array(ref bounds) = clause.value { + if bounds.len() == 2 { + value >= &bounds[0] && value <= &bounds[1] + } else { + false + } + } else { + false + } + } + WhereOperator::BetweenExcludeBounds => { + if let Value::Array(ref bounds) = clause.value { + if bounds.len() == 2 { + value > &bounds[0] && value < &bounds[1] + } else { + false + } + } else { + false + } + } + WhereOperator::BetweenExcludeLeft => { + if let Value::Array(ref bounds) = clause.value { + if bounds.len() == 2 { + value > &bounds[0] && value <= &bounds[1] + } else { + false + } + } else { + false + } + } + WhereOperator::BetweenExcludeRight => { + if let Value::Array(ref bounds) = clause.value { + if bounds.len() == 2 { + value >= &bounds[0] && value < &bounds[1] + } else { + false + } + } else { + false + } + } + WhereOperator::StartsWith => { + if let (Value::Text(text), Value::Text(prefix)) = (value, &clause.value) { + text.starts_with(prefix.as_str()) + } else { + false + } + } } } #[cfg(any(feature = "server", feature = "verify"))] pub fn validate(&self) -> bool { + // Do stuff like make sure that any filters defined actually align with the document type etc + // There should already be methods for this elsewhere in the codebase that we can just use + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::LATEST_PLATFORM_VERSION; + use std::collections::BTreeMap; + + #[test] + fn test_matches_document_basic() { + // Get a test contract from fixtures + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let document_type = contract + .document_type_for_name("niceDocument") + .expect("document type should exist"); + + // Create a filter with no clauses (should match if contract and type match) + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type, + internal_clauses: InternalClauses::default(), + }; + + // Create matching document base + let document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([3u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + }); + let document_data = BTreeMap::new(); + let owner_id = Identifier::from([4u8; 32]); + + // Should match since contract ID and type name are correct + assert!(filter.matches_document(owner_id, &document_base, &document_data)); + + // Test with wrong contract ID + let wrong_document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([3u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: Identifier::from([99u8; 32]), // Wrong ID + identity_contract_nonce: 0, + }); + + assert!(!filter.matches_document(owner_id, &wrong_document_base, &document_data)); } -} \ No newline at end of file + + #[test] + fn test_matches_document_with_primary_key_equal() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let document_type = contract + .document_type_for_name("niceDocument") + .expect("document type should exist"); + + let target_id = Identifier::from([42u8; 32]); + + let mut internal_clauses = InternalClauses::default(); + internal_clauses.primary_key_equal_clause = Some(WhereClause { + field: "$id".to_string(), + operator: crate::query::WhereOperator::Equal, + value: Value::Identifier(target_id.to_buffer()), + }); + + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type, + internal_clauses, + }; + + // Test with matching ID + let matching_doc = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: target_id, + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + }); + + let document_data = BTreeMap::new(); + let owner_id = Identifier::from([4u8; 32]); + + assert!(filter.matches_document(owner_id, &matching_doc, &document_data)); + + // Test with different ID + let non_matching_doc = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([99u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + }); + + assert!(!filter.matches_document(owner_id, &non_matching_doc, &document_data)); + } + + #[test] + fn test_matches_document_with_field_filters() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let document_type = contract + .document_type_for_name("niceDocument") + .expect("document type should exist"); + + // Test Equal operator + let mut equal_clauses = BTreeMap::new(); + equal_clauses.insert( + "name".to_string(), + WhereClause { + field: "name".to_string(), + operator: crate::query::WhereOperator::Equal, + value: Value::Text("example".to_string()), + }, + ); + + let mut internal_clauses = InternalClauses::default(); + internal_clauses.equal_clauses = equal_clauses; + + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type, + internal_clauses, + }; + + let document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([3u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + }); + + let owner_id = Identifier::from([4u8; 32]); + + // Test with matching data + let mut matching_data = BTreeMap::new(); + matching_data.insert("name".to_string(), Value::Text("example".to_string())); + + assert!(filter.matches_document(owner_id, &document_base, &matching_data)); + + // Test with non-matching data + let mut non_matching_data = BTreeMap::new(); + non_matching_data.insert("name".to_string(), Value::Text("different".to_string())); + + assert!(!filter.matches_document(owner_id, &document_base, &non_matching_data)); + + // Test with missing field + let empty_data = BTreeMap::new(); + assert!(!filter.matches_document(owner_id, &document_base, &empty_data)); + } + + #[test] + fn test_matches_document_with_in_operator() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let document_type = contract + .document_type_for_name("niceDocument") + .expect("document type should exist"); + + let allowed_values = vec![ + Value::Text("active".to_string()), + Value::Text("pending".to_string()), + ]; + + let mut internal_clauses = InternalClauses::default(); + internal_clauses.in_clause = Some(WhereClause { + field: "status".to_string(), + operator: crate::query::WhereOperator::In, + value: Value::Array(allowed_values), + }); + + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type, + internal_clauses, + }; + + let document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([3u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + }); + + let owner_id = Identifier::from([4u8; 32]); + + // Test with value in list + let mut matching_data = BTreeMap::new(); + matching_data.insert("status".to_string(), Value::Text("active".to_string())); + assert!(filter.matches_document(owner_id, &document_base, &matching_data)); + + // Test with value not in list + let mut non_matching_data = BTreeMap::new(); + non_matching_data.insert("status".to_string(), Value::Text("completed".to_string())); + assert!(!filter.matches_document(owner_id, &document_base, &non_matching_data)); + } + + #[test] + fn test_matches_document_with_range_operators() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let document_type = contract + .document_type_for_name("niceDocument") + .expect("document type should exist"); + + // Test GreaterThan + let mut internal_clauses = InternalClauses::default(); + internal_clauses.range_clause = Some(WhereClause { + field: "score".to_string(), + operator: crate::query::WhereOperator::GreaterThan, + value: Value::U64(50), + }); + + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type, + internal_clauses, + }; + + let document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([3u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + }); + + let owner_id = Identifier::from([4u8; 32]); + + // Test with value greater than threshold + let mut greater_data = BTreeMap::new(); + greater_data.insert("score".to_string(), Value::U64(75)); + assert!(filter.matches_document(owner_id, &document_base, &greater_data)); + + // Test with value equal to threshold (should fail for GreaterThan) + let mut equal_data = BTreeMap::new(); + equal_data.insert("score".to_string(), Value::U64(50)); + assert!(!filter.matches_document(owner_id, &document_base, &equal_data)); + + // Test with value less than threshold + let mut less_data = BTreeMap::new(); + less_data.insert("score".to_string(), Value::U64(25)); + assert!(!filter.matches_document(owner_id, &document_base, &less_data)); + } + + #[test] + fn test_matches_document_with_between_operator() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let document_type = contract + .document_type_for_name("niceDocument") + .expect("document type should exist"); + + let mut internal_clauses = InternalClauses::default(); + internal_clauses.range_clause = Some(WhereClause { + field: "value".to_string(), + operator: crate::query::WhereOperator::Between, + value: Value::Array(vec![Value::U64(10), Value::U64(20)]), + }); + + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type, + internal_clauses, + }; + + let document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([3u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + }); + + let owner_id = Identifier::from([4u8; 32]); + + // Test value in range + let mut in_range = BTreeMap::new(); + in_range.insert("value".to_string(), Value::U64(15)); + assert!(filter.matches_document(owner_id, &document_base, &in_range)); + + // Test lower bound (inclusive) + let mut lower_bound = BTreeMap::new(); + lower_bound.insert("value".to_string(), Value::U64(10)); + assert!(filter.matches_document(owner_id, &document_base, &lower_bound)); + + // Test upper bound (inclusive) + let mut upper_bound = BTreeMap::new(); + upper_bound.insert("value".to_string(), Value::U64(20)); + assert!(filter.matches_document(owner_id, &document_base, &upper_bound)); + + // Test below range + let mut below = BTreeMap::new(); + below.insert("value".to_string(), Value::U64(5)); + assert!(!filter.matches_document(owner_id, &document_base, &below)); + + // Test above range + let mut above = BTreeMap::new(); + above.insert("value".to_string(), Value::U64(25)); + assert!(!filter.matches_document(owner_id, &document_base, &above)); + } + + #[test] + fn test_conversion_between_filter_and_query() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let document_type = contract + .document_type_for_name("niceDocument") + .expect("document type should exist"); + + let mut internal_clauses = InternalClauses::default(); + internal_clauses.primary_key_equal_clause = Some(WhereClause { + field: "$id".to_string(), + operator: crate::query::WhereOperator::Equal, + value: Value::Identifier([42u8; 32]), + }); + + let original_filter = DriveDocumentQueryFilter { + contract: &contract, + document_type, + internal_clauses: internal_clauses.clone(), + }; + + // Convert to DriveDocumentQuery + let query: DriveDocumentQuery = original_filter.clone().into(); + + // Check that core fields are preserved + assert_eq!(query.contract.id(), contract.id()); + assert_eq!(query.document_type.name(), document_type.name()); + assert_eq!(query.internal_clauses, internal_clauses); + + // Check that optional fields are set to defaults + assert_eq!(query.offset, None); + assert_eq!(query.limit, None); + assert!(query.order_by.is_empty()); + assert_eq!(query.start_at, None); + assert_eq!(query.start_at_included, false); + assert_eq!(query.block_time_ms, None); + + // Convert back to filter + let converted_filter: DriveDocumentQueryFilter = query.into(); + + // Should preserve the internal clauses + assert_eq!( + converted_filter.internal_clauses, + original_filter.internal_clauses + ); + } +} diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index ebd2935a22f..31cb01723f1 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -148,10 +148,10 @@ pub mod identity_token_balance_drive_query; #[cfg(any(feature = "server", feature = "verify"))] pub mod identity_token_info_drive_query; +mod filter; /// A query to get the token's status #[cfg(any(feature = "server", feature = "verify"))] pub mod token_status_drive_query; -mod filter; #[cfg(any(feature = "server", feature = "verify"))] /// Represents a starting point for a query based on a specific document. From 2a4f93634606c3c47249bd9081bd082776af6e28 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Thu, 4 Sep 2025 18:27:04 +0700 Subject: [PATCH 03/30] remove owner_id --- packages/rs-drive/src/query/filter.rs | 55 ++++++++++----------------- 1 file changed, 20 insertions(+), 35 deletions(-) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 8c721b1ad20..d74bd372c58 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -63,10 +63,7 @@ impl DriveDocumentQueryFilter<'_> { StateTransition::Batch(batch) => { for transition in batch.transitions_iter() { if let BatchedTransitionRef::Document(document_transition) = transition { - if self.matches_document_state_transition( - batch.owner_id(), - document_transition, - ) { + if self.matches_document_state_transition(document_transition) { return true; } } @@ -80,15 +77,14 @@ impl DriveDocumentQueryFilter<'_> { #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_document_state_transition( &self, - owner_id: Identifier, document_transition: &DocumentTransition, ) -> bool { match document_transition { DocumentTransition::Create(create) => { - self.matches_document(owner_id, create.base(), create.data()) + self.matches_document(create.base(), create.data()) } DocumentTransition::Replace(replace) => { - self.matches_document(owner_id, replace.base(), replace.data()) + self.matches_document(replace.base(), replace.data()) } DocumentTransition::Delete(_) => { todo!() @@ -108,7 +104,6 @@ impl DriveDocumentQueryFilter<'_> { #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_document( &self, - _owner_id: Identifier, document_base_transition: &DocumentBaseTransition, document_data: &BTreeMap, ) -> bool { @@ -297,10 +292,9 @@ mod tests { }); let document_data = BTreeMap::new(); - let owner_id = Identifier::from([4u8; 32]); // Should match since contract ID and type name are correct - assert!(filter.matches_document(owner_id, &document_base, &document_data)); + assert!(filter.matches_document(&document_base, &document_data)); // Test with wrong contract ID let wrong_document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { @@ -310,7 +304,7 @@ mod tests { identity_contract_nonce: 0, }); - assert!(!filter.matches_document(owner_id, &wrong_document_base, &document_data)); + assert!(!filter.matches_document(&wrong_document_base, &document_data)); } #[test] @@ -345,9 +339,8 @@ mod tests { }); let document_data = BTreeMap::new(); - let owner_id = Identifier::from([4u8; 32]); - assert!(filter.matches_document(owner_id, &matching_doc, &document_data)); + assert!(filter.matches_document(&matching_doc, &document_data)); // Test with different ID let non_matching_doc = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { @@ -357,7 +350,7 @@ mod tests { identity_contract_nonce: 0, }); - assert!(!filter.matches_document(owner_id, &non_matching_doc, &document_data)); + assert!(!filter.matches_document(&non_matching_doc, &document_data)); } #[test] @@ -395,23 +388,21 @@ mod tests { identity_contract_nonce: 0, }); - let owner_id = Identifier::from([4u8; 32]); - // Test with matching data let mut matching_data = BTreeMap::new(); matching_data.insert("name".to_string(), Value::Text("example".to_string())); - assert!(filter.matches_document(owner_id, &document_base, &matching_data)); + assert!(filter.matches_document(&document_base, &matching_data)); // Test with non-matching data let mut non_matching_data = BTreeMap::new(); non_matching_data.insert("name".to_string(), Value::Text("different".to_string())); - assert!(!filter.matches_document(owner_id, &document_base, &non_matching_data)); + assert!(!filter.matches_document(&document_base, &non_matching_data)); // Test with missing field let empty_data = BTreeMap::new(); - assert!(!filter.matches_document(owner_id, &document_base, &empty_data)); + assert!(!filter.matches_document(&document_base, &empty_data)); } #[test] @@ -447,17 +438,15 @@ mod tests { identity_contract_nonce: 0, }); - let owner_id = Identifier::from([4u8; 32]); - // Test with value in list let mut matching_data = BTreeMap::new(); matching_data.insert("status".to_string(), Value::Text("active".to_string())); - assert!(filter.matches_document(owner_id, &document_base, &matching_data)); + assert!(filter.matches_document(&document_base, &matching_data)); // Test with value not in list let mut non_matching_data = BTreeMap::new(); non_matching_data.insert("status".to_string(), Value::Text("completed".to_string())); - assert!(!filter.matches_document(owner_id, &document_base, &non_matching_data)); + assert!(!filter.matches_document(&document_base, &non_matching_data)); } #[test] @@ -489,22 +478,20 @@ mod tests { identity_contract_nonce: 0, }); - let owner_id = Identifier::from([4u8; 32]); - // Test with value greater than threshold let mut greater_data = BTreeMap::new(); greater_data.insert("score".to_string(), Value::U64(75)); - assert!(filter.matches_document(owner_id, &document_base, &greater_data)); + assert!(filter.matches_document(&document_base, &greater_data)); // Test with value equal to threshold (should fail for GreaterThan) let mut equal_data = BTreeMap::new(); equal_data.insert("score".to_string(), Value::U64(50)); - assert!(!filter.matches_document(owner_id, &document_base, &equal_data)); + assert!(!filter.matches_document(&document_base, &equal_data)); // Test with value less than threshold let mut less_data = BTreeMap::new(); less_data.insert("score".to_string(), Value::U64(25)); - assert!(!filter.matches_document(owner_id, &document_base, &less_data)); + assert!(!filter.matches_document(&document_base, &less_data)); } #[test] @@ -535,32 +522,30 @@ mod tests { identity_contract_nonce: 0, }); - let owner_id = Identifier::from([4u8; 32]); - // Test value in range let mut in_range = BTreeMap::new(); in_range.insert("value".to_string(), Value::U64(15)); - assert!(filter.matches_document(owner_id, &document_base, &in_range)); + assert!(filter.matches_document(&document_base, &in_range)); // Test lower bound (inclusive) let mut lower_bound = BTreeMap::new(); lower_bound.insert("value".to_string(), Value::U64(10)); - assert!(filter.matches_document(owner_id, &document_base, &lower_bound)); + assert!(filter.matches_document(&document_base, &lower_bound)); // Test upper bound (inclusive) let mut upper_bound = BTreeMap::new(); upper_bound.insert("value".to_string(), Value::U64(20)); - assert!(filter.matches_document(owner_id, &document_base, &upper_bound)); + assert!(filter.matches_document(&document_base, &upper_bound)); // Test below range let mut below = BTreeMap::new(); below.insert("value".to_string(), Value::U64(5)); - assert!(!filter.matches_document(owner_id, &document_base, &below)); + assert!(!filter.matches_document(&document_base, &below)); // Test above range let mut above = BTreeMap::new(); above.insert("value".to_string(), Value::U64(25)); - assert!(!filter.matches_document(owner_id, &document_base, &above)); + assert!(!filter.matches_document(&document_base, &above)); } #[test] From dc382c0f4f044564a807b7084b5647a2da0a3760 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Fri, 5 Sep 2025 11:16:21 +0700 Subject: [PATCH 04/30] cleanup --- packages/rs-drive/src/query/filter.rs | 28 ++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index d74bd372c58..c0b0e2806f3 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -1,3 +1,13 @@ +//! DriveDocumentQueryFilter and related functionality +//! +//! This module defines the `DriveDocumentQueryFilter` struct, which is used to filter +//! document state transitions based on specified criteria. It includes methods to check +//! if a state transition or document matches the filter, as well as utility functions for +//! evaluating where clauses. +//! +//! The filter is primarily used in the context of clients subscribing to document events +//! in Platform. + use std::collections::BTreeMap; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; @@ -18,7 +28,12 @@ use indexmap::IndexMap; use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause}; #[cfg(any(feature = "server", feature = "verify"))] -/// Drive document query filter +/// DriveDocumentQueryFilter is meant to enable clients to subscribe to certain document events in Platform. +/// +/// Clients will send their desired filter to a node running Tenderdash, who will run the filter on +/// the state transitions of an accepted block (before the state transitions are actually executed). +/// +/// If a state transition matches the filter, the node will signal the client. #[derive(Debug, PartialEq, Clone)] pub struct DriveDocumentQueryFilter<'a> { /// DataContract @@ -56,7 +71,7 @@ impl<'a> From> for DriveDocumentQueryFilter<'a> { } impl DriveDocumentQueryFilter<'_> { - /// Figures out if a document matches the query + /// Figures out if a state transition matches the filter #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_state_transition(&self, state_transition: &StateTransition) -> bool { match state_transition { @@ -74,6 +89,7 @@ impl DriveDocumentQueryFilter<'_> { } } + /// Figures out if a document state transition matches the filter #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_document_state_transition( &self, @@ -100,7 +116,8 @@ impl DriveDocumentQueryFilter<'_> { } } } - /// Figures out if a document matches the query + + /// Figures out if a document matches the filter #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_document( &self, @@ -112,7 +129,7 @@ impl DriveDocumentQueryFilter<'_> { return false; } - // Check document type + // Check document type name if document_base_transition.document_type_name() != self.document_type.name() { return false; } @@ -145,7 +162,6 @@ impl DriveDocumentQueryFilter<'_> { return false; } } else { - // Field doesn't exist in document return false; } } @@ -158,7 +174,6 @@ impl DriveDocumentQueryFilter<'_> { return false; } } else { - // Field doesn't exist in document return false; } } @@ -171,7 +186,6 @@ impl DriveDocumentQueryFilter<'_> { return false; } } else { - // Field doesn't exist in document return false; } } From 794b5ef0bf78825d76f078f1f3cbc6e6527a32ec Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Fri, 5 Sep 2025 11:46:51 +0700 Subject: [PATCH 05/30] ok --- packages/rs-drive/src/query/filter.rs | 145 ++++++++++++++++++++------ 1 file changed, 111 insertions(+), 34 deletions(-) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index c0b0e2806f3..05467382a35 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -14,26 +14,21 @@ use dpp::data_contract::DataContract; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::platform_value::Value; -use dpp::prelude::Identifier; +use dpp::version::LATEST_PLATFORM_VERSION; use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; -use dpp::state_transition::{StateTransition, StateTransitionLike}; +use dpp::state_transition::StateTransition; use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; use dpp::state_transition::batch_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; use indexmap::IndexMap; -use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause}; +use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; #[cfg(any(feature = "server", feature = "verify"))] -/// DriveDocumentQueryFilter is meant to enable clients to subscribe to certain document events in Platform. -/// -/// Clients will send their desired filter to a node running Tenderdash, who will run the filter on -/// the state transitions of an accepted block (before the state transitions are actually executed). -/// -/// If a state transition matches the filter, the node will signal the client. +/// DriveDocumentQueryFilter struct for filtering document state transitions #[derive(Debug, PartialEq, Clone)] pub struct DriveDocumentQueryFilter<'a> { /// DataContract @@ -71,7 +66,7 @@ impl<'a> From> for DriveDocumentQueryFilter<'a> { } impl DriveDocumentQueryFilter<'_> { - /// Figures out if a state transition matches the filter + /// Checks if a state transition matches the filter #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_state_transition(&self, state_transition: &StateTransition) -> bool { match state_transition { @@ -89,7 +84,7 @@ impl DriveDocumentQueryFilter<'_> { } } - /// Figures out if a document state transition matches the filter + /// Checks if a document state transition matches the filter #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_document_state_transition( &self, @@ -117,7 +112,7 @@ impl DriveDocumentQueryFilter<'_> { } } - /// Figures out if a document matches the filter + /// Checks if a document (base transition and data) matches the filter #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_document( &self, @@ -196,8 +191,6 @@ impl DriveDocumentQueryFilter<'_> { /// Helper function to evaluate a where clause against a value #[cfg(any(feature = "server", feature = "verify"))] fn evaluate_where_clause(&self, clause: &WhereClause, value: &Value) -> bool { - use crate::query::WhereOperator; - match &clause.operator { WhereOperator::Equal => value == &clause.value, WhereOperator::GreaterThan => value > &clause.value, @@ -265,21 +258,21 @@ impl DriveDocumentQueryFilter<'_> { } } + /// Validates that the filter's clauses are valid for the document type #[cfg(any(feature = "server", feature = "verify"))] pub fn validate(&self) -> bool { - // Do stuff like make sure that any filters defined actually align with the document type etc - // There should already be methods for this elsewhere in the codebase that we can just use - true + // Convert the filter to a query to reuse find_best_index logic + let query: DriveDocumentQuery = self.clone().into(); + query.find_best_index(LATEST_PLATFORM_VERSION).is_ok() } } #[cfg(test)] mod tests { use super::*; - use dpp::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use dpp::prelude::Identifier; + use dpp::state_transition::batch_transition::document_base_transition::v1::DocumentBaseTransitionV1; use dpp::tests::fixtures::get_data_contract_fixture; - use dpp::version::LATEST_PLATFORM_VERSION; - use std::collections::BTreeMap; #[test] fn test_matches_document_basic() { @@ -298,11 +291,12 @@ mod tests { }; // Create matching document base - let document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { id: Identifier::from([3u8; 32]), document_type_name: "niceDocument".to_string(), data_contract_id: contract.id(), identity_contract_nonce: 0, + token_payment_info: None, }); let document_data = BTreeMap::new(); @@ -311,11 +305,12 @@ mod tests { assert!(filter.matches_document(&document_base, &document_data)); // Test with wrong contract ID - let wrong_document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + let wrong_document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { id: Identifier::from([3u8; 32]), document_type_name: "niceDocument".to_string(), data_contract_id: Identifier::from([99u8; 32]), // Wrong ID identity_contract_nonce: 0, + token_payment_info: None, }); assert!(!filter.matches_document(&wrong_document_base, &document_data)); @@ -334,7 +329,7 @@ mod tests { let mut internal_clauses = InternalClauses::default(); internal_clauses.primary_key_equal_clause = Some(WhereClause { field: "$id".to_string(), - operator: crate::query::WhereOperator::Equal, + operator: WhereOperator::Equal, value: Value::Identifier(target_id.to_buffer()), }); @@ -345,11 +340,12 @@ mod tests { }; // Test with matching ID - let matching_doc = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + let matching_doc = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { id: target_id, document_type_name: "niceDocument".to_string(), data_contract_id: contract.id(), identity_contract_nonce: 0, + token_payment_info: None, }); let document_data = BTreeMap::new(); @@ -357,11 +353,12 @@ mod tests { assert!(filter.matches_document(&matching_doc, &document_data)); // Test with different ID - let non_matching_doc = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + let non_matching_doc = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { id: Identifier::from([99u8; 32]), document_type_name: "niceDocument".to_string(), data_contract_id: contract.id(), identity_contract_nonce: 0, + token_payment_info: None, }); assert!(!filter.matches_document(&non_matching_doc, &document_data)); @@ -381,7 +378,7 @@ mod tests { "name".to_string(), WhereClause { field: "name".to_string(), - operator: crate::query::WhereOperator::Equal, + operator: WhereOperator::Equal, value: Value::Text("example".to_string()), }, ); @@ -395,11 +392,12 @@ mod tests { internal_clauses, }; - let document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { id: Identifier::from([3u8; 32]), document_type_name: "niceDocument".to_string(), data_contract_id: contract.id(), identity_contract_nonce: 0, + token_payment_info: None, }); // Test with matching data @@ -435,7 +433,7 @@ mod tests { let mut internal_clauses = InternalClauses::default(); internal_clauses.in_clause = Some(WhereClause { field: "status".to_string(), - operator: crate::query::WhereOperator::In, + operator: WhereOperator::In, value: Value::Array(allowed_values), }); @@ -445,11 +443,12 @@ mod tests { internal_clauses, }; - let document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { id: Identifier::from([3u8; 32]), document_type_name: "niceDocument".to_string(), data_contract_id: contract.id(), identity_contract_nonce: 0, + token_payment_info: None, }); // Test with value in list @@ -475,7 +474,7 @@ mod tests { let mut internal_clauses = InternalClauses::default(); internal_clauses.range_clause = Some(WhereClause { field: "score".to_string(), - operator: crate::query::WhereOperator::GreaterThan, + operator: WhereOperator::GreaterThan, value: Value::U64(50), }); @@ -485,11 +484,12 @@ mod tests { internal_clauses, }; - let document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { id: Identifier::from([3u8; 32]), document_type_name: "niceDocument".to_string(), data_contract_id: contract.id(), identity_contract_nonce: 0, + token_payment_info: None, }); // Test with value greater than threshold @@ -519,7 +519,7 @@ mod tests { let mut internal_clauses = InternalClauses::default(); internal_clauses.range_clause = Some(WhereClause { field: "value".to_string(), - operator: crate::query::WhereOperator::Between, + operator: WhereOperator::Between, value: Value::Array(vec![Value::U64(10), Value::U64(20)]), }); @@ -529,11 +529,12 @@ mod tests { internal_clauses, }; - let document_base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { id: Identifier::from([3u8; 32]), document_type_name: "niceDocument".to_string(), data_contract_id: contract.id(), identity_contract_nonce: 0, + token_payment_info: None, }); // Test value in range @@ -562,6 +563,82 @@ mod tests { assert!(!filter.matches_document(&document_base, &above)); } + #[test] + fn test_validate_filter() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let document_type = contract + .document_type_for_name("indexedDocument") + .expect("document type should exist"); + + // Test valid filter with indexed field + let mut internal_clauses = InternalClauses::default(); + let mut equal_clauses = BTreeMap::new(); + equal_clauses.insert( + "firstName".to_string(), + WhereClause { + field: "firstName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("Alice".to_string()), + }, + ); + internal_clauses.equal_clauses = equal_clauses; + + let valid_filter = DriveDocumentQueryFilter { + contract: &contract, + document_type, + internal_clauses, + }; + + assert!( + valid_filter.validate(), + "Filter with indexed field should be valid" + ); + + // Test invalid filter with non-indexed field + let mut internal_clauses = InternalClauses::default(); + let mut equal_clauses = BTreeMap::new(); + equal_clauses.insert( + "nonExistentField".to_string(), + WhereClause { + field: "nonExistentField".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("value".to_string()), + }, + ); + internal_clauses.equal_clauses = equal_clauses; + + let invalid_filter = DriveDocumentQueryFilter { + contract: &contract, + document_type, + internal_clauses, + }; + + assert!( + !invalid_filter.validate(), + "Filter with non-indexed field should be invalid" + ); + + // Test valid filter with only primary key + let mut internal_clauses = InternalClauses::default(); + internal_clauses.primary_key_equal_clause = Some(WhereClause { + field: "$id".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier([42u8; 32]), + }); + + let primary_key_filter = DriveDocumentQueryFilter { + contract: &contract, + document_type, + internal_clauses, + }; + + assert!( + primary_key_filter.validate(), + "Filter with only primary key should be valid" + ); + } + #[test] fn test_conversion_between_filter_and_query() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); @@ -573,7 +650,7 @@ mod tests { let mut internal_clauses = InternalClauses::default(); internal_clauses.primary_key_equal_clause = Some(WhereClause { field: "$id".to_string(), - operator: crate::query::WhereOperator::Equal, + operator: WhereOperator::Equal, value: Value::Identifier([42u8; 32]), }); From 2d37f8af3fd6ec785554faeb7e8567e66eb9160e Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Fri, 5 Sep 2025 11:59:21 +0700 Subject: [PATCH 06/30] just use document type name --- packages/rs-drive/src/query/filter.rs | 61 +++++++++------------------ 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 05467382a35..859112f13be 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -12,7 +12,6 @@ use std::collections::BTreeMap; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::DocumentTypeRef; use dpp::platform_value::Value; use dpp::version::LATEST_PLATFORM_VERSION; use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; @@ -29,21 +28,25 @@ use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperat #[cfg(any(feature = "server", feature = "verify"))] /// DriveDocumentQueryFilter struct for filtering document state transitions +/// +/// Full contract is needed to validate filters #[derive(Debug, PartialEq, Clone)] pub struct DriveDocumentQueryFilter<'a> { /// DataContract pub contract: &'a DataContract, - /// Document type - pub document_type: DocumentTypeRef<'a>, + /// Document type name + pub document_type_name: String, /// Internal clauses pub internal_clauses: InternalClauses, } impl<'a> From> for DriveDocumentQuery<'a> { fn from(value: DriveDocumentQueryFilter<'a>) -> Self { + let document_type = value.contract.document_type_for_name(&value.document_type_name) + .expect("Document type should exist in contract"); DriveDocumentQuery { contract: value.contract, - document_type: value.document_type, + document_type, internal_clauses: value.internal_clauses, offset: None, limit: None, @@ -59,7 +62,7 @@ impl<'a> From> for DriveDocumentQueryFilter<'a> { fn from(value: DriveDocumentQuery<'a>) -> Self { DriveDocumentQueryFilter { contract: value.contract, - document_type: value.document_type, + document_type_name: value.document_type.name().to_string(), internal_clauses: value.internal_clauses, } } @@ -125,7 +128,7 @@ impl DriveDocumentQueryFilter<'_> { } // Check document type name - if document_base_transition.document_type_name() != self.document_type.name() { + if document_base_transition.document_type_name() != &self.document_type_name { return false; } @@ -279,14 +282,11 @@ mod tests { // Get a test contract from fixtures let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); - let document_type = contract - .document_type_for_name("niceDocument") - .expect("document type should exist"); // Create a filter with no clauses (should match if contract and type match) let filter = DriveDocumentQueryFilter { contract: &contract, - document_type, + document_type_name: "niceDocument".to_string(), internal_clauses: InternalClauses::default(), }; @@ -320,9 +320,6 @@ mod tests { fn test_matches_document_with_primary_key_equal() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); - let document_type = contract - .document_type_for_name("niceDocument") - .expect("document type should exist"); let target_id = Identifier::from([42u8; 32]); @@ -335,7 +332,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, - document_type, + document_type_name: "niceDocument".to_string(), internal_clauses, }; @@ -368,9 +365,6 @@ mod tests { fn test_matches_document_with_field_filters() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); - let document_type = contract - .document_type_for_name("niceDocument") - .expect("document type should exist"); // Test Equal operator let mut equal_clauses = BTreeMap::new(); @@ -388,7 +382,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, - document_type, + document_type_name: "niceDocument".to_string(), internal_clauses, }; @@ -421,9 +415,6 @@ mod tests { fn test_matches_document_with_in_operator() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); - let document_type = contract - .document_type_for_name("niceDocument") - .expect("document type should exist"); let allowed_values = vec![ Value::Text("active".to_string()), @@ -439,7 +430,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, - document_type, + document_type_name: "niceDocument".to_string(), internal_clauses, }; @@ -466,9 +457,6 @@ mod tests { fn test_matches_document_with_range_operators() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); - let document_type = contract - .document_type_for_name("niceDocument") - .expect("document type should exist"); // Test GreaterThan let mut internal_clauses = InternalClauses::default(); @@ -480,7 +468,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, - document_type, + document_type_name: "niceDocument".to_string(), internal_clauses, }; @@ -512,9 +500,6 @@ mod tests { fn test_matches_document_with_between_operator() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); - let document_type = contract - .document_type_for_name("niceDocument") - .expect("document type should exist"); let mut internal_clauses = InternalClauses::default(); internal_clauses.range_clause = Some(WhereClause { @@ -525,7 +510,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, - document_type, + document_type_name: "niceDocument".to_string(), internal_clauses, }; @@ -567,9 +552,6 @@ mod tests { fn test_validate_filter() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); - let document_type = contract - .document_type_for_name("indexedDocument") - .expect("document type should exist"); // Test valid filter with indexed field let mut internal_clauses = InternalClauses::default(); @@ -586,7 +568,7 @@ mod tests { let valid_filter = DriveDocumentQueryFilter { contract: &contract, - document_type, + document_type_name: "indexedDocument".to_string(), internal_clauses, }; @@ -610,7 +592,7 @@ mod tests { let invalid_filter = DriveDocumentQueryFilter { contract: &contract, - document_type, + document_type_name: "indexedDocument".to_string(), internal_clauses, }; @@ -629,7 +611,7 @@ mod tests { let primary_key_filter = DriveDocumentQueryFilter { contract: &contract, - document_type, + document_type_name: "indexedDocument".to_string(), internal_clauses, }; @@ -643,9 +625,6 @@ mod tests { fn test_conversion_between_filter_and_query() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); - let document_type = contract - .document_type_for_name("niceDocument") - .expect("document type should exist"); let mut internal_clauses = InternalClauses::default(); internal_clauses.primary_key_equal_clause = Some(WhereClause { @@ -656,7 +635,7 @@ mod tests { let original_filter = DriveDocumentQueryFilter { contract: &contract, - document_type, + document_type_name: "niceDocument".to_string(), internal_clauses: internal_clauses.clone(), }; @@ -665,7 +644,7 @@ mod tests { // Check that core fields are preserved assert_eq!(query.contract.id(), contract.id()); - assert_eq!(query.document_type.name(), document_type.name()); + assert_eq!(query.document_type.name(), "niceDocument"); assert_eq!(query.internal_clauses, internal_clauses); // Check that optional fields are set to defaults From f7d613e7541b4f2d1bbf6a372d3d77880beaa362 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Fri, 5 Sep 2025 12:02:51 +0700 Subject: [PATCH 07/30] fmt --- packages/rs-drive/src/query/filter.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 859112f13be..2b76f9262c4 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -42,7 +42,9 @@ pub struct DriveDocumentQueryFilter<'a> { impl<'a> From> for DriveDocumentQuery<'a> { fn from(value: DriveDocumentQueryFilter<'a>) -> Self { - let document_type = value.contract.document_type_for_name(&value.document_type_name) + let document_type = value + .contract + .document_type_for_name(&value.document_type_name) .expect("Document type should exist in contract"); DriveDocumentQuery { contract: value.contract, From 6608d0409c62c8b23df7ceb27fe8aa044d753135 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Fri, 5 Sep 2025 13:00:15 +0700 Subject: [PATCH 08/30] Update packages/rs-drive/src/query/mod.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/rs-drive/src/query/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 31cb01723f1..47219a21183 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -148,6 +148,7 @@ pub mod identity_token_balance_drive_query; #[cfg(any(feature = "server", feature = "verify"))] pub mod identity_token_info_drive_query; +#[cfg(any(feature = "server", feature = "verify"))] mod filter; /// A query to get the token's status #[cfg(any(feature = "server", feature = "verify"))] From 2a66d98930745e7d58d76c6b799e0533df37b22c Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Fri, 5 Sep 2025 18:55:18 +0700 Subject: [PATCH 09/30] address comments and implement before and after clauses --- packages/rs-drive/src/query/conditions.rs | 46 + packages/rs-drive/src/query/filter.rs | 1173 +++++++++++++++++---- 2 files changed, 1022 insertions(+), 197 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 73aecb76768..60457cedf6c 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -217,6 +217,52 @@ impl<'a> WhereClause { self.field == "$id" } + /// Evaluate this clause against a provided `Value` + pub fn matches_value(&self, value: &Value) -> bool { + match &self.operator { + WhereOperator::Equal => value == &self.value, + WhereOperator::GreaterThan => value > &self.value, + WhereOperator::GreaterThanOrEquals => value >= &self.value, + WhereOperator::LessThan => value < &self.value, + WhereOperator::LessThanOrEquals => value <= &self.value, + WhereOperator::In => match &self.value { + Value::Array(array) => array.contains(value), + _ => false, + }, + WhereOperator::Between => match &self.value { + Value::Array(bounds) if bounds.len() == 2 => { + value >= &bounds[0] && value <= &bounds[1] + } + _ => false, + }, + WhereOperator::BetweenExcludeBounds => match &self.value { + Value::Array(bounds) if bounds.len() == 2 => { + value > &bounds[0] && value < &bounds[1] + } + _ => false, + }, + WhereOperator::BetweenExcludeLeft => match &self.value { + Value::Array(bounds) if bounds.len() == 2 => { + value > &bounds[0] && value <= &bounds[1] + } + _ => false, + }, + WhereOperator::BetweenExcludeRight => match &self.value { + Value::Array(bounds) if bounds.len() == 2 => { + value >= &bounds[0] && value < &bounds[1] + } + _ => false, + }, + WhereOperator::StartsWith => { + if let (Value::Text(text), Value::Text(prefix)) = (value, &self.value) { + text.starts_with(prefix.as_str()) + } else { + false + } + } + } + } + /// Returns the where clause `in` values if they are an array of values, else an error pub fn in_values(&self) -> Result>, Error> { let in_values = match &self.value { diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 2b76f9262c4..15f2279bf53 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -1,128 +1,329 @@ -//! DriveDocumentQueryFilter and related functionality +//! Document subscription filtering //! -//! This module defines the `DriveDocumentQueryFilter` struct, which is used to filter -//! document state transitions based on specified criteria. It includes methods to check -//! if a state transition or document matches the filter, as well as utility functions for -//! evaluating where clauses. +//! This module provides primitives to express and evaluate subscription filters for +//! document state transitions. The main entry point is `DriveDocumentQueryFilter`, which +//! holds a contract reference, a document type name, and action-specific clauses +//! (`DocumentActionClauses`). //! -//! The filter is primarily used in the context of clients subscribing to document events -//! in Platform. +//! Filtering in brief: +//! - Create: evaluates `final_clauses` on the transition's data payload. +//! - Replace: optionally evaluates `original_clauses` on the original document and/or +//! `final_clauses` on the replacement data. +//! - Delete: evaluates `original_clauses` on the original document. +//! - Transfer: optionally evaluates `original_clauses` and/or a new `owner_clause` against +//! the `recipient_owner_id`. +//! - UpdatePrice: optionally evaluates `original_clauses` and/or a `price_clause` against +//! the new price in the transition. +//! - Purchase: optionally evaluates `original_clauses` and/or an `owner_clause` against the +//! batch owner (purchaser) ID. +//! +//! Validation is structural and index-aware: it checks the document type exists, that +//! at least one optional clause is provided where required (Replace/Transfer/UpdatePrice/ +//! Purchase), and that applicable clauses have compatible indexes. use std::collections::BTreeMap; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::platform_value::Value; -use dpp::version::LATEST_PLATFORM_VERSION; -use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; -use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; +use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; -use dpp::state_transition::StateTransition; use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; use dpp::state_transition::batch_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::v0_methods::DocumentTransferTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::v0_methods::DocumentUpdatePriceTransitionV0Methods; use indexmap::IndexMap; -use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause}; +use dpp::platform_value::ValueMapHelper; +use dpp::version::LATEST_PLATFORM_VERSION; #[cfg(any(feature = "server", feature = "verify"))] -/// DriveDocumentQueryFilter struct for filtering document state transitions +/// Filter used to match document transitions for subscriptions. /// -/// Full contract is needed to validate filters +/// Targets a specific data contract and document type, and carries action-specific +/// clauses via `DocumentActionClauses`. Use `matches_document_transition()` to evaluate +/// batch/document transitions. `validate()` performs structural checks (document type +/// exists, clause composition rules). #[derive(Debug, PartialEq, Clone)] pub struct DriveDocumentQueryFilter<'a> { /// DataContract pub contract: &'a DataContract, /// Document type name pub document_type_name: String, - /// Internal clauses - pub internal_clauses: InternalClauses, -} - -impl<'a> From> for DriveDocumentQuery<'a> { - fn from(value: DriveDocumentQueryFilter<'a>) -> Self { - let document_type = value - .contract - .document_type_for_name(&value.document_type_name) - .expect("Document type should exist in contract"); - DriveDocumentQuery { - contract: value.contract, - document_type, - internal_clauses: value.internal_clauses, - offset: None, - limit: None, - order_by: IndexMap::new(), - start_at: None, - start_at_included: false, - block_time_ms: None, - } - } + /// Action-specific clauses + pub action_clauses: DocumentActionClauses, } -impl<'a> From> for DriveDocumentQueryFilter<'a> { - fn from(value: DriveDocumentQuery<'a>) -> Self { - DriveDocumentQueryFilter { - contract: value.contract, - document_type_name: value.document_type.name().to_string(), - internal_clauses: value.internal_clauses, - } - } +/// Action-specific filter clauses for matching document transitions. +/// +/// These clauses are used to evaluate whether a given document transition +/// (Create/Replace/Delete/Transfer/UpdatePrice/Purchase) matches a +/// subscription filter. Some variants allow optional sub-clauses; if an +/// optional sub-clause is `None`, it imposes no constraint. For variants with +/// multiple optional sub-clauses, at least one must be present (validated via +/// `DriveDocumentQueryFilter::validate`). +#[derive(Debug, PartialEq, Clone)] +pub enum DocumentActionClauses { + /// Create document: filter on final document only. + /// + /// The `final_clauses` apply to the transition's data payload. + Create { final_clauses: InternalClauses }, + /// Replace document: optionally filter on original and/or final. + /// + /// - If `original_clauses` is `Some`, the original document (pre-change) + /// must be provided and match. + /// - If `final_clauses` is `Some`, the transition's replacement data must + /// match. + /// - Validation requires that at least one of the two be `Some`. + Replace { + original_clauses: Option, + final_clauses: Option, + }, + /// Delete: filter on original (existing) document. + /// + /// The `original_clauses` apply to the original document; matching + /// requires the original document to be supplied. + Delete { original_clauses: InternalClauses }, + /// Transfer: filter on original document and/or new owner id. + /// + /// - `original_clauses`: optional constraints on the original document. + /// - `owner_clause`: optional constraint on the recipient owner id. + /// - Validation requires at least one of the two to be `Some`. + Transfer { + original_clauses: Option, + owner_clause: Option, + }, + /// Update price: filter on original doc and/or the new price using a simple clause. + /// + /// - `original_clauses`: optional constraints on the original document. + /// - `price_clause`: optional constraint evaluated against the new price. + /// - Validation requires at least one of the two to be `Some`. + UpdatePrice { + original_clauses: Option, + price_clause: Option, + }, + /// Purchase: filter on original document and/or new owner id (batch owner). + /// + /// - `original_clauses`: optional constraints on the original document. + /// - `owner_clause`: optional constraint evaluated against the batch + /// transition owner id (the purchaser). + /// - Validation requires at least one of the two to be `Some`. + Purchase { + original_clauses: Option, + owner_clause: Option, + }, } impl DriveDocumentQueryFilter<'_> { - /// Checks if a state transition matches the filter + /// Checks if a document transition matches the filter, with optional + /// original document data and an optional batch owner value. #[cfg(any(feature = "server", feature = "verify"))] - pub fn matches_state_transition(&self, state_transition: &StateTransition) -> bool { - match state_transition { - StateTransition::Batch(batch) => { - for transition in batch.transitions_iter() { - if let BatchedTransitionRef::Document(document_transition) = transition { - if self.matches_document_state_transition(document_transition) { - return true; - } - } - } - false - } - _ => false, - } - } - - /// Checks if a document state transition matches the filter - #[cfg(any(feature = "server", feature = "verify"))] - pub fn matches_document_state_transition( + pub fn matches_document_transition( &self, document_transition: &DocumentTransition, + original_document: Option<&BTreeMap>, + batch_owner_value: Option<&Value>, ) -> bool { - match document_transition { - DocumentTransition::Create(create) => { - self.matches_document(create.base(), create.data()) - } - DocumentTransition::Replace(replace) => { - self.matches_document(replace.base(), replace.data()) + match (&self.action_clauses, document_transition) { + ( + DocumentActionClauses::Create { final_clauses }, + DocumentTransition::Create(create), + ) => self.evaluate_document_with_clauses(final_clauses, create.base(), create.data()), + ( + DocumentActionClauses::Replace { + original_clauses, + final_clauses, + }, + DocumentTransition::Replace(replace), + ) => { + // Both must match if present + let orig_ok = match (original_clauses, original_document) { + (Some(clauses), Some(orig)) => { + self.evaluate_document_with_clauses(clauses, replace.base(), orig) + } + (Some(_), None) => false, // needed but not provided + (None, _) => true, + }; + let new_ok = match final_clauses { + Some(clauses) => { + self.evaluate_document_with_clauses(clauses, replace.base(), replace.data()) + } + None => true, + }; + orig_ok && new_ok } - DocumentTransition::Delete(_) => { - todo!() + ( + DocumentActionClauses::Delete { original_clauses }, + DocumentTransition::Delete(delete), + ) => match original_document { + Some(orig) => { + self.evaluate_document_with_clauses(original_clauses, delete.base(), orig) + } + None => false, + }, + ( + DocumentActionClauses::Transfer { + original_clauses, + owner_clause, + }, + DocumentTransition::Transfer(transfer), + ) => { + let orig_ok = match (original_clauses, original_document) { + (Some(clauses), Some(orig)) => { + self.evaluate_document_with_clauses(clauses, transfer.base(), orig) + } + (Some(_), None) => false, + (None, _) => true, + }; + let new_owner_value: Value = transfer.recipient_owner_id().into(); + let owner_ok = match owner_clause { + Some(clause) => clause.matches_value(&new_owner_value), + None => true, + }; + orig_ok && owner_ok } - DocumentTransition::Transfer(_) => { - todo!() + ( + DocumentActionClauses::UpdatePrice { + original_clauses, + price_clause, + }, + DocumentTransition::UpdatePrice(update_price), + ) => { + let orig_ok = match (original_clauses, original_document) { + (Some(clauses), Some(orig)) => { + self.evaluate_document_with_clauses(clauses, update_price.base(), orig) + } + (Some(_), None) => false, + (None, _) => true, + }; + // Evaluate price clause against the transition's price + let price_value = Value::U64(update_price.price()); + let price_ok = match price_clause { + Some(clause) => clause.matches_value(&price_value), + None => true, + }; + orig_ok && price_ok } - DocumentTransition::UpdatePrice(_) => { - todo!() + ( + DocumentActionClauses::Purchase { + original_clauses, + owner_clause, + }, + DocumentTransition::Purchase(purchase), + ) => { + let orig_ok = match (original_clauses, original_document) { + (Some(clauses), Some(orig)) => { + self.evaluate_document_with_clauses(clauses, purchase.base(), orig) + } + (Some(_), None) => false, + (None, _) => true, + }; + let owner_ok = match (owner_clause, batch_owner_value) { + (Some(clause), Some(val)) => clause.matches_value(val), + (Some(_), None) => false, + (None, _) => true, + }; + orig_ok && owner_ok } - DocumentTransition::Purchase(_) => { - todo!() + // Fallback: only allow matching on primary-key-only filters across actions + // (evaluate base: contract/type/$id). Avoids accidental matches for + // data-dependent filters when no document data exists on this action. + (_, _) => { + let pk_only = match &self.action_clauses { + DocumentActionClauses::Create { final_clauses } => { + final_clauses.is_for_primary_key() + } + DocumentActionClauses::Replace { + original_clauses, + final_clauses, + } => { + original_clauses + .as_ref() + .map(|c| c.is_for_primary_key()) + .unwrap_or(false) + || final_clauses + .as_ref() + .map(|c| c.is_for_primary_key()) + .unwrap_or(false) + } + DocumentActionClauses::Delete { original_clauses } => { + original_clauses.is_for_primary_key() + } + DocumentActionClauses::Transfer { + original_clauses, .. + } => original_clauses + .as_ref() + .map(|c| c.is_for_primary_key()) + .unwrap_or(false), + DocumentActionClauses::UpdatePrice { + original_clauses, .. + } => original_clauses + .as_ref() + .map(|c| c.is_for_primary_key()) + .unwrap_or(false), + DocumentActionClauses::Purchase { + original_clauses, .. + } => original_clauses + .as_ref() + .map(|c| c.is_for_primary_key()) + .unwrap_or(false), + }; + if pk_only { + self.matches_document(document_transition.base(), &BTreeMap::new()) + } else { + false + } } } } - /// Checks if a document (base transition and data) matches the filter + /// Low-level helper to evaluate an in-memory document payload (`base` + `data`) + /// against the clauses selected for the current action. + /// + /// Prefer the transition-oriented method unless you are matching raw + /// in-memory data. #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_document( &self, document_base_transition: &DocumentBaseTransition, document_data: &BTreeMap, + ) -> bool { + // When action-specific clauses exist, use them; default to empty clauses when not provided + let empty = InternalClauses::default(); + let clauses_ref: &InternalClauses = match &self.action_clauses { + DocumentActionClauses::Create { final_clauses } => final_clauses, + DocumentActionClauses::Replace { final_clauses, .. } => { + final_clauses.as_ref().unwrap_or(&empty) + } + DocumentActionClauses::Delete { original_clauses } => original_clauses, + DocumentActionClauses::Transfer { + original_clauses, .. + } => original_clauses.as_ref().unwrap_or(&empty), + DocumentActionClauses::Purchase { + original_clauses, .. + } => original_clauses.as_ref().unwrap_or(&empty), + DocumentActionClauses::UpdatePrice { + original_clauses, .. + } => original_clauses.as_ref().unwrap_or(&empty), + }; + self.evaluate_document_with_clauses(clauses_ref, document_base_transition, document_data) + } + + /// Core evaluator: checks the given base transition + document data against + /// the provided `InternalClauses`. + /// + /// This is used internally by action-specific matchers (e.g., Replace + /// evaluates both `original_clauses` and `final_clauses` separately). Most + /// callers should use `matches_document_transition`, which determines the + /// correct clause set(s) to apply for the configured `DocumentActionClauses`. + #[cfg(any(feature = "server", feature = "verify"))] + fn evaluate_document_with_clauses( + &self, + clauses: &InternalClauses, + document_base_transition: &DocumentBaseTransition, + document_data: &BTreeMap, ) -> bool { // Check contract ID if document_base_transition.data_contract_id() != self.contract.id() { @@ -135,30 +336,24 @@ impl DriveDocumentQueryFilter<'_> { } // Check primary key in clause (for document ID) - if let Some(primary_key_in_clause) = &self.internal_clauses.primary_key_in_clause { - if !self.evaluate_where_clause( - primary_key_in_clause, - &Value::Identifier(document_base_transition.id().to_buffer()), - ) { + if let Some(primary_key_in_clause) = &clauses.primary_key_in_clause { + if !primary_key_in_clause.matches_value(&document_base_transition.id().into()) { return false; } } // Check primary key equal clause (for document ID) - if let Some(primary_key_equal_clause) = &self.internal_clauses.primary_key_equal_clause { - if !self.evaluate_where_clause( - primary_key_equal_clause, - &Value::Identifier(document_base_transition.id().to_buffer()), - ) { + if let Some(primary_key_equal_clause) = &clauses.primary_key_equal_clause { + if !primary_key_equal_clause.matches_value(&document_base_transition.id().into()) { return false; } } // Check in clause - if let Some(in_clause) = &self.internal_clauses.in_clause { - let field_value = document_data.get(&in_clause.field); + if let Some(in_clause) = &clauses.in_clause { + let field_value = get_value_by_path(document_data, &in_clause.field); if let Some(value) = field_value { - if !self.evaluate_where_clause(in_clause, value) { + if !in_clause.matches_value(value) { return false; } } else { @@ -167,10 +362,10 @@ impl DriveDocumentQueryFilter<'_> { } // Check range clause - if let Some(range_clause) = &self.internal_clauses.range_clause { - let field_value = document_data.get(&range_clause.field); + if let Some(range_clause) = &clauses.range_clause { + let field_value = get_value_by_path(document_data, &range_clause.field); if let Some(value) = field_value { - if !self.evaluate_where_clause(range_clause, value) { + if !range_clause.matches_value(value) { return false; } } else { @@ -179,10 +374,10 @@ impl DriveDocumentQueryFilter<'_> { } // Check equal clauses - for (field, equal_clause) in &self.internal_clauses.equal_clauses { - let field_value = document_data.get(field); + for (field, equal_clause) in &clauses.equal_clauses { + let field_value = get_value_by_path(document_data, field); if let Some(value) = field_value { - if !self.evaluate_where_clause(equal_clause, value) { + if !equal_clause.matches_value(value) { return false; } } else { @@ -193,91 +388,137 @@ impl DriveDocumentQueryFilter<'_> { true } - /// Helper function to evaluate a where clause against a value + /// Validates that the filter's clauses are valid for the document type and indexes #[cfg(any(feature = "server", feature = "verify"))] - fn evaluate_where_clause(&self, clause: &WhereClause, value: &Value) -> bool { - match &clause.operator { - WhereOperator::Equal => value == &clause.value, - WhereOperator::GreaterThan => value > &clause.value, - WhereOperator::GreaterThanOrEquals => value >= &clause.value, - WhereOperator::LessThan => value < &clause.value, - WhereOperator::LessThanOrEquals => value <= &clause.value, - WhereOperator::In => { - if let Value::Array(ref array) = clause.value { - array.contains(value) - } else { - false - } + pub fn validate(&self) -> bool { + // Ensure the document type exists + let Ok(document_type) = self + .contract + .document_type_for_name(&self.document_type_name) + else { + return false; + }; + + let validate_indexes = |clauses: &InternalClauses| -> bool { + if !clauses.verify() { + return false; } - WhereOperator::Between => { - if let Value::Array(ref bounds) = clause.value { - if bounds.len() == 2 { - value >= &bounds[0] && value <= &bounds[1] - } else { - false - } - } else { - false - } + // If no data clauses or only primary key, skip index selection + if clauses.is_empty() || clauses.is_for_primary_key() { + return true; } - WhereOperator::BetweenExcludeBounds => { - if let Value::Array(ref bounds) = clause.value { - if bounds.len() == 2 { - value > &bounds[0] && value < &bounds[1] - } else { - false - } - } else { - false + let query = DriveDocumentQuery { + contract: self.contract, + document_type, + internal_clauses: clauses.clone(), + offset: None, + limit: None, + order_by: IndexMap::new(), + start_at: None, + start_at_included: false, + block_time_ms: None, + }; + query.find_best_index(LATEST_PLATFORM_VERSION).is_ok() + }; + + // Validate internal clauses depending on action + match &self.action_clauses { + DocumentActionClauses::Create { final_clauses } => validate_indexes(final_clauses), + DocumentActionClauses::Replace { + original_clauses, + final_clauses, + } => { + if original_clauses.is_none() && final_clauses.is_none() { + return false; } + let orig_ok = original_clauses + .as_ref() + .map(|c| validate_indexes(c)) + .unwrap_or(true); + let final_ok = final_clauses + .as_ref() + .map(|c| validate_indexes(c)) + .unwrap_or(true); + orig_ok && final_ok } - WhereOperator::BetweenExcludeLeft => { - if let Value::Array(ref bounds) = clause.value { - if bounds.len() == 2 { - value > &bounds[0] && value <= &bounds[1] - } else { - false - } - } else { - false + DocumentActionClauses::Delete { original_clauses } => { + validate_indexes(original_clauses) + } + DocumentActionClauses::Transfer { + original_clauses, + owner_clause, + } => { + if original_clauses.is_none() && owner_clause.is_none() { + return false; } + original_clauses + .as_ref() + .map(|c| validate_indexes(c)) + .unwrap_or(true) } - WhereOperator::BetweenExcludeRight => { - if let Value::Array(ref bounds) = clause.value { - if bounds.len() == 2 { - value >= &bounds[0] && value < &bounds[1] - } else { - false - } - } else { - false + DocumentActionClauses::UpdatePrice { + original_clauses, + price_clause, + } => { + if original_clauses.is_none() && price_clause.is_none() { + return false; } + original_clauses + .as_ref() + .map(|c| validate_indexes(c)) + .unwrap_or(true) } - WhereOperator::StartsWith => { - if let (Value::Text(text), Value::Text(prefix)) = (value, &clause.value) { - text.starts_with(prefix.as_str()) - } else { - false + DocumentActionClauses::Purchase { + original_clauses, + owner_clause, + } => { + if original_clauses.is_none() && owner_clause.is_none() { + return false; } + original_clauses + .as_ref() + .map(|c| validate_indexes(c)) + .unwrap_or(true) } } } +} - /// Validates that the filter's clauses are valid for the document type - #[cfg(any(feature = "server", feature = "verify"))] - pub fn validate(&self) -> bool { - // Convert the filter to a query to reuse find_best_index logic - let query: DriveDocumentQuery = self.clone().into(); - query.find_best_index(LATEST_PLATFORM_VERSION).is_ok() +/// Resolve a dot-notated path into a nested `BTreeMap` payload. +/// +/// Supports dot notation like `meta.status` by walking `Value::Map` entries +/// using `ValueMapHelper`. Returns `None` if any segment is missing or if a +/// non-map value is encountered before the final segment. An empty `path` +/// returns `None`. +#[cfg(any(feature = "server", feature = "verify"))] +fn get_value_by_path<'a>(root: &'a BTreeMap, path: &str) -> Option<&'a Value> { + if path.is_empty() { + return None; } + let mut current: Option<&Value> = None; + let mut segments = path.split('.'); + if let Some(first) = segments.next() { + current = root.get(first); + } + for seg in segments { + match current { + Some(Value::Map(ref vm)) => { + current = vm.get_optional_key(seg); + } + _ => return None, + } + } + current } #[cfg(test)] mod tests { use super::*; + use crate::query::WhereOperator; use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::document_base_transition::v1::DocumentBaseTransitionV1; use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::LATEST_PLATFORM_VERSION; #[test] fn test_matches_document_basic() { @@ -289,7 +530,9 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - internal_clauses: InternalClauses::default(), + action_clauses: DocumentActionClauses::Create { + final_clauses: InternalClauses::default(), + }, }; // Create matching document base @@ -329,13 +572,15 @@ mod tests { internal_clauses.primary_key_equal_clause = Some(WhereClause { field: "$id".to_string(), operator: WhereOperator::Equal, - value: Value::Identifier(target_id.to_buffer()), + value: target_id.into(), }); let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - internal_clauses, + action_clauses: DocumentActionClauses::Create { + final_clauses: internal_clauses, + }, }; // Test with matching ID @@ -385,7 +630,9 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - internal_clauses, + action_clauses: DocumentActionClauses::Create { + final_clauses: internal_clauses, + }, }; let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { @@ -433,7 +680,9 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - internal_clauses, + action_clauses: DocumentActionClauses::Create { + final_clauses: internal_clauses, + }, }; let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { @@ -471,7 +720,9 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - internal_clauses, + action_clauses: DocumentActionClauses::Create { + final_clauses: internal_clauses, + }, }; let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { @@ -498,6 +749,542 @@ mod tests { assert!(!filter.matches_document(&document_base, &less_data)); } + #[test] + fn test_matches_document_with_nested_field() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + // Equal on nested field: meta.status == "active" + let mut equal_clauses = BTreeMap::new(); + equal_clauses.insert( + "meta.status".to_string(), + WhereClause { + field: "meta.status".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("active".to_string()), + }, + ); + + let mut internal_clauses = InternalClauses::default(); + internal_clauses.equal_clauses = equal_clauses; + + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Create { + final_clauses: internal_clauses, + }, + }; + + let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { + id: Identifier::from([3u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + token_payment_info: None, + }); + + // Build nested data: { meta: { status: "active" } } + let nested = vec![( + Value::Text("status".to_string()), + Value::Text("active".to_string()), + )]; + let mut data = BTreeMap::new(); + data.insert("meta".to_string(), Value::Map(nested)); + + assert!(filter.matches_document(&document_base, &data)); + } + + #[test] + fn test_validate_requires_at_least_one_clause_for_optional_actions() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + // Replace with none/none -> invalid + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Replace { + original_clauses: None, + final_clauses: None, + }, + }; + assert!(!filter.validate()); + + // Replace with final only -> valid + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Replace { + original_clauses: None, + final_clauses: Some(InternalClauses::default()), + }, + }; + assert!(filter.validate()); + + // Transfer with none/none -> invalid + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Transfer { + original_clauses: None, + owner_clause: None, + }, + }; + assert!(!filter.validate()); + + // Transfer with owner only -> valid + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Transfer { + original_clauses: None, + owner_clause: Some(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier([1u8; 32]), + }), + }, + }; + assert!(filter.validate()); + + // UpdatePrice with none/none -> invalid + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::UpdatePrice { + original_clauses: None, + price_clause: None, + }, + }; + assert!(!filter.validate()); + + // UpdatePrice with price only -> valid + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::UpdatePrice { + original_clauses: None, + price_clause: Some(WhereClause { + field: "price".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::U64(0), + }), + }, + }; + assert!(filter.validate()); + + // Purchase with none/none -> invalid + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Purchase { + original_clauses: None, + owner_clause: None, + }, + }; + assert!(!filter.validate()); + + // Purchase with owner only -> valid + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Purchase { + original_clauses: None, + owner_clause: Some(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier([2u8; 32]), + }), + }, + }; + assert!(filter.validate()); + } + + #[test] + fn test_transfer_owner_clause_only_matches() { + use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::DocumentTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::DocumentTransferTransition; + + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + let new_owner = Identifier::from([5u8; 32]); + + // Filter checks only new owner + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Transfer { + original_clauses: None, + owner_clause: Some(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: new_owner.into(), + }), + }, + }; + + // Transfer transition with recipient = new_owner + let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { + id: Identifier::from([3u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + token_payment_info: None, + }); + + let transfer_v0 = DocumentTransferTransitionV0 { + base: document_base.clone(), + revision: 1 as u64, + recipient_owner_id: new_owner, + }; + let transfer = DocumentTransition::Transfer(DocumentTransferTransition::V0(transfer_v0)); + + // No original doc needed; owner is taken from transfer + assert!(filter.matches_document_transition(&transfer, None, None)); + + // Mismatch owner + let other_owner = Identifier::from([6u8; 32]); + let transfer_v0_mismatch = DocumentTransferTransitionV0 { + base: document_base, + revision: 1 as u64, + recipient_owner_id: other_owner, + }; + let transfer_mismatch = + DocumentTransition::Transfer(DocumentTransferTransition::V0(transfer_v0_mismatch)); + assert!(!filter.matches_document_transition(&transfer_mismatch, None, None)); + } + + #[test] + fn test_purchase_owner_clause_only_matches_and_requires_owner_context() { + use dpp::fee::Credits; + use dpp::state_transition::batch_transition::batched_transition::document_purchase_transition::v0::DocumentPurchaseTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_purchase_transition::DocumentPurchaseTransition; + + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + let purchaser = Identifier::from([7u8; 32]); + + // Filter checks batch owner (purchaser) + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Purchase { + original_clauses: None, + owner_clause: Some(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: purchaser.into(), + }), + }, + }; + + // Purchase transition + let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { + id: Identifier::from([4u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + token_payment_info: None, + }); + + let purchase_v0 = DocumentPurchaseTransitionV0 { + base: document_base, + revision: 1 as u64, + price: 10 as Credits, + }; + let purchase = DocumentTransition::Purchase(DocumentPurchaseTransition::V0(purchase_v0)); + + // Without passing the batch owner context, should fail (owner clause requires it) + assert!(!filter.matches_document_transition(&purchase, None, None)); + + // With batch owner context, should pass + let owner_value: Value = purchaser.into(); + assert!(filter.matches_document_transition(&purchase, None, Some(&owner_value))); + } + + #[test] + fn test_transfer_original_clause_only_matches_with_original_document() { + use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::DocumentTransferTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::DocumentTransferTransition; + + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + // Filter checks only original document field + let mut eq = BTreeMap::new(); + eq.insert( + "status".to_string(), + WhereClause { + field: "status".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("active".to_string()), + }, + ); + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Transfer { + original_clauses: Some(InternalClauses { + equal_clauses: eq, + ..Default::default() + }), + owner_clause: None, + }, + }; + + let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { + id: Identifier::from([9u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + token_payment_info: None, + }); + let transfer_v0 = DocumentTransferTransitionV0 { + base: document_base, + revision: 1, + recipient_owner_id: Identifier::from([8u8; 32]), + }; + let transfer = DocumentTransition::Transfer(DocumentTransferTransition::V0(transfer_v0)); + + // Original doc present and matching + let mut original = BTreeMap::new(); + original.insert("status".to_string(), Value::Text("active".to_string())); + assert!(filter.matches_document_transition(&transfer, Some(&original), None)); + + // Without original doc, clause is required -> no match + assert!(!filter.matches_document_transition(&transfer, None, None)); + } + + #[test] + fn test_delete_original_clause_only_matches_with_original_document() { + use dpp::state_transition::batch_transition::batched_transition::document_delete_transition::v0::DocumentDeleteTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_delete_transition::DocumentDeleteTransition; + + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + // Filter checks only original document field + let mut eq = BTreeMap::new(); + eq.insert( + "status".to_string(), + WhereClause { + field: "status".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("active".to_string()), + }, + ); + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Delete { + original_clauses: InternalClauses { + equal_clauses: eq, + ..Default::default() + }, + }, + }; + + let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { + id: Identifier::from([12u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + token_payment_info: None, + }); + let delete_v0 = DocumentDeleteTransitionV0 { + base: document_base, + }; + let delete = DocumentTransition::Delete(DocumentDeleteTransition::V0(delete_v0)); + + // Original doc present and matching + let mut original = BTreeMap::new(); + original.insert("status".to_string(), Value::Text("active".to_string())); + assert!(filter.matches_document_transition(&delete, Some(&original), None)); + + // Without original doc -> no match (required for Delete) + assert!(!filter.matches_document_transition(&delete, None, None)); + + // Original mismatching -> no match + let mut original_bad = BTreeMap::new(); + original_bad.insert("status".to_string(), Value::Text("inactive".to_string())); + assert!(!filter.matches_document_transition(&delete, Some(&original_bad), None)); + } + + #[test] + fn test_update_price_price_clause_only_matches_and_with_original_clause() { + use dpp::fee::Credits; + use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::DocumentUpdatePriceTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::DocumentUpdatePriceTransition; + + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + let base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { + id: Identifier::from([10u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + token_payment_info: None, + }); + + // Price-only clause + let update_v0 = DocumentUpdatePriceTransitionV0 { + base: base.clone(), + revision: 1, + price: 10 as Credits, + }; + let update = DocumentTransition::UpdatePrice(DocumentUpdatePriceTransition::V0(update_v0)); + + let filter_price_only = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::UpdatePrice { + original_clauses: None, + price_clause: Some(WhereClause { + field: "price".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::U64(5), + }), + }, + }; + assert!(filter_price_only.matches_document_transition(&update, None, None)); + + let filter_price_only_fail = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::UpdatePrice { + original_clauses: None, + price_clause: Some(WhereClause { + field: "price".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::U64(15), + }), + }, + }; + assert!(!filter_price_only_fail.matches_document_transition(&update, None, None)); + + // With original clauses as well + let mut eq = BTreeMap::new(); + eq.insert( + "kind".to_string(), + WhereClause { + field: "kind".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("sale".to_string()), + }, + ); + let filter_with_orig = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::UpdatePrice { + original_clauses: Some(InternalClauses { + equal_clauses: eq, + ..Default::default() + }), + price_clause: Some(WhereClause { + field: "price".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: Value::U64(10), + }), + }, + }; + let mut original_doc = BTreeMap::new(); + original_doc.insert("kind".to_string(), Value::Text("sale".to_string())); + assert!(filter_with_orig.matches_document_transition(&update, Some(&original_doc), None)); + + // Missing original doc -> required -> no match + assert!(!filter_with_orig.matches_document_transition(&update, None, None)); + } + + #[test] + fn test_replace_with_both_original_and_final_clauses() { + use dpp::state_transition::batch_transition::batched_transition::document_replace_transition::v0::DocumentReplaceTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_replace_transition::DocumentReplaceTransition; + + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + let base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { + id: Identifier::from([11u8; 32]), + document_type_name: "niceDocument".to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + token_payment_info: None, + }); + + // Original must have status=active; New must have score=10 + let mut orig_eq = BTreeMap::new(); + orig_eq.insert( + "status".to_string(), + WhereClause { + field: "status".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("active".to_string()), + }, + ); + let original_clauses = InternalClauses { + equal_clauses: orig_eq, + ..Default::default() + }; + + let mut final_eq = BTreeMap::new(); + final_eq.insert( + "score".to_string(), + WhereClause { + field: "score".to_string(), + operator: WhereOperator::Equal, + value: Value::U64(10), + }, + ); + let final_clauses = InternalClauses { + equal_clauses: final_eq, + ..Default::default() + }; + + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Replace { + original_clauses: Some(original_clauses), + final_clauses: Some(final_clauses), + }, + }; + + // Build Replace transition with new data + let mut data = BTreeMap::new(); + data.insert("score".to_string(), Value::U64(10)); + let replace_v0 = DocumentReplaceTransitionV0 { + base: base, + revision: 1, + data, + }; + let replace = DocumentTransition::Replace(DocumentReplaceTransition::V0(replace_v0)); + + // Original provided and matching; final matches + let mut original_doc = BTreeMap::new(); + original_doc.insert("status".to_string(), Value::Text("active".to_string())); + assert!(filter.matches_document_transition(&replace, Some(&original_doc), None)); + + // Original missing -> should fail as it's required + assert!(!filter.matches_document_transition(&replace, None, None)); + + // Original mismatching -> fail + let mut original_doc_bad = BTreeMap::new(); + original_doc_bad.insert("status".to_string(), Value::Text("inactive".to_string())); + assert!(!filter.matches_document_transition(&replace, Some(&original_doc_bad), None)); + + // Final mismatching -> fail (change score) + if let DocumentTransition::Replace(mut rep) = replace.clone() { + let DocumentReplaceTransition::V0(ref mut v0) = rep; + v0.data.insert("score".to_string(), Value::U64(9)); + let bad_final = DocumentTransition::Replace(rep); + assert!(!filter.matches_document_transition(&bad_final, Some(&original_doc), None)); + } + } + #[test] fn test_matches_document_with_between_operator() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); @@ -513,7 +1300,9 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - internal_clauses, + action_clauses: DocumentActionClauses::Create { + final_clauses: internal_clauses, + }, }; let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { @@ -571,7 +1360,9 @@ mod tests { let valid_filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "indexedDocument".to_string(), - internal_clauses, + action_clauses: DocumentActionClauses::Create { + final_clauses: internal_clauses, + }, }; assert!( @@ -579,7 +1370,7 @@ mod tests { "Filter with indexed field should be valid" ); - // Test invalid filter with non-indexed field + // Test filter with non-indexed field: index validation should fail let mut internal_clauses = InternalClauses::default(); let mut equal_clauses = BTreeMap::new(); equal_clauses.insert( @@ -595,7 +1386,9 @@ mod tests { let invalid_filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "indexedDocument".to_string(), - internal_clauses, + action_clauses: DocumentActionClauses::Create { + final_clauses: internal_clauses, + }, }; assert!( @@ -614,7 +1407,9 @@ mod tests { let primary_key_filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "indexedDocument".to_string(), - internal_clauses, + action_clauses: DocumentActionClauses::Create { + final_clauses: internal_clauses, + }, }; assert!( @@ -638,32 +1433,16 @@ mod tests { let original_filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - internal_clauses: internal_clauses.clone(), + action_clauses: DocumentActionClauses::Create { + final_clauses: internal_clauses.clone(), + }, }; - // Convert to DriveDocumentQuery - let query: DriveDocumentQuery = original_filter.clone().into(); - - // Check that core fields are preserved - assert_eq!(query.contract.id(), contract.id()); - assert_eq!(query.document_type.name(), "niceDocument"); - assert_eq!(query.internal_clauses, internal_clauses); - - // Check that optional fields are set to defaults - assert_eq!(query.offset, None); - assert_eq!(query.limit, None); - assert!(query.order_by.is_empty()); - assert_eq!(query.start_at, None); - assert_eq!(query.start_at_included, false); - assert_eq!(query.block_time_ms, None); - - // Convert back to filter - let converted_filter: DriveDocumentQueryFilter = query.into(); - - // Should preserve the internal clauses - assert_eq!( - converted_filter.internal_clauses, - original_filter.internal_clauses - ); + // No conversion helpers; verify the filter holds the expected clauses + if let DocumentActionClauses::Create { final_clauses } = original_filter.action_clauses { + assert_eq!(final_clauses, internal_clauses); + } else { + panic!("expected Create action clauses"); + } } } From c676087dc6b0af442f58717e35989a9ec3653eb2 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Tue, 9 Sep 2025 22:51:47 +0700 Subject: [PATCH 10/30] validation --- packages/rs-drive/src/query/conditions.rs | 522 +++++++++- packages/rs-drive/src/query/filter.rs | 1065 ++++++++++++++------- packages/rs-drive/src/query/mod.rs | 94 +- 3 files changed, 1348 insertions(+), 333 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 60457cedf6c..3965bf24fef 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -264,7 +264,7 @@ impl<'a> WhereClause { } /// Returns the where clause `in` values if they are an array of values, else an error - pub fn in_values(&self) -> Result>, Error> { + pub fn in_values(&'_ self) -> Result>, Error> { let in_values = match &self.value { Value::Array(array) => Ok(Cow::Borrowed(array)), Value::Bytes(bytes) => Ok(Cow::Owned( @@ -1308,20 +1308,404 @@ impl<'a> WhereClause { } } +/// Value-only clause used when there is no field lookup involved +/// (e.g., comparing a transition-supplied scalar like owner id or price). +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ValueClause { + /// Operator + pub operator: WhereOperator, + /// Value + pub value: Value, +} + +impl ValueClause { + /// Evaluate this clause against a provided `Value` + pub fn matches_value(&self, value: &Value) -> bool { + match &self.operator { + WhereOperator::Equal => value == &self.value, + WhereOperator::GreaterThan => value > &self.value, + WhereOperator::GreaterThanOrEquals => value >= &self.value, + WhereOperator::LessThan => value < &self.value, + WhereOperator::LessThanOrEquals => value <= &self.value, + WhereOperator::In => match &self.value { + Value::Array(array) => array.contains(value), + _ => false, + }, + WhereOperator::Between => match &self.value { + Value::Array(bounds) if bounds.len() == 2 => { + value >= &bounds[0] && value <= &bounds[1] + } + _ => false, + }, + WhereOperator::BetweenExcludeBounds => match &self.value { + Value::Array(bounds) if bounds.len() == 2 => { + value > &bounds[0] && value < &bounds[1] + } + _ => false, + }, + WhereOperator::BetweenExcludeLeft => match &self.value { + Value::Array(bounds) if bounds.len() == 2 => { + value > &bounds[0] && value <= &bounds[1] + } + _ => false, + }, + WhereOperator::BetweenExcludeRight => match &self.value { + Value::Array(bounds) if bounds.len() == 2 => { + value >= &bounds[0] && value < &bounds[1] + } + _ => false, + }, + WhereOperator::StartsWith => { + if let (Value::Text(text), Value::Text(prefix)) = (value, &self.value) { + text.starts_with(prefix.as_str()) + } else { + false + } + } + } + } +} + impl From for Value { fn from(value: WhereClause) -> Self { Value::Array(vec![value.field.into(), value.operator.into(), value.value]) } } +#[cfg(any(feature = "server", feature = "verify"))] +/// Returns the set of allowed operators for a given property type +pub fn allowed_ops_for_type( + property_type: &dpp::data_contract::document_type::DocumentPropertyType, +) -> &'static [WhereOperator] { + use dpp::data_contract::document_type::DocumentPropertyType as T; + use WhereOperator as Op; + match property_type { + T::U8 + | T::I8 + | T::U16 + | T::I16 + | T::U32 + | T::I32 + | T::U64 + | T::I64 + | T::U128 + | T::I128 + | T::F64 + | T::Date => &[ + Op::Equal, + Op::In, + Op::GreaterThan, + Op::GreaterThanOrEquals, + Op::LessThan, + Op::LessThanOrEquals, + Op::Between, + Op::BetweenExcludeBounds, + Op::BetweenExcludeLeft, + Op::BetweenExcludeRight, + ], + T::String(_) => &[Op::Equal, Op::In, Op::StartsWith], + T::Identifier => &[Op::Equal, Op::In], + T::ByteArray(_) => &[Op::Equal, Op::In], + T::Boolean => &[Op::Equal], + T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => &[], + } +} + +#[cfg(any(feature = "server", feature = "verify"))] +fn is_numeric_value(v: &Value) -> bool { + matches!( + v, + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + | Value::Float(_) + ) +} + +#[cfg(any(feature = "server", feature = "verify"))] +/// Validates that a value matches the expected shape for a given operator and property type +pub fn value_shape_ok( + op: WhereOperator, + v: &Value, + prop_ty: &dpp::data_contract::document_type::DocumentPropertyType, +) -> bool { + use WhereOperator as Op; + match op { + Op::Equal => true, + Op::In => matches!(v, Value::Array(_) | Value::Bytes(_)), + Op::StartsWith => matches!(v, Value::Text(_)), + Op::GreaterThan | Op::GreaterThanOrEquals | Op::LessThan | Op::LessThanOrEquals => { + match prop_ty { + dpp::data_contract::document_type::DocumentPropertyType::F64 => is_numeric_value(v), + _ => matches!( + v, + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), + } + } + Op::Between + | Op::BetweenExcludeBounds + | Op::BetweenExcludeLeft + | Op::BetweenExcludeRight => { + if let Value::Array(arr) = v { + arr.len() == 2 + && arr.iter().all(|x| match prop_ty { + dpp::data_contract::document_type::DocumentPropertyType::F64 => { + is_numeric_value(x) + } + _ => matches!( + x, + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), + }) + } else { + false + } + } + } +} + +#[cfg(any(feature = "server", feature = "verify"))] +/// Validate a single where clause against the document schema +pub fn validate_where_clause_against_schema( + document_type: DocumentTypeRef, + clause: &WhereClause, +) -> Result<(), crate::error::Error> { + // Check that the field exists + let property = document_type + .flattened_properties() + .get(&clause.field) + .ok_or_else(|| { + Error::Query(QuerySyntaxError::InvalidWhereClauseComponents( + "unknown field in where clause", + )) + })?; + + // Check operator is allowed for field type + let property_type = &property.property_type; + if !allowed_ops_for_type(property_type).contains(&clause.operator) { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents("operator not allowed for field type"), + )); + } + + // Check starts_with value is not empty + if clause.operator == WhereOperator::StartsWith { + if let Value::Text(s) = &clause.value { + if s.is_empty() { + return Err(Error::Query(QuerySyntaxError::StartsWithIllegalString( + "starts with can not start with an empty string", + ))); + } + } + } + + // Check in clause values + if clause.operator == WhereOperator::In { + // Ensure array value, length bounds and no duplicates + clause.in_values()?; + // If value provided as Bytes, only allow for U8 numeric fields + if matches!(clause.value, Value::Bytes(_)) + && !matches!( + property_type, + dpp::data_contract::document_type::DocumentPropertyType::U8 + ) + { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "IN Bytes only allowed for U8 fields", + ), + )); + } + } + + // Check value shape is correct for operator and field type + if !value_shape_ok(clause.operator, &clause.value, property_type) { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents("invalid value shape for operator"), + )); + } + + // Additional strict type checks for Equal and In element types + let value_type_matches = + |prop_ty: &dpp::data_contract::document_type::DocumentPropertyType, v: &Value| -> bool { + use dpp::data_contract::document_type::DocumentPropertyType as T; + match prop_ty { + T::String(_) => matches!(v, Value::Text(_)), + T::Identifier => matches!(v, Value::Identifier(_)), + T::Boolean => matches!(v, Value::Bool(_)), + T::ByteArray(_) => matches!(v, Value::Bytes(_)), + T::F64 => matches!(v, Value::Float(_)), + T::Date => matches!( + v, + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), + T::U8 | T::U16 | T::U32 | T::U64 | T::U128 => matches!( + v, + Value::U8(_) | Value::U16(_) | Value::U32(_) | Value::U64(_) | Value::U128(_) + ), + T::I8 | T::I16 | T::I32 | T::I64 | T::I128 => matches!( + v, + Value::I8(_) | Value::I16(_) | Value::I32(_) | Value::I64(_) | Value::I128(_) + ), + // No validation for object/array types as operators are disallowed + T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, + } + }; + + match clause.operator { + WhereOperator::Equal => { + if !value_type_matches(property_type, &clause.value) { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "invalid value type for equality", + ), + )); + } + } + WhereOperator::In => { + if let Value::Array(arr) = &clause.value { + if !arr.iter().all(|v| value_type_matches(property_type, v)) { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "invalid value type in IN clause", + ), + )); + } + } + } + _ => {} + } + + Ok(()) +} + +#[cfg(any(feature = "server", feature = "verify"))] +/// Validate a collection of InternalClauses against the document schema +pub fn validate_internal_clauses_against_schema( + document_type: dpp::data_contract::document_type::DocumentTypeRef, + clauses: &super::InternalClauses, +) -> Result<(), crate::error::Error> { + // Basic composition + if !clauses.verify() { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents("invalid composition of where clauses"), + )); + } + + // Validate in_clause against schema + if let Some(in_clause) = &clauses.in_clause { + // Forbid $id in non-primary-key clauses + if in_clause.field == "$id" { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents("use primary_key_* clauses for $id"), + )); + } + validate_where_clause_against_schema(document_type, in_clause)?; + } + + // Validate range_clause against schema + if let Some(range_clause) = &clauses.range_clause { + // Forbid $id in non-primary-key clauses + if range_clause.field == "$id" { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents("use primary_key_* clauses for $id"), + )); + } + validate_where_clause_against_schema(document_type, range_clause)?; + } + + // Validate equal_clauses against schema + for (field, eq_clause) in &clauses.equal_clauses { + // Forbid $id in non-primary-key clauses + if field.as_str() == "$id" { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents("use primary_key_* clauses for $id"), + )); + } + validate_where_clause_against_schema(document_type, eq_clause)?; + } + + // Validate primary key clauses typing + if let Some(pk_eq) = &clauses.primary_key_equal_clause { + if pk_eq.operator != WhereOperator::Equal || !matches!(pk_eq.value, Value::Identifier(_)) { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "primary key equality must compare an identifier", + ), + )); + } + } + if let Some(pk_in) = &clauses.primary_key_in_clause { + if pk_in.operator != WhereOperator::In { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "primary key IN must use IN operator", + ), + )); + } + // enforce array shape and no duplicates/size + pk_in.in_values()?; + if let Value::Array(arr) = &pk_in.value { + if !arr.iter().all(|v| matches!(v, Value::Identifier(_))) { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "primary key IN must contain identifiers", + ), + )); + } + } else { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "primary key IN must contain an array of identifiers", + ), + )); + } + } + + Ok(()) +} + #[cfg(feature = "server")] #[cfg(test)] mod tests { + use super::{validate_internal_clauses_against_schema, validate_where_clause_against_schema}; use crate::query::conditions::WhereClause; use crate::query::conditions::WhereOperator::{ Equal, GreaterThan, GreaterThanOrEquals, In, LessThan, LessThanOrEquals, }; + use crate::query::InternalClauses; + use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::platform_value::Value; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::LATEST_PLATFORM_VERSION; #[test] fn test_allowed_sup_query_pairs() { @@ -1446,4 +1830,140 @@ mod tests { .expect_err("expected to not have a groupable pair"); } } + + #[test] + fn validate_rejects_equality_with_wrong_type_for_string_field() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("niceDocument") + .expect("doc type exists"); + + let clause = WhereClause { + field: "name".to_string(), + operator: Equal, + value: Value::Identifier([1u8; 32]), + }; + let res = validate_where_clause_against_schema(doc_type, &clause); + assert!(matches!( + res, + Err(crate::error::Error::Query( + crate::error::query::QuerySyntaxError::InvalidWhereClauseComponents(_) + )) + )); + } + + #[test] + fn validate_rejects_in_with_wrong_element_types() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("indexedDocument") + .expect("doc type exists"); + + let clause = WhereClause { + field: "firstName".to_string(), + operator: In, + value: Value::Array(vec![ + Value::Text("alice".to_string()), + Value::Identifier([2u8; 32]), + ]), + }; + let res = validate_where_clause_against_schema(doc_type, &clause); + assert!(matches!( + res, + Err(crate::error::Error::Query( + crate::error::query::QuerySyntaxError::InvalidWhereClauseComponents(_) + )) + )); + } + + #[test] + fn validate_rejects_primary_key_in_with_non_identifiers() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("niceDocument") + .expect("doc type exists"); + + let mut clauses = InternalClauses::default(); + clauses.primary_key_in_clause = Some(WhereClause { + field: "$id".to_string(), + operator: In, + value: Value::Array(vec![ + Value::Text("a".to_string()), + Value::Text("b".to_string()), + ]), + }); + + let res = validate_internal_clauses_against_schema(doc_type, &clauses); + assert!(matches!( + res, + Err(crate::error::Error::Query( + crate::error::query::QuerySyntaxError::InvalidWhereClauseComponents(_) + )) + )); + } + + #[test] + fn validate_rejects_date_with_float_equality() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("uniqueDates") + .expect("doc type exists"); + + let clause = WhereClause { + field: "$createdAt".to_string(), + operator: Equal, + value: Value::Float(1.23), + }; + let res = validate_where_clause_against_schema(doc_type, &clause); + assert!(matches!( + res, + Err(crate::error::Error::Query( + crate::error::query::QuerySyntaxError::InvalidWhereClauseComponents(_) + )) + )); + } + + #[test] + fn validate_rejects_range_on_string_field() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("niceDocument") + .expect("doc type exists"); + + let clause = WhereClause { + field: "name".to_string(), + operator: GreaterThan, + value: Value::Text("a".to_string()), + }; + let res = validate_where_clause_against_schema(doc_type, &clause); + assert!(matches!( + res, + Err(crate::error::Error::Query( + crate::error::query::QuerySyntaxError::InvalidWhereClauseComponents(_) + )) + )); + } + + #[test] + fn validate_rejects_in_bytes_for_string_field() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("niceDocument") + .expect("doc type exists"); + + // IN with Bytes should be rejected on string fields + let clause = WhereClause { + field: "name".to_string(), + operator: In, + value: Value::Bytes(vec![1, 2, 3]), + }; + let res = validate_where_clause_against_schema(doc_type, &clause); + assert!(res.is_err()); + } } diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 15f2279bf53..482ad84b4ca 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -6,20 +6,28 @@ //! (`DocumentActionClauses`). //! //! Filtering in brief: -//! - Create: evaluates `final_clauses` on the transition's data payload. -//! - Replace: optionally evaluates `original_clauses` on the original document and/or -//! `final_clauses` on the replacement data. -//! - Delete: evaluates `original_clauses` on the original document. -//! - Transfer: optionally evaluates `original_clauses` and/or a new `owner_clause` against +//! - Create: evaluates `new_document_clauses` on the transition's data payload. +//! - Replace: evaluates `original_document_clauses` on the original document and +//! `new_document_clauses` on the replacement data. +//! - Delete: evaluates `original_document_clauses` on the original document. +//! - Transfer: evaluates `original_document_clauses` and a new `owner_clause` against //! the `recipient_owner_id`. -//! - UpdatePrice: optionally evaluates `original_clauses` and/or a `price_clause` against +//! - UpdatePrice: evaluates `original_document_clauses` and a `price_clause` against //! the new price in the transition. -//! - Purchase: optionally evaluates `original_clauses` and/or an `owner_clause` against the +//! - Purchase: evaluates `original_document_clauses` and an `owner_clause` against the //! batch owner (purchaser) ID. //! -//! Validation is structural and index-aware: it checks the document type exists, that -//! at least one optional clause is provided where required (Replace/Transfer/UpdatePrice/ -//! Purchase), and that applicable clauses have compatible indexes. +//! Usage: +//! - First check: call `matches_document_transition()` per transition to +//! evaluate applicable constraints before fetching the original document. Decide +//! whether to fetch the original document (returns Pass/Fail/NeedsOriginal). +//! - Second check: only if the first check returned `NeedsOriginal`, fetch the original +//! and call `matches_document_transition_original_document()` to evaluate original-dependent +//! clauses. +//! +//! Validation is structural: `validate()` checks the document type exists and that +//! action-specific composition rules hold (e.g., at least one non-empty clause where +//! required). use std::collections::BTreeMap; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -34,18 +42,19 @@ use dpp::state_transition::batch_transition::document_base_transition::v0::v0_me use dpp::state_transition::batch_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::v0_methods::DocumentTransferTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::v0_methods::DocumentUpdatePriceTransitionV0Methods; -use indexmap::IndexMap; -use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause}; +use crate::query::{ + validate_internal_clauses_against_schema, InternalClauses, ValueClause, WhereOperator, +}; +use crate::error::{query::QuerySyntaxError, Error}; use dpp::platform_value::ValueMapHelper; -use dpp::version::LATEST_PLATFORM_VERSION; -#[cfg(any(feature = "server", feature = "verify"))] /// Filter used to match document transitions for subscriptions. /// /// Targets a specific data contract and document type, and carries action-specific -/// clauses via `DocumentActionClauses`. Use `matches_document_transition()` to evaluate -/// batch/document transitions. `validate()` performs structural checks (document type -/// exists, clause composition rules). +/// clauses via `DocumentActionClauses`. Use `matches_document_transition()` +/// and `matches_document_transition_original_document()` to evaluate batch/document transitions. +/// `validate()` performs structural checks (document type exists, clause composition rules). +#[cfg(any(feature = "server", feature = "verify"))] #[derive(Debug, PartialEq, Clone)] pub struct DriveDocumentQueryFilter<'a> { /// DataContract @@ -56,229 +65,362 @@ pub struct DriveDocumentQueryFilter<'a> { pub action_clauses: DocumentActionClauses, } +/// Result of evaluating constraints for a transition before potentially fetching the original document. +#[cfg(any(feature = "server", feature = "verify"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransitionCheckResult { + /// All applicable transition-level checks pass and no original is required. + Pass, + /// Some transition-level check fails; do not fetch original. + Fail, + /// Transition-level checks pass, original clauses are non-empty and must be evaluated. + NeedsOriginal, +} + /// Action-specific filter clauses for matching document transitions. /// /// These clauses are used to evaluate whether a given document transition -/// (Create/Replace/Delete/Transfer/UpdatePrice/Purchase) matches a -/// subscription filter. Some variants allow optional sub-clauses; if an -/// optional sub-clause is `None`, it imposes no constraint. For variants with -/// multiple optional sub-clauses, at least one must be present (validated via -/// `DriveDocumentQueryFilter::validate`). +/// (Create/Replace/Delete/Transfer/UpdatePrice/Purchase) matches a subscription +/// filter. +/// +/// Conventions: +/// - Empty `InternalClauses` = no constraint for document-data checks. +/// - `Option` = optional scalar constraint (owner/price); `None` = no constraint. +/// - Action-specific “at least one present” rules are enforced by `validate()`. #[derive(Debug, PartialEq, Clone)] pub enum DocumentActionClauses { - /// Create document: filter on final document only. - /// - /// The `final_clauses` apply to the transition's data payload. - Create { final_clauses: InternalClauses }, - /// Replace document: optionally filter on original and/or final. - /// - /// - If `original_clauses` is `Some`, the original document (pre-change) - /// must be provided and match. - /// - If `final_clauses` is `Some`, the transition's replacement data must - /// match. - /// - Validation requires that at least one of the two be `Some`. + /// Create: filters on the new document data. + Create { + /// Clauses on the new document data. + new_document_clauses: InternalClauses, + }, + /// Replace: filters on original and/or new document data. Replace { - original_clauses: Option, - final_clauses: Option, + /// Clauses on the original document data (pre-change). + original_document_clauses: InternalClauses, + /// Clauses on the new document data (replacement). + new_document_clauses: InternalClauses, }, - /// Delete: filter on original (existing) document. - /// - /// The `original_clauses` apply to the original document; matching - /// requires the original document to be supplied. - Delete { original_clauses: InternalClauses }, - /// Transfer: filter on original document and/or new owner id. - /// - /// - `original_clauses`: optional constraints on the original document. - /// - `owner_clause`: optional constraint on the recipient owner id. - /// - Validation requires at least one of the two to be `Some`. + /// Delete: filters on the original (existing) document. + Delete { + /// Clauses on the original document data. + original_document_clauses: InternalClauses, + }, + /// Transfer: filters on original data and/or recipient owner id. Transfer { - original_clauses: Option, - owner_clause: Option, + /// Clauses on the original document data. + original_document_clauses: InternalClauses, + /// Constraint on the recipient owner id. + owner_clause: Option, }, - /// Update price: filter on original doc and/or the new price using a simple clause. - /// - /// - `original_clauses`: optional constraints on the original document. - /// - `price_clause`: optional constraint evaluated against the new price. - /// - Validation requires at least one of the two to be `Some`. + /// UpdatePrice: filters on original data and/or the new price. UpdatePrice { - original_clauses: Option, - price_clause: Option, + /// Clauses on the original document data. + original_document_clauses: InternalClauses, + /// Constraint on the new price. + price_clause: Option, }, - /// Purchase: filter on original document and/or new owner id (batch owner). - /// - /// - `original_clauses`: optional constraints on the original document. - /// - `owner_clause`: optional constraint evaluated against the batch - /// transition owner id (the purchaser). - /// - Validation requires at least one of the two to be `Some`. + /// Purchase: filters on original data and/or batch owner id. Purchase { - original_clauses: Option, - owner_clause: Option, + /// Clauses on the original document data. + original_document_clauses: InternalClauses, + /// Constraint on the batch owner (purchaser) id. + owner_clause: Option, }, } impl DriveDocumentQueryFilter<'_> { - /// Checks if a document transition matches the filter, with optional - /// original document data and an optional batch owner value. + /// Check a transition using only transition-level constraints. + /// + /// When to run: + /// - Call this for each incoming transition before performing any storage reads + /// to fetch the original document. It short-circuits on obvious mismatches and + /// tells you if an original is needed at all for the final decision. + /// + /// Returns: + /// - `Pass` if all applicable transition-level checks pass and no original is needed. + /// - `Fail` if any transition-level check fails (no need to fetch original). + /// - `NeedsOriginal` if transition-level checks pass but original clauses are non-empty + /// and must be evaluated with the original document. #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_document_transition( &self, document_transition: &DocumentTransition, - original_document: Option<&BTreeMap>, batch_owner_value: Option<&Value>, - ) -> bool { + ) -> TransitionCheckResult { match (&self.action_clauses, document_transition) { + // Create: evaluate final clauses only ( - DocumentActionClauses::Create { final_clauses }, + DocumentActionClauses::Create { + new_document_clauses, + }, DocumentTransition::Create(create), - ) => self.evaluate_document_with_clauses(final_clauses, create.base(), create.data()), + ) => { + if self.evaluate_document_with_clauses( + new_document_clauses, + create.base(), + create.data(), + ) { + TransitionCheckResult::Pass + } else { + TransitionCheckResult::Fail + } + } + // Replace: evaluate new-document clauses (if non-empty); if they pass, + // require original if `original_document_clauses` is non-empty and not primary-key-only. ( DocumentActionClauses::Replace { - original_clauses, - final_clauses, + original_document_clauses, + new_document_clauses, }, DocumentTransition::Replace(replace), ) => { - // Both must match if present - let orig_ok = match (original_clauses, original_document) { - (Some(clauses), Some(orig)) => { - self.evaluate_document_with_clauses(clauses, replace.base(), orig) - } - (Some(_), None) => false, // needed but not provided - (None, _) => true, + let final_ok = if new_document_clauses.is_empty() { + true + } else { + self.evaluate_document_with_clauses( + new_document_clauses, + replace.base(), + replace.data(), + ) }; - let new_ok = match final_clauses { - Some(clauses) => { - self.evaluate_document_with_clauses(clauses, replace.base(), replace.data()) + if !final_ok { + return TransitionCheckResult::Fail; + } + if original_document_clauses.is_empty() + || original_document_clauses.is_for_primary_key() + { + if self.matches_document(replace.base(), &BTreeMap::new()) { + TransitionCheckResult::Pass + } else { + TransitionCheckResult::Fail } - None => true, - }; - orig_ok && new_ok + } else { + TransitionCheckResult::NeedsOriginal + } } + // Delete: needs original only if original_document_clauses reference fields. + // If clauses are empty or primary-key-only, we can decide without original. ( - DocumentActionClauses::Delete { original_clauses }, + DocumentActionClauses::Delete { + original_document_clauses, + }, DocumentTransition::Delete(delete), - ) => match original_document { - Some(orig) => { - self.evaluate_document_with_clauses(original_clauses, delete.base(), orig) + ) => { + if original_document_clauses.is_empty() + || original_document_clauses.is_for_primary_key() + { + if self.matches_document(delete.base(), &BTreeMap::new()) { + TransitionCheckResult::Pass + } else { + TransitionCheckResult::Fail + } + } else { + TransitionCheckResult::NeedsOriginal } - None => false, - }, + } + // Transfer: check owner (if any), then gate on original clauses ( DocumentActionClauses::Transfer { - original_clauses, + original_document_clauses, owner_clause, }, DocumentTransition::Transfer(transfer), ) => { - let orig_ok = match (original_clauses, original_document) { - (Some(clauses), Some(orig)) => { - self.evaluate_document_with_clauses(clauses, transfer.base(), orig) - } - (Some(_), None) => false, - (None, _) => true, - }; let new_owner_value: Value = transfer.recipient_owner_id().into(); let owner_ok = match owner_clause { Some(clause) => clause.matches_value(&new_owner_value), None => true, }; - orig_ok && owner_ok + if !owner_ok { + return TransitionCheckResult::Fail; + } + if original_document_clauses.is_empty() + || original_document_clauses.is_for_primary_key() + { + if self.matches_document(transfer.base(), &BTreeMap::new()) { + TransitionCheckResult::Pass + } else { + TransitionCheckResult::Fail + } + } else { + TransitionCheckResult::NeedsOriginal + } } + // UpdatePrice: check price, then gate on original clauses ( DocumentActionClauses::UpdatePrice { - original_clauses, + original_document_clauses, price_clause, }, DocumentTransition::UpdatePrice(update_price), ) => { - let orig_ok = match (original_clauses, original_document) { - (Some(clauses), Some(orig)) => { - self.evaluate_document_with_clauses(clauses, update_price.base(), orig) - } - (Some(_), None) => false, - (None, _) => true, - }; - // Evaluate price clause against the transition's price let price_value = Value::U64(update_price.price()); let price_ok = match price_clause { Some(clause) => clause.matches_value(&price_value), None => true, }; - orig_ok && price_ok + if !price_ok { + return TransitionCheckResult::Fail; + } + if original_document_clauses.is_empty() + || original_document_clauses.is_for_primary_key() + { + if self.matches_document(update_price.base(), &BTreeMap::new()) { + TransitionCheckResult::Pass + } else { + TransitionCheckResult::Fail + } + } else { + TransitionCheckResult::NeedsOriginal + } } + // Purchase: check batch owner (if clause present, we must have a value), then gate on original ( DocumentActionClauses::Purchase { - original_clauses, + original_document_clauses, owner_clause, }, DocumentTransition::Purchase(purchase), ) => { - let orig_ok = match (original_clauses, original_document) { - (Some(clauses), Some(orig)) => { - self.evaluate_document_with_clauses(clauses, purchase.base(), orig) - } - (Some(_), None) => false, - (None, _) => true, - }; let owner_ok = match (owner_clause, batch_owner_value) { (Some(clause), Some(val)) => clause.matches_value(val), - (Some(_), None) => false, + (Some(_), None) => return TransitionCheckResult::Fail, // a required context is missing (None, _) => true, }; - orig_ok && owner_ok + if !owner_ok { + return TransitionCheckResult::Fail; + } + if original_document_clauses.is_empty() + || original_document_clauses.is_for_primary_key() + { + if self.matches_document(purchase.base(), &BTreeMap::new()) { + TransitionCheckResult::Pass + } else { + TransitionCheckResult::Fail + } + } else { + TransitionCheckResult::NeedsOriginal + } } - // Fallback: only allow matching on primary-key-only filters across actions - // (evaluate base: contract/type/$id). Avoids accidental matches for - // data-dependent filters when no document data exists on this action. - (_, _) => { + // Fallback for cross-action: allow only primary-key-only matches with empty data + _ => { let pk_only = match &self.action_clauses { - DocumentActionClauses::Create { final_clauses } => { - final_clauses.is_for_primary_key() - } + DocumentActionClauses::Create { + new_document_clauses, + } => new_document_clauses.is_for_primary_key(), DocumentActionClauses::Replace { - original_clauses, - final_clauses, + original_document_clauses, + new_document_clauses, } => { - original_clauses - .as_ref() - .map(|c| c.is_for_primary_key()) - .unwrap_or(false) - || final_clauses - .as_ref() - .map(|c| c.is_for_primary_key()) - .unwrap_or(false) - } - DocumentActionClauses::Delete { original_clauses } => { - original_clauses.is_for_primary_key() + original_document_clauses.is_for_primary_key() + || new_document_clauses.is_for_primary_key() } + DocumentActionClauses::Delete { + original_document_clauses, + } => original_document_clauses.is_for_primary_key(), DocumentActionClauses::Transfer { - original_clauses, .. - } => original_clauses - .as_ref() - .map(|c| c.is_for_primary_key()) - .unwrap_or(false), + original_document_clauses, + .. + } => original_document_clauses.is_for_primary_key(), DocumentActionClauses::UpdatePrice { - original_clauses, .. - } => original_clauses - .as_ref() - .map(|c| c.is_for_primary_key()) - .unwrap_or(false), + original_document_clauses, + .. + } => original_document_clauses.is_for_primary_key(), DocumentActionClauses::Purchase { - original_clauses, .. - } => original_clauses - .as_ref() - .map(|c| c.is_for_primary_key()) - .unwrap_or(false), + original_document_clauses, + .. + } => original_document_clauses.is_for_primary_key(), }; if pk_only { - self.matches_document(document_transition.base(), &BTreeMap::new()) + if self.matches_document(document_transition.base(), &BTreeMap::new()) { + TransitionCheckResult::Pass + } else { + TransitionCheckResult::Fail + } } else { - false + TransitionCheckResult::Fail } } } } + /// Checks if a document transition matches the filter, with optional + /// original document data and an optional batch owner value. + /// + /// When to run: + /// - After `matches_document_transition` returns `NeedsOriginal`. + /// This method evaluates only original-dependent clauses using the provided + /// original document. + #[cfg(any(feature = "server", feature = "verify"))] + pub fn matches_document_transition_original_document( + &self, + document_transition: &DocumentTransition, + original_document: Option<&BTreeMap>, + ) -> bool { + // Evaluate only original-dependent clauses. If the original is required + // but missing, return false. + match (&self.action_clauses, document_transition) { + ( + DocumentActionClauses::Replace { + original_document_clauses, + .. + }, + DocumentTransition::Replace(replace), + ) => original_document.map_or(false, |orig| { + self.evaluate_document_with_clauses(original_document_clauses, replace.base(), orig) + }), + ( + DocumentActionClauses::Delete { + original_document_clauses, + }, + DocumentTransition::Delete(delete), + ) => original_document.map_or(false, |orig| { + self.evaluate_document_with_clauses(original_document_clauses, delete.base(), orig) + }), + ( + DocumentActionClauses::Transfer { + original_document_clauses, + .. + }, + DocumentTransition::Transfer(transfer), + ) => original_document.map_or(false, |orig| { + self.evaluate_document_with_clauses( + original_document_clauses, + transfer.base(), + orig, + ) + }), + ( + DocumentActionClauses::UpdatePrice { + original_document_clauses, + .. + }, + DocumentTransition::UpdatePrice(update_price), + ) => original_document.map_or(false, |orig| { + self.evaluate_document_with_clauses( + original_document_clauses, + update_price.base(), + orig, + ) + }), + ( + DocumentActionClauses::Purchase { + original_document_clauses, + .. + }, + DocumentTransition::Purchase(purchase), + ) => original_document.map_or(false, |orig| { + self.evaluate_document_with_clauses( + original_document_clauses, + purchase.base(), + orig, + ) + }), + _ => false, + } + } + /// Low-level helper to evaluate an in-memory document payload (`base` + `data`) /// against the clauses selected for the current action. /// @@ -291,22 +433,29 @@ impl DriveDocumentQueryFilter<'_> { document_data: &BTreeMap, ) -> bool { // When action-specific clauses exist, use them; default to empty clauses when not provided - let empty = InternalClauses::default(); let clauses_ref: &InternalClauses = match &self.action_clauses { - DocumentActionClauses::Create { final_clauses } => final_clauses, - DocumentActionClauses::Replace { final_clauses, .. } => { - final_clauses.as_ref().unwrap_or(&empty) - } - DocumentActionClauses::Delete { original_clauses } => original_clauses, + DocumentActionClauses::Create { + new_document_clauses, + } => new_document_clauses, + DocumentActionClauses::Replace { + new_document_clauses, + .. + } => new_document_clauses, + DocumentActionClauses::Delete { + original_document_clauses, + } => original_document_clauses, DocumentActionClauses::Transfer { - original_clauses, .. - } => original_clauses.as_ref().unwrap_or(&empty), + original_document_clauses, + .. + } => original_document_clauses, DocumentActionClauses::Purchase { - original_clauses, .. - } => original_clauses.as_ref().unwrap_or(&empty), + original_document_clauses, + .. + } => original_document_clauses, DocumentActionClauses::UpdatePrice { - original_clauses, .. - } => original_clauses.as_ref().unwrap_or(&empty), + original_document_clauses, + .. + } => original_document_clauses, }; self.evaluate_document_with_clauses(clauses_ref, document_base_transition, document_data) } @@ -314,9 +463,9 @@ impl DriveDocumentQueryFilter<'_> { /// Core evaluator: checks the given base transition + document data against /// the provided `InternalClauses`. /// - /// This is used internally by action-specific matchers (e.g., Replace - /// evaluates both `original_clauses` and `final_clauses` separately). Most - /// callers should use `matches_document_transition`, which determines the + /// This is used internally by action-specific matchers (e.g., Replace evaluates + /// both `original_document_clauses` and `new_document_clauses` separately). Most + /// callers should use `matches_document_transition_original_document`, which determines the /// correct clause set(s) to apply for the configured `DocumentActionClauses`. #[cfg(any(feature = "server", feature = "verify"))] fn evaluate_document_with_clauses( @@ -388,99 +537,204 @@ impl DriveDocumentQueryFilter<'_> { true } - /// Validates that the filter's clauses are valid for the document type and indexes + /// Validate the filter structure and clauses. + /// + /// This function doesn't check the existence of the data contract. The subscription host must check. #[cfg(any(feature = "server", feature = "verify"))] - pub fn validate(&self) -> bool { + pub fn validate(&self) -> Result<(), crate::error::Error> { // Ensure the document type exists - let Ok(document_type) = self + let document_type = self .contract .document_type_for_name(&self.document_type_name) - else { - return false; - }; + .map_err(|_| { + Error::Query(QuerySyntaxError::DocumentTypeNotFound( + "unknown document type", + )) + })?; - let validate_indexes = |clauses: &InternalClauses| -> bool { - if !clauses.verify() { - return false; - } - // If no data clauses or only primary key, skip index selection - if clauses.is_empty() || clauses.is_for_primary_key() { - return true; - } - let query = DriveDocumentQuery { - contract: self.contract, - document_type, - internal_clauses: clauses.clone(), - offset: None, - limit: None, - order_by: IndexMap::new(), - start_at: None, - start_at_included: false, - block_time_ms: None, - }; - query.find_best_index(LATEST_PLATFORM_VERSION).is_ok() - }; - - // Validate internal clauses depending on action match &self.action_clauses { - DocumentActionClauses::Create { final_clauses } => validate_indexes(final_clauses), + DocumentActionClauses::Create { + new_document_clauses, + } => validate_internal_clauses_against_schema(document_type, new_document_clauses)?, DocumentActionClauses::Replace { - original_clauses, - final_clauses, + original_document_clauses, + new_document_clauses, } => { - if original_clauses.is_none() && final_clauses.is_none() { - return false; + if original_document_clauses.is_empty() && new_document_clauses.is_empty() { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "replace requires at least one of original/new clauses", + ), + )); + } + if !original_document_clauses.is_empty() { + validate_internal_clauses_against_schema( + document_type, + original_document_clauses, + )?; + } + if !new_document_clauses.is_empty() { + validate_internal_clauses_against_schema(document_type, new_document_clauses)?; } - let orig_ok = original_clauses - .as_ref() - .map(|c| validate_indexes(c)) - .unwrap_or(true); - let final_ok = final_clauses - .as_ref() - .map(|c| validate_indexes(c)) - .unwrap_or(true); - orig_ok && final_ok } - DocumentActionClauses::Delete { original_clauses } => { - validate_indexes(original_clauses) + DocumentActionClauses::Delete { + original_document_clauses, + } => { + validate_internal_clauses_against_schema(document_type, original_document_clauses)? } DocumentActionClauses::Transfer { - original_clauses, + original_document_clauses, owner_clause, } => { - if original_clauses.is_none() && owner_clause.is_none() { - return false; + if original_document_clauses.is_empty() && owner_clause.is_none() { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "transfer requires original clauses or owner clause", + ), + )); + } + if !original_document_clauses.is_empty() { + validate_internal_clauses_against_schema( + document_type, + original_document_clauses, + )?; + } + if let Some(owner) = owner_clause { + let ok = match owner.operator { + WhereOperator::Equal => matches!(owner.value, Value::Identifier(_)), + WhereOperator::In => match &owner.value { + Value::Array(arr) => { + arr.iter().all(|v| matches!(v, Value::Identifier(_))) + } + _ => false, + }, + _ => false, + }; + if !ok { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents("invalid owner clause"), + )); + } } - original_clauses - .as_ref() - .map(|c| validate_indexes(c)) - .unwrap_or(true) } DocumentActionClauses::UpdatePrice { - original_clauses, + original_document_clauses, price_clause, } => { - if original_clauses.is_none() && price_clause.is_none() { - return false; + if original_document_clauses.is_empty() && price_clause.is_none() { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "updatePrice requires original clauses or price clause", + ), + )); + } + if !original_document_clauses.is_empty() { + validate_internal_clauses_against_schema( + document_type, + original_document_clauses, + )?; + } + if let Some(price) = price_clause { + let ok = match price.operator { + WhereOperator::Equal + | WhereOperator::GreaterThan + | WhereOperator::GreaterThanOrEquals + | WhereOperator::LessThan + | WhereOperator::LessThanOrEquals => matches!( + price.value, + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), + WhereOperator::Between + | WhereOperator::BetweenExcludeBounds + | WhereOperator::BetweenExcludeLeft + | WhereOperator::BetweenExcludeRight => match &price.value { + Value::Array(arr) => { + arr.len() == 2 + && arr.iter().all(|v| { + matches!( + v, + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ) + }) + } + _ => false, + }, + WhereOperator::In => match &price.value { + Value::Array(arr) => arr.iter().all(|v| { + matches!( + v, + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ) + }), + _ => false, + }, + WhereOperator::StartsWith => false, + }; + if !ok { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents("invalid price clause"), + )); + } } - original_clauses - .as_ref() - .map(|c| validate_indexes(c)) - .unwrap_or(true) } DocumentActionClauses::Purchase { - original_clauses, + original_document_clauses, owner_clause, } => { - if original_clauses.is_none() && owner_clause.is_none() { - return false; + if original_document_clauses.is_empty() && owner_clause.is_none() { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "purchase requires original clauses or owner clause", + ), + )); + } + if !original_document_clauses.is_empty() { + validate_internal_clauses_against_schema( + document_type, + original_document_clauses, + )?; + } + if let Some(owner) = owner_clause { + let ok = match owner.operator { + WhereOperator::Equal => matches!(owner.value, Value::Identifier(_)), + WhereOperator::In => match &owner.value { + Value::Array(arr) => { + arr.iter().all(|v| matches!(v, Value::Identifier(_))) + } + _ => false, + }, + _ => false, + }; + if !ok { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents("invalid owner clause"), + )); + } } - original_clauses - .as_ref() - .map(|c| validate_indexes(c)) - .unwrap_or(true) } } + Ok(()) } } @@ -514,7 +768,7 @@ fn get_value_by_path<'a>(root: &'a BTreeMap, path: &str) -> Optio #[cfg(test)] mod tests { use super::*; - use crate::query::WhereOperator; + use crate::query::{ValueClause, WhereClause, WhereOperator}; use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::document_base_transition::v1::DocumentBaseTransitionV1; use dpp::tests::fixtures::get_data_contract_fixture; @@ -531,7 +785,7 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: InternalClauses::default(), + new_document_clauses: InternalClauses::default(), }, }; @@ -579,7 +833,7 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: internal_clauses, + new_document_clauses: internal_clauses, }, }; @@ -631,7 +885,7 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: internal_clauses, + new_document_clauses: internal_clauses, }, }; @@ -681,7 +935,7 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: internal_clauses, + new_document_clauses: internal_clauses, }, }; @@ -721,7 +975,7 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: internal_clauses, + new_document_clauses: internal_clauses, }, }; @@ -772,7 +1026,7 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: internal_clauses, + new_document_clauses: internal_clauses, }, }; @@ -805,100 +1059,104 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Replace { - original_clauses: None, - final_clauses: None, + original_document_clauses: InternalClauses::default(), + new_document_clauses: InternalClauses::default(), }, }; - assert!(!filter.validate()); + assert!(filter.validate().is_err()); - // Replace with final only -> valid + // Replace with final only -> valid (non-empty final clauses) let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Replace { - original_clauses: None, - final_clauses: Some(InternalClauses::default()), + original_document_clauses: InternalClauses::default(), + new_document_clauses: InternalClauses { + primary_key_equal_clause: Some(WhereClause { + field: "$id".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier([3u8; 32]), + }), + ..Default::default() + }, }, }; - assert!(filter.validate()); + assert!(filter.validate().is_ok()); // Transfer with none/none -> invalid let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Transfer { - original_clauses: None, + original_document_clauses: InternalClauses::default(), owner_clause: None, }, }; - assert!(!filter.validate()); + assert!(filter.validate().is_err()); // Transfer with owner only -> valid let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Transfer { - original_clauses: None, - owner_clause: Some(WhereClause { - field: "$ownerId".to_string(), + original_document_clauses: InternalClauses::default(), + owner_clause: Some(ValueClause { operator: WhereOperator::Equal, value: Value::Identifier([1u8; 32]), }), }, }; - assert!(filter.validate()); + assert!(filter.validate().is_ok()); // UpdatePrice with none/none -> invalid let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::UpdatePrice { - original_clauses: None, + original_document_clauses: InternalClauses::default(), price_clause: None, }, }; - assert!(!filter.validate()); + assert!(filter.validate().is_err()); // UpdatePrice with price only -> valid let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::UpdatePrice { - original_clauses: None, - price_clause: Some(WhereClause { - field: "price".to_string(), + original_document_clauses: InternalClauses::default(), + price_clause: Some(ValueClause { operator: WhereOperator::GreaterThan, value: Value::U64(0), }), }, }; - assert!(filter.validate()); + assert!(filter.validate().is_ok()); // Purchase with none/none -> invalid let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Purchase { - original_clauses: None, + original_document_clauses: InternalClauses::default(), owner_clause: None, }, }; - assert!(!filter.validate()); + assert!(filter.validate().is_err()); // Purchase with owner only -> valid let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Purchase { - original_clauses: None, - owner_clause: Some(WhereClause { - field: "$ownerId".to_string(), + original_document_clauses: InternalClauses::default(), + owner_clause: Some(ValueClause { operator: WhereOperator::Equal, value: Value::Identifier([2u8; 32]), }), }, }; - assert!(filter.validate()); + assert!(filter.validate().is_ok()); } #[test] @@ -916,9 +1174,8 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Transfer { - original_clauses: None, - owner_clause: Some(WhereClause { - field: "$ownerId".to_string(), + original_document_clauses: InternalClauses::default(), + owner_clause: Some(ValueClause { operator: WhereOperator::Equal, value: new_owner.into(), }), @@ -941,8 +1198,11 @@ mod tests { }; let transfer = DocumentTransition::Transfer(DocumentTransferTransition::V0(transfer_v0)); - // No original doc needed; owner is taken from transfer - assert!(filter.matches_document_transition(&transfer, None, None)); + // First check should pass without needing original + assert_eq!( + filter.matches_document_transition(&transfer, None), + TransitionCheckResult::Pass + ); // Mismatch owner let other_owner = Identifier::from([6u8; 32]); @@ -953,7 +1213,10 @@ mod tests { }; let transfer_mismatch = DocumentTransition::Transfer(DocumentTransferTransition::V0(transfer_v0_mismatch)); - assert!(!filter.matches_document_transition(&transfer_mismatch, None, None)); + assert_eq!( + filter.matches_document_transition(&transfer_mismatch, None), + TransitionCheckResult::Fail + ); } #[test] @@ -972,9 +1235,8 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Purchase { - original_clauses: None, - owner_clause: Some(WhereClause { - field: "$ownerId".to_string(), + original_document_clauses: InternalClauses::default(), + owner_clause: Some(ValueClause { operator: WhereOperator::Equal, value: purchaser.into(), }), @@ -997,12 +1259,17 @@ mod tests { }; let purchase = DocumentTransition::Purchase(DocumentPurchaseTransition::V0(purchase_v0)); - // Without passing the batch owner context, should fail (owner clause requires it) - assert!(!filter.matches_document_transition(&purchase, None, None)); - + // Without batch owner context, should fail (owner clause requires it) + assert_eq!( + filter.matches_document_transition(&purchase, None), + TransitionCheckResult::Fail + ); // With batch owner context, should pass - let owner_value: Value = purchaser.into(); - assert!(filter.matches_document_transition(&purchase, None, Some(&owner_value))); + let owner_value = Value::Identifier(purchaser.to_buffer()); + assert_eq!( + filter.matches_document_transition(&purchase, Some(&owner_value)), + TransitionCheckResult::Pass + ); } #[test] @@ -1027,10 +1294,10 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Transfer { - original_clauses: Some(InternalClauses { + original_document_clauses: InternalClauses { equal_clauses: eq, ..Default::default() - }), + }, owner_clause: None, }, }; @@ -1052,10 +1319,10 @@ mod tests { // Original doc present and matching let mut original = BTreeMap::new(); original.insert("status".to_string(), Value::Text("active".to_string())); - assert!(filter.matches_document_transition(&transfer, Some(&original), None)); + assert!(filter.matches_document_transition_original_document(&transfer, Some(&original),)); // Without original doc, clause is required -> no match - assert!(!filter.matches_document_transition(&transfer, None, None)); + assert!(!filter.matches_document_transition_original_document(&transfer, None)); } #[test] @@ -1080,7 +1347,7 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Delete { - original_clauses: InternalClauses { + original_document_clauses: InternalClauses { equal_clauses: eq, ..Default::default() }, @@ -1102,15 +1369,17 @@ mod tests { // Original doc present and matching let mut original = BTreeMap::new(); original.insert("status".to_string(), Value::Text("active".to_string())); - assert!(filter.matches_document_transition(&delete, Some(&original), None)); + assert!(filter.matches_document_transition_original_document(&delete, Some(&original),)); // Without original doc -> no match (required for Delete) - assert!(!filter.matches_document_transition(&delete, None, None)); + assert!(!filter.matches_document_transition_original_document(&delete, None)); // Original mismatching -> no match let mut original_bad = BTreeMap::new(); original_bad.insert("status".to_string(), Value::Text("inactive".to_string())); - assert!(!filter.matches_document_transition(&delete, Some(&original_bad), None)); + assert!( + !filter.matches_document_transition_original_document(&delete, Some(&original_bad),) + ); } #[test] @@ -1142,29 +1411,34 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::UpdatePrice { - original_clauses: None, - price_clause: Some(WhereClause { - field: "price".to_string(), + original_document_clauses: InternalClauses::default(), + price_clause: Some(ValueClause { operator: WhereOperator::GreaterThan, value: Value::U64(5), }), }, }; - assert!(filter_price_only.matches_document_transition(&update, None, None)); + // Price-only clause is decided in first check + assert_eq!( + filter_price_only.matches_document_transition(&update, None), + TransitionCheckResult::Pass + ); let filter_price_only_fail = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::UpdatePrice { - original_clauses: None, - price_clause: Some(WhereClause { - field: "price".to_string(), + original_document_clauses: InternalClauses::default(), + price_clause: Some(ValueClause { operator: WhereOperator::GreaterThan, value: Value::U64(15), }), }, }; - assert!(!filter_price_only_fail.matches_document_transition(&update, None, None)); + assert_eq!( + filter_price_only_fail.matches_document_transition(&update, None), + TransitionCheckResult::Fail + ); // With original clauses as well let mut eq = BTreeMap::new(); @@ -1180,12 +1454,11 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::UpdatePrice { - original_clauses: Some(InternalClauses { + original_document_clauses: InternalClauses { equal_clauses: eq, ..Default::default() - }), - price_clause: Some(WhereClause { - field: "price".to_string(), + }, + price_clause: Some(ValueClause { operator: WhereOperator::GreaterThanOrEquals, value: Value::U64(10), }), @@ -1193,14 +1466,19 @@ mod tests { }; let mut original_doc = BTreeMap::new(); original_doc.insert("kind".to_string(), Value::Text("sale".to_string())); - assert!(filter_with_orig.matches_document_transition(&update, Some(&original_doc), None)); + assert_eq!( + filter_with_orig.matches_document_transition(&update, None), + TransitionCheckResult::NeedsOriginal + ); + assert!(filter_with_orig + .matches_document_transition_original_document(&update, Some(&original_doc))); // Missing original doc -> required -> no match - assert!(!filter_with_orig.matches_document_transition(&update, None, None)); + assert!(!filter_with_orig.matches_document_transition_original_document(&update, None)); } #[test] - fn test_replace_with_both_original_and_final_clauses() { + fn test_replace_with_both_original_and_new_document_clauses() { use dpp::state_transition::batch_transition::batched_transition::document_replace_transition::v0::DocumentReplaceTransitionV0; use dpp::state_transition::batch_transition::batched_transition::document_replace_transition::DocumentReplaceTransition; @@ -1239,7 +1517,7 @@ mod tests { value: Value::U64(10), }, ); - let final_clauses = InternalClauses { + let new_document_clauses = InternalClauses { equal_clauses: final_eq, ..Default::default() }; @@ -1248,8 +1526,8 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Replace { - original_clauses: Some(original_clauses), - final_clauses: Some(final_clauses), + original_document_clauses: original_clauses, + new_document_clauses: new_document_clauses, }, }; @@ -1263,25 +1541,33 @@ mod tests { }; let replace = DocumentTransition::Replace(DocumentReplaceTransition::V0(replace_v0)); - // Original provided and matching; final matches + // Original provided and matching; final matches (requires original) let mut original_doc = BTreeMap::new(); original_doc.insert("status".to_string(), Value::Text("active".to_string())); - assert!(filter.matches_document_transition(&replace, Some(&original_doc), None)); + assert_eq!( + filter.matches_document_transition(&replace, None), + TransitionCheckResult::NeedsOriginal + ); + assert!(filter.matches_document_transition_original_document(&replace, Some(&original_doc))); // Original missing -> should fail as it's required - assert!(!filter.matches_document_transition(&replace, None, None)); + assert!(!filter.matches_document_transition_original_document(&replace, None)); // Original mismatching -> fail let mut original_doc_bad = BTreeMap::new(); original_doc_bad.insert("status".to_string(), Value::Text("inactive".to_string())); - assert!(!filter.matches_document_transition(&replace, Some(&original_doc_bad), None)); + assert!(!filter + .matches_document_transition_original_document(&replace, Some(&original_doc_bad),)); - // Final mismatching -> fail (change score) + // New-data mismatching should fail in first check (do not call final) if let DocumentTransition::Replace(mut rep) = replace.clone() { let DocumentReplaceTransition::V0(ref mut v0) = rep; v0.data.insert("score".to_string(), Value::U64(9)); let bad_final = DocumentTransition::Replace(rep); - assert!(!filter.matches_document_transition(&bad_final, Some(&original_doc), None)); + assert_eq!( + filter.matches_document_transition(&bad_final, None), + TransitionCheckResult::Fail + ); } } @@ -1301,7 +1587,7 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: internal_clauses, + new_document_clauses: internal_clauses, }, }; @@ -1361,22 +1647,23 @@ mod tests { contract: &contract, document_type_name: "indexedDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: internal_clauses, + new_document_clauses: internal_clauses, }, }; assert!( - valid_filter.validate(), + valid_filter.validate().is_ok(), "Filter with indexed field should be valid" ); - // Test filter with non-indexed field: index validation should fail + // Test filter with non-indexed field: structural validation should pass + // (indexes are not considered by subscription filters). let mut internal_clauses = InternalClauses::default(); let mut equal_clauses = BTreeMap::new(); equal_clauses.insert( - "nonExistentField".to_string(), + "name".to_string(), WhereClause { - field: "nonExistentField".to_string(), + field: "name".to_string(), operator: WhereOperator::Equal, value: Value::Text("value".to_string()), }, @@ -1385,16 +1672,17 @@ mod tests { let invalid_filter = DriveDocumentQueryFilter { contract: &contract, - document_type_name: "indexedDocument".to_string(), + document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: internal_clauses, + new_document_clauses: internal_clauses, }, }; assert!( - !invalid_filter.validate(), - "Filter with non-indexed field should be invalid" + invalid_filter.validate().is_ok(), + "Structural validate should ignore indexes" ); + // Index-aware validation removed; structural validation suffices for subscriptions. // Test valid filter with only primary key let mut internal_clauses = InternalClauses::default(); @@ -1408,16 +1696,132 @@ mod tests { contract: &contract, document_type_name: "indexedDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: internal_clauses, + new_document_clauses: internal_clauses, }, }; assert!( - primary_key_filter.validate(), + primary_key_filter.validate().is_ok(), "Filter with only primary key should be valid" ); } + #[test] + fn test_validate_rejects_id_in_generic_clauses() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + // $id in equal_clauses should be rejected + let mut eq = BTreeMap::new(); + eq.insert( + "$id".to_string(), + WhereClause { + field: "$id".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier([1u8; 32]), + }, + ); + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Create { + new_document_clauses: InternalClauses { + equal_clauses: eq, + ..Default::default() + }, + }, + }; + assert!(filter.validate().is_err()); + + // $id in range clause should be rejected + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Create { + new_document_clauses: InternalClauses { + range_clause: Some(WhereClause { + field: "$id".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::U64(0), + }), + ..Default::default() + }, + }, + }; + assert!(filter.validate().is_err()); + } + + #[test] + fn test_validate_owner_and_price_clause_types() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + // Owner clause must be Identifier + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Transfer { + original_document_clauses: InternalClauses::default(), + owner_clause: Some(ValueClause { + operator: WhereOperator::Equal, + value: Value::Text("not-id".to_string()), + }), + }, + }; + assert!(filter.validate().is_err()); + + // Price clause must be integer-like, not float + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::UpdatePrice { + original_document_clauses: InternalClauses::default(), + price_clause: Some(ValueClause { + operator: WhereOperator::Equal, + value: Value::Float(1.23), + }), + }, + }; + assert!(filter.validate().is_err()); + + // Price Between must be 2 integer-like values + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::UpdatePrice { + original_document_clauses: InternalClauses::default(), + price_clause: Some(ValueClause { + operator: WhereOperator::Between, + value: Value::Array(vec![Value::U64(1), Value::Float(2.0)]), + }), + }, + }; + assert!(filter.validate().is_err()); + } + + #[test] + fn test_validate_startswith_on_numeric_field_rejected() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + + // numeric field 'score' with StartsWith should be rejected + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionClauses::Create { + new_document_clauses: InternalClauses { + range_clause: Some(WhereClause { + field: "score".to_string(), + operator: WhereOperator::StartsWith, + value: Value::Text("1".to_string()), + }), + ..Default::default() + }, + }, + }; + assert!(filter.validate().is_err()); + } + #[test] fn test_conversion_between_filter_and_query() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); @@ -1434,13 +1838,16 @@ mod tests { contract: &contract, document_type_name: "niceDocument".to_string(), action_clauses: DocumentActionClauses::Create { - final_clauses: internal_clauses.clone(), + new_document_clauses: internal_clauses.clone(), }, }; // No conversion helpers; verify the filter holds the expected clauses - if let DocumentActionClauses::Create { final_clauses } = original_filter.action_clauses { - assert_eq!(final_clauses, internal_clauses); + if let DocumentActionClauses::Create { + new_document_clauses, + } = original_filter.action_clauses + { + assert_eq!(new_document_clauses, internal_clauses); } else { panic!("expected Create action clauses"); } diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 47219a21183..1b4c6f51d6e 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -2,7 +2,11 @@ use std::sync::Arc; #[cfg(any(feature = "server", feature = "verify"))] pub use { - conditions::{WhereClause, WhereOperator}, + conditions::{ + allowed_ops_for_type, validate_internal_clauses_against_schema, + validate_where_clause_against_schema, value_shape_ok, ValueClause, WhereClause, + WhereOperator, + }, grovedb::{PathQuery, Query, QueryItem, SizedQuery}, ordering::OrderClause, single_document_drive_query::SingleDocumentDriveQuery, @@ -148,8 +152,9 @@ pub mod identity_token_balance_drive_query; #[cfg(any(feature = "server", feature = "verify"))] pub mod identity_token_info_drive_query; +/// Document subscription filtering #[cfg(any(feature = "server", feature = "verify"))] -mod filter; +pub mod filter; /// A query to get the token's status #[cfg(any(feature = "server", feature = "verify"))] pub mod token_status_drive_query; @@ -174,8 +179,9 @@ pub struct StartAtDocument<'a> { /// - `false`: The document is excluded, and the query starts from the next matching document. pub included: bool, } -#[cfg(any(feature = "server", feature = "verify"))] + /// Internal clauses struct +#[cfg(any(feature = "server", feature = "verify"))] #[derive(Clone, Debug, PartialEq, Default)] pub struct InternalClauses { /// Primary key in clause @@ -543,6 +549,8 @@ impl<'a> DriveDocumentQuery<'a> { })?; let internal_clauses = InternalClauses::extract_from_clauses(all_where_clauses)?; + // Schema-aware validation: ensure operators and values are compatible with field types + validate_internal_clauses_against_schema(document_type, &internal_clauses)?; let start_at_option = query_document.remove("startAt"); let start_after_option = query_document.remove("startAfter"); @@ -669,6 +677,8 @@ impl<'a> DriveDocumentQuery<'a> { }?; let internal_clauses = InternalClauses::extract_from_clauses(all_where_clauses)?; + // Schema-aware validation + validate_internal_clauses_against_schema(document_type, &internal_clauses)?; let order_by: IndexMap = order_by .map_or(vec![], |id_cbor| { @@ -831,6 +841,8 @@ impl<'a> DriveDocumentQuery<'a> { } let internal_clauses = InternalClauses::extract_from_clauses(all_where_clauses)?; + // Schema-aware validation + validate_internal_clauses_against_schema(document_type.as_ref(), &internal_clauses)?; let start_at_option = None; //todo let start_after_option = None; //todo @@ -2216,6 +2228,82 @@ impl<'a> DriveDocumentQuery<'a> { } } +#[cfg(all(test, any(feature = "server", feature = "verify")))] +mod shared_validation_tests { + use super::*; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::LATEST_PLATFORM_VERSION; + + #[test] + fn from_decomposed_values_rejects_startswith_empty_string() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("niceDocument") + .expect("doc type exists"); + + // where: [["name", "StartsWith", ""]] + let where_clause = Value::Array(vec![Value::Array(vec![ + Value::Text("name".to_string()), + Value::Text("StartsWith".to_string()), + Value::Text("".to_string()), + ])]); + + let res = DriveDocumentQuery::from_decomposed_values( + where_clause, + None, + Some(100), + None, + true, + None, + &contract, + doc_type, + &DriveConfig::default(), + ); + + assert!(matches!( + res, + Err(Error::Query(QuerySyntaxError::StartsWithIllegalString(_))) + )) + } + + #[test] + fn from_decomposed_values_rejects_in_with_duplicates() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("niceDocument") + .expect("doc type exists"); + + // where: [["status", "In", ["active", "active"]]] -> duplicates not allowed + let where_clause = Value::Array(vec![Value::Array(vec![ + Value::Text("status".to_string()), + Value::Text("In".to_string()), + Value::Array(vec![ + Value::Text("active".to_string()), + Value::Text("active".to_string()), + ]), + ])]); + + let res = DriveDocumentQuery::from_decomposed_values( + where_clause, + None, + Some(100), + None, + true, + None, + &contract, + doc_type, + &DriveConfig::default(), + ); + + assert!(matches!( + res, + Err(Error::Query(QuerySyntaxError::InvalidInClause(_))) + )) + } +} + /// Convert DriveQuery to a BTreeMap of values impl<'a> From<&DriveDocumentQuery<'a>> for BTreeMap { fn from(query: &DriveDocumentQuery<'a>) -> Self { From bd67424d58037d7d4f8a582c17c93427445d854e Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Wed, 10 Sep 2025 18:13:12 +0700 Subject: [PATCH 11/30] clean --- packages/rs-drive/src/query/filter.rs | 655 ++++++++++++-------------- 1 file changed, 293 insertions(+), 362 deletions(-) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 482ad84b4ca..e7d331883a0 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -2,8 +2,8 @@ //! //! This module provides primitives to express and evaluate subscription filters for //! document state transitions. The main entry point is `DriveDocumentQueryFilter`, which -//! holds a contract reference, a document type name, and action-specific clauses -//! (`DocumentActionClauses`). +//! holds a contract reference, a document type name, and action-specific match clauses +//! (`DocumentActionMatchClauses`). //! //! Filtering in brief: //! - Create: evaluates `new_document_clauses` on the transition's data payload. @@ -22,22 +22,22 @@ //! evaluate applicable constraints before fetching the original document. Decide //! whether to fetch the original document (returns Pass/Fail/NeedsOriginal). //! - Second check: only if the first check returned `NeedsOriginal`, fetch the original -//! and call `matches_document_transition_original_document()` to evaluate original-dependent -//! clauses. +//! `Document` and call `matches_original_document()` to evaluate original-dependent clauses. //! -//! Validation is structural: `validate()` checks the document type exists and that -//! action-specific composition rules hold (e.g., at least one non-empty clause where -//! required). +//! Validation: +//! - `validate()` performs structural checks: confirms the document type exists for the +//! contract, enforces action-specific composition rules (e.g., at least one non-empty +//! clause where required), and validates operator/value compatibility for scalar clauses +//! like `owner_clause` and `price_clause`. use std::collections::BTreeMap; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; use dpp::platform_value::Value; +use dpp::document::{Document, DocumentV0Getters}; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; -use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; -use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; use dpp::state_transition::batch_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::v0_methods::DocumentTransferTransitionV0Methods; @@ -51,8 +51,8 @@ use dpp::platform_value::ValueMapHelper; /// Filter used to match document transitions for subscriptions. /// /// Targets a specific data contract and document type, and carries action-specific -/// clauses via `DocumentActionClauses`. Use `matches_document_transition()` -/// and `matches_document_transition_original_document()` to evaluate batch/document transitions. +/// match clauses via `DocumentActionMatchClauses`. Use `matches_document_transition()` +/// and `matches_original_document()` to evaluate document transitions. /// `validate()` performs structural checks (document type exists, clause composition rules). #[cfg(any(feature = "server", feature = "verify"))] #[derive(Debug, PartialEq, Clone)] @@ -62,7 +62,7 @@ pub struct DriveDocumentQueryFilter<'a> { /// Document type name pub document_type_name: String, /// Action-specific clauses - pub action_clauses: DocumentActionClauses, + pub action_clauses: DocumentActionMatchClauses, } /// Result of evaluating constraints for a transition before potentially fetching the original document. @@ -88,7 +88,7 @@ pub enum TransitionCheckResult { /// - `Option` = optional scalar constraint (owner/price); `None` = no constraint. /// - Action-specific “at least one present” rules are enforced by `validate()`. #[derive(Debug, PartialEq, Clone)] -pub enum DocumentActionClauses { +pub enum DocumentActionMatchClauses { /// Create: filters on the new document data. Create { /// Clauses on the new document data. @@ -133,8 +133,8 @@ impl DriveDocumentQueryFilter<'_> { /// Check a transition using only transition-level constraints. /// /// When to run: - /// - Call this for each incoming transition before performing any storage reads - /// to fetch the original document. It short-circuits on obvious mismatches and + /// - Call this for each incoming transition before + /// fetching the original document. It short-circuits on obvious mismatches and /// tells you if an original is needed at all for the final decision. /// /// Returns: @@ -146,21 +146,26 @@ impl DriveDocumentQueryFilter<'_> { pub fn matches_document_transition( &self, document_transition: &DocumentTransition, - batch_owner_value: Option<&Value>, + batch_owner_value: Option<&Value>, // Only used for Purchase ) -> TransitionCheckResult { match (&self.action_clauses, document_transition) { // Create: evaluate final clauses only ( - DocumentActionClauses::Create { + DocumentActionMatchClauses::Create { new_document_clauses, }, DocumentTransition::Create(create), ) => { - if self.evaluate_document_with_clauses( - new_document_clauses, - create.base(), - create.data(), - ) { + // Ensure transition targets this filter's contract and document type + if create.base().data_contract_id() != self.contract.id() + || create.base().document_type_name() != &self.document_type_name + { + return TransitionCheckResult::Fail; + } + + // Evaluate new-document clauses (if any) + let id_value: Value = create.base().id().into(); + if self.evaluate_clauses(new_document_clauses, &id_value, create.data()) { TransitionCheckResult::Pass } else { TransitionCheckResult::Fail @@ -169,64 +174,81 @@ impl DriveDocumentQueryFilter<'_> { // Replace: evaluate new-document clauses (if non-empty); if they pass, // require original if `original_document_clauses` is non-empty and not primary-key-only. ( - DocumentActionClauses::Replace { + DocumentActionMatchClauses::Replace { original_document_clauses, new_document_clauses, }, DocumentTransition::Replace(replace), ) => { + // Ensure transition targets this filter's contract and document type + if replace.base().data_contract_id() != self.contract.id() + || replace.base().document_type_name() != &self.document_type_name + { + return TransitionCheckResult::Fail; + } + + // Evaluate new-document clauses first (if any) + let id_value: Value = replace.base().id().into(); let final_ok = if new_document_clauses.is_empty() { true } else { - self.evaluate_document_with_clauses( - new_document_clauses, - replace.base(), - replace.data(), - ) + self.evaluate_clauses(new_document_clauses, &id_value, replace.data()) }; if !final_ok { return TransitionCheckResult::Fail; } - if original_document_clauses.is_empty() - || original_document_clauses.is_for_primary_key() - { - if self.matches_document(replace.base(), &BTreeMap::new()) { - TransitionCheckResult::Pass - } else { - TransitionCheckResult::Fail + + // New clauses passed; gate on original-document clauses + if original_document_clauses.is_empty() { + return TransitionCheckResult::Pass; + } + if original_document_clauses.is_for_primary_key() { + if self.evaluate_clauses(original_document_clauses, &id_value, &BTreeMap::new()) + { + return TransitionCheckResult::Pass; } - } else { - TransitionCheckResult::NeedsOriginal + return TransitionCheckResult::Fail; } + TransitionCheckResult::NeedsOriginal } // Delete: needs original only if original_document_clauses reference fields. // If clauses are empty or primary-key-only, we can decide without original. ( - DocumentActionClauses::Delete { + DocumentActionMatchClauses::Delete { original_document_clauses, }, DocumentTransition::Delete(delete), ) => { - if original_document_clauses.is_empty() - || original_document_clauses.is_for_primary_key() + // Ensure transition targets this filter's contract and document type + if delete.base().data_contract_id() != self.contract.id() + || delete.base().document_type_name() != &self.document_type_name { - if self.matches_document(delete.base(), &BTreeMap::new()) { - TransitionCheckResult::Pass - } else { - TransitionCheckResult::Fail + return TransitionCheckResult::Fail; + } + + // Evaluate original-document clauses (if any) + let id_value: Value = delete.base().id().into(); + if original_document_clauses.is_empty() { + return TransitionCheckResult::Pass; + } + if original_document_clauses.is_for_primary_key() { + if self.evaluate_clauses(original_document_clauses, &id_value, &BTreeMap::new()) + { + return TransitionCheckResult::Pass; } - } else { - TransitionCheckResult::NeedsOriginal + return TransitionCheckResult::Fail; } + TransitionCheckResult::NeedsOriginal } // Transfer: check owner (if any), then gate on original clauses ( - DocumentActionClauses::Transfer { + DocumentActionMatchClauses::Transfer { original_document_clauses, owner_clause, }, DocumentTransition::Transfer(transfer), ) => { + // Check owner clause (if any) let new_owner_value: Value = transfer.recipient_owner_id().into(); let owner_ok = match owner_clause { Some(clause) => clause.matches_value(&new_owner_value), @@ -235,26 +257,37 @@ impl DriveDocumentQueryFilter<'_> { if !owner_ok { return TransitionCheckResult::Fail; } - if original_document_clauses.is_empty() - || original_document_clauses.is_for_primary_key() + + // Ensure transition targets this filter's contract and document type + if transfer.base().data_contract_id() != self.contract.id() + || transfer.base().document_type_name() != &self.document_type_name { - if self.matches_document(transfer.base(), &BTreeMap::new()) { - TransitionCheckResult::Pass - } else { - TransitionCheckResult::Fail + return TransitionCheckResult::Fail; + } + + // Evaluate original-document clauses (if any) + let id_value: Value = transfer.base().id().into(); + if original_document_clauses.is_empty() { + return TransitionCheckResult::Pass; + } + if original_document_clauses.is_for_primary_key() { + if self.evaluate_clauses(original_document_clauses, &id_value, &BTreeMap::new()) + { + return TransitionCheckResult::Pass; } - } else { - TransitionCheckResult::NeedsOriginal + return TransitionCheckResult::Fail; } + TransitionCheckResult::NeedsOriginal } // UpdatePrice: check price, then gate on original clauses ( - DocumentActionClauses::UpdatePrice { + DocumentActionMatchClauses::UpdatePrice { original_document_clauses, price_clause, }, DocumentTransition::UpdatePrice(update_price), ) => { + // Check price clause (if any) let price_value = Value::U64(update_price.price()); let price_ok = match price_clause { Some(clause) => clause.matches_value(&price_value), @@ -263,26 +296,37 @@ impl DriveDocumentQueryFilter<'_> { if !price_ok { return TransitionCheckResult::Fail; } - if original_document_clauses.is_empty() - || original_document_clauses.is_for_primary_key() + + // Ensure transition targets this filter's contract and document type + if update_price.base().data_contract_id() != self.contract.id() + || update_price.base().document_type_name() != &self.document_type_name { - if self.matches_document(update_price.base(), &BTreeMap::new()) { - TransitionCheckResult::Pass - } else { - TransitionCheckResult::Fail + return TransitionCheckResult::Fail; + } + + // Evaluate original-document clauses (if any) + let id_value: Value = update_price.base().id().into(); + if original_document_clauses.is_empty() { + return TransitionCheckResult::Pass; + } + if original_document_clauses.is_for_primary_key() { + if self.evaluate_clauses(original_document_clauses, &id_value, &BTreeMap::new()) + { + return TransitionCheckResult::Pass; } - } else { - TransitionCheckResult::NeedsOriginal + return TransitionCheckResult::Fail; } + TransitionCheckResult::NeedsOriginal } // Purchase: check batch owner (if clause present, we must have a value), then gate on original ( - DocumentActionClauses::Purchase { + DocumentActionMatchClauses::Purchase { original_document_clauses, owner_clause, }, DocumentTransition::Purchase(purchase), ) => { + // Check owner clause (if any) let owner_ok = match (owner_clause, batch_owner_value) { (Some(clause), Some(val)) => clause.matches_value(val), (Some(_), None) => return TransitionCheckResult::Fail, // a required context is missing @@ -291,214 +335,101 @@ impl DriveDocumentQueryFilter<'_> { if !owner_ok { return TransitionCheckResult::Fail; } - if original_document_clauses.is_empty() - || original_document_clauses.is_for_primary_key() + + // Ensure transition targets this filter's contract and document type + if purchase.base().data_contract_id() != self.contract.id() + || purchase.base().document_type_name() != &self.document_type_name { - if self.matches_document(purchase.base(), &BTreeMap::new()) { - TransitionCheckResult::Pass - } else { - TransitionCheckResult::Fail - } - } else { - TransitionCheckResult::NeedsOriginal + return TransitionCheckResult::Fail; } - } - // Fallback for cross-action: allow only primary-key-only matches with empty data - _ => { - let pk_only = match &self.action_clauses { - DocumentActionClauses::Create { - new_document_clauses, - } => new_document_clauses.is_for_primary_key(), - DocumentActionClauses::Replace { - original_document_clauses, - new_document_clauses, - } => { - original_document_clauses.is_for_primary_key() - || new_document_clauses.is_for_primary_key() - } - DocumentActionClauses::Delete { - original_document_clauses, - } => original_document_clauses.is_for_primary_key(), - DocumentActionClauses::Transfer { - original_document_clauses, - .. - } => original_document_clauses.is_for_primary_key(), - DocumentActionClauses::UpdatePrice { - original_document_clauses, - .. - } => original_document_clauses.is_for_primary_key(), - DocumentActionClauses::Purchase { - original_document_clauses, - .. - } => original_document_clauses.is_for_primary_key(), - }; - if pk_only { - if self.matches_document(document_transition.base(), &BTreeMap::new()) { - TransitionCheckResult::Pass - } else { - TransitionCheckResult::Fail + + // Evaluate original-document clauses (if any) + let id_value: Value = purchase.base().id().into(); + if original_document_clauses.is_empty() { + return TransitionCheckResult::Pass; + } + if original_document_clauses.is_for_primary_key() { + if self.evaluate_clauses(original_document_clauses, &id_value, &BTreeMap::new()) + { + return TransitionCheckResult::Pass; } - } else { - TransitionCheckResult::Fail + return TransitionCheckResult::Fail; } + TransitionCheckResult::NeedsOriginal } + // Cross-action fallback: do not match. A subscription for a specific action + // (e.g., Purchase) must not match transitions of other actions even if the + // filter only targets the primary key. + _ => TransitionCheckResult::Fail, } } - /// Checks if a document transition matches the filter, with optional - /// original document data and an optional batch owner value. + /// Evaluates original-dependent clauses against the provided original `Document`. /// /// When to run: - /// - After `matches_document_transition` returns `NeedsOriginal`. - /// This method evaluates only original-dependent clauses using the provided - /// original document. + /// - After `matches_document_transition` returns `NeedsOriginal` and the caller fetches + /// the original document. + /// - This evaluates only original-dependent clauses; transition-level checks were + /// already applied during the first phase. #[cfg(any(feature = "server", feature = "verify"))] - pub fn matches_document_transition_original_document( - &self, - document_transition: &DocumentTransition, - original_document: Option<&BTreeMap>, - ) -> bool { - // Evaluate only original-dependent clauses. If the original is required - // but missing, return false. - match (&self.action_clauses, document_transition) { - ( - DocumentActionClauses::Replace { - original_document_clauses, - .. - }, - DocumentTransition::Replace(replace), - ) => original_document.map_or(false, |orig| { - self.evaluate_document_with_clauses(original_document_clauses, replace.base(), orig) - }), - ( - DocumentActionClauses::Delete { - original_document_clauses, - }, - DocumentTransition::Delete(delete), - ) => original_document.map_or(false, |orig| { - self.evaluate_document_with_clauses(original_document_clauses, delete.base(), orig) - }), - ( - DocumentActionClauses::Transfer { - original_document_clauses, - .. - }, - DocumentTransition::Transfer(transfer), - ) => original_document.map_or(false, |orig| { - self.evaluate_document_with_clauses( - original_document_clauses, - transfer.base(), - orig, - ) - }), - ( - DocumentActionClauses::UpdatePrice { - original_document_clauses, - .. - }, - DocumentTransition::UpdatePrice(update_price), - ) => original_document.map_or(false, |orig| { - self.evaluate_document_with_clauses( - original_document_clauses, - update_price.base(), - orig, - ) - }), - ( - DocumentActionClauses::Purchase { - original_document_clauses, - .. - }, - DocumentTransition::Purchase(purchase), - ) => original_document.map_or(false, |orig| { - self.evaluate_document_with_clauses( - original_document_clauses, - purchase.base(), - orig, - ) - }), - _ => false, - } - } - - /// Low-level helper to evaluate an in-memory document payload (`base` + `data`) - /// against the clauses selected for the current action. - /// - /// Prefer the transition-oriented method unless you are matching raw - /// in-memory data. - #[cfg(any(feature = "server", feature = "verify"))] - pub fn matches_document( - &self, - document_base_transition: &DocumentBaseTransition, - document_data: &BTreeMap, - ) -> bool { - // When action-specific clauses exist, use them; default to empty clauses when not provided - let clauses_ref: &InternalClauses = match &self.action_clauses { - DocumentActionClauses::Create { - new_document_clauses, - } => new_document_clauses, - DocumentActionClauses::Replace { - new_document_clauses, + pub fn matches_original_document(&self, original_document: &Document) -> bool { + // Evaluate only original-dependent clauses. Transition base was validated earlier. + match &self.action_clauses { + DocumentActionMatchClauses::Replace { + original_document_clauses, .. - } => new_document_clauses, - DocumentActionClauses::Delete { + } + | DocumentActionMatchClauses::Delete { original_document_clauses, - } => original_document_clauses, - DocumentActionClauses::Transfer { + } + | DocumentActionMatchClauses::Transfer { original_document_clauses, .. - } => original_document_clauses, - DocumentActionClauses::Purchase { + } + | DocumentActionMatchClauses::UpdatePrice { original_document_clauses, .. - } => original_document_clauses, - DocumentActionClauses::UpdatePrice { + } + | DocumentActionMatchClauses::Purchase { original_document_clauses, .. - } => original_document_clauses, - }; - self.evaluate_document_with_clauses(clauses_ref, document_base_transition, document_data) + } => { + let id_value: Value = original_document.id().into(); + self.evaluate_clauses( + original_document_clauses, + &id_value, + original_document.properties(), + ) + } + _ => false, + } } - /// Core evaluator: checks the given base transition + document data against - /// the provided `InternalClauses`. - /// - /// This is used internally by action-specific matchers (e.g., Replace evaluates - /// both `original_document_clauses` and `new_document_clauses` separately). Most - /// callers should use `matches_document_transition_original_document`, which determines the - /// correct clause set(s) to apply for the configured `DocumentActionClauses`. + // Note: tests use `evaluate_clauses` directly for unit coverage of clause logic. + + /// Single clause evaluator used by both transition and original-document paths. #[cfg(any(feature = "server", feature = "verify"))] - fn evaluate_document_with_clauses( + fn evaluate_clauses( &self, clauses: &InternalClauses, - document_base_transition: &DocumentBaseTransition, + document_id_value: &Value, document_data: &BTreeMap, ) -> bool { - // Check contract ID - if document_base_transition.data_contract_id() != self.contract.id() { - return false; - } - - // Check document type name - if document_base_transition.document_type_name() != &self.document_type_name { - return false; - } - - // Check primary key in clause (for document ID) + // Primary key IN clause if let Some(primary_key_in_clause) = &clauses.primary_key_in_clause { - if !primary_key_in_clause.matches_value(&document_base_transition.id().into()) { + if !primary_key_in_clause.matches_value(document_id_value) { return false; } } - // Check primary key equal clause (for document ID) + // Primary key EQUAL clause if let Some(primary_key_equal_clause) = &clauses.primary_key_equal_clause { - if !primary_key_equal_clause.matches_value(&document_base_transition.id().into()) { + if !primary_key_equal_clause.matches_value(document_id_value) { return false; } } - // Check in clause + // In clause if let Some(in_clause) = &clauses.in_clause { let field_value = get_value_by_path(document_data, &in_clause.field); if let Some(value) = field_value { @@ -510,7 +441,7 @@ impl DriveDocumentQueryFilter<'_> { } } - // Check range clause + // Range clause if let Some(range_clause) = &clauses.range_clause { let field_value = get_value_by_path(document_data, &range_clause.field); if let Some(value) = field_value { @@ -522,7 +453,7 @@ impl DriveDocumentQueryFilter<'_> { } } - // Check equal clauses + // Equal clauses for (field, equal_clause) in &clauses.equal_clauses { let field_value = get_value_by_path(document_data, field); if let Some(value) = field_value { @@ -539,7 +470,7 @@ impl DriveDocumentQueryFilter<'_> { /// Validate the filter structure and clauses. /// - /// This function doesn't check the existence of the data contract. The subscription host must check. + /// In addition to these validations, the subscription host should check the contract's existence. #[cfg(any(feature = "server", feature = "verify"))] pub fn validate(&self) -> Result<(), crate::error::Error> { // Ensure the document type exists @@ -553,10 +484,10 @@ impl DriveDocumentQueryFilter<'_> { })?; match &self.action_clauses { - DocumentActionClauses::Create { + DocumentActionMatchClauses::Create { new_document_clauses, } => validate_internal_clauses_against_schema(document_type, new_document_clauses)?, - DocumentActionClauses::Replace { + DocumentActionMatchClauses::Replace { original_document_clauses, new_document_clauses, } => { @@ -577,12 +508,12 @@ impl DriveDocumentQueryFilter<'_> { validate_internal_clauses_against_schema(document_type, new_document_clauses)?; } } - DocumentActionClauses::Delete { + DocumentActionMatchClauses::Delete { original_document_clauses, } => { validate_internal_clauses_against_schema(document_type, original_document_clauses)? } - DocumentActionClauses::Transfer { + DocumentActionMatchClauses::Transfer { original_document_clauses, owner_clause, } => { @@ -617,7 +548,7 @@ impl DriveDocumentQueryFilter<'_> { } } } - DocumentActionClauses::UpdatePrice { + DocumentActionMatchClauses::UpdatePrice { original_document_clauses, price_clause, } => { @@ -698,7 +629,7 @@ impl DriveDocumentQueryFilter<'_> { } } } - DocumentActionClauses::Purchase { + DocumentActionMatchClauses::Purchase { original_document_clauses, owner_clause, } => { @@ -769,8 +700,10 @@ fn get_value_by_path<'a>(root: &'a BTreeMap, path: &str) -> Optio mod tests { use super::*; use crate::query::{ValueClause, WhereClause, WhereOperator}; + use dpp::document::{Document, DocumentV0}; use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::document_base_transition::v1::DocumentBaseTransitionV1; + use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use dpp::tests::fixtures::get_data_contract_fixture; use dpp::version::LATEST_PLATFORM_VERSION; @@ -781,11 +714,12 @@ mod tests { let contract = fixture.data_contract_owned(); // Create a filter with no clauses (should match if contract and type match) + let internal_clauses = InternalClauses::default(); let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { - new_document_clauses: InternalClauses::default(), + action_clauses: DocumentActionMatchClauses::Create { + new_document_clauses: internal_clauses.clone(), }, }; @@ -800,19 +734,9 @@ mod tests { let document_data = BTreeMap::new(); - // Should match since contract ID and type name are correct - assert!(filter.matches_document(&document_base, &document_data)); - - // Test with wrong contract ID - let wrong_document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { - id: Identifier::from([3u8; 32]), - document_type_name: "niceDocument".to_string(), - data_contract_id: Identifier::from([99u8; 32]), // Wrong ID - identity_contract_nonce: 0, - token_payment_info: None, - }); - - assert!(!filter.matches_document(&wrong_document_base, &document_data)); + // With no clauses, evaluation should be true regardless of data + let id_value: Value = document_base.id().into(); + assert!(filter.evaluate_clauses(&internal_clauses, &id_value, &document_data)); } #[test] @@ -832,8 +756,8 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { - new_document_clauses: internal_clauses, + action_clauses: DocumentActionMatchClauses::Create { + new_document_clauses: internal_clauses.clone(), }, }; @@ -848,7 +772,8 @@ mod tests { let document_data = BTreeMap::new(); - assert!(filter.matches_document(&matching_doc, &document_data)); + let id_value: Value = matching_doc.id().into(); + assert!(filter.evaluate_clauses(&internal_clauses, &id_value, &document_data)); // Test with different ID let non_matching_doc = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { @@ -859,7 +784,8 @@ mod tests { token_payment_info: None, }); - assert!(!filter.matches_document(&non_matching_doc, &document_data)); + let non_id_value: Value = non_matching_doc.id().into(); + assert!(!filter.evaluate_clauses(&internal_clauses, &non_id_value, &document_data)); } #[test] @@ -884,8 +810,8 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { - new_document_clauses: internal_clauses, + action_clauses: DocumentActionMatchClauses::Create { + new_document_clauses: internal_clauses.clone(), }, }; @@ -901,17 +827,18 @@ mod tests { let mut matching_data = BTreeMap::new(); matching_data.insert("name".to_string(), Value::Text("example".to_string())); - assert!(filter.matches_document(&document_base, &matching_data)); + let id_value: Value = document_base.id().into(); + assert!(filter.evaluate_clauses(&internal_clauses, &id_value, &matching_data)); // Test with non-matching data let mut non_matching_data = BTreeMap::new(); non_matching_data.insert("name".to_string(), Value::Text("different".to_string())); - assert!(!filter.matches_document(&document_base, &non_matching_data)); + assert!(!filter.evaluate_clauses(&internal_clauses, &id_value, &non_matching_data)); // Test with missing field let empty_data = BTreeMap::new(); - assert!(!filter.matches_document(&document_base, &empty_data)); + assert!(!filter.evaluate_clauses(&internal_clauses, &id_value, &empty_data)); } #[test] @@ -934,8 +861,8 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { - new_document_clauses: internal_clauses, + action_clauses: DocumentActionMatchClauses::Create { + new_document_clauses: internal_clauses.clone(), }, }; @@ -950,12 +877,13 @@ mod tests { // Test with value in list let mut matching_data = BTreeMap::new(); matching_data.insert("status".to_string(), Value::Text("active".to_string())); - assert!(filter.matches_document(&document_base, &matching_data)); + let id_value: Value = document_base.id().into(); + assert!(filter.evaluate_clauses(&internal_clauses, &id_value, &matching_data)); // Test with value not in list let mut non_matching_data = BTreeMap::new(); non_matching_data.insert("status".to_string(), Value::Text("completed".to_string())); - assert!(!filter.matches_document(&document_base, &non_matching_data)); + assert!(!filter.evaluate_clauses(&internal_clauses, &id_value, &non_matching_data)); } #[test] @@ -974,8 +902,8 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { - new_document_clauses: internal_clauses, + action_clauses: DocumentActionMatchClauses::Create { + new_document_clauses: internal_clauses.clone(), }, }; @@ -990,17 +918,18 @@ mod tests { // Test with value greater than threshold let mut greater_data = BTreeMap::new(); greater_data.insert("score".to_string(), Value::U64(75)); - assert!(filter.matches_document(&document_base, &greater_data)); + let id_value: Value = document_base.id().into(); + assert!(filter.evaluate_clauses(&internal_clauses, &id_value, &greater_data)); // Test with value equal to threshold (should fail for GreaterThan) let mut equal_data = BTreeMap::new(); equal_data.insert("score".to_string(), Value::U64(50)); - assert!(!filter.matches_document(&document_base, &equal_data)); + assert!(!filter.evaluate_clauses(&internal_clauses, &id_value, &equal_data)); // Test with value less than threshold let mut less_data = BTreeMap::new(); less_data.insert("score".to_string(), Value::U64(25)); - assert!(!filter.matches_document(&document_base, &less_data)); + assert!(!filter.evaluate_clauses(&internal_clauses, &id_value, &less_data)); } #[test] @@ -1025,8 +954,8 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { - new_document_clauses: internal_clauses, + action_clauses: DocumentActionMatchClauses::Create { + new_document_clauses: internal_clauses.clone(), }, }; @@ -1046,7 +975,8 @@ mod tests { let mut data = BTreeMap::new(); data.insert("meta".to_string(), Value::Map(nested)); - assert!(filter.matches_document(&document_base, &data)); + let id_value: Value = document_base.id().into(); + assert!(filter.evaluate_clauses(&internal_clauses, &id_value, &data)); } #[test] @@ -1058,7 +988,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Replace { + action_clauses: DocumentActionMatchClauses::Replace { original_document_clauses: InternalClauses::default(), new_document_clauses: InternalClauses::default(), }, @@ -1069,7 +999,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Replace { + action_clauses: DocumentActionMatchClauses::Replace { original_document_clauses: InternalClauses::default(), new_document_clauses: InternalClauses { primary_key_equal_clause: Some(WhereClause { @@ -1087,7 +1017,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Transfer { + action_clauses: DocumentActionMatchClauses::Transfer { original_document_clauses: InternalClauses::default(), owner_clause: None, }, @@ -1098,7 +1028,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Transfer { + action_clauses: DocumentActionMatchClauses::Transfer { original_document_clauses: InternalClauses::default(), owner_clause: Some(ValueClause { operator: WhereOperator::Equal, @@ -1112,7 +1042,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::UpdatePrice { + action_clauses: DocumentActionMatchClauses::UpdatePrice { original_document_clauses: InternalClauses::default(), price_clause: None, }, @@ -1123,7 +1053,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::UpdatePrice { + action_clauses: DocumentActionMatchClauses::UpdatePrice { original_document_clauses: InternalClauses::default(), price_clause: Some(ValueClause { operator: WhereOperator::GreaterThan, @@ -1137,7 +1067,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Purchase { + action_clauses: DocumentActionMatchClauses::Purchase { original_document_clauses: InternalClauses::default(), owner_clause: None, }, @@ -1148,7 +1078,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Purchase { + action_clauses: DocumentActionMatchClauses::Purchase { original_document_clauses: InternalClauses::default(), owner_clause: Some(ValueClause { operator: WhereOperator::Equal, @@ -1173,7 +1103,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Transfer { + action_clauses: DocumentActionMatchClauses::Transfer { original_document_clauses: InternalClauses::default(), owner_clause: Some(ValueClause { operator: WhereOperator::Equal, @@ -1234,7 +1164,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Purchase { + action_clauses: DocumentActionMatchClauses::Purchase { original_document_clauses: InternalClauses::default(), owner_clause: Some(ValueClause { operator: WhereOperator::Equal, @@ -1274,9 +1204,6 @@ mod tests { #[test] fn test_transfer_original_clause_only_matches_with_original_document() { - use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::DocumentTransferTransitionV0; - use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::DocumentTransferTransition; - let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); @@ -1293,7 +1220,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Transfer { + action_clauses: DocumentActionMatchClauses::Transfer { original_document_clauses: InternalClauses { equal_clauses: eq, ..Default::default() @@ -1302,34 +1229,23 @@ mod tests { }, }; - let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { - id: Identifier::from([9u8; 32]), - document_type_name: "niceDocument".to_string(), - data_contract_id: contract.id(), - identity_contract_nonce: 0, - token_payment_info: None, - }); - let transfer_v0 = DocumentTransferTransitionV0 { - base: document_base, - revision: 1, - recipient_owner_id: Identifier::from([8u8; 32]), - }; - let transfer = DocumentTransition::Transfer(DocumentTransferTransition::V0(transfer_v0)); - // Original doc present and matching let mut original = BTreeMap::new(); original.insert("status".to_string(), Value::Text("active".to_string())); - assert!(filter.matches_document_transition_original_document(&transfer, Some(&original),)); + let original_doc = Document::V0(DocumentV0 { + id: Identifier::from([9u8; 32]), + owner_id: Identifier::from([0u8; 32]), + properties: original, + ..Default::default() + }); + assert!(filter.matches_original_document(&original_doc)); // Without original doc, clause is required -> no match - assert!(!filter.matches_document_transition_original_document(&transfer, None)); + // No call without original: first pass already signaled it is required } #[test] fn test_delete_original_clause_only_matches_with_original_document() { - use dpp::state_transition::batch_transition::batched_transition::document_delete_transition::v0::DocumentDeleteTransitionV0; - use dpp::state_transition::batch_transition::batched_transition::document_delete_transition::DocumentDeleteTransition; - let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); @@ -1346,7 +1262,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Delete { + action_clauses: DocumentActionMatchClauses::Delete { original_document_clauses: InternalClauses { equal_clauses: eq, ..Default::default() @@ -1354,32 +1270,30 @@ mod tests { }, }; - let document_base = DocumentBaseTransition::V1(DocumentBaseTransitionV1 { - id: Identifier::from([12u8; 32]), - document_type_name: "niceDocument".to_string(), - data_contract_id: contract.id(), - identity_contract_nonce: 0, - token_payment_info: None, - }); - let delete_v0 = DocumentDeleteTransitionV0 { - base: document_base, - }; - let delete = DocumentTransition::Delete(DocumentDeleteTransition::V0(delete_v0)); - // Original doc present and matching let mut original = BTreeMap::new(); original.insert("status".to_string(), Value::Text("active".to_string())); - assert!(filter.matches_document_transition_original_document(&delete, Some(&original),)); + let original_doc = Document::V0(DocumentV0 { + id: Identifier::from([12u8; 32]), + owner_id: Identifier::from([0u8; 32]), + properties: original, + ..Default::default() + }); + assert!(filter.matches_original_document(&original_doc)); // Without original doc -> no match (required for Delete) - assert!(!filter.matches_document_transition_original_document(&delete, None)); + // No call without original: first pass already signaled it is required // Original mismatching -> no match let mut original_bad = BTreeMap::new(); original_bad.insert("status".to_string(), Value::Text("inactive".to_string())); - assert!( - !filter.matches_document_transition_original_document(&delete, Some(&original_bad),) - ); + let original_doc_bad = Document::V0(DocumentV0 { + id: Identifier::from([12u8; 32]), + owner_id: Identifier::from([0u8; 32]), + properties: original_bad, + ..Default::default() + }); + assert!(!filter.matches_original_document(&original_doc_bad)); } #[test] @@ -1410,7 +1324,7 @@ mod tests { let filter_price_only = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::UpdatePrice { + action_clauses: DocumentActionMatchClauses::UpdatePrice { original_document_clauses: InternalClauses::default(), price_clause: Some(ValueClause { operator: WhereOperator::GreaterThan, @@ -1427,7 +1341,7 @@ mod tests { let filter_price_only_fail = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::UpdatePrice { + action_clauses: DocumentActionMatchClauses::UpdatePrice { original_document_clauses: InternalClauses::default(), price_clause: Some(ValueClause { operator: WhereOperator::GreaterThan, @@ -1453,7 +1367,7 @@ mod tests { let filter_with_orig = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::UpdatePrice { + action_clauses: DocumentActionMatchClauses::UpdatePrice { original_document_clauses: InternalClauses { equal_clauses: eq, ..Default::default() @@ -1470,11 +1384,16 @@ mod tests { filter_with_orig.matches_document_transition(&update, None), TransitionCheckResult::NeedsOriginal ); - assert!(filter_with_orig - .matches_document_transition_original_document(&update, Some(&original_doc))); + let original_document = Document::V0(DocumentV0 { + id: Identifier::from([10u8; 32]), + owner_id: Identifier::from([0u8; 32]), + properties: original_doc, + ..Default::default() + }); + assert!(filter_with_orig.matches_original_document(&original_document)); // Missing original doc -> required -> no match - assert!(!filter_with_orig.matches_document_transition_original_document(&update, None)); + // No call without original: first pass already signaled it is required } #[test] @@ -1525,7 +1444,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Replace { + action_clauses: DocumentActionMatchClauses::Replace { original_document_clauses: original_clauses, new_document_clauses: new_document_clauses, }, @@ -1548,16 +1467,27 @@ mod tests { filter.matches_document_transition(&replace, None), TransitionCheckResult::NeedsOriginal ); - assert!(filter.matches_document_transition_original_document(&replace, Some(&original_doc))); + let original_document = Document::V0(DocumentV0 { + id: Identifier::from([11u8; 32]), + owner_id: Identifier::from([0u8; 32]), + properties: original_doc, + ..Default::default() + }); + assert!(filter.matches_original_document(&original_document)); // Original missing -> should fail as it's required - assert!(!filter.matches_document_transition_original_document(&replace, None)); + // No call without original: first pass already signaled it is required // Original mismatching -> fail let mut original_doc_bad = BTreeMap::new(); original_doc_bad.insert("status".to_string(), Value::Text("inactive".to_string())); - assert!(!filter - .matches_document_transition_original_document(&replace, Some(&original_doc_bad),)); + let original_document_bad = Document::V0(DocumentV0 { + id: Identifier::from([11u8; 32]), + owner_id: Identifier::from([0u8; 32]), + properties: original_doc_bad, + ..Default::default() + }); + assert!(!filter.matches_original_document(&original_document_bad)); // New-data mismatching should fail in first check (do not call final) if let DocumentTransition::Replace(mut rep) = replace.clone() { @@ -1586,8 +1516,8 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { - new_document_clauses: internal_clauses, + action_clauses: DocumentActionMatchClauses::Create { + new_document_clauses: internal_clauses.clone(), }, }; @@ -1602,27 +1532,28 @@ mod tests { // Test value in range let mut in_range = BTreeMap::new(); in_range.insert("value".to_string(), Value::U64(15)); - assert!(filter.matches_document(&document_base, &in_range)); + let id_value: Value = document_base.id().into(); + assert!(filter.evaluate_clauses(&internal_clauses, &id_value, &in_range)); // Test lower bound (inclusive) let mut lower_bound = BTreeMap::new(); lower_bound.insert("value".to_string(), Value::U64(10)); - assert!(filter.matches_document(&document_base, &lower_bound)); + assert!(filter.evaluate_clauses(&internal_clauses, &id_value, &lower_bound)); // Test upper bound (inclusive) let mut upper_bound = BTreeMap::new(); upper_bound.insert("value".to_string(), Value::U64(20)); - assert!(filter.matches_document(&document_base, &upper_bound)); + assert!(filter.evaluate_clauses(&internal_clauses, &id_value, &upper_bound)); // Test below range let mut below = BTreeMap::new(); below.insert("value".to_string(), Value::U64(5)); - assert!(!filter.matches_document(&document_base, &below)); + assert!(!filter.evaluate_clauses(&internal_clauses, &id_value, &below)); // Test above range let mut above = BTreeMap::new(); above.insert("value".to_string(), Value::U64(25)); - assert!(!filter.matches_document(&document_base, &above)); + assert!(!filter.evaluate_clauses(&internal_clauses, &id_value, &above)); } #[test] @@ -1646,7 +1577,7 @@ mod tests { let valid_filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "indexedDocument".to_string(), - action_clauses: DocumentActionClauses::Create { + action_clauses: DocumentActionMatchClauses::Create { new_document_clauses: internal_clauses, }, }; @@ -1673,7 +1604,7 @@ mod tests { let invalid_filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { + action_clauses: DocumentActionMatchClauses::Create { new_document_clauses: internal_clauses, }, }; @@ -1695,7 +1626,7 @@ mod tests { let primary_key_filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "indexedDocument".to_string(), - action_clauses: DocumentActionClauses::Create { + action_clauses: DocumentActionMatchClauses::Create { new_document_clauses: internal_clauses, }, }; @@ -1724,7 +1655,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { + action_clauses: DocumentActionMatchClauses::Create { new_document_clauses: InternalClauses { equal_clauses: eq, ..Default::default() @@ -1737,7 +1668,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { + action_clauses: DocumentActionMatchClauses::Create { new_document_clauses: InternalClauses { range_clause: Some(WhereClause { field: "$id".to_string(), @@ -1760,7 +1691,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Transfer { + action_clauses: DocumentActionMatchClauses::Transfer { original_document_clauses: InternalClauses::default(), owner_clause: Some(ValueClause { operator: WhereOperator::Equal, @@ -1774,7 +1705,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::UpdatePrice { + action_clauses: DocumentActionMatchClauses::UpdatePrice { original_document_clauses: InternalClauses::default(), price_clause: Some(ValueClause { operator: WhereOperator::Equal, @@ -1788,7 +1719,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::UpdatePrice { + action_clauses: DocumentActionMatchClauses::UpdatePrice { original_document_clauses: InternalClauses::default(), price_clause: Some(ValueClause { operator: WhereOperator::Between, @@ -1808,7 +1739,7 @@ mod tests { let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { + action_clauses: DocumentActionMatchClauses::Create { new_document_clauses: InternalClauses { range_clause: Some(WhereClause { field: "score".to_string(), @@ -1837,13 +1768,13 @@ mod tests { let original_filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), - action_clauses: DocumentActionClauses::Create { + action_clauses: DocumentActionMatchClauses::Create { new_document_clauses: internal_clauses.clone(), }, }; // No conversion helpers; verify the filter holds the expected clauses - if let DocumentActionClauses::Create { + if let DocumentActionMatchClauses::Create { new_document_clauses, } = original_filter.action_clauses { From fe8b47f85c768e120e43095fbaafa6f423ba9f19 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Wed, 10 Sep 2025 18:27:31 +0700 Subject: [PATCH 12/30] more clean --- packages/rs-drive/src/query/filter.rs | 311 ++++++++++++-------------- 1 file changed, 137 insertions(+), 174 deletions(-) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index e7d331883a0..2d1f53a5a6f 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -34,10 +34,10 @@ use std::collections::BTreeMap; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; use dpp::platform_value::Value; +use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; use dpp::document::{Document, DocumentV0Getters}; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; -use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; use dpp::state_transition::batch_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::v0_methods::DocumentTransferTransitionV0Methods; @@ -148,219 +148,182 @@ impl DriveDocumentQueryFilter<'_> { document_transition: &DocumentTransition, batch_owner_value: Option<&Value>, // Only used for Purchase ) -> TransitionCheckResult { - match (&self.action_clauses, document_transition) { - // Create: evaluate final clauses only - ( - DocumentActionMatchClauses::Create { + // Fast reject on contract/type mismatch common to all transitions + if document_transition.base().data_contract_id() != self.contract.id() + || document_transition.base().document_type_name() != &self.document_type_name + { + return TransitionCheckResult::Fail; + } + + // Document ID value used by clause evaluation paths + let id_value: Value = document_transition.base().id().into(); + + match document_transition { + DocumentTransition::Create(create) => { + if let DocumentActionMatchClauses::Create { new_document_clauses, - }, - DocumentTransition::Create(create), - ) => { - // Ensure transition targets this filter's contract and document type - if create.base().data_contract_id() != self.contract.id() - || create.base().document_type_name() != &self.document_type_name + } = &self.action_clauses { - return TransitionCheckResult::Fail; - } - - // Evaluate new-document clauses (if any) - let id_value: Value = create.base().id().into(); - if self.evaluate_clauses(new_document_clauses, &id_value, create.data()) { - TransitionCheckResult::Pass + if self.evaluate_clauses(new_document_clauses, &id_value, create.data()) { + TransitionCheckResult::Pass + } else { + TransitionCheckResult::Fail + } } else { TransitionCheckResult::Fail } } - // Replace: evaluate new-document clauses (if non-empty); if they pass, - // require original if `original_document_clauses` is non-empty and not primary-key-only. - ( - DocumentActionMatchClauses::Replace { + DocumentTransition::Replace(replace) => { + if let DocumentActionMatchClauses::Replace { original_document_clauses, new_document_clauses, - }, - DocumentTransition::Replace(replace), - ) => { - // Ensure transition targets this filter's contract and document type - if replace.base().data_contract_id() != self.contract.id() - || replace.base().document_type_name() != &self.document_type_name + } = &self.action_clauses { - return TransitionCheckResult::Fail; - } - - // Evaluate new-document clauses first (if any) - let id_value: Value = replace.base().id().into(); - let final_ok = if new_document_clauses.is_empty() { - true - } else { - self.evaluate_clauses(new_document_clauses, &id_value, replace.data()) - }; - if !final_ok { - return TransitionCheckResult::Fail; - } - - // New clauses passed; gate on original-document clauses - if original_document_clauses.is_empty() { - return TransitionCheckResult::Pass; - } - if original_document_clauses.is_for_primary_key() { - if self.evaluate_clauses(original_document_clauses, &id_value, &BTreeMap::new()) - { + let final_ok = if new_document_clauses.is_empty() { + true + } else { + self.evaluate_clauses(new_document_clauses, &id_value, replace.data()) + }; + if !final_ok { + return TransitionCheckResult::Fail; + } + if original_document_clauses.is_empty() { return TransitionCheckResult::Pass; } - return TransitionCheckResult::Fail; + if original_document_clauses.is_for_primary_key() { + if self.evaluate_clauses( + original_document_clauses, + &id_value, + &BTreeMap::new(), + ) { + return TransitionCheckResult::Pass; + } + return TransitionCheckResult::Fail; + } + TransitionCheckResult::NeedsOriginal + } else { + TransitionCheckResult::Fail } - TransitionCheckResult::NeedsOriginal } - // Delete: needs original only if original_document_clauses reference fields. - // If clauses are empty or primary-key-only, we can decide without original. - ( - DocumentActionMatchClauses::Delete { + DocumentTransition::Delete(_) => { + if let DocumentActionMatchClauses::Delete { original_document_clauses, - }, - DocumentTransition::Delete(delete), - ) => { - // Ensure transition targets this filter's contract and document type - if delete.base().data_contract_id() != self.contract.id() - || delete.base().document_type_name() != &self.document_type_name + } = &self.action_clauses { - return TransitionCheckResult::Fail; - } - - // Evaluate original-document clauses (if any) - let id_value: Value = delete.base().id().into(); - if original_document_clauses.is_empty() { - return TransitionCheckResult::Pass; - } - if original_document_clauses.is_for_primary_key() { - if self.evaluate_clauses(original_document_clauses, &id_value, &BTreeMap::new()) - { + if original_document_clauses.is_empty() { return TransitionCheckResult::Pass; } - return TransitionCheckResult::Fail; + if original_document_clauses.is_for_primary_key() { + if self.evaluate_clauses( + original_document_clauses, + &id_value, + &BTreeMap::new(), + ) { + return TransitionCheckResult::Pass; + } + return TransitionCheckResult::Fail; + } + TransitionCheckResult::NeedsOriginal + } else { + TransitionCheckResult::Fail } - TransitionCheckResult::NeedsOriginal } - // Transfer: check owner (if any), then gate on original clauses - ( - DocumentActionMatchClauses::Transfer { + DocumentTransition::Transfer(transfer) => { + if let DocumentActionMatchClauses::Transfer { original_document_clauses, owner_clause, - }, - DocumentTransition::Transfer(transfer), - ) => { - // Check owner clause (if any) - let new_owner_value: Value = transfer.recipient_owner_id().into(); - let owner_ok = match owner_clause { - Some(clause) => clause.matches_value(&new_owner_value), - None => true, - }; - if !owner_ok { - return TransitionCheckResult::Fail; - } - - // Ensure transition targets this filter's contract and document type - if transfer.base().data_contract_id() != self.contract.id() - || transfer.base().document_type_name() != &self.document_type_name + } = &self.action_clauses { - return TransitionCheckResult::Fail; - } - - // Evaluate original-document clauses (if any) - let id_value: Value = transfer.base().id().into(); - if original_document_clauses.is_empty() { - return TransitionCheckResult::Pass; - } - if original_document_clauses.is_for_primary_key() { - if self.evaluate_clauses(original_document_clauses, &id_value, &BTreeMap::new()) - { + let new_owner_value: Value = transfer.recipient_owner_id().into(); + let owner_ok = match owner_clause { + Some(clause) => clause.matches_value(&new_owner_value), + None => true, + }; + if !owner_ok { + return TransitionCheckResult::Fail; + } + if original_document_clauses.is_empty() { return TransitionCheckResult::Pass; } - return TransitionCheckResult::Fail; + if original_document_clauses.is_for_primary_key() { + if self.evaluate_clauses( + original_document_clauses, + &id_value, + &BTreeMap::new(), + ) { + return TransitionCheckResult::Pass; + } + return TransitionCheckResult::Fail; + } + TransitionCheckResult::NeedsOriginal + } else { + TransitionCheckResult::Fail } - TransitionCheckResult::NeedsOriginal } - // UpdatePrice: check price, then gate on original clauses - ( - DocumentActionMatchClauses::UpdatePrice { + DocumentTransition::UpdatePrice(update_price) => { + if let DocumentActionMatchClauses::UpdatePrice { original_document_clauses, price_clause, - }, - DocumentTransition::UpdatePrice(update_price), - ) => { - // Check price clause (if any) - let price_value = Value::U64(update_price.price()); - let price_ok = match price_clause { - Some(clause) => clause.matches_value(&price_value), - None => true, - }; - if !price_ok { - return TransitionCheckResult::Fail; - } - - // Ensure transition targets this filter's contract and document type - if update_price.base().data_contract_id() != self.contract.id() - || update_price.base().document_type_name() != &self.document_type_name + } = &self.action_clauses { - return TransitionCheckResult::Fail; - } - - // Evaluate original-document clauses (if any) - let id_value: Value = update_price.base().id().into(); - if original_document_clauses.is_empty() { - return TransitionCheckResult::Pass; - } - if original_document_clauses.is_for_primary_key() { - if self.evaluate_clauses(original_document_clauses, &id_value, &BTreeMap::new()) - { + let price_value = Value::U64(update_price.price()); + let price_ok = match price_clause { + Some(clause) => clause.matches_value(&price_value), + None => true, + }; + if !price_ok { + return TransitionCheckResult::Fail; + } + if original_document_clauses.is_empty() { return TransitionCheckResult::Pass; } - return TransitionCheckResult::Fail; + if original_document_clauses.is_for_primary_key() { + if self.evaluate_clauses( + original_document_clauses, + &id_value, + &BTreeMap::new(), + ) { + return TransitionCheckResult::Pass; + } + return TransitionCheckResult::Fail; + } + TransitionCheckResult::NeedsOriginal + } else { + TransitionCheckResult::Fail } - TransitionCheckResult::NeedsOriginal } - // Purchase: check batch owner (if clause present, we must have a value), then gate on original - ( - DocumentActionMatchClauses::Purchase { + DocumentTransition::Purchase(_) => { + if let DocumentActionMatchClauses::Purchase { original_document_clauses, owner_clause, - }, - DocumentTransition::Purchase(purchase), - ) => { - // Check owner clause (if any) - let owner_ok = match (owner_clause, batch_owner_value) { - (Some(clause), Some(val)) => clause.matches_value(val), - (Some(_), None) => return TransitionCheckResult::Fail, // a required context is missing - (None, _) => true, - }; - if !owner_ok { - return TransitionCheckResult::Fail; - } - - // Ensure transition targets this filter's contract and document type - if purchase.base().data_contract_id() != self.contract.id() - || purchase.base().document_type_name() != &self.document_type_name + } = &self.action_clauses { - return TransitionCheckResult::Fail; - } - - // Evaluate original-document clauses (if any) - let id_value: Value = purchase.base().id().into(); - if original_document_clauses.is_empty() { - return TransitionCheckResult::Pass; - } - if original_document_clauses.is_for_primary_key() { - if self.evaluate_clauses(original_document_clauses, &id_value, &BTreeMap::new()) - { + let owner_ok = match (owner_clause, batch_owner_value) { + (Some(clause), Some(val)) => clause.matches_value(val), + (Some(_), None) => return TransitionCheckResult::Fail, + (None, _) => true, + }; + if !owner_ok { + return TransitionCheckResult::Fail; + } + if original_document_clauses.is_empty() { return TransitionCheckResult::Pass; } - return TransitionCheckResult::Fail; + if original_document_clauses.is_for_primary_key() { + if self.evaluate_clauses( + original_document_clauses, + &id_value, + &BTreeMap::new(), + ) { + return TransitionCheckResult::Pass; + } + return TransitionCheckResult::Fail; + } + TransitionCheckResult::NeedsOriginal + } else { + TransitionCheckResult::Fail } - TransitionCheckResult::NeedsOriginal } - // Cross-action fallback: do not match. A subscription for a specific action - // (e.g., Purchase) must not match transitions of other actions even if the - // filter only targets the primary key. - _ => TransitionCheckResult::Fail, } } From a079deb59091c092b0ec84e2fb65403cbd7880aa Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Wed, 10 Sep 2025 21:29:38 +0700 Subject: [PATCH 13/30] clean --- packages/rs-drive/src/query/filter.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 2d1f53a5a6f..6a889675a51 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -368,8 +368,6 @@ impl DriveDocumentQueryFilter<'_> { } } - // Note: tests use `evaluate_clauses` directly for unit coverage of clause logic. - /// Single clause evaluator used by both transition and original-document paths. #[cfg(any(feature = "server", feature = "verify"))] fn evaluate_clauses( From 3d17d9e2451a285ddcbbb740c4fdfe77aa551aad Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Fri, 12 Sep 2025 18:21:25 +0700 Subject: [PATCH 14/30] fixes --- packages/rs-drive/src/query/conditions.rs | 323 ++++++++++++---------- packages/rs-drive/src/query/filter.rs | 1 + packages/rs-drive/src/query/mod.rs | 82 ------ 3 files changed, 179 insertions(+), 227 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 3965bf24fef..10855bd45f8 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -218,49 +218,11 @@ impl<'a> WhereClause { } /// Evaluate this clause against a provided `Value` + /// + /// This function is used for filtering documents in memory for document subscriptions. + /// For query execution, the matching logic is handled in `to_path_query`. pub fn matches_value(&self, value: &Value) -> bool { - match &self.operator { - WhereOperator::Equal => value == &self.value, - WhereOperator::GreaterThan => value > &self.value, - WhereOperator::GreaterThanOrEquals => value >= &self.value, - WhereOperator::LessThan => value < &self.value, - WhereOperator::LessThanOrEquals => value <= &self.value, - WhereOperator::In => match &self.value { - Value::Array(array) => array.contains(value), - _ => false, - }, - WhereOperator::Between => match &self.value { - Value::Array(bounds) if bounds.len() == 2 => { - value >= &bounds[0] && value <= &bounds[1] - } - _ => false, - }, - WhereOperator::BetweenExcludeBounds => match &self.value { - Value::Array(bounds) if bounds.len() == 2 => { - value > &bounds[0] && value < &bounds[1] - } - _ => false, - }, - WhereOperator::BetweenExcludeLeft => match &self.value { - Value::Array(bounds) if bounds.len() == 2 => { - value > &bounds[0] && value <= &bounds[1] - } - _ => false, - }, - WhereOperator::BetweenExcludeRight => match &self.value { - Value::Array(bounds) if bounds.len() == 2 => { - value >= &bounds[0] && value < &bounds[1] - } - _ => false, - }, - WhereOperator::StartsWith => { - if let (Value::Text(text), Value::Text(prefix)) = (value, &self.value) { - text.starts_with(prefix.as_str()) - } else { - false - } - } - } + eval_operator(&self.operator, value, &self.value) } /// Returns the where clause `in` values if they are an array of values, else an error @@ -1322,48 +1284,66 @@ pub struct ValueClause { impl ValueClause { /// Evaluate this clause against a provided `Value` pub fn matches_value(&self, value: &Value) -> bool { - match &self.operator { - WhereOperator::Equal => value == &self.value, - WhereOperator::GreaterThan => value > &self.value, - WhereOperator::GreaterThanOrEquals => value >= &self.value, - WhereOperator::LessThan => value < &self.value, - WhereOperator::LessThanOrEquals => value <= &self.value, - WhereOperator::In => match &self.value { - Value::Array(array) => array.contains(value), - _ => false, - }, - WhereOperator::Between => match &self.value { - Value::Array(bounds) if bounds.len() == 2 => { - value >= &bounds[0] && value <= &bounds[1] - } + eval_operator(&self.operator, value, &self.value) + } +} + +/// Shared operator evaluator for both WhereClause and ValueClause +fn eval_operator(op: &WhereOperator, probe: &Value, clause_val: &Value) -> bool { + match op { + WhereOperator::Equal => probe == clause_val, + WhereOperator::GreaterThan => probe > clause_val, + WhereOperator::GreaterThanOrEquals => probe >= clause_val, + WhereOperator::LessThan => probe < clause_val, + WhereOperator::LessThanOrEquals => probe <= clause_val, + WhereOperator::In => match clause_val { + Value::Array(array) => array.contains(probe), + Value::Bytes(bytes) => match probe { + Value::U8(b) => bytes.contains(b), _ => false, }, - WhereOperator::BetweenExcludeBounds => match &self.value { - Value::Array(bounds) if bounds.len() == 2 => { - value > &bounds[0] && value < &bounds[1] + _ => false, + }, + WhereOperator::Between => match clause_val { + Value::Array(bounds) if bounds.len() == 2 => { + if !(bounds[0] <= bounds[1]) { + return false; } - _ => false, - }, - WhereOperator::BetweenExcludeLeft => match &self.value { - Value::Array(bounds) if bounds.len() == 2 => { - value > &bounds[0] && value <= &bounds[1] + probe >= &bounds[0] && probe <= &bounds[1] + } + _ => false, + }, + WhereOperator::BetweenExcludeBounds => match clause_val { + Value::Array(bounds) if bounds.len() == 2 => { + if !(bounds[0] <= bounds[1]) { + return false; } - _ => false, - }, - WhereOperator::BetweenExcludeRight => match &self.value { - Value::Array(bounds) if bounds.len() == 2 => { - value >= &bounds[0] && value < &bounds[1] + probe > &bounds[0] && probe < &bounds[1] + } + _ => false, + }, + WhereOperator::BetweenExcludeLeft => match clause_val { + Value::Array(bounds) if bounds.len() == 2 => { + if !(bounds[0] <= bounds[1]) { + return false; } - _ => false, - }, - WhereOperator::StartsWith => { - if let (Value::Text(text), Value::Text(prefix)) = (value, &self.value) { - text.starts_with(prefix.as_str()) - } else { - false + probe > &bounds[0] && probe <= &bounds[1] + } + _ => false, + }, + WhereOperator::BetweenExcludeRight => match clause_val { + Value::Array(bounds) if bounds.len() == 2 => { + if !(bounds[0] <= bounds[1]) { + return false; } + probe >= &bounds[0] && probe < &bounds[1] } - } + _ => false, + }, + WhereOperator::StartsWith => match (probe, clause_val) { + (Value::Text(text), Value::Text(prefix)) => text.starts_with(prefix.as_str()), + _ => false, + }, } } @@ -1375,10 +1355,8 @@ impl From for Value { #[cfg(any(feature = "server", feature = "verify"))] /// Returns the set of allowed operators for a given property type -pub fn allowed_ops_for_type( - property_type: &dpp::data_contract::document_type::DocumentPropertyType, -) -> &'static [WhereOperator] { - use dpp::data_contract::document_type::DocumentPropertyType as T; +pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [WhereOperator] { + use DocumentPropertyType as T; use WhereOperator as Op; match property_type { T::U8 @@ -1404,7 +1382,19 @@ pub fn allowed_ops_for_type( Op::BetweenExcludeLeft, Op::BetweenExcludeRight, ], - T::String(_) => &[Op::Equal, Op::In, Op::StartsWith], + T::String(_) => &[ + Op::Equal, + Op::In, + Op::StartsWith, + Op::GreaterThan, + Op::GreaterThanOrEquals, + Op::LessThan, + Op::LessThanOrEquals, + Op::Between, + Op::BetweenExcludeBounds, + Op::BetweenExcludeLeft, + Op::BetweenExcludeRight, + ], T::Identifier => &[Op::Equal, Op::In], T::ByteArray(_) => &[Op::Equal, Op::In], T::Boolean => &[Op::Equal], @@ -1416,7 +1406,9 @@ pub fn allowed_ops_for_type( fn is_numeric_value(v: &Value) -> bool { matches!( v, - Value::U64(_) + Value::U128(_) + | Value::I128(_) + | Value::U64(_) | Value::I64(_) | Value::U32(_) | Value::I32(_) @@ -1430,11 +1422,7 @@ fn is_numeric_value(v: &Value) -> bool { #[cfg(any(feature = "server", feature = "verify"))] /// Validates that a value matches the expected shape for a given operator and property type -pub fn value_shape_ok( - op: WhereOperator, - v: &Value, - prop_ty: &dpp::data_contract::document_type::DocumentPropertyType, -) -> bool { +pub fn value_shape_ok(op: WhereOperator, v: &Value, prop_ty: &DocumentPropertyType) -> bool { use WhereOperator as Op; match op { Op::Equal => true, @@ -1442,10 +1430,15 @@ pub fn value_shape_ok( Op::StartsWith => matches!(v, Value::Text(_)), Op::GreaterThan | Op::GreaterThanOrEquals | Op::LessThan | Op::LessThanOrEquals => { match prop_ty { - dpp::data_contract::document_type::DocumentPropertyType::F64 => is_numeric_value(v), + DocumentPropertyType::F64 => is_numeric_value(v), + DocumentPropertyType::String(_) => { + matches!(v, Value::Text(_)) + } _ => matches!( v, - Value::U64(_) + Value::U128(_) + | Value::I128(_) + | Value::U64(_) | Value::I64(_) | Value::U32(_) | Value::I32(_) @@ -1463,12 +1456,15 @@ pub fn value_shape_ok( if let Value::Array(arr) = v { arr.len() == 2 && arr.iter().all(|x| match prop_ty { - dpp::data_contract::document_type::DocumentPropertyType::F64 => { - is_numeric_value(x) + DocumentPropertyType::F64 => is_numeric_value(x), + DocumentPropertyType::String(_) => { + matches!(x, Value::Text(_)) } _ => matches!( x, - Value::U64(_) + Value::U128(_) + | Value::I128(_) + | Value::U64(_) | Value::I64(_) | Value::U32(_) | Value::I32(_) @@ -1526,10 +1522,7 @@ pub fn validate_where_clause_against_schema( clause.in_values()?; // If value provided as Bytes, only allow for U8 numeric fields if matches!(clause.value, Value::Bytes(_)) - && !matches!( - property_type, - dpp::data_contract::document_type::DocumentPropertyType::U8 - ) + && !matches!(property_type, DocumentPropertyType::U8) { return Err(Error::Query( QuerySyntaxError::InvalidWhereClauseComponents( @@ -1546,18 +1539,88 @@ pub fn validate_where_clause_against_schema( )); } + // For Between variants, ensure bounds are in ascending order to avoid surprising matches + match clause.operator { + WhereOperator::Between + | WhereOperator::BetweenExcludeBounds + | WhereOperator::BetweenExcludeLeft + | WhereOperator::BetweenExcludeRight => { + if let Value::Array(bounds) = &clause.value { + if bounds.len() == 2 && !(bounds[0] <= bounds[1]) { + return Err(Error::Query(QuerySyntaxError::InvalidBetweenClause( + "when using between operator bounds must be ascending", + ))); + } + } + } + _ => {} + } + // Additional strict type checks for Equal and In element types - let value_type_matches = - |prop_ty: &dpp::data_contract::document_type::DocumentPropertyType, v: &Value| -> bool { - use dpp::data_contract::document_type::DocumentPropertyType as T; - match prop_ty { - T::String(_) => matches!(v, Value::Text(_)), - T::Identifier => matches!(v, Value::Identifier(_)), - T::Boolean => matches!(v, Value::Bool(_)), - T::ByteArray(_) => matches!(v, Value::Bytes(_)), - T::F64 => matches!(v, Value::Float(_)), + let value_type_matches = |prop_ty: &DocumentPropertyType, v: &Value| -> bool { + use DocumentPropertyType as T; + match prop_ty { + T::String(_) => matches!(v, Value::Text(_)), + T::Identifier => matches!(v, Value::Identifier(_)), + T::Boolean => matches!(v, Value::Bool(_)), + T::ByteArray(_) => matches!(v, Value::Bytes(_)), + T::F64 => matches!(v, Value::Float(_)), + T::Date => matches!( + v, + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), + T::U8 | T::U16 | T::U32 | T::U64 | T::U128 => matches!( + v, + Value::U8(_) | Value::U16(_) | Value::U32(_) | Value::U64(_) | Value::U128(_) + ), + T::I8 | T::I16 | T::I32 | T::I64 | T::I128 => matches!( + v, + Value::I8(_) | Value::I16(_) | Value::I32(_) | Value::I64(_) | Value::I128(_) + ), + // No validation for object/array types as operators are disallowed + T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, + } + }; + + match clause.operator { + WhereOperator::Equal => { + use DocumentPropertyType as T; + let ok = match property_type { + // Accept any integer-like value for integer fields (signed/unsigned), reject floats + T::U8 + | T::U16 + | T::U32 + | T::U64 + | T::U128 + | T::I8 + | T::I16 + | T::I32 + | T::I64 + | T::I128 => { + matches!( + clause.value, + Value::U128(_) + | Value::I128(_) + | Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ) + } + T::F64 => matches!(clause.value, Value::Float(_)), T::Date => matches!( - v, + clause.value, Value::U64(_) | Value::I64(_) | Value::U32(_) @@ -1567,22 +1630,14 @@ pub fn validate_where_clause_against_schema( | Value::U8(_) | Value::I8(_) ), - T::U8 | T::U16 | T::U32 | T::U64 | T::U128 => matches!( - v, - Value::U8(_) | Value::U16(_) | Value::U32(_) | Value::U64(_) | Value::U128(_) - ), - T::I8 | T::I16 | T::I32 | T::I64 | T::I128 => matches!( - v, - Value::I8(_) | Value::I16(_) | Value::I32(_) | Value::I64(_) | Value::I128(_) - ), - // No validation for object/array types as operators are disallowed + T::String(_) => matches!(clause.value, Value::Text(_)), + T::Identifier => matches!(clause.value, Value::Identifier(_)), + T::ByteArray(_) => matches!(clause.value, Value::Bytes(_)), + T::Boolean => matches!(clause.value, Value::Bool(_)), + // Not applicable for object/array/variable arrays T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, - } - }; - - match clause.operator { - WhereOperator::Equal => { - if !value_type_matches(property_type, &clause.value) { + }; + if !ok { return Err(Error::Query( QuerySyntaxError::InvalidWhereClauseComponents( "invalid value type for equality", @@ -1610,7 +1665,7 @@ pub fn validate_where_clause_against_schema( #[cfg(any(feature = "server", feature = "verify"))] /// Validate a collection of InternalClauses against the document schema pub fn validate_internal_clauses_against_schema( - document_type: dpp::data_contract::document_type::DocumentTypeRef, + document_type: DocumentTypeRef, clauses: &super::InternalClauses, ) -> Result<(), crate::error::Error> { // Basic composition @@ -1927,28 +1982,6 @@ mod tests { )); } - #[test] - fn validate_rejects_range_on_string_field() { - let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); - let contract = fixture.data_contract_owned(); - let doc_type = contract - .document_type_for_name("niceDocument") - .expect("doc type exists"); - - let clause = WhereClause { - field: "name".to_string(), - operator: GreaterThan, - value: Value::Text("a".to_string()), - }; - let res = validate_where_clause_against_schema(doc_type, &clause); - assert!(matches!( - res, - Err(crate::error::Error::Query( - crate::error::query::QuerySyntaxError::InvalidWhereClauseComponents(_) - )) - )); - } - #[test] fn validate_rejects_in_bytes_for_string_field() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 6a889675a51..575d5957371 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -562,6 +562,7 @@ impl DriveDocumentQueryFilter<'_> { | Value::I8(_) ) }) + && arr[0] <= arr[1] } _ => false, }, diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 1b4c6f51d6e..84dbaaae1c2 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -549,8 +549,6 @@ impl<'a> DriveDocumentQuery<'a> { })?; let internal_clauses = InternalClauses::extract_from_clauses(all_where_clauses)?; - // Schema-aware validation: ensure operators and values are compatible with field types - validate_internal_clauses_against_schema(document_type, &internal_clauses)?; let start_at_option = query_document.remove("startAt"); let start_after_option = query_document.remove("startAfter"); @@ -677,8 +675,6 @@ impl<'a> DriveDocumentQuery<'a> { }?; let internal_clauses = InternalClauses::extract_from_clauses(all_where_clauses)?; - // Schema-aware validation - validate_internal_clauses_against_schema(document_type, &internal_clauses)?; let order_by: IndexMap = order_by .map_or(vec![], |id_cbor| { @@ -841,8 +837,6 @@ impl<'a> DriveDocumentQuery<'a> { } let internal_clauses = InternalClauses::extract_from_clauses(all_where_clauses)?; - // Schema-aware validation - validate_internal_clauses_against_schema(document_type.as_ref(), &internal_clauses)?; let start_at_option = None; //todo let start_after_option = None; //todo @@ -2228,82 +2222,6 @@ impl<'a> DriveDocumentQuery<'a> { } } -#[cfg(all(test, any(feature = "server", feature = "verify")))] -mod shared_validation_tests { - use super::*; - use dpp::tests::fixtures::get_data_contract_fixture; - use dpp::version::LATEST_PLATFORM_VERSION; - - #[test] - fn from_decomposed_values_rejects_startswith_empty_string() { - let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); - let contract = fixture.data_contract_owned(); - let doc_type = contract - .document_type_for_name("niceDocument") - .expect("doc type exists"); - - // where: [["name", "StartsWith", ""]] - let where_clause = Value::Array(vec![Value::Array(vec![ - Value::Text("name".to_string()), - Value::Text("StartsWith".to_string()), - Value::Text("".to_string()), - ])]); - - let res = DriveDocumentQuery::from_decomposed_values( - where_clause, - None, - Some(100), - None, - true, - None, - &contract, - doc_type, - &DriveConfig::default(), - ); - - assert!(matches!( - res, - Err(Error::Query(QuerySyntaxError::StartsWithIllegalString(_))) - )) - } - - #[test] - fn from_decomposed_values_rejects_in_with_duplicates() { - let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); - let contract = fixture.data_contract_owned(); - let doc_type = contract - .document_type_for_name("niceDocument") - .expect("doc type exists"); - - // where: [["status", "In", ["active", "active"]]] -> duplicates not allowed - let where_clause = Value::Array(vec![Value::Array(vec![ - Value::Text("status".to_string()), - Value::Text("In".to_string()), - Value::Array(vec![ - Value::Text("active".to_string()), - Value::Text("active".to_string()), - ]), - ])]); - - let res = DriveDocumentQuery::from_decomposed_values( - where_clause, - None, - Some(100), - None, - true, - None, - &contract, - doc_type, - &DriveConfig::default(), - ); - - assert!(matches!( - res, - Err(Error::Query(QuerySyntaxError::InvalidInClause(_))) - )) - } -} - /// Convert DriveQuery to a BTreeMap of values impl<'a> From<&DriveDocumentQuery<'a>> for BTreeMap { fn from(query: &DriveDocumentQuery<'a>) -> Self { From 9caf4f442120a7946ad9fde15e52bd802750ea99 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Mon, 15 Sep 2025 18:24:33 +0700 Subject: [PATCH 15/30] update doc --- packages/rs-drive/src/query/conditions.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index fc864ba149a..504d4bf132e 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -217,9 +217,9 @@ impl<'a> WhereClause { self.field == "$id" } - /// Evaluate this clause against a provided `Value` + /// Evaluate this WhereClause against a provided `Value` /// - /// This function is used for filtering documents in memory for document subscriptions. + /// Only used for filtering documents in memory for document subscriptions. /// For query execution, the matching logic is handled in `to_path_query`. pub fn matches_value(&self, value: &Value) -> bool { eval_operator(&self.operator, value, &self.value) From 6bba2e520bb706110c7b0f975d6adf98917598a4 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Mon, 15 Sep 2025 18:38:19 +0700 Subject: [PATCH 16/30] move --- packages/rs-drive/src/query/conditions.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 504d4bf132e..956a45febb5 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1270,6 +1270,12 @@ impl<'a> WhereClause { } } +impl From for Value { + fn from(value: WhereClause) -> Self { + Value::Array(vec![value.field.into(), value.operator.into(), value.value]) + } +} + /// Value-only clause used when there is no field lookup involved /// (e.g., comparing a transition-supplied scalar like owner id or price). #[derive(Clone, Debug, PartialEq)] @@ -1347,12 +1353,6 @@ fn eval_operator(op: &WhereOperator, probe: &Value, clause_val: &Value) -> bool } } -impl From for Value { - fn from(value: WhereClause) -> Self { - Value::Array(vec![value.field.into(), value.operator.into(), value.value]) - } -} - #[cfg(any(feature = "server", feature = "verify"))] /// Returns the set of allowed operators for a given property type pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [WhereOperator] { From 08ef0b14c9ac249999213735933a927a42bae481 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Mon, 15 Sep 2025 18:47:11 +0700 Subject: [PATCH 17/30] remove re-exports --- packages/rs-drive/src/query/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index e763fc4fc69..761b87f7358 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -3,9 +3,7 @@ use std::sync::Arc; #[cfg(any(feature = "server", feature = "verify"))] pub use { conditions::{ - allowed_ops_for_type, validate_internal_clauses_against_schema, - validate_where_clause_against_schema, value_shape_ok, ValueClause, WhereClause, - WhereOperator, + validate_internal_clauses_against_schema, ValueClause, WhereClause, WhereOperator, }, grovedb::{PathQuery, Query, QueryItem, SizedQuery}, ordering::OrderClause, From 3fb232bb0969368cc513c54a428a0aa2f763c7bf Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Mon, 15 Sep 2025 18:56:37 +0700 Subject: [PATCH 18/30] fix imports --- packages/rs-drive/src/query/conditions.rs | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 956a45febb5..2a68bbd0bfd 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1,26 +1,29 @@ //! Query Conditions //! +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +#[cfg(any(feature = "server", feature = "verify"))] +use crate::query::InternalClauses; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::{DocumentPropertyType, DocumentType, DocumentTypeRef}; +use dpp::document::document_methods::DocumentMethodsV0; +use dpp::document::Document; +use dpp::platform_value::Value; +use dpp::version::PlatformVersion; use grovedb::Query; use sqlparser::ast; use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Display; +use DocumentPropertyType as T; +use WhereOperator as Op; use WhereOperator::{ Between, BetweenExcludeBounds, BetweenExcludeLeft, BetweenExcludeRight, Equal, GreaterThan, GreaterThanOrEquals, In, LessThan, LessThanOrEquals, StartsWith, }; -use crate::error::query::QuerySyntaxError; -use crate::error::Error; -use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; -use dpp::data_contract::document_type::{DocumentPropertyType, DocumentType, DocumentTypeRef}; -use dpp::document::document_methods::DocumentMethodsV0; -use dpp::document::Document; -use dpp::platform_value::Value; -use dpp::version::PlatformVersion; - /// Converts SQL values to CBOR. fn sql_value_to_platform_value(sql_value: ast::Value) -> Option { match sql_value { @@ -1356,8 +1359,6 @@ fn eval_operator(op: &WhereOperator, probe: &Value, clause_val: &Value) -> bool #[cfg(any(feature = "server", feature = "verify"))] /// Returns the set of allowed operators for a given property type pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [WhereOperator] { - use DocumentPropertyType as T; - use WhereOperator as Op; match property_type { T::U8 | T::I8 @@ -1423,7 +1424,6 @@ fn is_numeric_value(v: &Value) -> bool { #[cfg(any(feature = "server", feature = "verify"))] /// Validates that a value matches the expected shape for a given operator and property type pub fn value_shape_ok(op: WhereOperator, v: &Value, prop_ty: &DocumentPropertyType) -> bool { - use WhereOperator as Op; match op { Op::Equal => true, Op::In => matches!(v, Value::Array(_) | Value::Bytes(_)), @@ -1666,7 +1666,7 @@ pub fn validate_where_clause_against_schema( /// Validate a collection of InternalClauses against the document schema pub fn validate_internal_clauses_against_schema( document_type: DocumentTypeRef, - clauses: &super::InternalClauses, + clauses: &InternalClauses, ) -> Result<(), crate::error::Error> { // Basic composition if !clauses.verify() { From dadf2c532c8977aa35b61265ca9bcbb1972a189a Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Mon, 15 Sep 2025 19:13:40 +0700 Subject: [PATCH 19/30] cleanup --- packages/rs-drive/src/query/conditions.rs | 58 ++++++++++++----------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 2a68bbd0bfd..a0d73748a44 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1298,14 +1298,14 @@ impl ValueClause { } /// Shared operator evaluator for both WhereClause and ValueClause -fn eval_operator(op: &WhereOperator, probe: &Value, clause_val: &Value) -> bool { - match op { - WhereOperator::Equal => probe == clause_val, - WhereOperator::GreaterThan => probe > clause_val, - WhereOperator::GreaterThanOrEquals => probe >= clause_val, - WhereOperator::LessThan => probe < clause_val, - WhereOperator::LessThanOrEquals => probe <= clause_val, - WhereOperator::In => match clause_val { +fn eval_operator(where_operator: &WhereOperator, probe: &Value, clause_value: &Value) -> bool { + match where_operator { + WhereOperator::Equal => probe == clause_value, + WhereOperator::GreaterThan => probe > clause_value, + WhereOperator::GreaterThanOrEquals => probe >= clause_value, + WhereOperator::LessThan => probe < clause_value, + WhereOperator::LessThanOrEquals => probe <= clause_value, + WhereOperator::In => match clause_value { Value::Array(array) => array.contains(probe), Value::Bytes(bytes) => match probe { Value::U8(b) => bytes.contains(b), @@ -1313,7 +1313,7 @@ fn eval_operator(op: &WhereOperator, probe: &Value, clause_val: &Value) -> bool }, _ => false, }, - WhereOperator::Between => match clause_val { + WhereOperator::Between => match clause_value { Value::Array(bounds) if bounds.len() == 2 => { if !(bounds[0] <= bounds[1]) { return false; @@ -1322,7 +1322,7 @@ fn eval_operator(op: &WhereOperator, probe: &Value, clause_val: &Value) -> bool } _ => false, }, - WhereOperator::BetweenExcludeBounds => match clause_val { + WhereOperator::BetweenExcludeBounds => match clause_value { Value::Array(bounds) if bounds.len() == 2 => { if !(bounds[0] <= bounds[1]) { return false; @@ -1331,7 +1331,7 @@ fn eval_operator(op: &WhereOperator, probe: &Value, clause_val: &Value) -> bool } _ => false, }, - WhereOperator::BetweenExcludeLeft => match clause_val { + WhereOperator::BetweenExcludeLeft => match clause_value { Value::Array(bounds) if bounds.len() == 2 => { if !(bounds[0] <= bounds[1]) { return false; @@ -1340,7 +1340,7 @@ fn eval_operator(op: &WhereOperator, probe: &Value, clause_val: &Value) -> bool } _ => false, }, - WhereOperator::BetweenExcludeRight => match clause_val { + WhereOperator::BetweenExcludeRight => match clause_value { Value::Array(bounds) if bounds.len() == 2 => { if !(bounds[0] <= bounds[1]) { return false; @@ -1349,15 +1349,15 @@ fn eval_operator(op: &WhereOperator, probe: &Value, clause_val: &Value) -> bool } _ => false, }, - WhereOperator::StartsWith => match (probe, clause_val) { + WhereOperator::StartsWith => match (probe, clause_value) { (Value::Text(text), Value::Text(prefix)) => text.starts_with(prefix.as_str()), _ => false, }, } } -#[cfg(any(feature = "server", feature = "verify"))] /// Returns the set of allowed operators for a given property type +#[cfg(any(feature = "server", feature = "verify"))] pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [WhereOperator] { match property_type { T::U8 @@ -1404,9 +1404,9 @@ pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [W } #[cfg(any(feature = "server", feature = "verify"))] -fn is_numeric_value(v: &Value) -> bool { +fn is_numeric_value(value: &Value) -> bool { matches!( - v, + value, Value::U128(_) | Value::I128(_) | Value::U64(_) @@ -1421,21 +1421,25 @@ fn is_numeric_value(v: &Value) -> bool { ) } -#[cfg(any(feature = "server", feature = "verify"))] /// Validates that a value matches the expected shape for a given operator and property type -pub fn value_shape_ok(op: WhereOperator, v: &Value, prop_ty: &DocumentPropertyType) -> bool { - match op { +#[cfg(any(feature = "server", feature = "verify"))] +pub fn value_shape_ok( + where_operator: WhereOperator, + value: &Value, + property_type: &DocumentPropertyType, +) -> bool { + match where_operator { Op::Equal => true, - Op::In => matches!(v, Value::Array(_) | Value::Bytes(_)), - Op::StartsWith => matches!(v, Value::Text(_)), + Op::In => matches!(value, Value::Array(_) | Value::Bytes(_)), + Op::StartsWith => matches!(value, Value::Text(_)), Op::GreaterThan | Op::GreaterThanOrEquals | Op::LessThan | Op::LessThanOrEquals => { - match prop_ty { - DocumentPropertyType::F64 => is_numeric_value(v), + match property_type { + DocumentPropertyType::F64 => is_numeric_value(value), DocumentPropertyType::String(_) => { - matches!(v, Value::Text(_)) + matches!(value, Value::Text(_)) } _ => matches!( - v, + value, Value::U128(_) | Value::I128(_) | Value::U64(_) @@ -1453,9 +1457,9 @@ pub fn value_shape_ok(op: WhereOperator, v: &Value, prop_ty: &DocumentPropertyTy | Op::BetweenExcludeBounds | Op::BetweenExcludeLeft | Op::BetweenExcludeRight => { - if let Value::Array(arr) = v { + if let Value::Array(arr) = value { arr.len() == 2 - && arr.iter().all(|x| match prop_ty { + && arr.iter().all(|x| match property_type { DocumentPropertyType::F64 => is_numeric_value(x), DocumentPropertyType::String(_) => { matches!(x, Value::Text(_)) From a7378117bcf691ced26b235a6f50e0e6906a5cd8 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Mon, 15 Sep 2025 19:49:47 +0700 Subject: [PATCH 20/30] meta fields --- packages/rs-drive/src/query/conditions.rs | 281 ++++++++++++++++------ 1 file changed, 201 insertions(+), 80 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index a0d73748a44..cae24ecadf9 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -17,8 +17,6 @@ use sqlparser::ast; use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Display; -use DocumentPropertyType as T; -use WhereOperator as Op; use WhereOperator::{ Between, BetweenExcludeBounds, BetweenExcludeLeft, BetweenExcludeRight, Equal, GreaterThan, GreaterThanOrEquals, In, LessThan, LessThanOrEquals, StartsWith, @@ -221,9 +219,6 @@ impl<'a> WhereClause { } /// Evaluate this WhereClause against a provided `Value` - /// - /// Only used for filtering documents in memory for document subscriptions. - /// For query execution, the matching logic is handled in `to_path_query`. pub fn matches_value(&self, value: &Value) -> bool { eval_operator(&self.operator, value, &self.value) } @@ -1360,46 +1355,48 @@ fn eval_operator(where_operator: &WhereOperator, probe: &Value, clause_value: &V #[cfg(any(feature = "server", feature = "verify"))] pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [WhereOperator] { match property_type { - T::U8 - | T::I8 - | T::U16 - | T::I16 - | T::U32 - | T::I32 - | T::U64 - | T::I64 - | T::U128 - | T::I128 - | T::F64 - | T::Date => &[ - Op::Equal, - Op::In, - Op::GreaterThan, - Op::GreaterThanOrEquals, - Op::LessThan, - Op::LessThanOrEquals, - Op::Between, - Op::BetweenExcludeBounds, - Op::BetweenExcludeLeft, - Op::BetweenExcludeRight, + DocumentPropertyType::U8 + | DocumentPropertyType::I8 + | DocumentPropertyType::U16 + | DocumentPropertyType::I16 + | DocumentPropertyType::U32 + | DocumentPropertyType::I32 + | DocumentPropertyType::U64 + | DocumentPropertyType::I64 + | DocumentPropertyType::U128 + | DocumentPropertyType::I128 + | DocumentPropertyType::F64 + | DocumentPropertyType::Date => &[ + WhereOperator::Equal, + WhereOperator::In, + WhereOperator::GreaterThan, + WhereOperator::GreaterThanOrEquals, + WhereOperator::LessThan, + WhereOperator::LessThanOrEquals, + WhereOperator::Between, + WhereOperator::BetweenExcludeBounds, + WhereOperator::BetweenExcludeLeft, + WhereOperator::BetweenExcludeRight, ], - T::String(_) => &[ - Op::Equal, - Op::In, - Op::StartsWith, - Op::GreaterThan, - Op::GreaterThanOrEquals, - Op::LessThan, - Op::LessThanOrEquals, - Op::Between, - Op::BetweenExcludeBounds, - Op::BetweenExcludeLeft, - Op::BetweenExcludeRight, + DocumentPropertyType::String(_) => &[ + WhereOperator::Equal, + WhereOperator::In, + WhereOperator::StartsWith, + WhereOperator::GreaterThan, + WhereOperator::GreaterThanOrEquals, + WhereOperator::LessThan, + WhereOperator::LessThanOrEquals, + WhereOperator::Between, + WhereOperator::BetweenExcludeBounds, + WhereOperator::BetweenExcludeLeft, + WhereOperator::BetweenExcludeRight, ], - T::Identifier => &[Op::Equal, Op::In], - T::ByteArray(_) => &[Op::Equal, Op::In], - T::Boolean => &[Op::Equal], - T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => &[], + DocumentPropertyType::Identifier => &[WhereOperator::Equal, WhereOperator::In], + DocumentPropertyType::ByteArray(_) => &[WhereOperator::Equal, WhereOperator::In], + DocumentPropertyType::Boolean => &[WhereOperator::Equal], + DocumentPropertyType::Object(_) + | DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) => &[], } } @@ -1429,34 +1426,35 @@ pub fn value_shape_ok( property_type: &DocumentPropertyType, ) -> bool { match where_operator { - Op::Equal => true, - Op::In => matches!(value, Value::Array(_) | Value::Bytes(_)), - Op::StartsWith => matches!(value, Value::Text(_)), - Op::GreaterThan | Op::GreaterThanOrEquals | Op::LessThan | Op::LessThanOrEquals => { - match property_type { - DocumentPropertyType::F64 => is_numeric_value(value), - DocumentPropertyType::String(_) => { - matches!(value, Value::Text(_)) - } - _ => matches!( - value, - Value::U128(_) - | Value::I128(_) - | Value::U64(_) - | Value::I64(_) - | Value::U32(_) - | Value::I32(_) - | Value::U16(_) - | Value::I16(_) - | Value::U8(_) - | Value::I8(_) - ), + WhereOperator::Equal => true, + WhereOperator::In => matches!(value, Value::Array(_) | Value::Bytes(_)), + WhereOperator::StartsWith => matches!(value, Value::Text(_)), + WhereOperator::GreaterThan + | WhereOperator::GreaterThanOrEquals + | WhereOperator::LessThan + | WhereOperator::LessThanOrEquals => match property_type { + DocumentPropertyType::F64 => is_numeric_value(value), + DocumentPropertyType::String(_) => { + matches!(value, Value::Text(_)) } - } - Op::Between - | Op::BetweenExcludeBounds - | Op::BetweenExcludeLeft - | Op::BetweenExcludeRight => { + _ => matches!( + value, + Value::U128(_) + | Value::I128(_) + | Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), + }, + WhereOperator::Between + | WhereOperator::BetweenExcludeBounds + | WhereOperator::BetweenExcludeLeft + | WhereOperator::BetweenExcludeRight => { if let Value::Array(arr) = value { arr.len() == 2 && arr.iter().all(|x| match property_type { @@ -1491,18 +1489,53 @@ pub fn validate_where_clause_against_schema( document_type: DocumentTypeRef, clause: &WhereClause, ) -> Result<(), crate::error::Error> { - // Check that the field exists - let property = document_type - .flattened_properties() - .get(&clause.field) - .ok_or_else(|| { - Error::Query(QuerySyntaxError::InvalidWhereClauseComponents( - "unknown field in where clause", - )) - })?; + // Resolve field type: support meta/system fields first, then fall back to schema properties + // Recognized meta fields mirror the SQL builder mapping in this module + fn meta_field_property_type(field: &str) -> Option { + // Only consider the first path segment for meta fields + let head = field.split('.').next().unwrap_or(field); + match head { + // Identifiers + "$id" | "$ownerId" | "$dataContractId" => Some(DocumentPropertyType::Identifier), + // Dates (millis since epoch) + "$createdAt" | "$updatedAt" | "$transferredAt" => Some(DocumentPropertyType::Date), + // Block heights and core block heights + "$createdAtBlockHeight" + | "$updatedAtBlockHeight" + | "$transferredAtBlockHeight" + | "$createdAtCoreBlockHeight" + | "$updatedAtCoreBlockHeight" + | "$transferredAtCoreBlockHeight" => Some(DocumentPropertyType::U64), + // Revision and protocol version are integers + "$revision" | "$protocolVersion" => Some(DocumentPropertyType::U64), + // Type name is a string + "$type" => Some(DocumentPropertyType::String( + dpp::data_contract::document_type::StringPropertySizes { + min_length: None, + max_length: None, + }, + )), + _ => None, + } + } + + let property_type_cow = if let Some(meta_ty) = meta_field_property_type(&clause.field) { + Cow::Owned(meta_ty) + } else { + // Check that the field exists in the schema + let property = document_type + .flattened_properties() + .get(&clause.field) + .ok_or_else(|| { + Error::Query(QuerySyntaxError::InvalidWhereClauseComponents( + "unknown field in where clause", + )) + })?; + Cow::Borrowed(&property.property_type) + }; // Check operator is allowed for field type - let property_type = &property.property_type; + let property_type = property_type_cow.as_ref(); if !allowed_ops_for_type(property_type).contains(&clause.operator) { return Err(Error::Query( QuerySyntaxError::InvalidWhereClauseComponents("operator not allowed for field type"), @@ -2003,4 +2036,92 @@ mod tests { let res = validate_where_clause_against_schema(doc_type, &clause); assert!(res.is_err()); } + + #[test] + fn validate_accepts_meta_owner_id_in_identifiers() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("niceDocument") + .expect("doc type exists"); + + let clause = WhereClause { + field: "$ownerId".to_string(), + operator: In, + value: Value::Array(vec![ + Value::Identifier([1u8; 32]), + Value::Identifier([2u8; 32]), + ]), + }; + let res = validate_where_clause_against_schema(doc_type, &clause); + assert!(res.is_ok()); + } + + #[test] + fn validate_accepts_meta_created_at_between_integers() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("uniqueDates") + .expect("doc type exists"); + + let clause = WhereClause { + field: "$createdAt".to_string(), + operator: crate::query::conditions::WhereOperator::Between, + value: Value::Array(vec![Value::U64(1000), Value::U64(2000)]), + }; + let res = validate_where_clause_against_schema(doc_type, &clause); + assert!(res.is_ok()); + } + + #[test] + fn validate_rejects_meta_revision_float_equality() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("niceDocument") + .expect("doc type exists"); + + let clause = WhereClause { + field: "$revision".to_string(), + operator: Equal, + value: Value::Float(3.14), + }; + let res = validate_where_clause_against_schema(doc_type, &clause); + assert!(res.is_err()); + } + + #[test] + fn validate_accepts_meta_created_at_block_height_range() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("uniqueDates") + .expect("doc type exists"); + + let clause = WhereClause { + field: "$createdAtBlockHeight".to_string(), + operator: GreaterThanOrEquals, + value: Value::U64(100), + }; + let res = validate_where_clause_against_schema(doc_type, &clause); + assert!(res.is_ok()); + } + + #[test] + fn validate_accepts_meta_data_contract_id_equality() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("niceDocument") + .expect("doc type exists"); + + let clause = WhereClause { + field: "$dataContractId".to_string(), + operator: Equal, + value: Value::Identifier([3u8; 32]), + }; + let res = validate_where_clause_against_schema(doc_type, &clause); + assert!(res.is_ok()); + } } From 5d2e3d9e7b8f9ad37fa6e7f1d8c4f3df9afceb85 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Tue, 16 Sep 2025 13:29:02 +0700 Subject: [PATCH 21/30] cleanup --- packages/rs-drive/src/query/conditions.rs | 268 +++++++++++----------- 1 file changed, 136 insertions(+), 132 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index cae24ecadf9..28aeafe4fc5 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -15,8 +15,36 @@ use dpp::version::PlatformVersion; use grovedb::Query; use sqlparser::ast; use std::borrow::Cow; +use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Display; +/// Map known meta/system fields to their corresponding property types. +/// Meta fields are top-level and always start with `$`. +fn meta_field_property_type(field: &str) -> Option { + match field { + // Identifiers + "$id" | "$ownerId" | "$dataContractId" => Some(DocumentPropertyType::Identifier), + // Dates (millis since epoch) + "$createdAt" | "$updatedAt" | "$transferredAt" => Some(DocumentPropertyType::Date), + // Block heights and core block heights + "$createdAtBlockHeight" + | "$updatedAtBlockHeight" + | "$transferredAtBlockHeight" + | "$createdAtCoreBlockHeight" + | "$updatedAtCoreBlockHeight" + | "$transferredAtCoreBlockHeight" => Some(DocumentPropertyType::U64), + // Revision and protocol version are integers + "$revision" | "$protocolVersion" => Some(DocumentPropertyType::U64), + // Type name is a string + "$type" => Some(DocumentPropertyType::String( + dpp::data_contract::document_type::StringPropertySizes { + min_length: None, + max_length: None, + }, + )), + _ => None, + } +} use WhereOperator::{ Between, BetweenExcludeBounds, BetweenExcludeLeft, BetweenExcludeRight, Equal, GreaterThan, GreaterThanOrEquals, In, LessThan, LessThanOrEquals, StartsWith, @@ -172,6 +200,73 @@ impl WhereOperator { _ => None, } } + + /// Shared operator evaluator for both WhereClause and ValueClause + pub fn eval(&self, probe: &Value, clause_value: &Value) -> bool { + match self { + WhereOperator::Equal => probe == clause_value, + WhereOperator::GreaterThan => probe > clause_value, + WhereOperator::GreaterThanOrEquals => probe >= clause_value, + WhereOperator::LessThan => probe < clause_value, + WhereOperator::LessThanOrEquals => probe <= clause_value, + WhereOperator::In => match clause_value { + Value::Array(array) => array.contains(probe), + Value::Bytes(bytes) => match probe { + Value::U8(b) => bytes.contains(b), + _ => false, + }, + _ => false, + }, + WhereOperator::Between => match clause_value { + Value::Array(bounds) if bounds.len() == 2 => { + match bounds[0].partial_cmp(&bounds[1]) { + Some(Ordering::Less) | Some(Ordering::Equal) => { + probe >= &bounds[0] && probe <= &bounds[1] + } + _ => false, + } + } + _ => false, + }, + WhereOperator::BetweenExcludeBounds => match clause_value { + Value::Array(bounds) if bounds.len() == 2 => { + match bounds[0].partial_cmp(&bounds[1]) { + Some(Ordering::Less) | Some(Ordering::Equal) => { + probe > &bounds[0] && probe < &bounds[1] + } + _ => false, + } + } + _ => false, + }, + WhereOperator::BetweenExcludeLeft => match clause_value { + Value::Array(bounds) if bounds.len() == 2 => { + match bounds[0].partial_cmp(&bounds[1]) { + Some(Ordering::Less) | Some(Ordering::Equal) => { + probe > &bounds[0] && probe <= &bounds[1] + } + _ => false, + } + } + _ => false, + }, + WhereOperator::BetweenExcludeRight => match clause_value { + Value::Array(bounds) if bounds.len() == 2 => { + match bounds[0].partial_cmp(&bounds[1]) { + Some(Ordering::Less) | Some(Ordering::Equal) => { + probe >= &bounds[0] && probe < &bounds[1] + } + _ => false, + } + } + _ => false, + }, + WhereOperator::StartsWith => match (probe, clause_value) { + (Value::Text(text), Value::Text(prefix)) => text.starts_with(prefix.as_str()), + _ => false, + }, + } + } } impl Display for WhereOperator { @@ -218,11 +313,6 @@ impl<'a> WhereClause { self.field == "$id" } - /// Evaluate this WhereClause against a provided `Value` - pub fn matches_value(&self, value: &Value) -> bool { - eval_operator(&self.operator, value, &self.value) - } - /// Returns the where clause `in` values if they are an array of values, else an error pub fn in_values(&self) -> Result>, Error> { let in_values = match &self.value { @@ -1081,22 +1171,19 @@ impl<'a> WhereClause { ))); }; - let property_type = match field_name.as_str() { - "$id" | "$ownerId" => Cow::Owned(DocumentPropertyType::Identifier), - "$createdAt" | "$updatedAt" => Cow::Owned(DocumentPropertyType::Date), - "$revision" => Cow::Owned(DocumentPropertyType::U64), - _ => { - let property = document_type - .flattened_properties() - .get(&field_name) - .ok_or_else(|| { - Error::Query(QuerySyntaxError::InvalidSQL(format!( - "Invalid query: property named {} not in document type", - field_name - ))) - })?; - Cow::Borrowed(&property.property_type) - } + let property_type = if let Some(ty) = meta_field_property_type(&field_name) { + Cow::Owned(ty) + } else { + let property = document_type + .flattened_properties() + .get(&field_name) + .ok_or_else(|| { + Error::Query(QuerySyntaxError::InvalidSQL(format!( + "Invalid query: property named {} not in document type", + field_name + ))) + })?; + Cow::Borrowed(&property.property_type) }; let mut in_values: Vec = Vec::new(); @@ -1219,22 +1306,19 @@ impl<'a> WhereClause { panic!("unreachable: confirmed it's identifier variant"); }; - let property_type = match field_name.as_str() { - "$id" | "$ownerId" => Cow::Owned(DocumentPropertyType::Identifier), - "$createdAt" | "$updatedAt" => Cow::Owned(DocumentPropertyType::Date), - "$revision" => Cow::Owned(DocumentPropertyType::U64), - _ => { - let property = document_type - .flattened_properties() - .get(&field_name) - .ok_or_else(|| { - Error::Query(QuerySyntaxError::InvalidSQL(format!( - "Invalid query: property named {} not in document type", - field_name - ))) - })?; - Cow::Borrowed(&property.property_type) - } + let property_type = if let Some(ty) = meta_field_property_type(&field_name) { + Cow::Owned(ty) + } else { + let property = document_type + .flattened_properties() + .get(&field_name) + .ok_or_else(|| { + Error::Query(QuerySyntaxError::InvalidSQL(format!( + "Invalid query: property named {} not in document type", + field_name + ))) + })?; + Cow::Borrowed(&property.property_type) }; let transformed_value = if let ast::Expr::Value(value) = value_expr { @@ -1266,6 +1350,11 @@ impl<'a> WhereClause { ))), } } + + /// Evaluate this WhereClause against a provided `Value` + pub fn matches_value(&self, value: &Value) -> bool { + self.operator.eval(value, &self.value) + } } impl From for Value { @@ -1275,7 +1364,6 @@ impl From for Value { } /// Value-only clause used when there is no field lookup involved -/// (e.g., comparing a transition-supplied scalar like owner id or price). #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ValueClause { @@ -1288,66 +1376,7 @@ pub struct ValueClause { impl ValueClause { /// Evaluate this clause against a provided `Value` pub fn matches_value(&self, value: &Value) -> bool { - eval_operator(&self.operator, value, &self.value) - } -} - -/// Shared operator evaluator for both WhereClause and ValueClause -fn eval_operator(where_operator: &WhereOperator, probe: &Value, clause_value: &Value) -> bool { - match where_operator { - WhereOperator::Equal => probe == clause_value, - WhereOperator::GreaterThan => probe > clause_value, - WhereOperator::GreaterThanOrEquals => probe >= clause_value, - WhereOperator::LessThan => probe < clause_value, - WhereOperator::LessThanOrEquals => probe <= clause_value, - WhereOperator::In => match clause_value { - Value::Array(array) => array.contains(probe), - Value::Bytes(bytes) => match probe { - Value::U8(b) => bytes.contains(b), - _ => false, - }, - _ => false, - }, - WhereOperator::Between => match clause_value { - Value::Array(bounds) if bounds.len() == 2 => { - if !(bounds[0] <= bounds[1]) { - return false; - } - probe >= &bounds[0] && probe <= &bounds[1] - } - _ => false, - }, - WhereOperator::BetweenExcludeBounds => match clause_value { - Value::Array(bounds) if bounds.len() == 2 => { - if !(bounds[0] <= bounds[1]) { - return false; - } - probe > &bounds[0] && probe < &bounds[1] - } - _ => false, - }, - WhereOperator::BetweenExcludeLeft => match clause_value { - Value::Array(bounds) if bounds.len() == 2 => { - if !(bounds[0] <= bounds[1]) { - return false; - } - probe > &bounds[0] && probe <= &bounds[1] - } - _ => false, - }, - WhereOperator::BetweenExcludeRight => match clause_value { - Value::Array(bounds) if bounds.len() == 2 => { - if !(bounds[0] <= bounds[1]) { - return false; - } - probe >= &bounds[0] && probe < &bounds[1] - } - _ => false, - }, - WhereOperator::StartsWith => match (probe, clause_value) { - (Value::Text(text), Value::Text(prefix)) => text.starts_with(prefix.as_str()), - _ => false, - }, + self.operator.eval(value, &self.value) } } @@ -1489,36 +1518,6 @@ pub fn validate_where_clause_against_schema( document_type: DocumentTypeRef, clause: &WhereClause, ) -> Result<(), crate::error::Error> { - // Resolve field type: support meta/system fields first, then fall back to schema properties - // Recognized meta fields mirror the SQL builder mapping in this module - fn meta_field_property_type(field: &str) -> Option { - // Only consider the first path segment for meta fields - let head = field.split('.').next().unwrap_or(field); - match head { - // Identifiers - "$id" | "$ownerId" | "$dataContractId" => Some(DocumentPropertyType::Identifier), - // Dates (millis since epoch) - "$createdAt" | "$updatedAt" | "$transferredAt" => Some(DocumentPropertyType::Date), - // Block heights and core block heights - "$createdAtBlockHeight" - | "$updatedAtBlockHeight" - | "$transferredAtBlockHeight" - | "$createdAtCoreBlockHeight" - | "$updatedAtCoreBlockHeight" - | "$transferredAtCoreBlockHeight" => Some(DocumentPropertyType::U64), - // Revision and protocol version are integers - "$revision" | "$protocolVersion" => Some(DocumentPropertyType::U64), - // Type name is a string - "$type" => Some(DocumentPropertyType::String( - dpp::data_contract::document_type::StringPropertySizes { - min_length: None, - max_length: None, - }, - )), - _ => None, - } - } - let property_type_cow = if let Some(meta_ty) = meta_field_property_type(&clause.field) { Cow::Owned(meta_ty) } else { @@ -1583,10 +1582,15 @@ pub fn validate_where_clause_against_schema( | WhereOperator::BetweenExcludeLeft | WhereOperator::BetweenExcludeRight => { if let Value::Array(bounds) = &clause.value { - if bounds.len() == 2 && !(bounds[0] <= bounds[1]) { - return Err(Error::Query(QuerySyntaxError::InvalidBetweenClause( - "when using between operator bounds must be ascending", - ))); + if bounds.len() == 2 { + match bounds[0].partial_cmp(&bounds[1]) { + Some(Ordering::Less) | Some(Ordering::Equal) => {} + _ => { + return Err(Error::Query(QuerySyntaxError::InvalidBetweenClause( + "when using between operator bounds must be ascending", + ))); + } + } } } } From ebe370de3db75c7aec0a14571b6cf2c7720c9a84 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Tue, 16 Sep 2025 13:30:10 +0700 Subject: [PATCH 22/30] move --- packages/rs-drive/src/query/conditions.rs | 55 ++++++++++++----------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 28aeafe4fc5..8c748a734ab 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -18,33 +18,6 @@ use std::borrow::Cow; use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Display; -/// Map known meta/system fields to their corresponding property types. -/// Meta fields are top-level and always start with `$`. -fn meta_field_property_type(field: &str) -> Option { - match field { - // Identifiers - "$id" | "$ownerId" | "$dataContractId" => Some(DocumentPropertyType::Identifier), - // Dates (millis since epoch) - "$createdAt" | "$updatedAt" | "$transferredAt" => Some(DocumentPropertyType::Date), - // Block heights and core block heights - "$createdAtBlockHeight" - | "$updatedAtBlockHeight" - | "$transferredAtBlockHeight" - | "$createdAtCoreBlockHeight" - | "$updatedAtCoreBlockHeight" - | "$transferredAtCoreBlockHeight" => Some(DocumentPropertyType::U64), - // Revision and protocol version are integers - "$revision" | "$protocolVersion" => Some(DocumentPropertyType::U64), - // Type name is a string - "$type" => Some(DocumentPropertyType::String( - dpp::data_contract::document_type::StringPropertySizes { - min_length: None, - max_length: None, - }, - )), - _ => None, - } -} use WhereOperator::{ Between, BetweenExcludeBounds, BetweenExcludeLeft, BetweenExcludeRight, Equal, GreaterThan, GreaterThanOrEquals, In, LessThan, LessThanOrEquals, StartsWith, @@ -1789,6 +1762,34 @@ pub fn validate_internal_clauses_against_schema( Ok(()) } +/// Map known meta/system fields to their corresponding property types. +/// Meta fields are top-level and always start with `$`. +fn meta_field_property_type(field: &str) -> Option { + match field { + // Identifiers + "$id" | "$ownerId" | "$dataContractId" => Some(DocumentPropertyType::Identifier), + // Dates (millis since epoch) + "$createdAt" | "$updatedAt" | "$transferredAt" => Some(DocumentPropertyType::Date), + // Block heights and core block heights + "$createdAtBlockHeight" + | "$updatedAtBlockHeight" + | "$transferredAtBlockHeight" + | "$createdAtCoreBlockHeight" + | "$updatedAtCoreBlockHeight" + | "$transferredAtCoreBlockHeight" => Some(DocumentPropertyType::U64), + // Revision and protocol version are integers + "$revision" | "$protocolVersion" => Some(DocumentPropertyType::U64), + // Type name is a string + "$type" => Some(DocumentPropertyType::String( + dpp::data_contract::document_type::StringPropertySizes { + min_length: None, + max_length: None, + }, + )), + _ => None, + } +} + #[cfg(feature = "server")] #[cfg(test)] mod tests { From b14f451f35507e28d8438018cef85f4f4b5f9f1a Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Tue, 16 Sep 2025 14:12:42 +0700 Subject: [PATCH 23/30] cleanup --- packages/rs-drive/src/query/conditions.rs | 618 +++++++++------------- packages/rs-drive/src/query/filter.rs | 32 +- packages/rs-drive/src/query/mod.rs | 100 +++- 3 files changed, 369 insertions(+), 381 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 8c748a734ab..c665acf6e0a 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -4,7 +4,6 @@ use crate::error::query::QuerySyntaxError; use crate::error::Error; #[cfg(any(feature = "server", feature = "verify"))] -use crate::query::InternalClauses; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::{DocumentPropertyType, DocumentType, DocumentTypeRef}; @@ -240,6 +239,67 @@ impl WhereOperator { }, } } + + /// Validates that a value matches the expected shape for this operator and property type + #[cfg(any(feature = "server", feature = "verify"))] + pub fn value_shape_ok(&self, value: &Value, property_type: &DocumentPropertyType) -> bool { + match self { + WhereOperator::Equal => true, + WhereOperator::In => matches!(value, Value::Array(_) | Value::Bytes(_)), + WhereOperator::StartsWith => matches!(value, Value::Text(_)), + WhereOperator::GreaterThan + | WhereOperator::GreaterThanOrEquals + | WhereOperator::LessThan + | WhereOperator::LessThanOrEquals => match property_type { + DocumentPropertyType::F64 => is_numeric_value(value), + DocumentPropertyType::String(_) => { + matches!(value, Value::Text(_)) + } + _ => matches!( + value, + Value::U128(_) + | Value::I128(_) + | Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), + }, + WhereOperator::Between + | WhereOperator::BetweenExcludeBounds + | WhereOperator::BetweenExcludeLeft + | WhereOperator::BetweenExcludeRight => { + if let Value::Array(arr) = value { + arr.len() == 2 + && arr.iter().all(|x| match property_type { + DocumentPropertyType::F64 => is_numeric_value(x), + DocumentPropertyType::String(_) => { + matches!(x, Value::Text(_)) + } + _ => matches!( + x, + Value::U128(_) + | Value::I128(_) + | Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), + }) + } else { + false + } + } + } + } } impl Display for WhereOperator { @@ -1328,6 +1388,199 @@ impl<'a> WhereClause { pub fn matches_value(&self, value: &Value) -> bool { self.operator.eval(value, &self.value) } + + /// Validate this where clause against the document schema + #[cfg(any(feature = "server", feature = "verify"))] + pub fn validate_against_schema( + &self, + document_type: DocumentTypeRef, + ) -> Result<(), crate::error::Error> { + let property_type_cow = if let Some(meta_ty) = meta_field_property_type(&self.field) { + Cow::Owned(meta_ty) + } else { + // Check that the field exists in the schema + let property = document_type + .flattened_properties() + .get(&self.field) + .ok_or_else(|| { + Error::Query(QuerySyntaxError::InvalidWhereClauseComponents( + "unknown field in where clause", + )) + })?; + Cow::Borrowed(&property.property_type) + }; + + // Check operator is allowed for field type + let property_type = property_type_cow.as_ref(); + if !allowed_ops_for_type(property_type).contains(&self.operator) { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "operator not allowed for field type", + ), + )); + } + + // Check starts_with value is not empty + if self.operator == WhereOperator::StartsWith { + if let Value::Text(s) = &self.value { + if s.is_empty() { + return Err(Error::Query(QuerySyntaxError::StartsWithIllegalString( + "starts with can not start with an empty string", + ))); + } + } + } + + // Check in clause values + if self.operator == WhereOperator::In { + // Ensure array value, length bounds and no duplicates + self.in_values()?; + // If value provided as Bytes, only allow for U8 numeric fields + if matches!(self.value, Value::Bytes(_)) + && !matches!(property_type, DocumentPropertyType::U8) + { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "IN Bytes only allowed for U8 fields", + ), + )); + } + } + + // Check value shape is correct for operator and field type + if !self.operator.value_shape_ok(&self.value, property_type) { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents("invalid value shape for operator"), + )); + } + + // For Between variants, ensure bounds are in ascending order to avoid surprising matches + match self.operator { + WhereOperator::Between + | WhereOperator::BetweenExcludeBounds + | WhereOperator::BetweenExcludeLeft + | WhereOperator::BetweenExcludeRight => { + if let Value::Array(bounds) = &self.value { + if bounds.len() == 2 { + match bounds[0].partial_cmp(&bounds[1]) { + Some(Ordering::Less) | Some(Ordering::Equal) => {} + _ => { + return Err(Error::Query(QuerySyntaxError::InvalidBetweenClause( + "when using between operator bounds must be ascending", + ))); + } + } + } + } + } + _ => {} + } + + // Additional strict type checks for Equal and In element types + let value_type_matches = |prop_ty: &DocumentPropertyType, v: &Value| -> bool { + use DocumentPropertyType as T; + match prop_ty { + T::String(_) => matches!(v, Value::Text(_)), + T::Identifier => matches!(v, Value::Identifier(_)), + T::Boolean => matches!(v, Value::Bool(_)), + T::ByteArray(_) => matches!(v, Value::Bytes(_)), + T::F64 => matches!(v, Value::Float(_)), + T::Date => matches!( + v, + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), + T::U8 | T::U16 | T::U32 | T::U64 | T::U128 => matches!( + v, + Value::U8(_) | Value::U16(_) | Value::U32(_) | Value::U64(_) | Value::U128(_) + ), + T::I8 | T::I16 | T::I32 | T::I64 | T::I128 => matches!( + v, + Value::I8(_) | Value::I16(_) | Value::I32(_) | Value::I64(_) | Value::I128(_) + ), + // No validation for object/array types as operators are disallowed + T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, + } + }; + + match self.operator { + WhereOperator::Equal => { + use DocumentPropertyType as T; + let ok = match property_type { + // Accept any integer-like value for integer fields (signed/unsigned), reject floats + T::U8 + | T::U16 + | T::U32 + | T::U64 + | T::U128 + | T::I8 + | T::I16 + | T::I32 + | T::I64 + | T::I128 => { + matches!( + self.value, + Value::U128(_) + | Value::I128(_) + | Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ) + } + T::F64 => matches!(self.value, Value::Float(_)), + T::Date => matches!( + self.value, + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), + T::String(_) => matches!(self.value, Value::Text(_)), + T::Identifier => matches!(self.value, Value::Identifier(_)), + T::ByteArray(_) => matches!(self.value, Value::Bytes(_)), + T::Boolean => matches!(self.value, Value::Bool(_)), + // Not applicable for object/array/variable arrays + T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, + }; + if !ok { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "invalid value type for equality", + ), + )); + } + } + WhereOperator::In => { + if let Value::Array(arr) = &self.value { + if !arr.iter().all(|v| value_type_matches(property_type, v)) { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "invalid value type in IN clause", + ), + )); + } + } + } + _ => {} + } + + Ok(()) + } } impl From for Value { @@ -1420,348 +1673,6 @@ fn is_numeric_value(value: &Value) -> bool { ) } -/// Validates that a value matches the expected shape for a given operator and property type -#[cfg(any(feature = "server", feature = "verify"))] -pub fn value_shape_ok( - where_operator: WhereOperator, - value: &Value, - property_type: &DocumentPropertyType, -) -> bool { - match where_operator { - WhereOperator::Equal => true, - WhereOperator::In => matches!(value, Value::Array(_) | Value::Bytes(_)), - WhereOperator::StartsWith => matches!(value, Value::Text(_)), - WhereOperator::GreaterThan - | WhereOperator::GreaterThanOrEquals - | WhereOperator::LessThan - | WhereOperator::LessThanOrEquals => match property_type { - DocumentPropertyType::F64 => is_numeric_value(value), - DocumentPropertyType::String(_) => { - matches!(value, Value::Text(_)) - } - _ => matches!( - value, - Value::U128(_) - | Value::I128(_) - | Value::U64(_) - | Value::I64(_) - | Value::U32(_) - | Value::I32(_) - | Value::U16(_) - | Value::I16(_) - | Value::U8(_) - | Value::I8(_) - ), - }, - WhereOperator::Between - | WhereOperator::BetweenExcludeBounds - | WhereOperator::BetweenExcludeLeft - | WhereOperator::BetweenExcludeRight => { - if let Value::Array(arr) = value { - arr.len() == 2 - && arr.iter().all(|x| match property_type { - DocumentPropertyType::F64 => is_numeric_value(x), - DocumentPropertyType::String(_) => { - matches!(x, Value::Text(_)) - } - _ => matches!( - x, - Value::U128(_) - | Value::I128(_) - | Value::U64(_) - | Value::I64(_) - | Value::U32(_) - | Value::I32(_) - | Value::U16(_) - | Value::I16(_) - | Value::U8(_) - | Value::I8(_) - ), - }) - } else { - false - } - } - } -} - -#[cfg(any(feature = "server", feature = "verify"))] -/// Validate a single where clause against the document schema -pub fn validate_where_clause_against_schema( - document_type: DocumentTypeRef, - clause: &WhereClause, -) -> Result<(), crate::error::Error> { - let property_type_cow = if let Some(meta_ty) = meta_field_property_type(&clause.field) { - Cow::Owned(meta_ty) - } else { - // Check that the field exists in the schema - let property = document_type - .flattened_properties() - .get(&clause.field) - .ok_or_else(|| { - Error::Query(QuerySyntaxError::InvalidWhereClauseComponents( - "unknown field in where clause", - )) - })?; - Cow::Borrowed(&property.property_type) - }; - - // Check operator is allowed for field type - let property_type = property_type_cow.as_ref(); - if !allowed_ops_for_type(property_type).contains(&clause.operator) { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents("operator not allowed for field type"), - )); - } - - // Check starts_with value is not empty - if clause.operator == WhereOperator::StartsWith { - if let Value::Text(s) = &clause.value { - if s.is_empty() { - return Err(Error::Query(QuerySyntaxError::StartsWithIllegalString( - "starts with can not start with an empty string", - ))); - } - } - } - - // Check in clause values - if clause.operator == WhereOperator::In { - // Ensure array value, length bounds and no duplicates - clause.in_values()?; - // If value provided as Bytes, only allow for U8 numeric fields - if matches!(clause.value, Value::Bytes(_)) - && !matches!(property_type, DocumentPropertyType::U8) - { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "IN Bytes only allowed for U8 fields", - ), - )); - } - } - - // Check value shape is correct for operator and field type - if !value_shape_ok(clause.operator, &clause.value, property_type) { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents("invalid value shape for operator"), - )); - } - - // For Between variants, ensure bounds are in ascending order to avoid surprising matches - match clause.operator { - WhereOperator::Between - | WhereOperator::BetweenExcludeBounds - | WhereOperator::BetweenExcludeLeft - | WhereOperator::BetweenExcludeRight => { - if let Value::Array(bounds) = &clause.value { - if bounds.len() == 2 { - match bounds[0].partial_cmp(&bounds[1]) { - Some(Ordering::Less) | Some(Ordering::Equal) => {} - _ => { - return Err(Error::Query(QuerySyntaxError::InvalidBetweenClause( - "when using between operator bounds must be ascending", - ))); - } - } - } - } - } - _ => {} - } - - // Additional strict type checks for Equal and In element types - let value_type_matches = |prop_ty: &DocumentPropertyType, v: &Value| -> bool { - use DocumentPropertyType as T; - match prop_ty { - T::String(_) => matches!(v, Value::Text(_)), - T::Identifier => matches!(v, Value::Identifier(_)), - T::Boolean => matches!(v, Value::Bool(_)), - T::ByteArray(_) => matches!(v, Value::Bytes(_)), - T::F64 => matches!(v, Value::Float(_)), - T::Date => matches!( - v, - Value::U64(_) - | Value::I64(_) - | Value::U32(_) - | Value::I32(_) - | Value::U16(_) - | Value::I16(_) - | Value::U8(_) - | Value::I8(_) - ), - T::U8 | T::U16 | T::U32 | T::U64 | T::U128 => matches!( - v, - Value::U8(_) | Value::U16(_) | Value::U32(_) | Value::U64(_) | Value::U128(_) - ), - T::I8 | T::I16 | T::I32 | T::I64 | T::I128 => matches!( - v, - Value::I8(_) | Value::I16(_) | Value::I32(_) | Value::I64(_) | Value::I128(_) - ), - // No validation for object/array types as operators are disallowed - T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, - } - }; - - match clause.operator { - WhereOperator::Equal => { - use DocumentPropertyType as T; - let ok = match property_type { - // Accept any integer-like value for integer fields (signed/unsigned), reject floats - T::U8 - | T::U16 - | T::U32 - | T::U64 - | T::U128 - | T::I8 - | T::I16 - | T::I32 - | T::I64 - | T::I128 => { - matches!( - clause.value, - Value::U128(_) - | Value::I128(_) - | Value::U64(_) - | Value::I64(_) - | Value::U32(_) - | Value::I32(_) - | Value::U16(_) - | Value::I16(_) - | Value::U8(_) - | Value::I8(_) - ) - } - T::F64 => matches!(clause.value, Value::Float(_)), - T::Date => matches!( - clause.value, - Value::U64(_) - | Value::I64(_) - | Value::U32(_) - | Value::I32(_) - | Value::U16(_) - | Value::I16(_) - | Value::U8(_) - | Value::I8(_) - ), - T::String(_) => matches!(clause.value, Value::Text(_)), - T::Identifier => matches!(clause.value, Value::Identifier(_)), - T::ByteArray(_) => matches!(clause.value, Value::Bytes(_)), - T::Boolean => matches!(clause.value, Value::Bool(_)), - // Not applicable for object/array/variable arrays - T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, - }; - if !ok { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "invalid value type for equality", - ), - )); - } - } - WhereOperator::In => { - if let Value::Array(arr) = &clause.value { - if !arr.iter().all(|v| value_type_matches(property_type, v)) { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "invalid value type in IN clause", - ), - )); - } - } - } - _ => {} - } - - Ok(()) -} - -#[cfg(any(feature = "server", feature = "verify"))] -/// Validate a collection of InternalClauses against the document schema -pub fn validate_internal_clauses_against_schema( - document_type: DocumentTypeRef, - clauses: &InternalClauses, -) -> Result<(), crate::error::Error> { - // Basic composition - if !clauses.verify() { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents("invalid composition of where clauses"), - )); - } - - // Validate in_clause against schema - if let Some(in_clause) = &clauses.in_clause { - // Forbid $id in non-primary-key clauses - if in_clause.field == "$id" { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents("use primary_key_* clauses for $id"), - )); - } - validate_where_clause_against_schema(document_type, in_clause)?; - } - - // Validate range_clause against schema - if let Some(range_clause) = &clauses.range_clause { - // Forbid $id in non-primary-key clauses - if range_clause.field == "$id" { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents("use primary_key_* clauses for $id"), - )); - } - validate_where_clause_against_schema(document_type, range_clause)?; - } - - // Validate equal_clauses against schema - for (field, eq_clause) in &clauses.equal_clauses { - // Forbid $id in non-primary-key clauses - if field.as_str() == "$id" { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents("use primary_key_* clauses for $id"), - )); - } - validate_where_clause_against_schema(document_type, eq_clause)?; - } - - // Validate primary key clauses typing - if let Some(pk_eq) = &clauses.primary_key_equal_clause { - if pk_eq.operator != WhereOperator::Equal || !matches!(pk_eq.value, Value::Identifier(_)) { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "primary key equality must compare an identifier", - ), - )); - } - } - if let Some(pk_in) = &clauses.primary_key_in_clause { - if pk_in.operator != WhereOperator::In { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "primary key IN must use IN operator", - ), - )); - } - // enforce array shape and no duplicates/size - pk_in.in_values()?; - if let Value::Array(arr) = &pk_in.value { - if !arr.iter().all(|v| matches!(v, Value::Identifier(_))) { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "primary key IN must contain identifiers", - ), - )); - } - } else { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "primary key IN must contain an array of identifiers", - ), - )); - } - } - - Ok(()) -} - /// Map known meta/system fields to their corresponding property types. /// Meta fields are top-level and always start with `$`. fn meta_field_property_type(field: &str) -> Option { @@ -1793,7 +1704,6 @@ fn meta_field_property_type(field: &str) -> Option { #[cfg(feature = "server")] #[cfg(test)] mod tests { - use super::{validate_internal_clauses_against_schema, validate_where_clause_against_schema}; use crate::query::conditions::WhereClause; use crate::query::conditions::WhereOperator::{ Equal, GreaterThan, GreaterThanOrEquals, In, LessThan, LessThanOrEquals, @@ -1941,7 +1851,7 @@ mod tests { operator: Equal, value: Value::Identifier([1u8; 32]), }; - let res = validate_where_clause_against_schema(doc_type, &clause); + let res = clause.validate_against_schema(doc_type); assert!(matches!( res, Err(crate::error::Error::Query( @@ -1966,7 +1876,7 @@ mod tests { Value::Identifier([2u8; 32]), ]), }; - let res = validate_where_clause_against_schema(doc_type, &clause); + let res = clause.validate_against_schema(doc_type); assert!(matches!( res, Err(crate::error::Error::Query( @@ -1993,7 +1903,7 @@ mod tests { ]), }); - let res = validate_internal_clauses_against_schema(doc_type, &clauses); + let res = clauses.validate_against_schema(doc_type); assert!(matches!( res, Err(crate::error::Error::Query( @@ -2015,7 +1925,7 @@ mod tests { operator: Equal, value: Value::Float(1.23), }; - let res = validate_where_clause_against_schema(doc_type, &clause); + let res = clause.validate_against_schema(doc_type); assert!(matches!( res, Err(crate::error::Error::Query( @@ -2038,7 +1948,7 @@ mod tests { operator: In, value: Value::Bytes(vec![1, 2, 3]), }; - let res = validate_where_clause_against_schema(doc_type, &clause); + let res = clause.validate_against_schema(doc_type); assert!(res.is_err()); } @@ -2058,7 +1968,7 @@ mod tests { Value::Identifier([2u8; 32]), ]), }; - let res = validate_where_clause_against_schema(doc_type, &clause); + let res = clause.validate_against_schema(doc_type); assert!(res.is_ok()); } @@ -2075,7 +1985,7 @@ mod tests { operator: crate::query::conditions::WhereOperator::Between, value: Value::Array(vec![Value::U64(1000), Value::U64(2000)]), }; - let res = validate_where_clause_against_schema(doc_type, &clause); + let res = clause.validate_against_schema(doc_type); assert!(res.is_ok()); } @@ -2092,7 +2002,7 @@ mod tests { operator: Equal, value: Value::Float(3.14), }; - let res = validate_where_clause_against_schema(doc_type, &clause); + let res = clause.validate_against_schema(doc_type); assert!(res.is_err()); } @@ -2109,7 +2019,7 @@ mod tests { operator: GreaterThanOrEquals, value: Value::U64(100), }; - let res = validate_where_clause_against_schema(doc_type, &clause); + let res = clause.validate_against_schema(doc_type); assert!(res.is_ok()); } @@ -2126,7 +2036,7 @@ mod tests { operator: Equal, value: Value::Identifier([3u8; 32]), }; - let res = validate_where_clause_against_schema(doc_type, &clause); + let res = clause.validate_against_schema(doc_type); assert!(res.is_ok()); } } diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 575d5957371..fbadcb6a00c 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -42,9 +42,7 @@ use dpp::state_transition::batch_transition::document_base_transition::v0::v0_me use dpp::state_transition::batch_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::v0_methods::DocumentTransferTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::v0_methods::DocumentUpdatePriceTransitionV0Methods; -use crate::query::{ - validate_internal_clauses_against_schema, InternalClauses, ValueClause, WhereOperator, -}; +use crate::query::{InternalClauses, ValueClause, WhereOperator}; use crate::error::{query::QuerySyntaxError, Error}; use dpp::platform_value::ValueMapHelper; @@ -447,7 +445,7 @@ impl DriveDocumentQueryFilter<'_> { match &self.action_clauses { DocumentActionMatchClauses::Create { new_document_clauses, - } => validate_internal_clauses_against_schema(document_type, new_document_clauses)?, + } => new_document_clauses.validate_against_schema(document_type)?, DocumentActionMatchClauses::Replace { original_document_clauses, new_document_clauses, @@ -460,20 +458,15 @@ impl DriveDocumentQueryFilter<'_> { )); } if !original_document_clauses.is_empty() { - validate_internal_clauses_against_schema( - document_type, - original_document_clauses, - )?; + original_document_clauses.validate_against_schema(document_type)?; } if !new_document_clauses.is_empty() { - validate_internal_clauses_against_schema(document_type, new_document_clauses)?; + new_document_clauses.validate_against_schema(document_type)?; } } DocumentActionMatchClauses::Delete { original_document_clauses, - } => { - validate_internal_clauses_against_schema(document_type, original_document_clauses)? - } + } => original_document_clauses.validate_against_schema(document_type)?, DocumentActionMatchClauses::Transfer { original_document_clauses, owner_clause, @@ -486,10 +479,7 @@ impl DriveDocumentQueryFilter<'_> { )); } if !original_document_clauses.is_empty() { - validate_internal_clauses_against_schema( - document_type, - original_document_clauses, - )?; + original_document_clauses.validate_against_schema(document_type)?; } if let Some(owner) = owner_clause { let ok = match owner.operator { @@ -521,10 +511,7 @@ impl DriveDocumentQueryFilter<'_> { )); } if !original_document_clauses.is_empty() { - validate_internal_clauses_against_schema( - document_type, - original_document_clauses, - )?; + original_document_clauses.validate_against_schema(document_type)?; } if let Some(price) = price_clause { let ok = match price.operator { @@ -603,10 +590,7 @@ impl DriveDocumentQueryFilter<'_> { )); } if !original_document_clauses.is_empty() { - validate_internal_clauses_against_schema( - document_type, - original_document_clauses, - )?; + original_document_clauses.validate_against_schema(document_type)?; } if let Some(owner) = owner_clause { let ok = match owner.operator { diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 761b87f7358..85015a2a2a8 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -2,9 +2,7 @@ use std::sync::Arc; #[cfg(any(feature = "server", feature = "verify"))] pub use { - conditions::{ - validate_internal_clauses_against_schema, ValueClause, WhereClause, WhereOperator, - }, + conditions::{ValueClause, WhereClause, WhereOperator}, grovedb::{PathQuery, Query, QueryItem, SizedQuery}, ordering::OrderClause, single_document_drive_query::SingleDocumentDriveQuery, @@ -301,6 +299,102 @@ impl InternalClauses { )), } } + + /// Validate this collection of InternalClauses against the document schema + #[cfg(any(feature = "server", feature = "verify"))] + pub fn validate_against_schema( + &self, + document_type: DocumentTypeRef, + ) -> Result<(), crate::error::Error> { + // Basic composition + if !self.verify() { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "invalid composition of where clauses", + ), + )); + } + + // Validate in_clause against schema + if let Some(in_clause) = &self.in_clause { + // Forbid $id in non-primary-key clauses + if in_clause.field == "$id" { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "use primary_key_* clauses for $id", + ), + )); + } + in_clause.validate_against_schema(document_type)?; + } + + // Validate range_clause against schema + if let Some(range_clause) = &self.range_clause { + // Forbid $id in non-primary-key clauses + if range_clause.field == "$id" { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "use primary_key_* clauses for $id", + ), + )); + } + range_clause.validate_against_schema(document_type)?; + } + + // Validate equal_clauses against schema + for (field, eq_clause) in &self.equal_clauses { + // Forbid $id in non-primary-key clauses + if field.as_str() == "$id" { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "use primary_key_* clauses for $id", + ), + )); + } + eq_clause.validate_against_schema(document_type)?; + } + + // Validate primary key clauses typing + if let Some(pk_eq) = &self.primary_key_equal_clause { + if pk_eq.operator != WhereOperator::Equal + || !matches!(pk_eq.value, Value::Identifier(_)) + { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "primary key equality must compare an identifier", + ), + )); + } + } + if let Some(pk_in) = &self.primary_key_in_clause { + if pk_in.operator != WhereOperator::In { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "primary key IN must use IN operator", + ), + )); + } + // enforce array shape and no duplicates/size + pk_in.in_values()?; + if let Value::Array(arr) = &pk_in.value { + if !arr.iter().all(|v| matches!(v, Value::Identifier(_))) { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "primary key IN must contain identifiers", + ), + )); + } + } else { + return Err(Error::Query( + QuerySyntaxError::InvalidWhereClauseComponents( + "primary key IN must contain an array of identifiers", + ), + )); + } + } + + Ok(()) + } } impl From for Vec { From 9ac0a06cb7fcf86770f5af6b49b2904470d6b176 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Tue, 16 Sep 2025 14:31:49 +0700 Subject: [PATCH 24/30] clippy --- packages/rs-drive/src/query/filter.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index fbadcb6a00c..e250792aeaf 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -85,6 +85,7 @@ pub enum TransitionCheckResult { /// - Empty `InternalClauses` = no constraint for document-data checks. /// - `Option` = optional scalar constraint (owner/price); `None` = no constraint. /// - Action-specific “at least one present” rules are enforced by `validate()`. +#[allow(clippy::large_enum_variant)] #[derive(Debug, PartialEq, Clone)] pub enum DocumentActionMatchClauses { /// Create: filters on the new document data. From e4c7a435ec0586ce48a7d286bb9184283380293f Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Tue, 16 Sep 2025 14:49:44 +0700 Subject: [PATCH 25/30] doc --- packages/rs-drive/src/query/conditions.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index c665acf6e0a..1cbbd2d0169 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1395,6 +1395,7 @@ impl<'a> WhereClause { &self, document_type: DocumentTypeRef, ) -> Result<(), crate::error::Error> { + // First determine the property type of self.field let property_type_cow = if let Some(meta_ty) = meta_field_property_type(&self.field) { Cow::Owned(meta_ty) } else { @@ -1425,7 +1426,7 @@ impl<'a> WhereClause { if let Value::Text(s) = &self.value { if s.is_empty() { return Err(Error::Query(QuerySyntaxError::StartsWithIllegalString( - "starts with can not start with an empty string", + "starts_with can not start with an empty string", ))); } } @@ -1476,7 +1477,7 @@ impl<'a> WhereClause { _ => {} } - // Additional strict type checks for Equal and In element types + // Check value type matches field type for equality and IN operators let value_type_matches = |prop_ty: &DocumentPropertyType, v: &Value| -> bool { use DocumentPropertyType as T; match prop_ty { @@ -1509,6 +1510,7 @@ impl<'a> WhereClause { } }; + // For equality, allow some type coercion (e.g. integer types) match self.operator { WhereOperator::Equal => { use DocumentPropertyType as T; From c0f88a46f6fcef90ecc1c8649d8e38ca8dee5ea7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 19 Sep 2025 08:37:08 +0700 Subject: [PATCH 26/30] update --- .../src/validation/validation_result.rs | 17 + packages/rs-drive/src/query/conditions.rs | 306 +++++++++--------- packages/rs-drive/src/query/filter.rs | 167 ++++------ packages/rs-drive/src/query/mod.rs | 82 +++-- packages/rs-platform-value/src/lib.rs | 31 ++ 5 files changed, 318 insertions(+), 285 deletions(-) diff --git a/packages/rs-dpp/src/validation/validation_result.rs b/packages/rs-dpp/src/validation/validation_result.rs index a711e5c62f5..bc9e7bce34f 100644 --- a/packages/rs-dpp/src/validation/validation_result.rs +++ b/packages/rs-dpp/src/validation/validation_result.rs @@ -202,6 +202,10 @@ impl ValidationResult { self.errors.is_empty() } + pub fn is_err(&self) -> bool { + !self.errors.is_empty() + } + pub fn first_error(&self) -> Option<&E> { self.errors.first() } @@ -237,6 +241,19 @@ impl ValidationResult { ))) } + pub fn into_data_with_error(mut self) -> Result, ProtocolError> { + if let Some(error) = self.errors.pop() { + Ok(Err(error)) + } else { + self.data + .map(Ok) + .ok_or(ProtocolError::CorruptedCodeExecution(format!( + "trying to push validation result into data (errors are {:?})", + self.errors + ))) + } + } + pub fn into_data_and_errors(self) -> Result<(TData, Vec), ProtocolError> { Ok(( self.data diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 1cbbd2d0169..a21fefca986 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -3,6 +3,7 @@ use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::{QuerySyntaxSimpleValidationResult, QuerySyntaxValidationResult}; #[cfg(any(feature = "server", feature = "verify"))] use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; @@ -164,76 +165,76 @@ impl WhereOperator { /// Matches the where operator as a SQL operator and returns it as a proper `WhereOperator` pub(crate) fn from_sql_operator(sql_operator: ast::BinaryOperator) -> Option { match sql_operator { - ast::BinaryOperator::Eq => Some(WhereOperator::Equal), - ast::BinaryOperator::Gt => Some(WhereOperator::GreaterThan), - ast::BinaryOperator::GtEq => Some(WhereOperator::GreaterThanOrEquals), - ast::BinaryOperator::Lt => Some(WhereOperator::LessThan), - ast::BinaryOperator::LtEq => Some(WhereOperator::LessThanOrEquals), + ast::BinaryOperator::Eq => Some(Equal), + ast::BinaryOperator::Gt => Some(GreaterThan), + ast::BinaryOperator::GtEq => Some(GreaterThanOrEquals), + ast::BinaryOperator::Lt => Some(LessThan), + ast::BinaryOperator::LtEq => Some(LessThanOrEquals), _ => None, } } /// Shared operator evaluator for both WhereClause and ValueClause - pub fn eval(&self, probe: &Value, clause_value: &Value) -> bool { + pub fn eval(&self, left_value: &Value, right_value: &Value) -> bool { match self { - WhereOperator::Equal => probe == clause_value, - WhereOperator::GreaterThan => probe > clause_value, - WhereOperator::GreaterThanOrEquals => probe >= clause_value, - WhereOperator::LessThan => probe < clause_value, - WhereOperator::LessThanOrEquals => probe <= clause_value, - WhereOperator::In => match clause_value { - Value::Array(array) => array.contains(probe), - Value::Bytes(bytes) => match probe { + Equal => left_value == right_value, + GreaterThan => left_value > right_value, + GreaterThanOrEquals => left_value >= right_value, + LessThan => left_value < right_value, + LessThanOrEquals => left_value <= right_value, + In => match right_value { + Value::Array(array) => array.contains(left_value), + Value::Bytes(bytes) => match left_value { Value::U8(b) => bytes.contains(b), _ => false, }, _ => false, }, - WhereOperator::Between => match clause_value { + Between => match right_value { Value::Array(bounds) if bounds.len() == 2 => { match bounds[0].partial_cmp(&bounds[1]) { Some(Ordering::Less) | Some(Ordering::Equal) => { - probe >= &bounds[0] && probe <= &bounds[1] + left_value >= &bounds[0] && left_value <= &bounds[1] } _ => false, } } _ => false, }, - WhereOperator::BetweenExcludeBounds => match clause_value { + BetweenExcludeBounds => match right_value { Value::Array(bounds) if bounds.len() == 2 => { match bounds[0].partial_cmp(&bounds[1]) { Some(Ordering::Less) | Some(Ordering::Equal) => { - probe > &bounds[0] && probe < &bounds[1] + left_value > &bounds[0] && left_value < &bounds[1] } _ => false, } } _ => false, }, - WhereOperator::BetweenExcludeLeft => match clause_value { + BetweenExcludeLeft => match right_value { Value::Array(bounds) if bounds.len() == 2 => { match bounds[0].partial_cmp(&bounds[1]) { Some(Ordering::Less) | Some(Ordering::Equal) => { - probe > &bounds[0] && probe <= &bounds[1] + left_value > &bounds[0] && left_value <= &bounds[1] } _ => false, } } _ => false, }, - WhereOperator::BetweenExcludeRight => match clause_value { + BetweenExcludeRight => match right_value { Value::Array(bounds) if bounds.len() == 2 => { match bounds[0].partial_cmp(&bounds[1]) { Some(Ordering::Less) | Some(Ordering::Equal) => { - probe >= &bounds[0] && probe < &bounds[1] + left_value >= &bounds[0] && left_value < &bounds[1] } _ => false, } } _ => false, }, - WhereOperator::StartsWith => match (probe, clause_value) { + StartsWith => match (left_value, right_value) { (Value::Text(text), Value::Text(prefix)) => text.starts_with(prefix.as_str()), _ => false, }, @@ -244,35 +245,31 @@ impl WhereOperator { #[cfg(any(feature = "server", feature = "verify"))] pub fn value_shape_ok(&self, value: &Value, property_type: &DocumentPropertyType) -> bool { match self { - WhereOperator::Equal => true, - WhereOperator::In => matches!(value, Value::Array(_) | Value::Bytes(_)), - WhereOperator::StartsWith => matches!(value, Value::Text(_)), - WhereOperator::GreaterThan - | WhereOperator::GreaterThanOrEquals - | WhereOperator::LessThan - | WhereOperator::LessThanOrEquals => match property_type { - DocumentPropertyType::F64 => is_numeric_value(value), - DocumentPropertyType::String(_) => { - matches!(value, Value::Text(_)) + Equal => true, + In => matches!(value, Value::Array(_) | Value::Bytes(_)), + StartsWith => matches!(value, Value::Text(_)), + GreaterThan | GreaterThanOrEquals | LessThan | LessThanOrEquals => { + match property_type { + DocumentPropertyType::F64 => is_numeric_value(value), + DocumentPropertyType::String(_) => { + matches!(value, Value::Text(_)) + } + _ => matches!( + value, + Value::U128(_) + | Value::I128(_) + | Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) + ), } - _ => matches!( - value, - Value::U128(_) - | Value::I128(_) - | Value::U64(_) - | Value::I64(_) - | Value::U32(_) - | Value::I32(_) - | Value::U16(_) - | Value::I16(_) - | Value::U8(_) - | Value::I8(_) - ), - }, - WhereOperator::Between - | WhereOperator::BetweenExcludeBounds - | WhereOperator::BetweenExcludeLeft - | WhereOperator::BetweenExcludeRight => { + } + Between | BetweenExcludeBounds | BetweenExcludeLeft | BetweenExcludeRight => { if let Value::Array(arr) = value { arr.len() == 2 && arr.iter().all(|x| match property_type { @@ -305,17 +302,17 @@ impl WhereOperator { impl Display for WhereOperator { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { - Self::Equal => "=", - Self::GreaterThan => ">", - Self::GreaterThanOrEquals => ">=", - Self::LessThan => "<", - Self::LessThanOrEquals => "<=", - Self::Between => "Between", - Self::BetweenExcludeBounds => "BetweenExcludeBounds", - Self::BetweenExcludeLeft => "BetweenExcludeLeft", - Self::BetweenExcludeRight => "BetweenExcludeRight", - Self::In => "In", - Self::StartsWith => "StartsWith", + Equal => "=", + GreaterThan => ">", + GreaterThanOrEquals => ">=", + LessThan => "<", + LessThanOrEquals => "<=", + Between => "Between", + BetweenExcludeBounds => "BetweenExcludeBounds", + BetweenExcludeLeft => "BetweenExcludeLeft", + BetweenExcludeRight => "BetweenExcludeRight", + In => "In", + StartsWith => "StartsWith", }; write!(f, "{}", s) @@ -347,37 +344,39 @@ impl<'a> WhereClause { } /// Returns the where clause `in` values if they are an array of values, else an error - pub fn in_values(&self) -> Result>, Error> { + pub fn in_values(&self) -> QuerySyntaxValidationResult>> { let in_values = match &self.value { - Value::Array(array) => Ok(Cow::Borrowed(array)), - Value::Bytes(bytes) => Ok(Cow::Owned( - bytes.iter().map(|int| Value::U8(*int)).collect(), - )), - _ => Err(Error::Query(QuerySyntaxError::InvalidInClause( - "when using in operator you must provide an array of values".to_string(), - ))), - }?; + Value::Array(array) => Cow::Borrowed(array), + Value::Bytes(bytes) => Cow::Owned(bytes.iter().map(|int| Value::U8(*int)).collect()), + _ => { + return QuerySyntaxValidationResult::new_with_error( + QuerySyntaxError::InvalidInClause( + "when using in operator you must provide an array of values".to_string(), + ), + ) + } + }; let len = in_values.len(); if len == 0 { - return Err(Error::Query(QuerySyntaxError::InvalidInClause( + return QuerySyntaxValidationResult::new_with_error(QuerySyntaxError::InvalidInClause( "in clause must have at least 1 value".to_string(), - ))); + )); } if len > 100 { - return Err(Error::Query(QuerySyntaxError::InvalidInClause( + return QuerySyntaxValidationResult::new_with_error(QuerySyntaxError::InvalidInClause( "in clause must have at most 100 values".to_string(), - ))); + )); } // Throw an error if there are duplicates if (1..in_values.len()).any(|i| in_values[i..].contains(&in_values[i - 1])) { - return Err(Error::Query(QuerySyntaxError::InvalidInClause( + return QuerySyntaxValidationResult::new_with_error(QuerySyntaxError::InvalidInClause( "there should be no duplicates values for In query".to_string(), - ))); + )); } - Ok(in_values) + QuerySyntaxValidationResult::new_with_data(in_values) } /// Returns true if the less than where clause is true @@ -600,7 +599,7 @@ impl<'a> WhereClause { let in_clauses_array = where_clauses .iter() .filter_map(|where_clause| match where_clause.operator { - WhereOperator::In => match where_clause.is_identifier() { + In => match where_clause.is_identifier() { true => None, false => Some(where_clause.clone()), }, @@ -856,7 +855,7 @@ impl<'a> WhereClause { } } In => { - let in_values = self.in_values()?; + let in_values = self.in_values().into_data_with_error()??; match starts_at_key_option { None => { @@ -1244,7 +1243,7 @@ impl<'a> WhereClause { where_clauses.push(WhereClause { field: field_name, - operator: WhereOperator::In, + operator: In, value: Value::Array(in_values), }); @@ -1256,7 +1255,7 @@ impl<'a> WhereClause { pattern, escape_char: _, } => { - let where_operator = WhereOperator::StartsWith; + let where_operator = StartsWith; if *negated { return Err(Error::Query(QuerySyntaxError::Unsupported( "Negated Like not supported".to_string(), @@ -1276,7 +1275,7 @@ impl<'a> WhereClause { )) })?; - // make sure the value is of the right format i.e prefix% + // make sure the value is of the right format i.e. prefix% let inner_text = platform_value.as_text().ok_or({ Error::Query(QuerySyntaxError::InvalidStartsWithClause( "Invalid query: startsWith takes text", @@ -1394,81 +1393,82 @@ impl<'a> WhereClause { pub fn validate_against_schema( &self, document_type: DocumentTypeRef, - ) -> Result<(), crate::error::Error> { + ) -> QuerySyntaxSimpleValidationResult { // First determine the property type of self.field let property_type_cow = if let Some(meta_ty) = meta_field_property_type(&self.field) { Cow::Owned(meta_ty) } else { // Check that the field exists in the schema - let property = document_type - .flattened_properties() - .get(&self.field) - .ok_or_else(|| { - Error::Query(QuerySyntaxError::InvalidWhereClauseComponents( - "unknown field in where clause", - )) - })?; + let Some(property) = document_type.flattened_properties().get(&self.field) else { + return QuerySyntaxSimpleValidationResult::new_with_error( + QuerySyntaxError::InvalidWhereClauseComponents("unknown field in where clause"), + ); + }; Cow::Borrowed(&property.property_type) }; // Check operator is allowed for field type let property_type = property_type_cow.as_ref(); if !allowed_ops_for_type(property_type).contains(&self.operator) { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "operator not allowed for field type", ), - )); + ); } // Check starts_with value is not empty - if self.operator == WhereOperator::StartsWith { + if self.operator == StartsWith { if let Value::Text(s) = &self.value { if s.is_empty() { - return Err(Error::Query(QuerySyntaxError::StartsWithIllegalString( - "starts_with can not start with an empty string", - ))); + return QuerySyntaxSimpleValidationResult::new_with_error( + QuerySyntaxError::StartsWithIllegalString( + "starts_with can not start with an empty string", + ), + ); } } } // Check in clause values - if self.operator == WhereOperator::In { + if self.operator == In { // Ensure array value, length bounds and no duplicates - self.in_values()?; + let result = self.in_values(); + if !result.is_valid() { + return QuerySyntaxSimpleValidationResult::new_with_errors(result.errors); + } // If value provided as Bytes, only allow for U8 numeric fields if matches!(self.value, Value::Bytes(_)) && !matches!(property_type, DocumentPropertyType::U8) { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "IN Bytes only allowed for U8 fields", ), - )); + ); } } // Check value shape is correct for operator and field type if !self.operator.value_shape_ok(&self.value, property_type) { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents("invalid value shape for operator"), - )); + ); } // For Between variants, ensure bounds are in ascending order to avoid surprising matches match self.operator { - WhereOperator::Between - | WhereOperator::BetweenExcludeBounds - | WhereOperator::BetweenExcludeLeft - | WhereOperator::BetweenExcludeRight => { + Between | BetweenExcludeBounds | BetweenExcludeLeft | BetweenExcludeRight => { if let Value::Array(bounds) = &self.value { if bounds.len() == 2 { match bounds[0].partial_cmp(&bounds[1]) { Some(Ordering::Less) | Some(Ordering::Equal) => {} _ => { - return Err(Error::Query(QuerySyntaxError::InvalidBetweenClause( - "when using between operator bounds must be ascending", - ))); + return QuerySyntaxSimpleValidationResult::new_with_error( + QuerySyntaxError::InvalidBetweenClause( + "when using between operator bounds must be ascending", + ), + ); } } } @@ -1512,7 +1512,7 @@ impl<'a> WhereClause { // For equality, allow some type coercion (e.g. integer types) match self.operator { - WhereOperator::Equal => { + Equal => { use DocumentPropertyType as T; let ok = match property_type { // Accept any integer-like value for integer fields (signed/unsigned), reject floats @@ -1560,28 +1560,28 @@ impl<'a> WhereClause { T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, }; if !ok { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "invalid value type for equality", ), - )); + ); } } - WhereOperator::In => { + In => { if let Value::Array(arr) = &self.value { if !arr.iter().all(|v| value_type_matches(property_type, v)) { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "invalid value type in IN clause", ), - )); + ); } } } _ => {} } - Ok(()) + QuerySyntaxSimpleValidationResult::new() } } @@ -1624,33 +1624,33 @@ pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [W | DocumentPropertyType::I128 | DocumentPropertyType::F64 | DocumentPropertyType::Date => &[ - WhereOperator::Equal, - WhereOperator::In, - WhereOperator::GreaterThan, - WhereOperator::GreaterThanOrEquals, - WhereOperator::LessThan, - WhereOperator::LessThanOrEquals, - WhereOperator::Between, - WhereOperator::BetweenExcludeBounds, - WhereOperator::BetweenExcludeLeft, - WhereOperator::BetweenExcludeRight, + Equal, + In, + GreaterThan, + GreaterThanOrEquals, + LessThan, + LessThanOrEquals, + Between, + BetweenExcludeBounds, + BetweenExcludeLeft, + BetweenExcludeRight, ], DocumentPropertyType::String(_) => &[ - WhereOperator::Equal, - WhereOperator::In, - WhereOperator::StartsWith, - WhereOperator::GreaterThan, - WhereOperator::GreaterThanOrEquals, - WhereOperator::LessThan, - WhereOperator::LessThanOrEquals, - WhereOperator::Between, - WhereOperator::BetweenExcludeBounds, - WhereOperator::BetweenExcludeLeft, - WhereOperator::BetweenExcludeRight, + Equal, + In, + StartsWith, + GreaterThan, + GreaterThanOrEquals, + LessThan, + LessThanOrEquals, + Between, + BetweenExcludeBounds, + BetweenExcludeLeft, + BetweenExcludeRight, ], - DocumentPropertyType::Identifier => &[WhereOperator::Equal, WhereOperator::In], - DocumentPropertyType::ByteArray(_) => &[WhereOperator::Equal, WhereOperator::In], - DocumentPropertyType::Boolean => &[WhereOperator::Equal], + DocumentPropertyType::Identifier => &[Equal, In], + DocumentPropertyType::ByteArray(_) => &[Equal, In], + DocumentPropertyType::Boolean => &[Equal], DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => &[], @@ -1684,12 +1684,12 @@ fn meta_field_property_type(field: &str) -> Option { // Dates (millis since epoch) "$createdAt" | "$updatedAt" | "$transferredAt" => Some(DocumentPropertyType::Date), // Block heights and core block heights - "$createdAtBlockHeight" - | "$updatedAtBlockHeight" - | "$transferredAtBlockHeight" - | "$createdAtCoreBlockHeight" + "$createdAtBlockHeight" | "$updatedAtBlockHeight" | "$transferredAtBlockHeight" => { + Some(DocumentPropertyType::U64) + } + "$createdAtCoreBlockHeight" | "$updatedAtCoreBlockHeight" - | "$transferredAtCoreBlockHeight" => Some(DocumentPropertyType::U64), + | "$transferredAtCoreBlockHeight" => Some(DocumentPropertyType::U32), // Revision and protocol version are integers "$revision" | "$protocolVersion" => Some(DocumentPropertyType::U64), // Type name is a string @@ -1707,7 +1707,7 @@ fn meta_field_property_type(field: &str) -> Option { #[cfg(test)] mod tests { use crate::query::conditions::WhereClause; - use crate::query::conditions::WhereOperator::{ + use crate::query::conditions::{ Equal, GreaterThan, GreaterThanOrEquals, In, LessThan, LessThanOrEquals, }; use crate::query::InternalClauses; @@ -1971,7 +1971,7 @@ mod tests { ]), }; let res = clause.validate_against_schema(doc_type); - assert!(res.is_ok()); + assert!(res.is_valid()); } #[test] @@ -1984,11 +1984,11 @@ mod tests { let clause = WhereClause { field: "$createdAt".to_string(), - operator: crate::query::conditions::WhereOperator::Between, + operator: crate::query::conditions::Between, value: Value::Array(vec![Value::U64(1000), Value::U64(2000)]), }; let res = clause.validate_against_schema(doc_type); - assert!(res.is_ok()); + assert!(res.is_valid()); } #[test] @@ -2022,7 +2022,7 @@ mod tests { value: Value::U64(100), }; let res = clause.validate_against_schema(doc_type); - assert!(res.is_ok()); + assert!(res.is_valid()); } #[test] @@ -2039,6 +2039,6 @@ mod tests { value: Value::Identifier([3u8; 32]), }; let res = clause.validate_against_schema(doc_type); - assert!(res.is_ok()); + assert!(res.is_valid()); } } diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index e250792aeaf..0765993be12 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -42,8 +42,8 @@ use dpp::state_transition::batch_transition::document_base_transition::v0::v0_me use dpp::state_transition::batch_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::v0_methods::DocumentTransferTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::v0_methods::DocumentUpdatePriceTransitionV0Methods; -use crate::query::{InternalClauses, ValueClause, WhereOperator}; -use crate::error::{query::QuerySyntaxError, Error}; +use crate::query::{InternalClauses, QuerySyntaxSimpleValidationResult, ValueClause, WhereOperator}; +use crate::error::query::QuerySyntaxError; use dpp::platform_value::ValueMapHelper; /// Filter used to match document transitions for subscriptions. @@ -432,55 +432,49 @@ impl DriveDocumentQueryFilter<'_> { /// /// In addition to these validations, the subscription host should check the contract's existence. #[cfg(any(feature = "server", feature = "verify"))] - pub fn validate(&self) -> Result<(), crate::error::Error> { + pub fn validate(&self) -> QuerySyntaxSimpleValidationResult { // Ensure the document type exists - let document_type = self + let Some(document_type) = self .contract - .document_type_for_name(&self.document_type_name) - .map_err(|_| { - Error::Query(QuerySyntaxError::DocumentTypeNotFound( - "unknown document type", - )) - })?; + .document_type_optional_for_name(&self.document_type_name) + else { + return QuerySyntaxSimpleValidationResult::new_with_error( + QuerySyntaxError::DocumentTypeNotFound("unknown document type"), + ); + }; match &self.action_clauses { DocumentActionMatchClauses::Create { new_document_clauses, - } => new_document_clauses.validate_against_schema(document_type)?, + } => new_document_clauses.validate_against_schema(document_type), DocumentActionMatchClauses::Replace { original_document_clauses, new_document_clauses, } => { - if original_document_clauses.is_empty() && new_document_clauses.is_empty() { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "replace requires at least one of original/new clauses", - ), - )); - } if !original_document_clauses.is_empty() { - original_document_clauses.validate_against_schema(document_type)?; + let result = original_document_clauses.validate_against_schema(document_type); + if result.is_err() { + return result; + } } if !new_document_clauses.is_empty() { - new_document_clauses.validate_against_schema(document_type)?; + new_document_clauses.validate_against_schema(document_type) + } else { + QuerySyntaxSimpleValidationResult::new() } } DocumentActionMatchClauses::Delete { original_document_clauses, - } => original_document_clauses.validate_against_schema(document_type)?, + } => original_document_clauses.validate_against_schema(document_type), DocumentActionMatchClauses::Transfer { original_document_clauses, owner_clause, } => { - if original_document_clauses.is_empty() && owner_clause.is_none() { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "transfer requires original clauses or owner clause", - ), - )); - } if !original_document_clauses.is_empty() { - original_document_clauses.validate_against_schema(document_type)?; + let result = original_document_clauses.validate_against_schema(document_type); + if result.is_err() { + return result; + } } if let Some(owner) = owner_clause { let ok = match owner.operator { @@ -493,26 +487,26 @@ impl DriveDocumentQueryFilter<'_> { }, _ => false, }; - if !ok { - return Err(Error::Query( + if ok { + QuerySyntaxSimpleValidationResult::new() + } else { + QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents("invalid owner clause"), - )); + ) } + } else { + QuerySyntaxSimpleValidationResult::new() } } DocumentActionMatchClauses::UpdatePrice { original_document_clauses, price_clause, } => { - if original_document_clauses.is_empty() && price_clause.is_none() { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "updatePrice requires original clauses or price clause", - ), - )); - } if !original_document_clauses.is_empty() { - original_document_clauses.validate_against_schema(document_type)?; + let result = original_document_clauses.validate_against_schema(document_type); + if result.is_err() { + return result; + } } if let Some(price) = price_clause { let ok = match price.operator { @@ -520,78 +514,48 @@ impl DriveDocumentQueryFilter<'_> { | WhereOperator::GreaterThan | WhereOperator::GreaterThanOrEquals | WhereOperator::LessThan - | WhereOperator::LessThanOrEquals => matches!( - price.value, - Value::U64(_) - | Value::I64(_) - | Value::U32(_) - | Value::I32(_) - | Value::U16(_) - | Value::I16(_) - | Value::U8(_) - | Value::I8(_) - ), + | WhereOperator::LessThanOrEquals => { + price.value.is_integer_can_fit_64_bytes() + } WhereOperator::Between | WhereOperator::BetweenExcludeBounds | WhereOperator::BetweenExcludeLeft | WhereOperator::BetweenExcludeRight => match &price.value { Value::Array(arr) => { arr.len() == 2 - && arr.iter().all(|v| { - matches!( - v, - Value::U64(_) - | Value::I64(_) - | Value::U32(_) - | Value::I32(_) - | Value::U16(_) - | Value::I16(_) - | Value::U8(_) - | Value::I8(_) - ) - }) - && arr[0] <= arr[1] + && arr.iter().all(|v| v.is_integer_can_fit_64_bytes()) + && arr[0] < arr[1] } _ => false, }, WhereOperator::In => match &price.value { - Value::Array(arr) => arr.iter().all(|v| { - matches!( - v, - Value::U64(_) - | Value::I64(_) - | Value::U32(_) - | Value::I32(_) - | Value::U16(_) - | Value::I16(_) - | Value::U8(_) - | Value::I8(_) - ) - }), + Value::Array(arr) => { + arr.iter().all(|v| v.is_integer_can_fit_64_bytes()) + } _ => false, }, WhereOperator::StartsWith => false, }; - if !ok { - return Err(Error::Query( + if ok { + QuerySyntaxSimpleValidationResult::new() + } else { + QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents("invalid price clause"), - )); + ) } + } else { + QuerySyntaxSimpleValidationResult::new() } } DocumentActionMatchClauses::Purchase { original_document_clauses, owner_clause, } => { - if original_document_clauses.is_empty() && owner_clause.is_none() { - return Err(Error::Query( - QuerySyntaxError::InvalidWhereClauseComponents( - "purchase requires original clauses or owner clause", - ), - )); - } if !original_document_clauses.is_empty() { - original_document_clauses.validate_against_schema(document_type)?; + let result = original_document_clauses.validate_against_schema(document_type); + if result.is_err() { + return result; + } } if let Some(owner) = owner_clause { let ok = match owner.operator { @@ -604,15 +568,18 @@ impl DriveDocumentQueryFilter<'_> { }, _ => false, }; - if !ok { - return Err(Error::Query( + if ok { + QuerySyntaxSimpleValidationResult::new() + } else { + QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents("invalid owner clause"), - )); + ) } + } else { + QuerySyntaxSimpleValidationResult::new() } } } - Ok(()) } } @@ -958,7 +925,7 @@ mod tests { }, }, }; - assert!(filter.validate().is_ok()); + assert!(filter.validate().is_valid()); // Transfer with none/none -> invalid let filter = DriveDocumentQueryFilter { @@ -983,7 +950,7 @@ mod tests { }), }, }; - assert!(filter.validate().is_ok()); + assert!(filter.validate().is_valid()); // UpdatePrice with none/none -> invalid let filter = DriveDocumentQueryFilter { @@ -1008,7 +975,7 @@ mod tests { }), }, }; - assert!(filter.validate().is_ok()); + assert!(filter.validate().is_valid()); // Purchase with none/none -> invalid let filter = DriveDocumentQueryFilter { @@ -1033,7 +1000,7 @@ mod tests { }), }, }; - assert!(filter.validate().is_ok()); + assert!(filter.validate().is_valid()); } #[test] @@ -1530,7 +1497,7 @@ mod tests { }; assert!( - valid_filter.validate().is_ok(), + valid_filter.validate().is_valid(), "Filter with indexed field should be valid" ); @@ -1557,7 +1524,7 @@ mod tests { }; assert!( - invalid_filter.validate().is_ok(), + invalid_filter.validate().is_valid(), "Structural validate should ignore indexes" ); // Index-aware validation removed; structural validation suffices for subscriptions. @@ -1579,7 +1546,7 @@ mod tests { }; assert!( - primary_key_filter.validate().is_ok(), + primary_key_filter.validate().is_valid(), "Filter with only primary key should be valid" ); } diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 85015a2a2a8..6253e379a67 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -51,6 +51,7 @@ pub use grovedb::{ use dpp::document; use dpp::prelude::Identifier; +use dpp::validation::{SimpleValidationResult, ValidationResult}; #[cfg(feature = "server")] use { crate::{drive::Drive, fees::op::LowLevelDriveOperation}, @@ -102,8 +103,8 @@ pub mod vote_polls_by_document_type_query; /// It should be implemented by the caller in order to provide data /// contract required for operations like proof verification. #[cfg(any(feature = "server", feature = "verify"))] -pub type ContractLookupFn<'a> = dyn Fn(&dpp::identifier::Identifier) -> Result>, crate::error::Error> - + 'a; +pub type ContractLookupFn<'a> = + dyn Fn(&Identifier) -> Result>, Error> + 'a; /// Creates a [ContractLookupFn] function that returns provided data contract when requested. /// @@ -119,13 +120,12 @@ pub type ContractLookupFn<'a> = dyn Fn(&dpp::identifier::Identifier) -> Result( data_contract: Arc, ) -> Box> { - let func = move - |id: &dpp::identifier::Identifier| -> Result>, crate::error::Error> { - if data_contract.id().ne(id) { - return Ok(None); - } - Ok(Some(Arc::clone(&data_contract))) - }; + let func = move |id: &Identifier| -> Result>, Error> { + if data_contract.id().ne(id) { + return Ok(None); + } + Ok(Some(Arc::clone(&data_contract))) + }; Box::new(func) } @@ -154,6 +154,12 @@ pub mod filter; #[cfg(any(feature = "server", feature = "verify"))] pub mod token_status_drive_query; +/// A Query Syntax Validation Result that contains data +pub type QuerySyntaxValidationResult = ValidationResult; + +/// A Query Syntax Validation Result +pub type QuerySyntaxSimpleValidationResult = SimpleValidationResult; + #[cfg(any(feature = "server", feature = "verify"))] /// Represents a starting point for a query based on a specific document. /// @@ -305,53 +311,62 @@ impl InternalClauses { pub fn validate_against_schema( &self, document_type: DocumentTypeRef, - ) -> Result<(), crate::error::Error> { + ) -> QuerySyntaxSimpleValidationResult { // Basic composition if !self.verify() { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "invalid composition of where clauses", ), - )); + ); } // Validate in_clause against schema if let Some(in_clause) = &self.in_clause { // Forbid $id in non-primary-key clauses if in_clause.field == "$id" { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "use primary_key_* clauses for $id", ), - )); + ); + } + let result = in_clause.validate_against_schema(document_type); + if !result.is_valid() { + return result; } - in_clause.validate_against_schema(document_type)?; } // Validate range_clause against schema if let Some(range_clause) = &self.range_clause { // Forbid $id in non-primary-key clauses if range_clause.field == "$id" { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "use primary_key_* clauses for $id", ), - )); + ); + } + let result = range_clause.validate_against_schema(document_type); + if !result.is_valid() { + return result; } - range_clause.validate_against_schema(document_type)?; } // Validate equal_clauses against schema for (field, eq_clause) in &self.equal_clauses { // Forbid $id in non-primary-key clauses if field.as_str() == "$id" { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "use primary_key_* clauses for $id", ), - )); + ); + } + let result = eq_clause.validate_against_schema(document_type); + if !result.is_valid() { + return result; } - eq_clause.validate_against_schema(document_type)?; } // Validate primary key clauses typing @@ -359,41 +374,44 @@ impl InternalClauses { if pk_eq.operator != WhereOperator::Equal || !matches!(pk_eq.value, Value::Identifier(_)) { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "primary key equality must compare an identifier", ), - )); + ); } } if let Some(pk_in) = &self.primary_key_in_clause { if pk_in.operator != WhereOperator::In { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "primary key IN must use IN operator", ), - )); + ); } // enforce array shape and no duplicates/size - pk_in.in_values()?; + let result = pk_in.in_values(); + if !result.is_valid() { + return QuerySyntaxSimpleValidationResult::new_with_errors(result.errors); + } if let Value::Array(arr) = &pk_in.value { if !arr.iter().all(|v| matches!(v, Value::Identifier(_))) { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "primary key IN must contain identifiers", ), - )); + ); } } else { - return Err(Error::Query( + return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidWhereClauseComponents( "primary key IN must contain an array of identifiers", ), - )); + ); } } - Ok(()) + QuerySyntaxSimpleValidationResult::default() } } @@ -1215,7 +1233,7 @@ impl<'a> DriveDocumentQuery<'a> { }; if let Some(primary_key_in_clause) = &self.internal_clauses.primary_key_in_clause { - let in_values = primary_key_in_clause.in_values()?; + let in_values = primary_key_in_clause.in_values().into_data_with_error()??; match starts_at_key_option { None => { diff --git a/packages/rs-platform-value/src/lib.rs b/packages/rs-platform-value/src/lib.rs index c372fc64787..a7dd81747c9 100644 --- a/packages/rs-platform-value/src/lib.rs +++ b/packages/rs-platform-value/src/lib.rs @@ -148,6 +148,37 @@ impl Value { ) } + /// Returns true if the `Value` is an integer that fits in 64 bits (u64/i64). + /// Returns false otherwise. + /// + /// ``` + /// # use platform_value::Value; + /// # + /// let value = Value::U128(17); + /// + /// assert!(value.is_integer_can_fit_64_bytes()); + /// ``` + pub fn is_integer_can_fit_64_bytes(&self) -> bool { + match self { + // Already ≤ 64-bit widths + Value::U64(_) + | Value::I64(_) + | Value::U32(_) + | Value::I32(_) + | Value::U16(_) + | Value::I16(_) + | Value::U8(_) + | Value::I8(_) => true, + + // 128-bit -> check if within 64-bit range + Value::U128(v) => *v <= u64::MAX as u128, + Value::I128(v) => (*v >= i64::MIN as i128) && (*v <= i64::MAX as i128), + + // Non-integer variants + _ => false, + } + } + /// If the `Value` is a `Integer`, returns a reference to the associated `Integer` data. /// Returns None otherwise. /// From adb84775d0906b9e6d96e13467a00d21384a4fa5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 19 Sep 2025 15:44:19 +0700 Subject: [PATCH 27/30] sams suggestions --- packages/rs-drive/src/query/conditions.rs | 29 ++++++++++------------- packages/rs-drive/src/query/filter.rs | 18 +++++++------- packages/wasm-sdk/src/error.rs | 6 +++++ 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index a21fefca986..e51ab4fe4d2 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1706,6 +1706,7 @@ fn meta_field_property_type(field: &str) -> Option { #[cfg(feature = "server")] #[cfg(test)] mod tests { + use crate::error::query::QuerySyntaxError; use crate::query::conditions::WhereClause; use crate::query::conditions::{ Equal, GreaterThan, GreaterThanOrEquals, In, LessThan, LessThanOrEquals, @@ -1854,11 +1855,10 @@ mod tests { value: Value::Identifier([1u8; 32]), }; let res = clause.validate_against_schema(doc_type); + assert!(res.is_err()); assert!(matches!( - res, - Err(crate::error::Error::Query( - crate::error::query::QuerySyntaxError::InvalidWhereClauseComponents(_) - )) + res.first_error(), + Some(QuerySyntaxError::InvalidWhereClauseComponents(_)) )); } @@ -1879,11 +1879,10 @@ mod tests { ]), }; let res = clause.validate_against_schema(doc_type); + assert!(res.is_err()); assert!(matches!( - res, - Err(crate::error::Error::Query( - crate::error::query::QuerySyntaxError::InvalidWhereClauseComponents(_) - )) + res.first_error(), + Some(QuerySyntaxError::InvalidWhereClauseComponents(_)) )); } @@ -1906,11 +1905,10 @@ mod tests { }); let res = clauses.validate_against_schema(doc_type); + assert!(res.is_err()); assert!(matches!( - res, - Err(crate::error::Error::Query( - crate::error::query::QuerySyntaxError::InvalidWhereClauseComponents(_) - )) + res.first_error(), + Some(QuerySyntaxError::InvalidWhereClauseComponents(_)) )); } @@ -1928,11 +1926,10 @@ mod tests { value: Value::Float(1.23), }; let res = clause.validate_against_schema(doc_type); + assert!(res.is_err()); assert!(matches!( - res, - Err(crate::error::Error::Query( - crate::error::query::QuerySyntaxError::InvalidWhereClauseComponents(_) - )) + res.first_error(), + Some(QuerySyntaxError::InvalidWhereClauseComponents(_)) )); } diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 0765993be12..5abce12b4d6 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -894,11 +894,11 @@ mod tests { } #[test] - fn test_validate_requires_at_least_one_clause_for_optional_actions() { + fn test_validate_optional_actions_allow_empty_clauses() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); let contract = fixture.data_contract_owned(); - // Replace with none/none -> invalid + // Replace with none/none -> allowed let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), @@ -907,7 +907,7 @@ mod tests { new_document_clauses: InternalClauses::default(), }, }; - assert!(filter.validate().is_err()); + assert!(filter.validate().is_valid()); // Replace with final only -> valid (non-empty final clauses) let filter = DriveDocumentQueryFilter { @@ -927,7 +927,7 @@ mod tests { }; assert!(filter.validate().is_valid()); - // Transfer with none/none -> invalid + // Transfer with none/none -> allowed let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), @@ -936,7 +936,7 @@ mod tests { owner_clause: None, }, }; - assert!(filter.validate().is_err()); + assert!(filter.validate().is_valid()); // Transfer with owner only -> valid let filter = DriveDocumentQueryFilter { @@ -952,7 +952,7 @@ mod tests { }; assert!(filter.validate().is_valid()); - // UpdatePrice with none/none -> invalid + // UpdatePrice with none/none -> allowed let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), @@ -961,7 +961,7 @@ mod tests { price_clause: None, }, }; - assert!(filter.validate().is_err()); + assert!(filter.validate().is_valid()); // UpdatePrice with price only -> valid let filter = DriveDocumentQueryFilter { @@ -977,7 +977,7 @@ mod tests { }; assert!(filter.validate().is_valid()); - // Purchase with none/none -> invalid + // Purchase with none/none -> allowed let filter = DriveDocumentQueryFilter { contract: &contract, document_type_name: "niceDocument".to_string(), @@ -986,7 +986,7 @@ mod tests { owner_clause: None, }, }; - assert!(filter.validate().is_err()); + assert!(filter.validate().is_valid()); // Purchase with owner only -> valid let filter = DriveDocumentQueryFilter { diff --git a/packages/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index a2b2e264462..fce025dbcb3 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -169,6 +169,12 @@ impl From for WasmSdkError { Cancelled(msg) => Self::new(WasmSdkErrorKind::Cancelled, msg, None, retriable), StaleNode(e) => Self::new(WasmSdkErrorKind::StaleNode, e.to_string(), None, retriable), StateTransitionBroadcastError(e) => WasmSdkError::from(e), + other => Self::new( + WasmSdkErrorKind::Generic, + other.to_string(), + None, + retriable, + ), } } } From 649cec7d4f42d03bebd5780ed4bd948784c9aa78 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 19 Sep 2025 15:52:23 +0700 Subject: [PATCH 28/30] fix --- packages/rs-drive/src/query/filter.rs | 6 +++--- packages/rs-platform-value/src/lib.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 5abce12b4d6..31d092708f8 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -515,7 +515,7 @@ impl DriveDocumentQueryFilter<'_> { | WhereOperator::GreaterThanOrEquals | WhereOperator::LessThan | WhereOperator::LessThanOrEquals => { - price.value.is_integer_can_fit_64_bytes() + price.value.is_integer_can_fit_in_64_bits() } WhereOperator::Between | WhereOperator::BetweenExcludeBounds @@ -523,14 +523,14 @@ impl DriveDocumentQueryFilter<'_> { | WhereOperator::BetweenExcludeRight => match &price.value { Value::Array(arr) => { arr.len() == 2 - && arr.iter().all(|v| v.is_integer_can_fit_64_bytes()) + && arr.iter().all(|v| v.is_integer_can_fit_in_64_bits()) && arr[0] < arr[1] } _ => false, }, WhereOperator::In => match &price.value { Value::Array(arr) => { - arr.iter().all(|v| v.is_integer_can_fit_64_bytes()) + arr.iter().all(|v| v.is_integer_can_fit_in_64_bits()) } _ => false, }, diff --git a/packages/rs-platform-value/src/lib.rs b/packages/rs-platform-value/src/lib.rs index a7dd81747c9..5900ccdeada 100644 --- a/packages/rs-platform-value/src/lib.rs +++ b/packages/rs-platform-value/src/lib.rs @@ -156,9 +156,9 @@ impl Value { /// # /// let value = Value::U128(17); /// - /// assert!(value.is_integer_can_fit_64_bytes()); + /// assert!(value.is_integer_can_fit_in_64_bits()); /// ``` - pub fn is_integer_can_fit_64_bytes(&self) -> bool { + pub fn is_integer_can_fit_in_64_bits(&self) -> bool { match self { // Already ≤ 64-bit widths Value::U64(_) From 5f33a5bc49724ef9886cc5f6d63b22dccb29d003 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 21 Sep 2025 05:32:00 +0700 Subject: [PATCH 29/30] commit --- packages/rs-drive/src/query/conditions.rs | 76 +++++++++++++++++++---- packages/rs-drive/src/query/filter.rs | 24 +++++++ 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index e51ab4fe4d2..1061501bdd3 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -193,7 +193,7 @@ impl WhereOperator { Between => match right_value { Value::Array(bounds) if bounds.len() == 2 => { match bounds[0].partial_cmp(&bounds[1]) { - Some(Ordering::Less) | Some(Ordering::Equal) => { + Some(Ordering::Less) => { left_value >= &bounds[0] && left_value <= &bounds[1] } _ => false, @@ -204,9 +204,7 @@ impl WhereOperator { BetweenExcludeBounds => match right_value { Value::Array(bounds) if bounds.len() == 2 => { match bounds[0].partial_cmp(&bounds[1]) { - Some(Ordering::Less) | Some(Ordering::Equal) => { - left_value > &bounds[0] && left_value < &bounds[1] - } + Some(Ordering::Less) => left_value > &bounds[0] && left_value < &bounds[1], _ => false, } } @@ -215,9 +213,7 @@ impl WhereOperator { BetweenExcludeLeft => match right_value { Value::Array(bounds) if bounds.len() == 2 => { match bounds[0].partial_cmp(&bounds[1]) { - Some(Ordering::Less) | Some(Ordering::Equal) => { - left_value > &bounds[0] && left_value <= &bounds[1] - } + Some(Ordering::Less) => left_value > &bounds[0] && left_value <= &bounds[1], _ => false, } } @@ -226,9 +222,7 @@ impl WhereOperator { BetweenExcludeRight => match right_value { Value::Array(bounds) if bounds.len() == 2 => { match bounds[0].partial_cmp(&bounds[1]) { - Some(Ordering::Less) | Some(Ordering::Equal) => { - left_value >= &bounds[0] && left_value < &bounds[1] - } + Some(Ordering::Less) => left_value >= &bounds[0] && left_value < &bounds[1], _ => false, } } @@ -1462,11 +1456,11 @@ impl<'a> WhereClause { if let Value::Array(bounds) = &self.value { if bounds.len() == 2 { match bounds[0].partial_cmp(&bounds[1]) { - Some(Ordering::Less) | Some(Ordering::Equal) => {} + Some(Ordering::Less) => {} _ => { return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::InvalidBetweenClause( - "when using between operator bounds must be ascending", + "when using between operator bounds must be strictly ascending", ), ); } @@ -1709,7 +1703,8 @@ mod tests { use crate::error::query::QuerySyntaxError; use crate::query::conditions::WhereClause; use crate::query::conditions::{ - Equal, GreaterThan, GreaterThanOrEquals, In, LessThan, LessThanOrEquals, + Between, BetweenExcludeBounds, BetweenExcludeLeft, BetweenExcludeRight, Equal, GreaterThan, + GreaterThanOrEquals, In, LessThan, LessThanOrEquals, ValueClause, }; use crate::query::InternalClauses; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -1988,6 +1983,61 @@ mod tests { assert!(res.is_valid()); } + #[test] + fn validate_rejects_between_variants_with_equal_bounds() { + let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); + let contract = fixture.data_contract_owned(); + let doc_type = contract + .document_type_for_name("uniqueDates") + .expect("doc type exists"); + + for operator in [ + Between, + BetweenExcludeBounds, + BetweenExcludeLeft, + BetweenExcludeRight, + ] { + let clause = WhereClause { + field: "$createdAt".to_string(), + operator, + value: Value::Array(vec![Value::U64(1000), Value::U64(1000)]), + }; + + let res = clause.validate_against_schema(doc_type); + assert!( + res.is_err(), + "{operator:?} should reject equal bounds during validation" + ); + assert!(matches!( + res.first_error(), + Some(QuerySyntaxError::InvalidBetweenClause(_)) + )); + } + } + + #[test] + fn value_clause_between_variants_do_not_match_equal_bounds() { + let equal_bounds = Value::Array(vec![Value::U64(1000), Value::U64(1000)]); + let value_to_test = Value::U64(1000); + + for operator in [ + Between, + BetweenExcludeBounds, + BetweenExcludeLeft, + BetweenExcludeRight, + ] { + let clause = ValueClause { + operator, + value: equal_bounds.clone(), + }; + + assert!( + !clause.matches_value(&value_to_test), + "{operator:?} should not match when bounds are equal" + ); + } + } + #[test] fn validate_rejects_meta_revision_float_equality() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 31d092708f8..9ee1b988589 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -1642,6 +1642,30 @@ mod tests { }, }; assert!(filter.validate().is_err()); + + // Price Between variants must reject equal bounds + for operator in [ + WhereOperator::Between, + WhereOperator::BetweenExcludeBounds, + WhereOperator::BetweenExcludeLeft, + WhereOperator::BetweenExcludeRight, + ] { + let filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "niceDocument".to_string(), + action_clauses: DocumentActionMatchClauses::UpdatePrice { + original_document_clauses: InternalClauses::default(), + price_clause: Some(ValueClause { + operator, + value: Value::Array(vec![Value::U64(10), Value::U64(10)]), + }), + }, + }; + assert!( + filter.validate().is_err(), + "{operator:?} should reject equal price bounds" + ); + } } #[test] From 863f2d4776b36f1a678311b48c1f6114ead54a65 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 21 Sep 2025 05:56:03 +0700 Subject: [PATCH 30/30] commit --- packages/wasm-sdk/src/error.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index fce025dbcb3..a2b2e264462 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -169,12 +169,6 @@ impl From for WasmSdkError { Cancelled(msg) => Self::new(WasmSdkErrorKind::Cancelled, msg, None, retriable), StaleNode(e) => Self::new(WasmSdkErrorKind::StaleNode, e.to_string(), None, retriable), StateTransitionBroadcastError(e) => WasmSdkError::from(e), - other => Self::new( - WasmSdkErrorKind::Generic, - other.to_string(), - None, - retriable, - ), } } }