diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 4a946a4..ec99a34 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,3 +1,17 @@ +## Upcoming + +### 💥 BREAKING CHANGES + +- `FilterField` now requires a value getter function `Object? Function(T)` +- Filter classes renamed (e.g., `EqualOperator` → `Equal`, `AndOperator` → `And`) +- `Filter` signature changed to `Filter` + +### ✨ Features + +- Added `matches(T other)` method for client-side filtering with PostgreSQL-like semantics +- Added utility functions for deep equality, subset containment, and type-safe comparisons +- Enhanced `Sort` comparator to handle incompatible types safely + ## 0.2.0 ### 💥 BREAKING CHANGES diff --git a/packages/stream_core/lib/src/query/filter.dart b/packages/stream_core/lib/src/query/filter.dart index fcdb01c..16ba854 100644 --- a/packages/stream_core/lib/src/query/filter.dart +++ b/packages/stream_core/lib/src/query/filter.dart @@ -1,246 +1,204 @@ +import '../utils.dart'; +import 'filter_operation_utils.dart'; import 'filter_operator.dart'; -/// A type-safe field identifier for filtering operations. +/// Function that extracts a field value from a model instance. /// -/// This extension type wraps a String to provide compile-time type safety when -/// specifying fields in filter operations. It implements String, making it -/// transparent and zero-cost at runtime while ensuring only valid fields -/// can be used in filter operations. +/// Returns the field value of type [V] from an instance of type [T]. +typedef FilterFieldValueGetter = V? Function(T); + +/// Type-safe field identifier for filtering and value extraction. /// -/// ## Supported Operators +/// Associates a field name with a value getter function for type-safe filtering. +/// The getter extracts field values from model instances for filter matching. /// -/// All filter fields support the following operators: -/// - **Comparison**: `.equal`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual` -/// - **List**: `.in_`, `.contains` -/// - **Existence**: `.exists` -/// - **Evaluation**: `.query`, `.autoComplete` -/// - **Path**: `.pathExists` -/// - **Logical**: `.and`, `.or` +/// Use extension types to create filter field definitions for your models: /// -/// Example implementation: /// ```dart -/// extension type const MyFilterField(String remote) implements FilterField { -/// /// Filter by unique identifier. -/// /// -/// /// **Supported operators**: `.equal`, `.in_` -/// static const id = MyFilterField('id'); -/// -/// /// Filter by display name. -/// /// -/// /// **Supported operators**: `.equal`, `.query`, `.autoComplete` -/// static const name = MyFilterField('name'); -/// -/// /// Filter by numeric age value. -/// /// -/// /// **Supported operators**: `.equal`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual` -/// static const age = MyFilterField('age'); -/// -/// /// Filter by creation timestamp. -/// /// -/// /// **Supported operators**: `.equal`, `.greater`, `.greaterOrEqual`, `.less`, `.lessOrEqual` -/// static const createdAt = MyFilterField('created_at'); -/// -/// /// Filter by tag collections. -/// /// -/// /// **Supported operators**: `.in_`, `.contains` -/// static const tags = MyFilterField('tags'); -/// -/// /// Filter by JSON metadata existence. -/// /// -/// /// **Supported operators**: `.exists`, `.pathExists` -/// static const metadata = MyFilterField('metadata'); +/// class User { +/// User(this.id, this.name, this.age); +/// +/// final String id; +/// final String name; +/// final int age; /// } +/// +/// class UserFilterField extends FilterField { +/// UserFilterField(super.remote, super.value); +/// +/// static const id = UserFilterField('id', (u) => u.id); +/// static const name = UserFilterField('name', (u) => u.name); +/// static const age = UserFilterField('age', (u) => u.age); +/// } +/// +/// // Use in filters +/// final filter = Filter.equal(UserFilterField.name, 'John'); +/// final matches = filter.matches(User('1', 'John', 30)); // true /// ``` -extension type FilterField(String _) implements String {} +class FilterField { + const FilterField(this.remote, this.value); + + /// The remote field name used in API queries. + final String remote; -/// A filter for querying data with type-safe field specifications. + /// The function that extracts the field value of type [T] from a model instance. + final FilterFieldValueGetter value; +} + +/// Type-safe filter for querying data. /// -/// The primary interface for creating filters in queries that provides -/// comprehensive filtering capabilities with compile-time type safety. +/// Provides comprehensive filtering with compile-time type safety. /// Supports comparison, list, existence, evaluation, path, and logical operations. /// -/// Each [Filter] instance is associated with a specific [FilterField] extension type -/// that ensures only valid fields can be used in filter operations. The extension type -/// approach provides zero runtime overhead while maintaining full type safety. +/// ```dart +/// class User { +/// final String name; +/// final int age; +/// final List tags; +/// User(this.name, this.age, this.tags); +/// } /// -/// ## Basic Usage +/// extension type const UserField._(FilterField field) +/// implements FilterField { +/// const UserField(String name, Object? Function(User) getter) +/// : this._(FilterField(name, getter)); /// -/// ```dart -/// // Define your field types -/// extension type const UserField(String remote) implements FilterField { -/// static const id = UserField('id'); -/// static const name = UserField('name'); -/// static const age = UserField('age'); -/// static const email = UserField('email'); -/// static const tags = UserField('tags'); -/// static const metadata = UserField('metadata'); +/// static const name = UserField('name', (u) => u.name); +/// static const age = UserField('age', (u) => u.age); +/// static const tags = UserField('tags', (u) => u.tags); /// } /// -/// // Create complex filters with inline composition -/// final complexFilter = Filter.and([ +/// // Create complex filters +/// final filter = Filter.and([ /// Filter.equal(UserField.name, 'John'), -/// Filter.or([ -/// Filter.greater(UserField.age, 18), -/// Filter.contains(UserField.email, '@company.com'), -/// ]), -/// Filter.in_(UserField.tags, ['admin', 'moderator']), -/// Filter.exists(UserField.metadata, exists: true), +/// Filter.greater(UserField.age, 18), +/// Filter.contains(UserField.tags, 'admin'), /// ]); /// -/// // Convert to JSON for API usage -/// final json = complexFilter.toJson(); +/// // Use for matching +/// final user = User('John', 25, ['admin']); +/// print(filter.matches(user)); // true +/// +/// // Convert to JSON for API +/// final json = filter.toJson(); /// ``` -sealed class Filter { +sealed class Filter { /// Creates a new [Filter] instance. const Filter._(); // Comparison operators - /// Creates an equality filter that matches values equal to [value]. - /// - /// Returns an [EqualOperator] that matches when the specified [field] - /// exactly equals the provided [value]. - const factory Filter.equal(F field, Object? value) = - EqualOperator; - - /// Creates a greater-than filter that matches values greater than [value]. - /// - /// Returns a [GreaterOperator] that matches when the specified [field] - /// is greater than the provided [value]. - const factory Filter.greater(F field, Object? value) = - GreaterOperator; - - /// Creates a greater-than-or-equal filter that matches values greater than or equal to [value]. - /// - /// Returns a [GreaterOrEqualOperator] that matches when the specified [field] - /// is greater than or equal to the provided [value]. - const factory Filter.greaterOrEqual(F field, Object? value) = - GreaterOrEqualOperator; - - /// Creates a less-than filter that matches values less than [value]. - /// - /// Returns a [LessOperator] that matches when the specified [field] - /// is less than the provided [value]. - const factory Filter.less(F field, Object? value) = LessOperator; - - /// Creates a less-than-or-equal filter that matches values less than or equal to [value]. - /// - /// Returns a [LessOrEqualOperator] that matches when the specified [field] - /// is less than or equal to the provided [value]. - const factory Filter.lessOrEqual(F field, Object? value) = - LessOrEqualOperator; + /// Equality filter matching [field] equal to [value]. + const factory Filter.equal( + FilterField field, + Object? value, + ) = EqualOperator; + + /// Greater-than filter matching [field] greater than [value]. + const factory Filter.greater( + FilterField field, + Object? value, + ) = GreaterOperator; + + /// Greater-than-or-equal filter matching [field] >= [value]. + const factory Filter.greaterOrEqual( + FilterField field, + Object? value, + ) = GreaterOrEqualOperator; + + /// Less-than filter matching [field] less than [value]. + const factory Filter.less( + FilterField field, + Object? value, + ) = LessOperator; + + /// Less-than-or-equal filter matching [field] <= [value]. + const factory Filter.lessOrEqual( + FilterField field, + Object? value, + ) = LessOrEqualOperator; // List operators - /// Creates an 'in' filter that matches values contained in the provided values. - /// - /// Returns an [InOperator] that matches when the specified [field] - /// value is found within the provided [values] iterable. - /// - /// Example: - /// ```dart - /// // Match users with specific roles - /// final roleFilter = Filter.in_(UserField.role, ['admin', 'moderator', 'editor']); - /// - /// // Works with any Iterable - /// final statusFilter = Filter.in_(UserField.status, {'active', 'pending'}); - /// ``` - const factory Filter.in_(F field, Iterable values) = - InOperator; + /// Membership filter matching [field] value in [values]. + const factory Filter.in_( + FilterField field, + Iterable values, + ) = InOperator; - /// Creates a contains filter that matches lists containing the specified value. - /// - /// Returns a [ContainsOperator] that matches when the specified [field] - /// (which should be a list) contains the provided [value]. - const factory Filter.contains(F field, Object? value) = - ContainsOperator; + /// Containment filter matching [field] containing [value]. + const factory Filter.contains( + FilterField field, + Object? value, + ) = ContainsOperator; // Existence operator - /// Creates an existence filter that matches based on field presence. - /// - /// When [exists] is true, matches records where the [field] is present and not null. - /// When [exists] is false, matches records where the [field] is null or absent. - /// - /// Returns an [ExistsOperator] for the specified field existence check. - const factory Filter.exists(F field, {required bool exists}) = - ExistsOperator; + /// Existence filter matching [field] presence when [exists] is true. + const factory Filter.exists( + FilterField field, { + required bool exists, + }) = ExistsOperator; // Evaluation operators - /// Creates a text search filter that performs full-text search on the field. - /// - /// Returns a [QueryOperator] that matches when the specified [field] - /// contains text matching the provided search [query]. Uses optimized - /// full-text search capabilities for efficient text matching and ranking. - const factory Filter.query(F field, String query) = QueryOperator; + /// Full-text search filter matching [field] containing [query]. + const factory Filter.query( + FilterField field, + String query, + ) = QueryOperator; - /// Creates an autocomplete filter that matches field values with the specified prefix. - /// - /// Returns an [AutoCompleteOperator] that matches when the specified [field] - /// starts with the provided [query] string. - const factory Filter.autoComplete(F field, String query) = - AutoCompleteOperator; + /// Autocomplete filter matching [field] words starting with [query]. + const factory Filter.autoComplete( + FilterField field, + String query, + ) = AutoCompleteOperator; // Path operator - /// Creates a path existence filter for nested JSON field checking. - /// - /// Returns a [PathExistsOperator] that matches when the specified [field] - /// contains JSON with the given nested [path]. Uses optimized JSON - /// path operations for efficient querying. - const factory Filter.pathExists(F field, String path) = PathExistsOperator; + /// Nested JSON path existence filter for [field] at [path]. + const factory Filter.pathExists( + FilterField field, + String path, + ) = PathExistsOperator; // Logical operators - /// Creates a logical AND filter that matches when all provided filters match. + /// Logical AND filter matching when all [filters] match. + const factory Filter.and(Iterable> filters) = AndOperator; + + /// Logical OR filter matching when any [filters] match. + const factory Filter.or(Iterable> filters) = OrOperator; + + /// Whether this filter matches the given [other] instance. /// - /// Returns an [AndOperator] that combines multiple [filters] with logical AND, - /// matching only when every filter in the collection matches. + /// Evaluates filter criteria against field values extracted from [other] + /// using the field's value getter function. /// - /// Example: /// ```dart - /// // All conditions must be true - /// final strictFilter = Filter.and([ - /// Filter.equal(UserField.status, 'active'), + /// final filter = Filter.and([ + /// Filter.equal(UserField.name, 'John'), /// Filter.greater(UserField.age, 18), - /// Filter.exists(UserField.email, exists: true), /// ]); - /// ``` - const factory Filter.and(Iterable> filters) = AndOperator; - - /// Creates a logical OR filter that matches when any of the provided filters match. /// - /// Returns an [OrOperator] that combines multiple [filters] with logical OR, - /// matching when at least one filter in the collection matches. + /// final user1 = User('1', 'John', 25); + /// print(filter.matches(user1)); // true /// - /// Example: - /// ```dart - /// // Any condition can be true - /// final flexibleFilter = Filter.or([ - /// Filter.equal(UserField.role, 'admin'), - /// Filter.equal(UserField.role, 'moderator'), - /// Filter.greater(UserField.loginCount, 1000), - /// ]); + /// final user2 = User('2', 'Jane', 25); + /// print(filter.matches(user2)); // false /// ``` - const factory Filter.or(Iterable> filters) = OrOperator; + bool matches(T other); - /// Converts this filter to a JSON representation for API queries. - /// - /// Returns a [Map] containing the filter structure in the format - /// expected by the API. + /// Converts this filter to JSON format for API queries. Map toJson(); } // region Comparison operators ($eq, $gt, $gte, $lt, $lte) -/// A filter operator that compares field values using comparison operations. +/// Base class for comparison-based filter operations. /// -/// Base class for all comparison-based filtering operations including equality, -/// greater-than, less-than, and their variants. Each comparison operator -/// evaluates a field against a specific value using the designated comparison logic. -sealed class ComparisonOperator - extends Filter { +/// Supports equality, greater-than, less-than, and their variants. +sealed class ComparisonOperator extends Filter { const ComparisonOperator._( this.field, this.value, { @@ -248,93 +206,148 @@ sealed class ComparisonOperator }) : super._(); /// The field being compared in this filter operation. - final F field; + final FilterField field; /// The comparison operator used for this filter. final FilterOperator operator; /// The value to compare the field against. - final V value; + final Object? value; @override Map toJson() { return { - field: {operator: value}, + field.remote: {operator: value}, }; } } -/// A comparison filter that matches values equal to the specified value. +/// Equality filter using exact value matching. /// -/// Performs exact equality matching between the field value and the provided -/// comparison value. Supports all data types including strings, numbers, -/// booleans, and null values. +/// Performs deep equality comparison for all data types: +/// - **Primitives**: Standard equality (`==`) +/// - **Arrays**: Order-sensitive, element-by-element comparison +/// - **Objects**: Key-value equality, order-insensitive for keys /// /// **Supported with**: `.equal` factory method -final class EqualOperator - extends ComparisonOperator { +final class EqualOperator extends ComparisonOperator { /// Creates an equality filter for the specified [field] and [value]. const EqualOperator(super.field, super.value) : super._(operator: FilterOperator.equal); + + @override + bool matches(T other) { + final fieldValue = field.value(other); + final comparisonValue = value; + + // NULL values can't be compared. + if (fieldValue == null || comparisonValue == null) return false; + + // Deep equality: order-sensitive for arrays, order-insensitive for objects. + return fieldValue.deepEquals(comparisonValue); + } } -/// A comparison filter that matches values greater than the specified value. +/// Greater-than comparison filter. /// -/// Performs greater-than comparison between the field value and the provided -/// comparison value. Primarily used with numeric values and dates. +/// Primarily used with numeric values and dates. /// /// **Supported with**: `.greater` factory method -final class GreaterOperator - extends ComparisonOperator { +final class GreaterOperator extends ComparisonOperator { /// Creates a greater-than filter for the specified [field] and [value]. const GreaterOperator(super.field, super.value) : super._(operator: FilterOperator.greater); + + @override + bool matches(T other) { + final fieldValue = ComparableField.fromValue(field.value(other)); + final comparisonValue = ComparableField.fromValue(value); + + // NULL values can't be compared. + if (fieldValue == null || comparisonValue == null) return false; + + // Safely compare values, returning false for incomparable types. + final result = runSafelySync(() => fieldValue > comparisonValue); + return result.getOrDefault(false); + } } -/// A comparison filter that matches values greater than or equal to the specified value. +/// Greater-than-or-equal comparison filter. /// -/// Performs greater-than-or-equal comparison between the field value and the -/// provided comparison value. Primarily used with numeric values and dates. -final class GreaterOrEqualOperator - extends ComparisonOperator { +/// Primarily used with numeric values and dates. +final class GreaterOrEqualOperator + extends ComparisonOperator { /// Creates a greater-than-or-equal filter for the specified [field] and [value]. const GreaterOrEqualOperator(super.field, super.value) : super._(operator: FilterOperator.greaterOrEqual); + + @override + bool matches(T other) { + final fieldValue = ComparableField.fromValue(field.value(other)); + final comparisonValue = ComparableField.fromValue(value); + + // NULL values can't be compared. + if (fieldValue == null || comparisonValue == null) return false; + + // Safely compare values, returning false for incomparable types. + final result = runSafelySync(() => fieldValue >= comparisonValue); + return result.getOrDefault(false); + } } -/// A comparison filter that matches values less than the specified value. +/// Less-than comparison filter. /// -/// Performs less-than comparison between the field value and the provided -/// comparison value. Primarily used with numeric values and dates. -final class LessOperator - extends ComparisonOperator { +/// Primarily used with numeric values and dates. +final class LessOperator extends ComparisonOperator { /// Creates a less-than filter for the specified [field] and [value]. const LessOperator(super.field, super.value) : super._(operator: FilterOperator.less); + + @override + bool matches(T other) { + final fieldValue = ComparableField.fromValue(field.value(other)); + final comparisonValue = ComparableField.fromValue(value); + + // NULL values can't be compared. + if (fieldValue == null || comparisonValue == null) return false; + + // Safely compare values, returning false for incomparable types. + final result = runSafelySync(() => fieldValue < comparisonValue); + return result.getOrDefault(false); + } } -/// A comparison filter that matches values less than or equal to the specified value. +/// Less-than-or-equal comparison filter. /// -/// Performs less-than-or-equal comparison between the field value and the -/// provided comparison value. Primarily used with numeric values and dates. -final class LessOrEqualOperator - extends ComparisonOperator { +/// Primarily used with numeric values and dates. +final class LessOrEqualOperator + extends ComparisonOperator { /// Creates a less-than-or-equal filter for the specified [field] and [value]. const LessOrEqualOperator(super.field, super.value) : super._(operator: FilterOperator.lessOrEqual); + + @override + bool matches(T other) { + final fieldValue = ComparableField.fromValue(field.value(other)); + final comparisonValue = ComparableField.fromValue(value); + + // NULL values can't be compared. + if (fieldValue == null || comparisonValue == null) return false; + + // Safely compare values, returning false for incomparable types. + final result = runSafelySync(() => fieldValue <= comparisonValue); + return result.getOrDefault(false); + } } // endregion // region List / multi-value operators ($in, $contains) -/// A filter operator that performs list-based matching operations. +/// Base class for list-based filter operations. /// -/// Base class for filtering operations that work with collections or lists, -/// including membership testing and containment checking. These operators -/// are designed to handle multi-value scenarios efficiently. -sealed class ListOperator - extends Filter { +/// Supports membership testing and containment checking for multi-value scenarios. +sealed class ListOperator extends Filter { const ListOperator._( this.field, this.value, { @@ -342,7 +355,7 @@ sealed class ListOperator }) : super._(); /// The field being filtered in this list operation. - final F field; + final FilterField field; /// The list operator used for this filter. final FilterOperator operator; @@ -353,53 +366,72 @@ sealed class ListOperator @override Map toJson() { return { - field: {operator: value}, + field.remote: {operator: value}, }; } } -/// A list filter that matches values contained within a specified list. +/// Membership test filter for list containment. /// -/// Performs membership testing to determine if the field value exists -/// within the provided list of values. Useful for filtering records -/// where a field matches any of several possible values. +/// Tests whether the field value exists within the provided list of values. +/// Uses deep equality with order-sensitive comparison for arrays. /// /// **Supported with**: `.in_` factory method -final class InOperator - extends ListOperator { +final class InOperator extends ListOperator { /// Creates an 'in' filter for the specified [field] and [values] iterable. - const InOperator(super.field, Iterable super.values) + const InOperator(super.field, Iterable super.values) : super._(operator: FilterOperator.in_); + + @override + bool matches(T other) { + final fieldValue = field.value(other); + + final comparisonValues = value; + if (comparisonValues is! Iterable) return false; + + // Deep equality (order-sensitive for arrays). + return comparisonValues.any(fieldValue.deepEquals); + } } -/// A list filter that matches lists containing the specified value. +/// Containment filter for JSON and array subset matching. /// -/// Performs containment checking to determine if the field (which should be a list) -/// contains the provided value. Useful for filtering records where a list field -/// includes a specific item. -final class ContainsOperator - extends ListOperator { +/// Tests whether the field contains the specified value: +/// - **Arrays**: Order-independent containment (all subset items must exist) +/// - **Objects**: Recursive subset matching (all subset keys/values must exist) +/// - **Single values**: Direct equality check +/// +/// **Supported with**: `.contains` factory method +final class ContainsOperator extends ListOperator { /// Creates a contains filter for the specified [field] and [value]. const ContainsOperator(super.field, super.value) : super._(operator: FilterOperator.contains_); + + @override + bool matches(T other) { + final fieldValue = field.value(other); + final comparisonValue = value; + + // Order-independent containment for arrays, recursive for objects. + return fieldValue.containsValue(comparisonValue); + } } // endregion // region Element / existence operators ($exists) -/// A filter that matches based on field existence or absence. +/// Field existence/absence filter. /// -/// Checks whether a field exists in the record or not, regardless of its value. -/// Useful for filtering records based on the presence or absence of optional fields. +/// Tests whether a field is present (non-null) or absent in the record. /// /// **Supported with**: `.exists` factory method -final class ExistsOperator extends Filter { +final class ExistsOperator extends Filter { /// Creates an existence filter for the specified [field] and [exists] condition. const ExistsOperator(this.field, {required this.exists}) : super._(); /// The field to check for existence. - final F field; + final FilterField field; /// Whether the field should exist (true) or not exist (false). final bool exists; @@ -407,10 +439,18 @@ final class ExistsOperator extends Filter { /// The existence operator used for this filter. FilterOperator get operator => FilterOperator.exists; + @override + bool matches(T other) { + final fieldValue = field.value(other); + final valueExists = fieldValue != null; + + return exists ? valueExists : !valueExists; + } + @override Map toJson() { return { - field: {operator: exists}, + field.remote: {operator: exists}, }; } } @@ -419,11 +459,10 @@ final class ExistsOperator extends Filter { // region Evaluation / text operators ($q, $autocomplete) -/// A filter operator that performs text-based evaluation operations. +/// Base class for text-based evaluation filters. /// -/// Base class for filtering operations that evaluate text content, -/// including full-text search and autocomplete functionality. -sealed class EvaluationOperator extends Filter { +/// Supports full-text search and autocomplete functionality. +sealed class EvaluationOperator extends Filter { const EvaluationOperator._( this.field, this.query, { @@ -431,7 +470,7 @@ sealed class EvaluationOperator extends Filter { }) : super._(); /// The field being evaluated in this text operation. - final F field; + final FilterField field; /// The evaluation operator used for this filter. final FilterOperator operator; @@ -442,49 +481,71 @@ sealed class EvaluationOperator extends Filter { @override Map toJson() { return { - field: {operator: query}, + field.remote: {operator: query}, }; } } -/// An evaluation filter that performs full-text search on field content. +/// Full-text search filter. /// -/// Searches for the specified query within the field's text content using -/// optimized full-text search capabilities, including ranking and relevance scoring. +/// Performs case-insensitive text search within the field's content. /// /// **Supported with**: `.query` factory method -final class QueryOperator extends EvaluationOperator { +final class QueryOperator extends EvaluationOperator { /// Creates a text search filter for the specified [field] and search [query]. const QueryOperator(super.field, super.query) : super._(operator: FilterOperator.query); + + @override + bool matches(T other) { + if (query.isEmpty) return false; + + final fieldValue = field.value(other); + if (fieldValue is! String || fieldValue.isEmpty) return false; + + final queryRegex = RegExp(RegExp.escape(query), caseSensitive: false); + return fieldValue.contains(queryRegex); + } } -/// An evaluation filter that matches field values starting with the specified prefix. +/// Word prefix matching filter for autocomplete. /// -/// Performs prefix matching for autocomplete functionality, finding records -/// where the field value begins with the provided query string. -final class AutoCompleteOperator - extends EvaluationOperator { +/// Matches field values where any word starts with the provided prefix. +final class AutoCompleteOperator + extends EvaluationOperator { /// Creates an autocomplete filter for the specified [field] and prefix [query]. const AutoCompleteOperator(super.field, super.query) : super._(operator: FilterOperator.autoComplete); + + @override + bool matches(T other) { + if (query.isEmpty) return false; + + final fieldValue = field.value(other); + if (fieldValue is! String || fieldValue.isEmpty) return false; + + // Split the text into words and check for any word starting with the query prefix. + final splitRegex = RegExp(r'[\s\p{P}]+', unicode: true); + final words = fieldValue.split(splitRegex).where((word) => word.isNotEmpty); + + final queryRegex = RegExp(RegExp.escape(query), caseSensitive: false); + return words.any((word) => word.startsWith(queryRegex)); + } } // endregion // region Path operators ($path_exists) -/// A filter that checks for the existence of nested JSON paths within a field. +/// Nested JSON path existence filter. /// -/// Evaluates whether the specified field contains JSON data with the given -/// nested path. Useful for filtering records based on complex nested data structures. -/// Uses optimized JSON path operations for efficient querying. -final class PathExistsOperator extends Filter { +/// Tests whether the field contains JSON data with the specified nested path. +final class PathExistsOperator extends Filter { /// Creates a path existence filter for the specified [field] and nested [path]. const PathExistsOperator(this.field, this.path) : super._(); /// The field containing JSON data to check. - final F field; + final FilterField field; /// The nested path to check for existence within the JSON. final String path; @@ -492,10 +553,31 @@ final class PathExistsOperator extends Filter { /// The path existence operator used for this filter. FilterOperator get operator => FilterOperator.pathExists; + @override + bool matches(T other) { + final root = field.value(other); + if (root is! Map) return false; + if (path.isEmpty) return false; + + final pathParts = path.split('.'); + + Object? current = root; + for (final part in pathParts) { + // Empty path segments (e.g., from "" or "a..b") are invalid + if (part.isEmpty) return false; + if (current is! Map) return false; + if (!current.containsKey(part)) return false; + + current = current[part]; + } + + return true; + } + @override Map toJson() { return { - field: {operator: path}, + field.remote: {operator: path}, }; } } @@ -504,12 +586,10 @@ final class PathExistsOperator extends Filter { // region Logical operators ($and, $or) -/// A filter operator that combines multiple filters using logical operations. +/// Base class for logical filter composition. /// -/// Base class for logical filtering operations that combine multiple filter -/// conditions using AND/OR logic. Enables complex query construction through -/// filter composition. -sealed class LogicalOperator extends Filter { +/// Combines multiple filters using AND/OR logic. +sealed class LogicalOperator extends Filter { const LogicalOperator._( this.filters, { required this.operator, @@ -519,7 +599,7 @@ sealed class LogicalOperator extends Filter { final FilterOperator operator; /// The list of filters to combine with logical operation. - final Iterable> filters; + final Iterable> filters; @override Map toJson() { @@ -529,26 +609,28 @@ sealed class LogicalOperator extends Filter { } } -/// A logical filter that matches when all provided filters match (logical AND). +/// Logical AND filter requiring all conditions to match. /// -/// Combines multiple filter conditions where every condition must be satisfied -/// for a record to match. Useful for creating restrictive queries that require -/// multiple criteria to be met simultaneously. +/// All provided filters must match for a record to be included. /// /// **Supported with**: `.and` factory method -final class AndOperator extends LogicalOperator { +final class AndOperator extends LogicalOperator { /// Creates a logical AND filter combining the specified [filters]. const AndOperator(super.filters) : super._(operator: FilterOperator.and); + + @override + bool matches(T other) => filters.every((filter) => filter.matches(other)); } -/// A logical filter that matches when any of the provided filters match (logical OR). +/// Logical OR filter requiring any condition to match. /// -/// Combines multiple filter conditions where at least one condition must be satisfied -/// for a record to match. Useful for creating inclusive queries that accept -/// records meeting any of several criteria. -final class OrOperator extends LogicalOperator { +/// At least one provided filter must match for a record to be included. +final class OrOperator extends LogicalOperator { /// Creates a logical OR filter combining the specified [filters]. const OrOperator(super.filters) : super._(operator: FilterOperator.or); + + @override + bool matches(T other) => filters.any((filter) => filter.matches(other)); } // endregion diff --git a/packages/stream_core/lib/src/query/filter_operation_utils.dart b/packages/stream_core/lib/src/query/filter_operation_utils.dart new file mode 100644 index 0000000..278b09b --- /dev/null +++ b/packages/stream_core/lib/src/query/filter_operation_utils.dart @@ -0,0 +1,88 @@ +import 'package:collection/collection.dart'; + +// Deep equality checker. +// +// Maps are always compared with key-order-insensitivity (MapEquality). +// Lists/Iterables use order-sensitive comparison (ListEquality/IterableEquality). +const _deepEquality = DeepCollectionEquality(); + +/// Extension methods for deep equality and containment with PostgreSQL semantics. +extension DeepEqualityExtension on T { + /// Whether this value deeply equals [other] using PostgreSQL `=` semantics. + /// + /// For iterables (lists), order matters and all elements must match. + /// For JSON objects (maps), key order is ignored but all key-value pairs + /// must match. Nested structures are compared recursively. + /// + /// ```dart + /// [1, 2, 3].deepEquals([1, 2, 3]); // true + /// [1, 2, 3].deepEquals([3, 2, 1]); // false (order matters) + /// {'a': 1, 'b': 2}.deepEquals({'b': 2, 'a': 1}); // true (key order ignored) + /// ``` + bool deepEquals(Object? other) => _deepEquality.equals(this, other); + + /// Whether this value contains [subset] using PostgreSQL `@>` semantics. + /// + /// For JSON objects, all key-value pairs in [subset] must exist in this object. + /// For iterables, all items in [subset] must exist in this iterable + /// (order-independent). For primitives, checks direct equality. + /// + /// ```dart + /// {'a': 1, 'b': 2}.containsValue({'a': 1}); // true + /// [1, 2, 3].containsValue([2, 3]); // true (order doesn't matter) + /// [1, 2].containsValue(2); // true + /// ``` + bool containsValue(Object? subset) { + // JSON objects use recursive subset checking. + if (this is Map && subset is Map) { + return (this as Map).containsSubset(subset); + } + + // Iterables use order-independent containment. + if (this is Iterable) { + final parent = this as Iterable; + bool parentContains(Object? subsetItem) { + return parent.any((item) => item.containsValue(subsetItem)); + } + + if (subset is Iterable) { + // Check if all subset items exist in parent (order doesn't matter). + return subset.every(parentContains); + } + + // Check if parent contains the single value. + return parentContains(subset); + } + + return deepEquals(subset); + } +} + +/// Extension methods for JSON subset checking. +extension JSONContainmentExtension on Map { + /// Whether this JSON object contains [subset] as a subset. + /// + /// All keys in [subset] must exist in this object with matching values. + /// Values are compared recursively using [containsValue], so nested arrays + /// use order-independent containment and nested objects use recursive subset + /// checking. Null values are distinguished from missing keys. + /// + /// ```dart + /// {'a': 1, 'b': 2}.containsSubset({'a': 1}); // true + /// {'a': null}.containsSubset({'a': null}); // true + /// {'a': 1}.containsSubset({'b': 2}); // false + /// {'items': [1, 2, 3]}.containsSubset({'items': [2, 1]}); // true + /// ``` + bool containsSubset(Map subset) { + // An empty subset is always contained. + if (subset.isEmpty) return true; + + return subset.entries.every((entry) { + // Key must exist (distinguishes null value from missing key). + if (!containsKey(entry.key)) return false; + + // Value must match recursively. + return this[entry.key].containsValue(entry.value); + }); + } +} diff --git a/packages/stream_core/lib/src/query/sort.dart b/packages/stream_core/lib/src/query/sort.dart index 1cfcb16..164fbc4 100644 --- a/packages/stream_core/lib/src/query/sort.dart +++ b/packages/stream_core/lib/src/query/sort.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -import '../utils/standard.dart'; +import '../utils.dart'; part 'sort.g.dart'; @@ -130,21 +130,10 @@ class SortField { /// The [remote] parameter specifies the field name as it appears in API queries. /// The [localValue] parameter is a function that extracts the corresponding value /// from local model instances for comparison operations. - factory SortField( - String remote, + SortField( + this.remote, SortFieldValueGetter localValue, - ) { - final comparator = SortComparator(localValue).toAny(); - return SortField._( - remote: remote, - comparator: comparator, - ); - } - - const SortField._({ - required this.remote, - required this.comparator, - }); + ) : comparator = SortComparator(localValue).toAny(); /// The remote field name used in API queries. final String remote; @@ -189,8 +178,12 @@ class SortComparator { if (aValue == null) return nullOrdering == NullOrdering.nullsFirst ? -1 : 1; if (bValue == null) return nullOrdering == NullOrdering.nullsFirst ? 1 : -1; + final comparisonResult = runSafelySync(() => aValue.compareTo(bValue)); + // If comparison fails, treat as equal in sorting + final comparison = comparisonResult.getOrDefault(0); + // Apply direction only to non-null comparisons - return direction.value * aValue.compareTo(bValue); + return direction.value * comparison; } AnySortComparator toAny() => AnySortComparator(call); @@ -238,38 +231,3 @@ extension CompositeComparator on Iterable> { return 0; // All comparisons were equal } } - -/// A wrapper class for values that implements [Comparable]. -/// -/// This class is used to compare values of different types in a way that -/// allows for consistent ordering. -/// -/// This is useful when sorting or comparing values in a consistent manner. -/// -/// For example, when sorting a list of objects with different types of fields, -/// using this class will ensure that all values are compared correctly -/// regardless of their type. -class ComparableField implements Comparable> { - const ComparableField._(this.value); - - /// Creates a new [ComparableField] instance from a [value]. - static ComparableField? fromValue(T? value) { - if (value == null) return null; - return ComparableField._(value); - } - - /// The value to be compared. - final T value; - - @override - int compareTo(ComparableField other) { - return switch ((value, other.value)) { - (final num a, final num b) => a.compareTo(b), - (final String a, final String b) => a.compareTo(b), - (final DateTime a, final DateTime b) => a.compareTo(b), - (final bool a, final bool b) when a == b => 0, - (final bool a, final bool b) => a && !b ? 1 : -1, // true > false - _ => 0 // All comparisons were equal or incomparable types - }; - } -} diff --git a/packages/stream_core/lib/src/utils.dart b/packages/stream_core/lib/src/utils.dart index c8991b5..0428cbb 100644 --- a/packages/stream_core/lib/src/utils.dart +++ b/packages/stream_core/lib/src/utils.dart @@ -1,4 +1,5 @@ export 'utils/comparable_extensions.dart'; +export 'utils/comparable_field.dart'; export 'utils/disposable.dart'; export 'utils/lifecycle_state_provider.dart'; export 'utils/list_extensions.dart'; diff --git a/packages/stream_core/lib/src/utils/comparable_field.dart b/packages/stream_core/lib/src/utils/comparable_field.dart new file mode 100644 index 0000000..00c1310 --- /dev/null +++ b/packages/stream_core/lib/src/utils/comparable_field.dart @@ -0,0 +1,91 @@ +import 'package:meta/meta.dart'; + +/// A type-safe wrapper for ordering values. +/// +/// Wraps values to provide consistent comparison and ordering semantics, +/// particularly useful for handling mixed numeric types (e.g., comparing int +/// with double) and providing clear error messages for incompatible types. +/// +/// ## Supported Types +/// +/// - **Numeric** (`num`, `int`, `double`): Natural numeric comparison +/// - Handles mixed types: `42` vs `42.0` compares correctly +/// - **String**: Lexicographic (alphabetical) order +/// - **DateTime**: Chronological order +/// - **Boolean**: `false < true` +/// - **Other types**: Throws [ArgumentError] on comparison +/// +/// ## Examples +/// +/// ### Basic comparison +/// ```dart +/// final age = ComparableField.fromValue(25); +/// final minAge = ComparableField.fromValue(18); +/// +/// if (age != null && minAge != null) { +/// print(age > minAge); // true +/// print(age.compareTo(minAge)); // positive number (> 0) +/// } +/// ``` +/// +/// ### Null handling +/// ```dart +/// final nullField = ComparableField.fromValue(null); +/// print(nullField); // null (not a ComparableField instance) +/// ``` +/// +/// ### Mixed numeric types +/// ```dart +/// final intField = ComparableField.fromValue(42); +/// final doubleField = ComparableField.fromValue(42.0); +/// print(intField?.compareTo(doubleField!)); // 0 (equal) +/// ``` +@immutable +class ComparableField + implements Comparable> { + const ComparableField._(this.value); + + /// Creates a [ComparableField] from [value]. + /// + /// Returns `null` if [value] is `null`. + /// + /// Example: + /// ```dart + /// ComparableField.fromValue(42); // ComparableField + /// ComparableField.fromValue(null); // null + /// ``` + static ComparableField? fromValue(T? value) { + if (value == null) return null; + return ComparableField._(value); + } + + /// The wrapped value. + final T value; + + /// Compares this field to [other] for ordering. + /// + /// Returns a negative integer if this value is less than [other], zero if + /// they are equal, or a positive integer if this value is greater. + /// + /// **Comparison Rules:** + /// - Numbers: Natural numeric comparison (handles int vs double) + /// - Strings: Lexicographic (dictionary) order + /// - DateTime: Chronological order + /// - Booleans: `false` < `true` + /// + /// Throws [ArgumentError] when comparing incompatible types (e.g., comparing + /// a String to an int, or attempting to order collections like Maps or Lists). + @override + int compareTo(ComparableField other) { + return switch ((value, other.value)) { + (final num a, final num b) => a.compareTo(b), + (final String a, final String b) => a.compareTo(b), + (final DateTime a, final DateTime b) => a.compareTo(b), + (final bool a, final bool b) when a == b => 0, + (final bool a, final bool b) => a && !b ? 1 : -1, + _ => throw ArgumentError( + 'Incompatible types: ${value.runtimeType} vs ${other.value.runtimeType}', + ), + }; + } +} diff --git a/packages/stream_core/test/query/filter_test.dart b/packages/stream_core/test/query/filter_test.dart index a8fd486..1046adf 100644 --- a/packages/stream_core/test/query/filter_test.dart +++ b/packages/stream_core/test/query/filter_test.dart @@ -1,788 +1,1937 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +class TestModel { + TestModel({ + this.id, + this.name, + this.createdAt, + this.members, + this.type, + this.metadata, + this.tags, + this.projects, + }); + + final String? id; + final String? name; + final DateTime? createdAt; + final List? members; + final String? type; + final Map? metadata; + final List? tags; + final List>? projects; +} + // Test implementation of FilterField for testing purposes -extension type const TestFilterField(String remote) implements FilterField { - static const id = TestFilterField('id'); - static const name = TestFilterField('name'); - static const createdAt = TestFilterField('created_at'); - static const members = TestFilterField('members'); - static const type = TestFilterField('type'); - static const metadata = TestFilterField('metadata'); - static const tags = TestFilterField('tags'); +class TestFilterField extends FilterField { + TestFilterField(super.remote, super.value); + + static final id = TestFilterField('id', (it) => it.id); + static final name = TestFilterField('name', (it) => it.name); + static final createdAt = TestFilterField('created_at', (it) => it.createdAt); + static final members = TestFilterField('members', (it) => it.members); + static final type = TestFilterField('type', (it) => it.type); + static final metadata = TestFilterField('metadata', (it) => it.metadata); + static final tags = TestFilterField('tags', (it) => it.tags); + static final projects = TestFilterField('projects', (it) => it.projects); } void main() { - group('Filter Implementation Tests', () { - group('Factory Constructors', () { - test('should create filters using Filter factory constructors', () { - const field = TestFilterField.name; - - // Test all factory constructors - const equalFilter = Filter.equal(field, 'test'); - const greaterFilter = Filter.greater(field, 100); - const greaterOrEqualFilter = Filter.greaterOrEqual(field, 100); - const lessFilter = Filter.less(field, 100); - const lessOrEqualFilter = Filter.lessOrEqual(field, 100); - const inFilter = Filter.in_(field, ['a', 'b']); - const containsFilter = Filter.contains(field, 'test'); - const existsFilter = Filter.exists(field, exists: true); - const queryFilter = Filter.query(field, 'search'); - const autoCompleteFilter = Filter.autoComplete(field, 'prefix'); - const pathExistsFilter = Filter.pathExists(field, 'nested.field'); - - // Verify types and basic functionality - expect(equalFilter, isA>()); - expect(greaterFilter, isA>()); - expect( - greaterOrEqualFilter, - isA>(), - ); - expect(lessFilter, isA>()); - expect( - lessOrEqualFilter, - isA>(), - ); - expect(inFilter, isA>()); - expect( - containsFilter, - isA>(), - ); - expect(existsFilter, isA>()); - expect(queryFilter, isA>()); - expect( - autoCompleteFilter, - isA>(), - ); - expect(pathExistsFilter, isA>()); - }); - - test('should create logical filters using factory constructors', () { - const filter1 = Filter.equal(TestFilterField.name, 'test'); - const filter2 = Filter.greater(TestFilterField.id, 100); - - const andFilter = Filter.and([filter1, filter2]); - const orFilter = Filter.or([filter1, filter2]); - - expect(andFilter, isA>()); - expect(orFilter, isA>()); - - expect((andFilter as AndOperator).filters.length, 2); - expect((orFilter as OrOperator).filters.length, 2); - }); - - test('should serialize factory-created filters correctly', () { - const field = TestFilterField.name; - - const equalFilter = Filter.equal(field, 'test'); - const inFilter = Filter.in_(field, ['a', 'b']); - const andFilter = Filter.and([equalFilter, inFilter]); - - expect(equalFilter.toJson(), { - 'name': {r'$eq': 'test'}, - }); - expect(inFilter.toJson(), { - 'name': { - r'$in': ['a', 'b'], - }, - }); - expect(andFilter.toJson(), { - r'$and': [ - { - 'name': {r'$eq': 'test'}, - }, - { - 'name': { - r'$in': ['a', 'b'], - }, - }, - ], - }); - }); + group('Comparison', () { + group('Equal', () { + test('should create equal filter correctly', () { + final field = TestFilterField.name; + const value = 'test'; - test('should maintain type safety with factory constructors', () { - // This should compile and work correctly - const filter1 = Filter.equal( - TestFilterField.name, - 'test', - ); - const filter2 = Filter.greater( - TestFilterField.id, - 100, - ); - const logicalFilter = Filter.and([filter1, filter2]); + var filter = Filter.equal(field, value); - expect( - (logicalFilter as AndOperator).filters.length, - 2, - ); - expect(logicalFilter.toJson()[r'$and'], hasLength(2)); + expect(filter, isA>()); + filter = filter as EqualOperator; + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.equal); }); - test('should provide equivalent functionality to direct constructors', - () { - const field = TestFilterField.name; + test('should serialize to JSON correctly', () { + final field = TestFilterField.name; const value = 'test'; - // Factory constructor vs direct constructor - const factoryFilter = Filter.equal(field, value); - const directFilter = EqualOperator(field, value); + final filter = Filter.equal(field, value); + final json = filter.toJson(); - // Should produce identical JSON output - expect(factoryFilter.toJson(), directFilter.toJson()); - expect(factoryFilter.toJson(), { + expect(json, { 'name': {r'$eq': 'test'}, }); }); - test('should enable fluent API usage', () { - // Demonstrate how factory constructors enable clean, readable filter building - const complexFilter = Filter.and([ - Filter.equal(TestFilterField.type, 'messaging'), - Filter.or([ - Filter.contains(TestFilterField.members, 'user123'), - Filter.query(TestFilterField.name, 'general'), - ]), - Filter.exists(TestFilterField.metadata, exists: true), - ]); + test('should work with different value types', () { + final numericFilter = Filter.equal(TestFilterField.id, 123); + final boolFilter = Filter.equal(TestFilterField.type, true); - final json = complexFilter.toJson(); - expect(json, { - r'$and': [ - { - 'type': {r'$eq': 'messaging'}, - }, - { - r'$or': [ - { - 'members': {r'$contains': 'user123'}, - }, - { - 'name': {r'$q': 'general'}, - }, - ], - }, - { - 'metadata': {r'$exists': true}, - }, - ], + expect(numericFilter.toJson(), { + 'id': {r'$eq': 123}, + }); + expect(boolFilter.toJson(), { + 'type': {r'$eq': true}, }); }); }); - group('ComparisonOperator', () { - group('EqualOperator', () { - test('should create equal filter correctly', () { - const field = TestFilterField.name; - const value = 'test'; - - const filter = EqualOperator(field, value); - - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.equal); - }); - - test('should serialize to JSON correctly', () { - const field = TestFilterField.name; - const value = 'test'; - - const filter = EqualOperator(field, value); - final json = filter.toJson(); - - expect(json, { - 'name': {r'$eq': 'test'}, - }); - }); + group('Greater', () { + test('should create greater filter correctly', () { + final field = TestFilterField.createdAt; + final value = DateTime(2023); - test('should work with different value types', () { - const numericFilter = EqualOperator(TestFilterField.id, 123); - const boolFilter = EqualOperator(TestFilterField.type, true); + var filter = Filter.greater(field, value); - expect(numericFilter.toJson(), { - 'id': {r'$eq': 123}, - }); - expect(boolFilter.toJson(), { - 'type': {r'$eq': true}, - }); - }); + expect(filter, isA>()); + filter = filter as GreaterOperator; + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.greater); }); - group('GreaterOperator', () { - test('should create greater filter correctly', () { - const field = TestFilterField.createdAt; - final value = DateTime(2023); - - final filter = GreaterOperator(field, value); - - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.greater); - }); - - test('should serialize to JSON correctly', () { - const field = TestFilterField.id; - const value = 100; + test('should serialize to JSON correctly', () { + final field = TestFilterField.id; + const value = 100; - const filter = GreaterOperator(field, value); - final json = filter.toJson(); + final filter = Filter.greater(field, value); + final json = filter.toJson(); - expect(json, { - 'id': {r'$gt': 100}, - }); + expect(json, { + 'id': {r'$gt': 100}, }); }); + }); - group('GreaterOrEqualOperator', () { - test('should create greater or equal filter correctly', () { - const field = TestFilterField.id; - const value = 50; + group('GreaterOrEqual', () { + test('should create greater or equal filter correctly', () { + final field = TestFilterField.id; + const value = 50; - const filter = GreaterOrEqualOperator(field, value); + var filter = Filter.greaterOrEqual(field, value); - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.greaterOrEqual); - }); + expect(filter, isA>()); + filter = filter as GreaterOrEqualOperator; + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.greaterOrEqual); + }); - test('should serialize to JSON correctly', () { - const field = TestFilterField.id; - const value = 50; + test('should serialize to JSON correctly', () { + final field = TestFilterField.id; + const value = 50; - const filter = GreaterOrEqualOperator(field, value); - final json = filter.toJson(); + final filter = Filter.greaterOrEqual(field, value); + final json = filter.toJson(); - expect(json, { - 'id': {r'$gte': 50}, - }); + expect(json, { + 'id': {r'$gte': 50}, }); }); + }); - group('LessOperator', () { - test('should create less filter correctly', () { - const field = TestFilterField.id; - const value = 100; + group('Less', () { + test('should create less filter correctly', () { + final field = TestFilterField.id; + const value = 100; - const filter = LessOperator(field, value); + var filter = Filter.less(field, value); - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.less); - }); + expect(filter, isA>()); + filter = filter as LessOperator; + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.less); + }); - test('should serialize to JSON correctly', () { - const field = TestFilterField.id; - const value = 100; + test('should serialize to JSON correctly', () { + final field = TestFilterField.id; + const value = 100; - const filter = LessOperator(field, value); - final json = filter.toJson(); + final filter = Filter.less(field, value); + final json = filter.toJson(); - expect(json, { - 'id': {r'$lt': 100}, - }); + expect(json, { + 'id': {r'$lt': 100}, }); }); + }); - group('LessOrEqualOperator', () { - test('should create less or equal filter correctly', () { - const field = TestFilterField.id; - const value = 100; + group('LessOrEqual', () { + test('should create less or equal filter correctly', () { + final field = TestFilterField.id; + const value = 100; - const filter = LessOrEqualOperator(field, value); + var filter = Filter.lessOrEqual(field, value); - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.lessOrEqual); - }); + expect(filter, isA>()); + filter = filter as LessOrEqualOperator; + expect(filter.field, field); + expect(filter.value, value); + expect(filter.operator, FilterOperator.lessOrEqual); + }); - test('should serialize to JSON correctly', () { - const field = TestFilterField.id; - const value = 100; + test('should serialize to JSON correctly', () { + final field = TestFilterField.id; + const value = 100; - const filter = LessOrEqualOperator(field, value); - final json = filter.toJson(); + final filter = Filter.lessOrEqual(field, value); + final json = filter.toJson(); - expect(json, { - 'id': {r'$lte': 100}, - }); + expect(json, { + 'id': {r'$lte': 100}, }); }); }); + }); - group('ListOperator', () { - group('InOperator', () { - test('should create in filter correctly', () { - const field = TestFilterField.tags; - const values = ['tag1', 'tag2', 'tag3']; - - const filter = InOperator(field, values); - - expect(filter.field, field); - expect(filter.value, values); - expect(filter.operator, FilterOperator.in_); - }); - - test('should serialize to JSON correctly', () { - const field = TestFilterField.tags; - const values = ['tag1', 'tag2', 'tag3']; - - const filter = InOperator(field, values); - final json = filter.toJson(); - - expect(json, { - 'tags': { - r'$in': ['tag1', 'tag2', 'tag3'], - }, - }); - }); + group('List', () { + group('In', () { + test('should create in filter correctly', () { + final field = TestFilterField.tags; + final values = ['tag1', 'tag2', 'tag3']; - test('should work with different value types', () { - const numericFilter = InOperator(TestFilterField.id, [1, 2, 3]); + var filter = Filter.in_(field, values); - expect(numericFilter.toJson(), { - 'id': { - r'$in': [1, 2, 3], - }, - }); - }); + expect(filter, isA>()); + filter = filter as InOperator; + expect(filter.field, field); + expect(filter.value, values); + expect(filter.operator, FilterOperator.in_); }); - group('ContainsOperator', () { - test('should create contains filter correctly', () { - const field = TestFilterField.members; - const value = 'user123'; + test('should serialize to JSON correctly', () { + final field = TestFilterField.tags; + final values = ['tag1', 'tag2', 'tag3']; - const filter = ContainsOperator(field, value); + final filter = Filter.in_(field, values); + final json = filter.toJson(); - expect(filter.field, field); - expect(filter.value, value); - expect(filter.operator, FilterOperator.contains_); + expect(json, { + 'tags': { + r'$in': ['tag1', 'tag2', 'tag3'], + }, }); + }); - test('should serialize to JSON correctly', () { - const field = TestFilterField.members; - const value = 'user123'; - - const filter = ContainsOperator(field, value); - final json = filter.toJson(); + test('should work with different value types', () { + final numericFilter = Filter.in_(TestFilterField.id, [1, 2, 3]); - expect(json, { - 'members': {r'$contains': 'user123'}, - }); + expect(numericFilter.toJson(), { + 'id': { + r'$in': [1, 2, 3], + }, }); }); }); - group('ExistsOperator', () { - test('should create exists filter correctly', () { - const field = TestFilterField.metadata; - const exists = true; + group('Contains', () { + test('should create contains filter correctly', () { + final field = TestFilterField.members; + const value = 'user123'; - const filter = ExistsOperator(field, exists: exists); + var filter = Filter.contains(field, value); + expect(filter, isA>()); + filter = filter as ContainsOperator; expect(filter.field, field); - expect(filter.exists, exists); - expect(filter.operator, FilterOperator.exists); + expect(filter.value, value); + expect(filter.operator, FilterOperator.contains_); }); - test('should serialize to JSON correctly with exists=true', () { - const field = TestFilterField.metadata; - const exists = true; + test('should serialize to JSON correctly', () { + final field = TestFilterField.members; + const value = 'user123'; - const filter = ExistsOperator(field, exists: exists); + final filter = Filter.contains(field, value); final json = filter.toJson(); expect(json, { - 'metadata': {r'$exists': true}, + 'members': {r'$contains': 'user123'}, }); }); + }); + }); - test('should serialize to JSON correctly with exists=false', () { - const field = TestFilterField.metadata; - const exists = false; + group('Exists', () { + test('should create exists filter correctly', () { + final field = TestFilterField.metadata; + const exists = true; - const filter = ExistsOperator(field, exists: exists); - final json = filter.toJson(); + var filter = Filter.exists(field, exists: exists); - expect(json, { - 'metadata': {r'$exists': false}, - }); - }); + expect(filter, isA>()); + filter = filter as ExistsOperator; + expect(filter.field, field); + expect(filter.exists, exists); + expect(filter.operator, FilterOperator.exists); }); - group('EvaluationOperator', () { - group('QueryOperator', () { - test('should create query filter correctly', () { - const field = TestFilterField.name; - const query = 'search term'; + test('should serialize to JSON correctly with exists=true', () { + final field = TestFilterField.metadata; + const exists = true; - const filter = QueryOperator(field, query); + final filter = Filter.exists(field, exists: exists); + final json = filter.toJson(); - expect(filter.field, field); - expect(filter.query, query); - expect(filter.operator, FilterOperator.query); - }); + expect(json, { + 'metadata': {r'$exists': true}, + }); + }); - test('should serialize to JSON correctly', () { - const field = TestFilterField.name; - const query = 'search term'; + test('should serialize to JSON correctly with exists=false', () { + final field = TestFilterField.metadata; + const exists = false; - const filter = QueryOperator(field, query); - final json = filter.toJson(); + final filter = Filter.exists(field, exists: exists); + final json = filter.toJson(); - expect(json, { - 'name': {r'$q': 'search term'}, - }); - }); + expect(json, { + 'metadata': {r'$exists': false}, }); + }); + }); - group('AutoCompleteOperator', () { - test('should create autocomplete filter correctly', () { - const field = TestFilterField.name; - const query = 'prefix'; + group('Evaluation', () { + group('Query', () { + test('should create query filter correctly', () { + final field = TestFilterField.name; + const query = 'search term'; - const filter = AutoCompleteOperator(field, query); + var filter = Filter.query(field, query); - expect(filter.field, field); - expect(filter.query, query); - expect(filter.operator, FilterOperator.autoComplete); - }); + expect(filter, isA>()); + filter = filter as QueryOperator; + expect(filter.field, field); + expect(filter.query, query); + expect(filter.operator, FilterOperator.query); + }); - test('should serialize to JSON correctly', () { - const field = TestFilterField.name; - const query = 'prefix'; + test('should serialize to JSON correctly', () { + final field = TestFilterField.name; + const query = 'search term'; - const filter = AutoCompleteOperator(field, query); - final json = filter.toJson(); + final filter = Filter.query(field, query); + final json = filter.toJson(); - expect(json, { - 'name': {r'$autocomplete': 'prefix'}, - }); + expect(json, { + 'name': {r'$q': 'search term'}, }); }); }); - group('PathExistsOperator', () { - test('should create path exists filter correctly', () { - const field = TestFilterField.metadata; - const path = 'nested.field'; + group('AutoComplete', () { + test('should create autocomplete filter correctly', () { + final field = TestFilterField.name; + const query = 'prefix'; - const filter = PathExistsOperator(field, path); + var filter = Filter.autoComplete(field, query); + expect(filter, isA>()); + filter = filter as AutoCompleteOperator; expect(filter.field, field); - expect(filter.path, path); - expect(filter.operator, FilterOperator.pathExists); + expect(filter.query, query); + expect(filter.operator, FilterOperator.autoComplete); }); test('should serialize to JSON correctly', () { - const field = TestFilterField.metadata; - const path = 'nested.field'; + final field = TestFilterField.name; + const query = 'prefix'; - const filter = PathExistsOperator(field, path); + final filter = Filter.autoComplete(field, query); final json = filter.toJson(); expect(json, { - 'metadata': {r'$path_exists': 'nested.field'}, + 'name': {r'$autocomplete': 'prefix'}, }); }); }); + }); - group('LogicalOperator', () { - group('AndOperator', () { - test('should create and filter correctly', () { - const filter1 = EqualOperator(TestFilterField.name, 'test'); - const filter2 = GreaterOperator(TestFilterField.id, 100); - const filters = [filter1, filter2]; - - const andFilter = AndOperator(filters); - - expect(andFilter.filters, filters); - expect(andFilter.operator, FilterOperator.and); - }); - - test('should serialize to JSON correctly', () { - const filter1 = EqualOperator(TestFilterField.name, 'test'); - const filter2 = GreaterOperator(TestFilterField.id, 100); - const filters = [filter1, filter2]; - - const andFilter = AndOperator(filters); - final json = andFilter.toJson(); + group('PathExists', () { + test('should create path exists filter correctly', () { + final field = TestFilterField.metadata; + const path = 'nested.field'; - expect(json, { - r'$and': [ - { - 'name': {r'$eq': 'test'}, - }, - { - 'id': {r'$gt': 100}, - }, - ], - }); - }); + var filter = Filter.pathExists(field, path); - test('should handle nested logical filters', () { - const filter1 = EqualOperator(TestFilterField.name, 'test'); - const filter2 = GreaterOperator(TestFilterField.id, 100); - const orFilter = OrOperator([filter1, filter2]); + expect(filter, isA>()); + filter = filter as PathExistsOperator; + expect(filter.field, field); + expect(filter.path, path); + expect(filter.operator, FilterOperator.pathExists); + }); - const filter3 = EqualOperator(TestFilterField.type, 'messaging'); - const andFilter = AndOperator([orFilter, filter3]); + test('should serialize to JSON correctly', () { + final field = TestFilterField.metadata; + const path = 'nested.field'; - final json = andFilter.toJson(); + final filter = Filter.pathExists(field, path); + final json = filter.toJson(); - expect(json, { - r'$and': [ - { - r'$or': [ - { - 'name': {r'$eq': 'test'}, - }, - { - 'id': {r'$gt': 100}, - }, - ], - }, - { - 'type': {r'$eq': 'messaging'}, - }, - ], - }); - }); + expect(json, { + 'metadata': {r'$path_exists': 'nested.field'}, }); + }); + }); - group('OrOperator', () { - test('should create or filter correctly', () { - const filter1 = EqualOperator(TestFilterField.name, 'test'); - const filter2 = GreaterOperator(TestFilterField.id, 100); - const filters = [filter1, filter2]; + group('Logical', () { + group('And', () { + test('should create and filter correctly', () { + final filter1 = Filter.equal(TestFilterField.name, 'test'); + final filter2 = Filter.greater(TestFilterField.id, 100); + final filters = [filter1, filter2]; - const orFilter = OrOperator(filters); + var andFilter = Filter.and(filters); - expect(orFilter.filters, filters); - expect(orFilter.operator, FilterOperator.or); - }); + expect(andFilter, isA>()); + andFilter = andFilter as AndOperator; + expect(andFilter.filters, filters); + expect(andFilter.operator, FilterOperator.and); + }); - test('should serialize to JSON correctly', () { - const filter1 = EqualOperator(TestFilterField.name, 'test'); - const filter2 = GreaterOperator(TestFilterField.id, 100); - const filters = [filter1, filter2]; + test('should serialize to JSON correctly', () { + final filter1 = Filter.equal(TestFilterField.name, 'test'); + final filter2 = Filter.greater(TestFilterField.id, 100); + final filters = [filter1, filter2]; - const orFilter = OrOperator(filters); - final json = orFilter.toJson(); + final andFilter = Filter.and(filters); + final json = andFilter.toJson(); - expect(json, { - r'$or': [ - { - 'name': {r'$eq': 'test'}, - }, - { - 'id': {r'$gt': 100}, - }, - ], - }); + expect(json, { + r'$and': [ + { + 'name': {r'$eq': 'test'}, + }, + { + 'id': {r'$gt': 100}, + }, + ], }); }); - }); - - group('JSON Encoding', () { - test('should encode filters correctly using json.encode', () { - const filter = EqualOperator(TestFilterField.name, 'test'); - final encoded = json.encode(filter); - - expect(encoded, r'{"name":{"$eq":"test"}}'); - }); - test('should encode complex nested filters correctly', () { - const filter1 = EqualOperator(TestFilterField.type, 'messaging'); - const filter2 = InOperator( - TestFilterField.members, - ['user1', 'user2'], - ); - const filter3 = GreaterOperator( - TestFilterField.createdAt, - '2023-01-01', - ); + test('should handle nested logical filters', () { + final filter1 = Filter.equal(TestFilterField.name, 'test'); + final filter2 = Filter.greater(TestFilterField.id, 100); + final orFilter = Filter.or([filter1, filter2]); - const complexFilter = AndOperator( - [ - OrOperator([filter1, filter2]), - filter3, - ], - ); + final filter3 = Filter.equal(TestFilterField.type, 'messaging'); + final andFilter = Filter.and([orFilter, filter3]); - final encoded = json.encode(complexFilter); - final decoded = json.decode(encoded); + final json = andFilter.toJson(); - expect(decoded, { + expect(json, { r'$and': [ { r'$or': [ { - 'type': {r'$eq': 'messaging'}, + 'name': {r'$eq': 'test'}, }, { - 'members': { - r'$in': ['user1', 'user2'], - }, + 'id': {r'$gt': 100}, }, ], }, { - 'created_at': {r'$gt': '2023-01-01'}, + 'type': {r'$eq': 'messaging'}, }, ], }); }); }); - group('Edge Cases', () { - test('should handle null values in filters', () { - const filter = EqualOperator(TestFilterField.metadata, null); - final json = filter.toJson(); + group('Or', () { + test('should create or filter correctly', () { + final filter1 = Filter.equal(TestFilterField.name, 'test'); + final filter2 = Filter.greater(TestFilterField.id, 100); + final filters = [filter1, filter2]; - expect(json, { - 'metadata': {r'$eq': null}, - }); + var orFilter = Filter.or(filters); + + expect(orFilter, isA>()); + orFilter = orFilter as OrOperator; + expect(orFilter.filters, filters); + expect(orFilter.operator, FilterOperator.or); }); - test('should handle empty list in InOperator', () { - const filter = InOperator(TestFilterField.tags, []); - final json = filter.toJson(); + test('should serialize to JSON correctly', () { + final filter1 = Filter.equal(TestFilterField.name, 'test'); + final filter2 = Filter.greater(TestFilterField.id, 100); + final filters = [filter1, filter2]; + + final orFilter = Filter.or(filters); + final json = orFilter.toJson(); expect(json, { - 'tags': {r'$in': []}, + r'$or': [ + { + 'name': {r'$eq': 'test'}, + }, + { + 'id': {r'$gt': 100}, + }, + ], }); }); + }); + }); - test('should handle single item list in InOperator', () { - const filter = InOperator(TestFilterField.tags, ['single']); - final json = filter.toJson(); + group('JSON Encoding', () { + test('should encode filters correctly using json.encode', () { + final filter = Filter.equal(TestFilterField.name, 'test'); + final encoded = json.encode(filter); - expect(json, { - 'tags': { - r'$in': ['single'], + expect(encoded, r'{"name":{"$eq":"test"}}'); + }); + + test('should encode complex nested filters correctly', () { + final filter1 = Filter.equal(TestFilterField.type, 'messaging'); + final filter2 = Filter.in_( + TestFilterField.members, + ['user1', 'user2'], + ); + final filter3 = Filter.greater( + TestFilterField.createdAt, + '2023-01-01', + ); + + final complexFilter = Filter.and( + [ + Filter.or([filter1, filter2]), + filter3, + ], + ); + + final encoded = json.encode(complexFilter); + final decoded = json.decode(encoded); + + expect(decoded, { + r'$and': [ + { + r'$or': [ + { + 'type': {r'$eq': 'messaging'}, + }, + { + 'members': { + r'$in': ['user1', 'user2'], + }, + }, + ], }, - }); + { + 'created_at': {r'$gt': '2023-01-01'}, + }, + ], }); + }); + }); - test('should handle empty filters list in LogicalOperator', () { - const andFilter = AndOperator(>[]); - final json = andFilter.toJson(); + group('Edge Cases', () { + test('should handle null values in filters', () { + final filter = Filter.equal(TestFilterField.metadata, null); + final json = filter.toJson(); - expect(json, {r'$and': >[]}); + expect(json, { + 'metadata': {r'$eq': null}, }); }); - group('Type Safety', () { - test('should enforce FilterField type consistency', () { - // This test ensures that the generic type system works correctly - const filter1 = EqualOperator( - TestFilterField.name, - 'test', - ); - const filter2 = GreaterOperator( - TestFilterField.id, - 100, - ); - - const logicalFilter = AndOperator([filter1, filter2]); + test('should handle empty list in In', () { + final filter = Filter.in_(TestFilterField.tags, []); + final json = filter.toJson(); - expect(logicalFilter.filters.length, 2); - expect(logicalFilter.filters.elementAt(0), filter1); - expect(logicalFilter.filters.elementAt(1), filter2); + expect(json, { + 'tags': {r'$in': []}, }); }); - group('Real-world Usage Examples', () { - test('should create a complex chat channel filter', () { - // Example: Find messaging channels where user is a member, created after a date, and has specific metadata - const userId = 'user123'; - const createdAfter = '2023-01-01T00:00:00Z'; - - const filter = AndOperator( - [ - EqualOperator(TestFilterField.type, 'messaging'), - ContainsOperator(TestFilterField.members, userId), - GreaterOperator( - TestFilterField.createdAt, - createdAfter, - ), - ExistsOperator(TestFilterField.metadata, exists: true), - ], - ); - - final json = filter.toJson(); + test('should handle single item list in In', () { + final filter = Filter.in_(TestFilterField.tags, ['single']); + final json = filter.toJson(); - expect(json, { - r'$and': [ - { - 'type': {r'$eq': 'messaging'}, - }, - { - 'members': {r'$contains': 'user123'}, - }, - { - 'created_at': {r'$gt': '2023-01-01T00:00:00Z'}, - }, - { - 'metadata': {r'$exists': true}, - }, - ], - }); + expect(json, { + 'tags': { + r'$in': ['single'], + }, }); + }); - test('should create a search filter with autocomplete', () { - const searchQuery = 'john'; + test('should handle empty filters list in Logical', () { + const andFilter = Filter.and(>[]); + final json = andFilter.toJson(); - const filter = OrOperator( - [ - QueryOperator(TestFilterField.name, searchQuery), - AutoCompleteOperator( - TestFilterField.name, - searchQuery, - ), - ], + expect(json, {r'$and': >[]}); + }); + }); + + group('Type Safety', () { + test('should enforce FilterField type consistency', () { + // This test ensures that the generic type system works correctly + final filter1 = Filter.equal(TestFilterField.name, 'test'); + final filter2 = Filter.greater(TestFilterField.id, 100); + + var logicalFilter = Filter.and([filter1, filter2]); + + expect(logicalFilter, isA>()); + logicalFilter = logicalFilter as AndOperator; + expect(logicalFilter.filters.length, 2); + expect(logicalFilter.filters.elementAt(0), filter1); + expect(logicalFilter.filters.elementAt(1), filter2); + }); + }); + + group('Real-world Usage Examples', () { + test('should create a complex chat channel filter', () { + // Example: Find messaging channels where user is a member, created after a date, and has specific metadata + const userId = 'user123'; + const createdAfter = '2023-01-01T00:00:00Z'; + + final filter = Filter.and( + [ + Filter.equal(TestFilterField.type, 'messaging'), + Filter.contains(TestFilterField.members, userId), + Filter.greater( + TestFilterField.createdAt, + createdAfter, + ), + Filter.exists(TestFilterField.metadata, exists: true), + ], + ); + + final json = filter.toJson(); + + expect(json, { + r'$and': [ + { + 'type': {r'$eq': 'messaging'}, + }, + { + 'members': {r'$contains': 'user123'}, + }, + { + 'created_at': {r'$gt': '2023-01-01T00:00:00Z'}, + }, + { + 'metadata': {r'$exists': true}, + }, + ], + }); + }); + + test('should create a search filter with autocomplete', () { + const searchQuery = 'john'; + + final filter = Filter.or( + [ + Filter.query(TestFilterField.name, searchQuery), + Filter.autoComplete( + TestFilterField.name, + searchQuery, + ), + ], + ); + + final json = filter.toJson(); + + expect(json, { + r'$or': [ + { + 'name': {r'$q': 'john'}, + }, + { + 'name': {r'$autocomplete': 'john'}, + }, + ], + }); + }); + + test('should create a range filter', () { + const minId = 100; + const maxId = 200; + + final filter = Filter.and( + [ + Filter.greaterOrEqual(TestFilterField.id, minId), + Filter.lessOrEqual(TestFilterField.id, maxId), + ], + ); + + final json = filter.toJson(); + + expect(json, { + r'$and': [ + { + 'id': {r'$gte': 100}, + }, + { + 'id': {r'$lte': 200}, + }, + ], + }); + }); + }); + + group('Filter.matches()', () { + group('Equal', () { + test('should match primitive values', () { + final model = TestModel(name: 'John', id: '123'); + + expect( + Filter.equal(TestFilterField.name, 'John').matches(model), + isTrue, + ); + expect(Filter.equal(TestFilterField.id, '123').matches(model), isTrue); + expect( + Filter.equal(TestFilterField.name, 'Jane').matches(model), + isFalse, ); + }); - final json = filter.toJson(); + test('should not match null values (PostgreSQL semantics)', () { + final modelWithNull = TestModel(name: null); + final modelWithValue = TestModel(name: 'John'); - expect(json, { - r'$or': [ - { - 'name': {r'$q': 'john'}, - }, - { - 'name': {r'$autocomplete': 'john'}, - }, - ], + // NULL = NULL → false (PostgreSQL three-valued logic) + expect( + Filter.equal(TestFilterField.name, null).matches(modelWithNull), + isFalse, + ); + // NULL = 'John' → false + expect( + Filter.equal(TestFilterField.name, 'John').matches(modelWithNull), + isFalse, + ); + // 'John' = NULL → false + expect( + Filter.equal(TestFilterField.name, null).matches(modelWithValue), + isFalse, + ); + }); + + test('should match arrays with order-sensitivity', () { + final model = TestModel(tags: ['a', 'b', 'c']); + + expect( + Filter.equal(TestFilterField.tags, ['a', 'b', 'c']).matches(model), + isTrue, + ); + expect( + Filter.equal(TestFilterField.tags, ['c', 'b', 'a']).matches(model), + isFalse, + ); + expect( + Filter.equal(TestFilterField.tags, ['a', 'b']).matches(model), + isFalse, + ); + }); + + test('should match objects with key order-insensitivity', () { + final model = TestModel(metadata: {'a': 1, 'b': 2}); + + expect( + Filter.equal(TestFilterField.metadata, {'b': 2, 'a': 1}) + .matches(model), + isTrue, + ); + }); + + test('should respect order rules in nested structures', () { + final model = TestModel( + metadata: { + 'user': {'name': 'John', 'age': 30}, + 'tags': ['a', 'b'], + }, + ); + + // Object key order doesn't matter, but array order does + final match = Filter.equal(TestFilterField.metadata, { + 'tags': ['a', 'b'], // Same order - OK + 'user': {'age': 30, 'name': 'John'}, // Different key order - OK + }); + + expect(match.matches(model), isTrue); + + // Array with different order should NOT match + final noMatch = Filter.equal(TestFilterField.metadata, { + 'tags': ['b', 'a'], // Different order - NOT OK + 'user': {'age': 30, 'name': 'John'}, }); + + expect(noMatch.matches(model), isFalse); }); - test('should create a range filter', () { - const minId = 100; - const maxId = 200; + test('should be case-sensitive for strings', () { + final model = TestModel(name: 'John'); - const filter = AndOperator( - [ - GreaterOrEqualOperator(TestFilterField.id, minId), - LessOrEqualOperator(TestFilterField.id, maxId), - ], + expect( + Filter.equal(TestFilterField.name, 'John').matches(model), + isTrue, + ); + expect( + Filter.equal(TestFilterField.name, 'john').matches(model), + isFalse, + ); + expect( + Filter.equal(TestFilterField.name, 'JOHN').matches(model), + isFalse, ); + }); - final json = filter.toJson(); + test('should handle diacritics in strings', () { + final modelWithDiacritic = TestModel(name: 'José'); + final modelWithoutDiacritic = TestModel(name: 'Jose'); - expect(json, { - r'$and': [ - { - 'id': {r'$gte': 100}, - }, - { - 'id': {r'$lte': 200}, - }, + expect( + Filter.equal(TestFilterField.name, 'José') + .matches(modelWithDiacritic), + isTrue, + ); + expect( + Filter.equal(TestFilterField.name, 'José') + .matches(modelWithoutDiacritic), + isFalse, + ); + expect( + Filter.equal(TestFilterField.name, 'Jose') + .matches(modelWithDiacritic), + isFalse, + ); + expect( + Filter.equal(TestFilterField.name, 'jose') + .matches(modelWithDiacritic), + isFalse, + ); + }); + }); + + group('In', () { + test('should match primitives in list', () { + final model = TestModel(name: 'John'); + + expect( + Filter.in_(TestFilterField.name, ['John', 'Jane']).matches(model), + isTrue, + ); + expect( + Filter.in_(TestFilterField.name, ['Alice', 'Bob']).matches(model), + isFalse, + ); + }); + + test('should return false for empty list', () { + final model = TestModel(name: 'John'); + + expect(Filter.in_(TestFilterField.name, []).matches(model), isFalse); + }); + + test('should match arrays with order-sensitivity', () { + final model = TestModel(tags: ['a', 'b', 'c']); + + expect( + Filter.in_(TestFilterField.tags, [ + ['a', 'b', 'c'], + ['x', 'y'], + ]).matches(model), + isTrue, + ); + expect( + Filter.in_(TestFilterField.tags, [ + ['c', 'b', 'a'], + ['x', 'y'], + ]).matches(model), + isFalse, + ); + }); + + test('should match objects with key order-insensitivity', () { + final model = TestModel(metadata: {'a': 1, 'b': 2}); + + expect( + Filter.in_(TestFilterField.metadata, [ + {'b': 2, 'a': 1}, + {'c': 3}, + ]).matches(model), + isTrue, + ); + }); + + test('should be case-sensitive for strings', () { + final model = TestModel(name: 'John'); + + expect( + Filter.in_(TestFilterField.name, ['John', 'Jane', 'Bob']) + .matches(model), + isTrue, + ); + expect( + Filter.in_(TestFilterField.name, ['john', 'Jane', 'Bob']) + .matches(model), + isFalse, + ); + expect( + Filter.in_(TestFilterField.name, ['JOHN', 'Jane', 'Bob']) + .matches(model), + isFalse, + ); + }); + + test('should handle diacritics in strings', () { + final modelWithDiacritic = TestModel(name: 'José'); + final modelWithoutDiacritic = TestModel(name: 'Jose'); + + expect( + Filter.in_(TestFilterField.name, ['José', 'François', 'Müller']) + .matches(modelWithDiacritic), + isTrue, + ); + expect( + Filter.in_(TestFilterField.name, ['José', 'François', 'Müller']) + .matches(modelWithoutDiacritic), + isFalse, + ); + expect( + Filter.in_(TestFilterField.name, ['Jose', 'Francois']) + .matches(modelWithDiacritic), + isFalse, + ); + expect( + Filter.in_(TestFilterField.name, ['jose', 'françois']) + .matches(modelWithDiacritic), + isFalse, + ); + }); + }); + + group('Contains', () { + test('should match JSON subset', () { + final model = TestModel(metadata: {'a': 1, 'b': 2, 'c': 3}); + + expect( + Filter.contains(TestFilterField.metadata, {'a': 1, 'b': 2}) + .matches(model), + isTrue, + ); + expect( + Filter.contains(TestFilterField.metadata, {'d': 4}).matches(model), + isFalse, + ); + }); + + test('should distinguish null value vs missing key', () { + final modelWithNull = TestModel(metadata: {'status': null}); + final modelWithoutKey = TestModel(metadata: {'name': 'John'}); + + final filter = + Filter.contains(TestFilterField.metadata, {'status': null}); + + expect(filter.matches(modelWithNull), isTrue); + expect(filter.matches(modelWithoutKey), isFalse); + }); + + test('should match nested structures with order-independence', () { + final model = TestModel( + projects: [ + {'name': 'Project A', 'status': 'active'}, + {'name': 'Project B', 'status': 'done'}, ], - }); + ); + + final filter = Filter.contains(TestFilterField.projects, [ + {'status': 'done', 'name': 'Project B'}, // Different key order + ]); + + expect(filter.matches(model), isTrue); + }); + + test('should match nested maps with all key-value pairs', () { + final filter = Filter.contains( + TestFilterField.metadata, + { + 'category': 'test', + 'config': {'enabled': true}, + }, + ); + + final itemWithMatchingNestedData = TestModel( + metadata: { + 'category': 'test', + 'priority': 1, + 'config': {'enabled': true, 'timeout': 30}, + }, + ); + final itemWithDifferentNestedValue = TestModel( + metadata: { + 'category': 'test', + 'config': {'enabled': false, 'timeout': 30}, + }, + ); + final itemWithoutNestedMap = TestModel( + metadata: {'category': 'test', 'priority': 1}, + ); + + expect(filter.matches(itemWithMatchingNestedData), isTrue); + expect(filter.matches(itemWithDifferentNestedValue), isFalse); + expect(filter.matches(itemWithoutNestedMap), isFalse); + }); + + test('should match array subset (order-independent)', () { + final model = TestModel(tags: ['a', 'b', 'c', 'd']); + + expect( + Filter.contains(TestFilterField.tags, ['c', 'a']).matches(model), + isTrue, + ); + expect( + Filter.contains(TestFilterField.tags, ['a', 'x']).matches(model), + isFalse, + ); + }); + + test( + 'should treat duplicate elements in filter as single occurrence', + () { + final model = TestModel(tags: ['a', 'b', 'c']); + + // Filter with duplicates should match (duplicates ignored) + expect( + Filter.contains(TestFilterField.tags, ['a', 'a', 'a']) + .matches(model), + isTrue, // Should be TRUE, not FALSE + ); + + expect( + Filter.contains(TestFilterField.tags, ['a', 'b', 'a']) + .matches(model), + isTrue, + ); + }, + ); + + test('should match single item in array', () { + final model = TestModel(tags: ['a', 'b', 'c']); + + expect( + Filter.contains(TestFilterField.tags, 'b').matches(model), + isTrue, + ); + expect( + Filter.contains(TestFilterField.tags, 'x').matches(model), + isFalse, + ); + }); + + test('should match empty list', () { + final model = TestModel(tags: ['a', 'b']); + + expect( + Filter.contains(TestFilterField.tags, []).matches(model), + isTrue, + ); + }); + + test('should return false for null field', () { + final model = TestModel(tags: null); + + expect( + Filter.contains(TestFilterField.tags, 'a').matches(model), + isFalse, + ); + }); + + test('should return false for non-iterable/non-json field', () { + final model = TestModel(name: 'John'); + + expect( + Filter.contains(TestFilterField.name, 'J').matches(model), + isFalse, + ); + }); + }); + + group('Comparison Operators', () { + test('Greater should match when field value is greater', () { + final model = TestModel(createdAt: DateTime(2023, 6, 15)); + + expect( + Filter.greater(TestFilterField.createdAt, DateTime(2023, 1, 1)) + .matches(model), + isTrue, + ); + expect( + Filter.greater(TestFilterField.createdAt, DateTime(2023, 6, 15)) + .matches(model), + isFalse, + ); + expect( + Filter.greater(TestFilterField.createdAt, DateTime(2024, 1, 1)) + .matches(model), + isFalse, + ); + }); + + test('Greater should use lexicographic comparison for strings', () { + // Lexicographic: uppercase < lowercase + expect( + Filter.greater(TestFilterField.name, 'John') + .matches(TestModel(name: 'Johnny')), + isTrue, + ); + expect( + Filter.greater(TestFilterField.name, 'John') + .matches(TestModel(name: 'Mike')), + isTrue, + ); + expect( + Filter.greater(TestFilterField.name, 'John') + .matches(TestModel(name: 'john')), + isTrue, + ); + expect( + Filter.greater(TestFilterField.name, 'John') + .matches(TestModel(name: 'John')), + isFalse, + ); + expect( + Filter.greater(TestFilterField.name, 'John') + .matches(TestModel(name: 'JOHN')), + isFalse, + ); + expect( + Filter.greater(TestFilterField.name, 'John') + .matches(TestModel(name: 'Alice')), + isFalse, + ); + }); + + test('Greater should handle diacritics lexicographically', () { + expect( + Filter.greater(TestFilterField.name, 'José') + .matches(TestModel(name: 'Joséa')), + isTrue, + ); + expect( + Filter.greater(TestFilterField.name, 'José') + .matches(TestModel(name: 'joséa')), + isTrue, + ); + expect( + Filter.greater(TestFilterField.name, 'José') + .matches(TestModel(name: 'José')), + isFalse, + ); + expect( + Filter.greater(TestFilterField.name, 'José') + .matches(TestModel(name: 'Jose')), + isFalse, + ); + expect( + Filter.greater(TestFilterField.name, 'José') + .matches(TestModel(name: 'jose')), + isTrue, + ); + }); + + test('GreaterOrEqual should match when field value is greater or equal', + () { + final model = TestModel(id: '50'); + + expect( + Filter.greaterOrEqual(TestFilterField.id, '30').matches(model), + isTrue, + ); + expect( + Filter.greaterOrEqual(TestFilterField.id, '50').matches(model), + isTrue, + ); + expect( + Filter.greaterOrEqual(TestFilterField.id, '70').matches(model), + isFalse, + ); + }); + + test('GreaterOrEqual should use lexicographic comparison for strings', + () { + expect( + Filter.greaterOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'Johnny')), + isTrue, + ); + expect( + Filter.greaterOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'Mike')), + isTrue, + ); + expect( + Filter.greaterOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'john')), + isTrue, + ); + expect( + Filter.greaterOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'John')), + isTrue, + ); + expect( + Filter.greaterOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'JOHN')), + isFalse, + ); + expect( + Filter.greaterOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'Alice')), + isFalse, + ); + }); + + test('Less should match when field value is less', () { + final model = TestModel(createdAt: DateTime(2023, 6, 15)); + + expect( + Filter.less(TestFilterField.createdAt, DateTime(2024, 1, 1)) + .matches(model), + isTrue, + ); + expect( + Filter.less(TestFilterField.createdAt, DateTime(2023, 6, 15)) + .matches(model), + isFalse, + ); + expect( + Filter.less(TestFilterField.createdAt, DateTime(2023, 1, 1)) + .matches(model), + isFalse, + ); + }); + + test('Less should use lexicographic comparison for strings', () { + expect( + Filter.less(TestFilterField.name, 'John') + .matches(TestModel(name: 'Johnny')), + isFalse, + ); + expect( + Filter.less(TestFilterField.name, 'John') + .matches(TestModel(name: 'Mike')), + isFalse, + ); + expect( + Filter.less(TestFilterField.name, 'John') + .matches(TestModel(name: 'john')), + isFalse, + ); + expect( + Filter.less(TestFilterField.name, 'John') + .matches(TestModel(name: 'John')), + isFalse, + ); + expect( + Filter.less(TestFilterField.name, 'John') + .matches(TestModel(name: 'JOHN')), + isTrue, + ); + expect( + Filter.less(TestFilterField.name, 'John') + .matches(TestModel(name: 'Alice')), + isTrue, + ); + }); + + test('LessOrEqual should match when field value is less or equal', () { + final model = TestModel(id: '50'); + + expect( + Filter.lessOrEqual(TestFilterField.id, '70').matches(model), + isTrue, + ); + expect( + Filter.lessOrEqual(TestFilterField.id, '50').matches(model), + isTrue, + ); + expect( + Filter.lessOrEqual(TestFilterField.id, '30').matches(model), + isFalse, + ); + }); + + test('LessOrEqual should use lexicographic comparison for strings', () { + expect( + Filter.lessOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'Johnny')), + isFalse, + ); + expect( + Filter.lessOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'Mike')), + isFalse, + ); + expect( + Filter.lessOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'john')), + isFalse, + ); + expect( + Filter.lessOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'John')), + isTrue, + ); + expect( + Filter.lessOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'JOHN')), + isTrue, + ); + expect( + Filter.lessOrEqual(TestFilterField.name, 'John') + .matches(TestModel(name: 'Alice')), + isTrue, + ); + }); + + test('should return false for incomparable types', () { + final model = TestModel(name: 'John'); + + expect( + Filter.greater(TestFilterField.name, 100).matches(model), + isFalse, + ); + expect( + Filter.greaterOrEqual(TestFilterField.name, 100).matches(model), + isFalse, + ); + expect( + Filter.less(TestFilterField.name, 100).matches(model), + isFalse, + ); + expect( + Filter.lessOrEqual(TestFilterField.name, 100).matches(model), + isFalse, + ); + }); + + test('should return false for NULL comparisons (PostgreSQL semantics)', + () { + final modelWithNull = TestModel(id: null); + final modelWithValue = TestModel(id: '50'); + + // Greater: NULL > value → false + expect( + Filter.greater(TestFilterField.id, '30').matches(modelWithNull), + isFalse, + ); + // Greater: value > NULL → false + expect( + Filter.greater(TestFilterField.id, null).matches(modelWithValue), + isFalse, + ); + // Greater: NULL > NULL → false + expect( + Filter.greater(TestFilterField.id, null).matches(modelWithNull), + isFalse, + ); + + // GreaterOrEqual: NULL >= value → false + expect( + Filter.greaterOrEqual(TestFilterField.id, '30') + .matches(modelWithNull), + isFalse, + ); + // GreaterOrEqual: value >= NULL → false + expect( + Filter.greaterOrEqual(TestFilterField.id, null) + .matches(modelWithValue), + isFalse, + ); + // GreaterOrEqual: NULL >= NULL → false + expect( + Filter.greaterOrEqual(TestFilterField.id, null) + .matches(modelWithNull), + isFalse, + ); + + // Less: NULL < value → false + expect( + Filter.less(TestFilterField.id, '70').matches(modelWithNull), + isFalse, + ); + // Less: value < NULL → false + expect( + Filter.less(TestFilterField.id, null).matches(modelWithValue), + isFalse, + ); + // Less: NULL < NULL → false + expect( + Filter.less(TestFilterField.id, null).matches(modelWithNull), + isFalse, + ); + + // LessOrEqual: NULL <= value → false + expect( + Filter.lessOrEqual(TestFilterField.id, '70').matches(modelWithNull), + isFalse, + ); + // LessOrEqual: value <= NULL → false + expect( + Filter.lessOrEqual(TestFilterField.id, null).matches(modelWithValue), + isFalse, + ); + // LessOrEqual: NULL <= NULL → false + expect( + Filter.lessOrEqual(TestFilterField.id, null).matches(modelWithNull), + isFalse, + ); + }); + }); + + group('Exists', () { + test('should match when exists = true and field is non-null', () { + final model = TestModel(name: 'John'); + + expect( + Filter.exists(TestFilterField.name, exists: true).matches(model), + isTrue, + ); + }); + + test('should not match when exists = true and field is null', () { + final model = TestModel(name: null); + + expect( + Filter.exists(TestFilterField.name, exists: true).matches(model), + isFalse, + ); + }); + + test('should match when exists = false and field is null', () { + final model = TestModel(name: null); + + expect( + Filter.exists(TestFilterField.name, exists: false).matches(model), + isTrue, + ); + }); + + test('should not match when exists = false and field is non-null', () { + final model = TestModel(name: 'John'); + + expect( + Filter.exists(TestFilterField.name, exists: false).matches(model), + isFalse, + ); + }); + }); + + group('Query', () { + test('should match case-insensitive substring', () { + final model = TestModel(name: 'John Doe'); + + expect( + Filter.query(TestFilterField.name, 'john').matches(model), + isTrue, + ); + expect( + Filter.query(TestFilterField.name, 'doe').matches(model), + isTrue, + ); + expect( + Filter.query(TestFilterField.name, 'JOHN').matches(model), + isTrue, + ); + expect( + Filter.query(TestFilterField.name, 'jane').matches(model), + isFalse, + ); + }); + + test('should not match empty query', () { + final modelWithContent = TestModel(name: 'any content'); + final modelWithEmptyName = TestModel(name: ''); + + expect( + Filter.query(TestFilterField.name, '').matches(modelWithContent), + isFalse, + ); + expect( + Filter.query(TestFilterField.name, '').matches(modelWithEmptyName), + isFalse, + ); + }); + + test('should match partial words and case variations', () { + final filter = Filter.query(TestFilterField.name, 'PROD'); + + final itemWithLowercase = TestModel(name: 'production server'); + final itemWithMixedCase = + TestModel(name: 'Development Production Environment'); + final itemWithPartialMatch = TestModel(name: 'reproduced issue'); + final itemWithoutMatch = TestModel(name: 'staging server'); + + expect(filter.matches(itemWithLowercase), isTrue); + expect(filter.matches(itemWithMixedCase), isTrue); + expect(filter.matches(itemWithPartialMatch), isTrue); + expect(filter.matches(itemWithoutMatch), isFalse); + }); + + test('should return false for non-string fields', () { + final model = TestModel(createdAt: DateTime(2023)); + + expect( + Filter.query(TestFilterField.createdAt, 'test').matches(model), + isFalse, + ); + }); + + test('should handle diacritics case-insensitively', () { + expect( + Filter.query(TestFilterField.name, 'josé') + .matches(TestModel(name: 'José')), + isTrue, + ); + expect( + Filter.query(TestFilterField.name, 'josé') + .matches(TestModel(name: 'JOSÉ')), + isTrue, + ); + expect( + Filter.query(TestFilterField.name, 'josé') + .matches(TestModel(name: 'josé')), + isTrue, + ); + expect( + Filter.query(TestFilterField.name, 'josé') + .matches(TestModel(name: 'Jose')), + isFalse, + ); + expect( + Filter.query(TestFilterField.name, 'josé') + .matches(TestModel(name: 'jose')), + isFalse, + ); + }); + + test('should match middle and end substrings', () { + expect( + Filter.query(TestFilterField.name, 'hn') + .matches(TestModel(name: 'John')), + isTrue, + ); + expect( + Filter.query(TestFilterField.name, 'hn') + .matches(TestModel(name: 'JOHN')), + isTrue, + ); + expect( + Filter.query(TestFilterField.name, 'hn') + .matches(TestModel(name: 'Jane')), + isFalse, + ); + }); + }); + + group('AutoComplete', () { + test('should match word prefix (case-insensitive)', () { + final model = TestModel(name: 'John Doe Smith'); + + expect( + Filter.autoComplete(TestFilterField.name, 'jo').matches(model), + isTrue, + ); + expect( + Filter.autoComplete(TestFilterField.name, 'do').matches(model), + isTrue, + ); + expect( + Filter.autoComplete(TestFilterField.name, 'JO').matches(model), + isTrue, + ); + }); + + test('should not match empty query', () { + final modelWithContent = TestModel(name: 'any content'); + final modelWithEmptyName = TestModel(name: ''); + + expect( + Filter.autoComplete(TestFilterField.name, '') + .matches(modelWithContent), + isFalse, + ); + expect( + Filter.autoComplete(TestFilterField.name, '') + .matches(modelWithEmptyName), + isFalse, + ); + }); + + test('should match word prefixes with punctuation boundaries', () { + final filter = Filter.autoComplete(TestFilterField.name, 'con'); + + final itemWithDotSeparation = TestModel(name: 'app.config.json'); + final itemWithDashSeparation = + TestModel(name: 'user-configuration-file'); + final itemWithMixedPunctuation = + TestModel(name: 'system/container,settings.xml'); + final itemWithoutWordPrefix = TestModel(name: 'application'); + final itemWithInWordMatch = TestModel(name: 'reconstruction'); + + expect(filter.matches(itemWithDotSeparation), isTrue); + expect(filter.matches(itemWithDashSeparation), isTrue); + expect(filter.matches(itemWithMixedPunctuation), isTrue); + expect(filter.matches(itemWithoutWordPrefix), isFalse); + expect(filter.matches(itemWithInWordMatch), isFalse); + }); + + test('should not match middle of word', () { + final model = TestModel(name: 'John Doe'); + + expect( + Filter.autoComplete(TestFilterField.name, 'oh').matches(model), + isFalse, + ); + }); + + test('should handle diacritics case-insensitively', () { + expect( + Filter.autoComplete(TestFilterField.name, 'jos') + .matches(TestModel(name: 'José')), + isTrue, + ); + expect( + Filter.autoComplete(TestFilterField.name, 'jos') + .matches(TestModel(name: 'JOSÉ')), + isTrue, + ); + expect( + Filter.autoComplete(TestFilterField.name, 'jos') + .matches(TestModel(name: 'josé')), + isTrue, + ); + }); + + test('should match word boundaries in multi-word text', () { + expect( + Filter.autoComplete(TestFilterField.name, 'john') + .matches(TestModel(name: 'John Smith')), + isTrue, + ); + expect( + Filter.autoComplete(TestFilterField.name, 'john') + .matches(TestModel(name: 'JOHN DOE')), + isTrue, + ); + expect( + Filter.autoComplete(TestFilterField.name, 'john') + .matches(TestModel(name: 'Smith John')), + isTrue, + ); + expect( + Filter.autoComplete(TestFilterField.name, 'smi') + .matches(TestModel(name: 'John Smith')), + isTrue, + ); + expect( + Filter.autoComplete(TestFilterField.name, 'smi') + .matches(TestModel(name: 'Johnson')), + isFalse, + ); + }); + + test('should handle punctuation as word boundaries', () { + expect( + Filter.autoComplete(TestFilterField.name, 'john') + .matches(TestModel(name: 'john-doe')), + isTrue, + ); + expect( + Filter.autoComplete(TestFilterField.name, 'john') + .matches(TestModel(name: 'john.doe')), + isTrue, + ); + expect( + Filter.autoComplete(TestFilterField.name, 'john') + .matches(TestModel(name: 'Johnson')), + isTrue, + ); + }); + + test('should not match when query is longer than word', () { + expect( + Filter.autoComplete(TestFilterField.name, 'johnathan') + .matches(TestModel(name: 'John')), + isFalse, + ); + }); + + test('should return false for non-string fields', () { + expect( + Filter.autoComplete(TestFilterField.metadata, 'test') + .matches(TestModel(metadata: {'key': 'value'})), + isFalse, + ); + }); + + test('should return false for fields with only punctuation', () { + expect( + Filter.autoComplete(TestFilterField.name, 'test') + .matches(TestModel(name: '...')), + isFalse, + ); + }); + }); + + group('PathExists', () { + test('should match nested paths', () { + final model = TestModel( + metadata: { + 'user': { + 'profile': {'name': 'John'}, + }, + }, + ); + + expect( + Filter.pathExists(TestFilterField.metadata, 'user.profile.name') + .matches(model), + isTrue, + ); + expect( + Filter.pathExists(TestFilterField.metadata, 'user.profile.age') + .matches(model), + isFalse, + ); + }); + + test('should match shallow paths', () { + final model = TestModel(metadata: {'user': 'John'}); + + expect( + Filter.pathExists(TestFilterField.metadata, 'user').matches(model), + isTrue, + ); + expect( + Filter.pathExists(TestFilterField.metadata, 'age').matches(model), + isFalse, + ); + }); + + test('should return false for empty path', () { + final model = TestModel(metadata: {'user': 'John'}); + + expect( + Filter.pathExists(TestFilterField.metadata, '').matches(model), + isFalse, + ); + }); + + test('should return false for null field', () { + final model = TestModel(metadata: null); + + expect( + Filter.pathExists(TestFilterField.metadata, 'user.name') + .matches(model), + isFalse, + ); + }); + + test('should return false for non-map field', () { + final model = TestModel(name: 'John'); + + expect( + Filter.pathExists(TestFilterField.name, 'subfield').matches(model), + isFalse, + ); + }); + + test('should return false when path goes through non-map value', () { + final model = TestModel( + metadata: { + 'user': 'John', // String, not a map + }, + ); + + expect( + Filter.pathExists(TestFilterField.metadata, 'user.name') + .matches(model), + isFalse, + ); + }); + + test('should distinguish between null value and missing key', () { + final modelWithNull = TestModel(metadata: {'user': null}); + final modelWithoutKey = TestModel(metadata: {'other': 'value'}); + + // Path exists but value is null - should match (key exists) + expect( + Filter.pathExists(TestFilterField.metadata, 'user') + .matches(modelWithNull), + isTrue, + ); + + // Path doesn't exist - should not match + expect( + Filter.pathExists(TestFilterField.metadata, 'user') + .matches(modelWithoutKey), + isFalse, + ); + }); + }); + + group('Logical Operators', () { + test('And should match when all filters match', () { + final model = TestModel(name: 'John', type: 'public'); + final filter = Filter.and([ + Filter.equal(TestFilterField.name, 'John'), + Filter.equal(TestFilterField.type, 'public'), + ]); + + expect(filter.matches(model), isTrue); + }); + + test('And should not match when any filter fails', () { + final model = TestModel(name: 'John', type: 'public'); + final filter = Filter.and([ + Filter.equal(TestFilterField.name, 'John'), + Filter.equal(TestFilterField.type, 'private'), + ]); + + expect(filter.matches(model), isFalse); + }); + + test('Or should match when any filter matches', () { + final model = TestModel(type: 'public'); + final filter = Filter.or([ + Filter.equal(TestFilterField.type, 'public'), + Filter.equal(TestFilterField.type, 'private'), + ]); + + expect(filter.matches(model), isTrue); + }); + + test('Or should not match when all filters fail', () { + final model = TestModel(type: 'archived'); + final filter = Filter.or([ + Filter.equal(TestFilterField.type, 'public'), + Filter.equal(TestFilterField.type, 'private'), + ]); + + expect(filter.matches(model), isFalse); + }); + + test('should handle complex nested combinations', () { + final model = TestModel( + name: 'John', + type: 'public', + createdAt: DateTime(2023, 6, 15), + ); + final filter = Filter.and([ + Filter.or([ + Filter.equal(TestFilterField.name, 'John'), + Filter.equal(TestFilterField.name, 'Jane'), + ]), + Filter.equal(TestFilterField.type, 'public'), + Filter.greater(TestFilterField.createdAt, DateTime(2023, 1, 1)), + ]); + + expect(filter.matches(model), isTrue); + }); + }); + + group('Type Mismatches', () { + test('should return false for string vs number comparisons', () { + final model = TestModel(name: 'John'); + + expect( + Filter.greater(TestFilterField.name, 25).matches(model), + isFalse, + ); + expect( + Filter.less(TestFilterField.name, 25).matches(model), + isFalse, + ); + }); + }); + + group('Edge Cases', () { + test('should handle empty string values', () { + final model = TestModel(name: ''); + + expect( + Filter.equal(TestFilterField.name, '').matches(model), + isTrue, + ); + expect( + Filter.equal(TestFilterField.name, 'John').matches(model), + isFalse, + ); + }); + + test( + 'should handle null values in optional fields (PostgreSQL semantics)', + () { + final modelWithNull = TestModel(name: null); + final modelWithValue = TestModel(name: 'John'); + + // NULL = NULL → false (PostgreSQL three-valued logic) + expect( + Filter.equal(TestFilterField.name, null).matches(modelWithNull), + isFalse, + ); + // 'John' = NULL → false + expect( + Filter.equal(TestFilterField.name, null).matches(modelWithValue), + isFalse, + ); + // NULL = 'John' → false + expect( + Filter.equal(TestFilterField.name, 'John').matches(modelWithNull), + isFalse, + ); + }); + + test('should handle empty arrays', () { + final modelWithEmpty = TestModel(tags: []); + final modelWithValues = TestModel(tags: ['a', 'b']); + + expect( + Filter.equal(TestFilterField.tags, []).matches(modelWithEmpty), + isTrue, + ); + expect( + Filter.equal(TestFilterField.tags, []).matches(modelWithValues), + isFalse, + ); + expect( + Filter.contains(TestFilterField.tags, []).matches(modelWithValues), + isTrue, + ); + }); + + test('should handle empty maps', () { + final modelWithEmpty = TestModel(metadata: {}); + final modelWithValues = TestModel(metadata: {'a': 1}); + + expect( + Filter.equal(TestFilterField.metadata, {}).matches(modelWithEmpty), + isTrue, + ); + expect( + Filter.equal(TestFilterField.metadata, {}).matches(modelWithValues), + isFalse, + ); + // Empty map `{}` is contained in any object + expect( + Filter.contains(TestFilterField.metadata, {}).matches(modelWithEmpty), + isTrue, + ); + expect( + Filter.contains(TestFilterField.metadata, {}) + .matches(modelWithValues), + isTrue, + ); }); }); });