kiroshi 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3df16fe842c4fcd160633af8a5e4d91fa19cbb857dc135a31160c1f389a5f6c1
4
- data.tar.gz: b3f98740895ae5c611d616c36472d77ce412a1086d9d6a9ffe4532a8b3fa2235
3
+ metadata.gz: ff66735688ded970094d5a88a9bcd67ad4f9cac7679fc55ceb75534d9b16d9fc
4
+ data.tar.gz: d6da2da4ec294c0d134ceff028e19b30fa5e13ddab2fae2ad0582a5fdfc559aa
5
5
  SHA512:
6
- metadata.gz: 328641f23218cf3a3a3841af7027f42981dced635c03e47642e5f7bd3a9194d94e13e59a87f51f71adc51344642a682148606daff113ebf80f638e0cb0f5be19
7
- data.tar.gz: e3cb7920836e0ad2d86d05875c74a57759c5b28d9309124539c61c902adaaaa3a9bf1d0f094e9b19500d23f4034563ac9de234b9fd6f36a21084054bfed09c13
6
+ metadata.gz: 15f142b43b0fcd7fc12f8cd2c1246031aabec104deaf1270030eca5afa56d5776d5c3fec301422c12baec0c531d5366f39832b4afe6ec3d1c83f40ce3e94aea5
7
+ data.tar.gz: af94b97459b7a157c6ea30f36c36b937e9a75c25b7081106cf6b6d104b3514687a7da4842e9ac847b7669954bab65f707e8a609d55e3666393f6da09cad6d9c4
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2025-08-17 15:11:59 UTC using RuboCop version 1.79.2.
3
+ # on 2025-08-18 22:27:27 UTC using RuboCop version 1.79.2.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -13,11 +13,7 @@ RSpec/NoExpectationExample:
13
13
  Exclude:
14
14
  - 'spec/lib/kiroshi_spec.rb'
15
15
 
16
- # Offense count: 2
17
- # Configuration parameters: AllowedConstants.
18
- Style/Documentation:
16
+ # Offense count: 1
17
+ RSpec/PendingWithoutReason:
19
18
  Exclude:
20
- - 'spec/**/*'
21
- - 'test/**/*'
22
- - 'lib/kiroshi/filter.rb'
23
- - 'lib/kiroshi/filters.rb'
19
+ - 'spec/lib/kiroshi/filters_spec.rb'
data/README.md CHANGED
@@ -7,16 +7,16 @@
7
7
 
8
8
  ## Yard Documentation
9
9
 
10
- [https://www.rubydoc.info/gems/kiroshi/0.1.1](https://www.rubydoc.info/gems/kiroshi/0.1.1)
10
+ [https://www.rubydoc.info/gems/kiroshi/0.2.0](https://www.rubydoc.info/gems/kiroshi/0.2.0)
11
11
 
12
12
  Kiroshi has been designed to make filtering ActiveRecord queries easier
13
13
  by providing a flexible and reusable filtering system. It allows you to
14
14
  define filter sets that can be applied to any ActiveRecord scope,
15
15
  supporting both exact matches and partial matching using SQL LIKE operations.
16
16
 
17
- Current Release: [0.1.1](https://github.com/darthjee/kiroshi/tree/0.1.1)
17
+ Current Release: [0.2.0](https://github.com/darthjee/kiroshi/tree/0.2.0)
18
18
 
19
- [Next release](https://github.com/darthjee/kiroshi/compare/0.1.1...master)
19
+ [Next release](https://github.com/darthjee/kiroshi/compare/0.2.0...master)
20
20
 
21
21
  ## Installation
22
22
 
@@ -202,82 +202,8 @@ result = filters.apply(scope)
202
202
 
203
203
  The `table` parameter accepts both symbols and strings, and helps resolve column name ambiguity in complex joined queries.
204
204
 
205
- ### Kiroshi::Filter
205
+ ## API Reference
206
206
 
207
- [Filter](https://www.rubydoc.info/gems/kiroshi/Kiroshi/Filter)
208
- is the individual filter class that applies filtering logic to ActiveRecord scopes.
209
- It's automatically used by `Kiroshi::Filters`, but can also be used standalone.
207
+ Kiroshi provides a simple, clean API focused on the `Kiroshi::Filters` class. Individual filters are handled internally and don't require direct interaction in most use cases.
210
208
 
211
- #### Standalone Usage
212
-
213
- ```ruby
214
- # Create individual filters
215
- name_filter = Kiroshi::Filter.new(:name, match: :like)
216
- status_filter = Kiroshi::Filter.new(:status, match: :exact)
217
-
218
- # Apply filters manually
219
- scope = Document.all
220
- scope = name_filter.apply(scope, { name: 'report' })
221
- scope = status_filter.apply(scope, { status: 'published' })
222
- ```
223
-
224
- #### Filter Options
225
-
226
- - `match: :exact` - Performs exact matching (default)
227
- - `match: :like` - Performs partial matching using SQL LIKE
228
- - `table: :table_name` - Specifies which table to filter on (useful for joined queries)
229
-
230
- ```ruby
231
- # Exact match filter
232
- exact_filter = Kiroshi::Filter.new(:status)
233
- exact_filter.apply(Document.all, { status: 'published' })
234
- # Generates: WHERE status = 'published'
235
-
236
- # LIKE match filter
237
- like_filter = Kiroshi::Filter.new(:title, match: :like)
238
- like_filter.apply(Document.all, { title: 'Ruby' })
239
- # Generates: WHERE title LIKE '%Ruby%'
240
-
241
- # Table-qualified filter for joined queries
242
- tag_filter = Kiroshi::Filter.new(:name, match: :like, table: :tags)
243
- tag_filter.apply(Document.joins(:tags), { name: 'programming' })
244
- # Generates: WHERE tags.name LIKE '%programming%'
245
-
246
- # Document-specific filter in joined query
247
- doc_filter = Kiroshi::Filter.new(:title, match: :exact, table: :documents)
248
- doc_filter.apply(Document.joins(:tags), { title: 'Ruby Guide' })
249
- # Generates: WHERE documents.title = 'Ruby Guide'
250
- ```
251
-
252
- #### Empty Value Handling
253
-
254
- Filters automatically ignore empty or nil values:
255
-
256
- ```ruby
257
- filter = Kiroshi::Filter.new(:name)
258
- filter.apply(Document.all, { name: nil }) # Returns original scope
259
- filter.apply(Document.all, { name: '' }) # Returns original scope
260
- filter.apply(Document.all, {}) # Returns original scope
261
- filter.apply(Document.all, { name: 'value' }) # Applies filter
262
- ```
263
-
264
- #### Handling Column Name Ambiguity
265
-
266
- When working with joined tables that have columns with the same name, use the `table` parameter to specify which table's column to filter:
267
-
268
- ```ruby
269
- # Without table specification - may cause ambiguity
270
- scope = Document.joins(:tags) # Both documents and tags have 'name' column
271
-
272
- # Specify which table to filter on
273
- name_filter = Kiroshi::Filter.new(:name, match: :like, table: :tags)
274
- result = name_filter.apply(scope, { name: 'ruby' })
275
- # Generates: WHERE tags.name LIKE '%ruby%'
276
-
277
- # Or filter by document name specifically
278
- doc_name_filter = Kiroshi::Filter.new(:name, match: :like, table: :documents)
279
- result = doc_name_filter.apply(scope, { name: 'guide' })
280
- # Generates: WHERE documents.name LIKE '%guide%'
281
- ```
282
-
283
- **Priority**: When using `Kiroshi::Filters`, if a filter specifies a `table`, it takes priority over the scope's default table name.
209
+ For detailed API documentation, see the [YARD documentation](https://www.rubydoc.info/gems/kiroshi/0.2.0).
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kiroshi
4
+ # @api private
4
5
  # @author darthjee
5
6
  #
6
7
  # A filter class that applies filtering logic to ActiveRecord scopes
@@ -10,11 +11,15 @@ module Kiroshi
10
11
  #
11
12
  # @example Creating and applying an exact filter
12
13
  # filter = Kiroshi::Filter.new(:name)
13
- # filtered_scope = filter.apply(Document.all, { name: 'John' })
14
+ # filtered_scope = filter.apply(scope: Document.all, value: 'John')
14
15
  #
15
16
  # @example Creating and applying a LIKE filter
16
17
  # filter = Kiroshi::Filter.new(:title, match: :like)
17
- # filtered_scope = filter.apply(Article.all, { title: 'Ruby' })
18
+ # filtered_scope = filter.apply(scope: Article.all, value: 'Ruby')
19
+ #
20
+ # @example Creating and applying a filter with specific value
21
+ # filter = Kiroshi::Filter.new(:status)
22
+ # filtered_scope = filter.apply(scope: Document.all, value: 'published')
18
23
  #
19
24
  # @since 0.1.0
20
25
  class Filter
@@ -67,43 +72,43 @@ module Kiroshi
67
72
 
68
73
  # Applies the filter to the given scope
69
74
  #
70
- # This method examines the filters hash for a value corresponding to the
71
- # filter's attribute and applies the appropriate WHERE clause to the scope.
72
- # If no value is present or the value is blank, the original scope is returned unchanged.
75
+ # This method applies the appropriate WHERE clause to the scope using the
76
+ # provided value. If no value is present or the value is blank, the original
77
+ # scope is returned unchanged.
73
78
  #
74
79
  # @param scope [ActiveRecord::Relation] the ActiveRecord scope to filter
75
- # @param filters [Hash] a hash containing filter values
80
+ # @param value [Object, nil] the value to use for filtering, defaults to nil
76
81
  #
77
82
  # @return [ActiveRecord::Relation] the filtered scope
78
83
  #
79
84
  # @example Applying an exact filter
80
85
  # filter = Kiroshi::Filter.new(:status)
81
- # filter.apply(Document.all, { status: 'published' })
86
+ # filter.apply(scope: Document.all, value: 'published')
82
87
  # # Generates: WHERE status = 'published'
83
88
  #
84
89
  # @example Applying a LIKE filter
85
90
  # filter = Kiroshi::Filter.new(:title, match: :like)
86
- # filter.apply(Article.all, { title: 'Ruby' })
91
+ # filter.apply(scope: Article.all, value: 'Ruby')
87
92
  # # Generates: WHERE title LIKE '%Ruby%'
88
93
  #
89
94
  # @example Applying a filter with table qualification
90
95
  # filter = Kiroshi::Filter.new(:name, table: 'documents')
91
- # filter.apply(Document.joins(:tags), { name: 'report' })
96
+ # filter.apply(scope: Document.joins(:tags), value: 'report')
92
97
  # # Generates: WHERE documents.name = 'report'
93
98
  #
94
99
  # @example Applying a filter with table qualification for tags
95
100
  # filter = Kiroshi::Filter.new(:name, table: 'tags')
96
- # filter.apply(Document.joins(:tags), { name: 'ruby' })
101
+ # filter.apply(scope: Document.joins(:tags), value: 'ruby')
97
102
  # # Generates: WHERE tags.name = 'ruby'
98
103
  #
99
104
  # @example With empty filter value
100
105
  # filter = Kiroshi::Filter.new(:name)
101
- # filter.apply(User.all, { name: nil })
106
+ # filter.apply(scope: User.all, value: nil)
102
107
  # # Returns the original scope unchanged
103
108
  #
104
- # @since 0.1.0
105
- def apply(scope, filters)
106
- runner = FilterRunner.new(filter: self, scope: scope, filters: filters)
109
+ # @since 0.2.0
110
+ def apply(scope:, value: nil)
111
+ runner = FilterRunner.new(filter: self, scope: scope, value: value)
107
112
  runner.apply
108
113
  end
109
114
  end
@@ -31,7 +31,7 @@ module Kiroshi
31
31
  #
32
32
  # @since 0.1.1
33
33
  def apply
34
- scope.where(table_name => { attribute => filter_value })
34
+ scope.where(table_name => { attribute => value })
35
35
  end
36
36
  end
37
37
  end
@@ -34,7 +34,7 @@ module Kiroshi
34
34
  def apply
35
35
  scope.where(
36
36
  "#{table_name}.#{attribute} LIKE ?",
37
- "%#{filter_value}%"
37
+ "%#{value}%"
38
38
  )
39
39
  end
40
40
  end
@@ -94,38 +94,34 @@ module Kiroshi
94
94
  #
95
95
  # @return [Kiroshi::FilterRunner] the filter runner instance
96
96
 
97
- delegate :scope, :attribute, :table_name, :filter_value, to: :filter_runner
97
+ delegate :scope, :attribute, :table_name, :value, to: :filter_runner
98
98
 
99
99
  # @!method scope
100
100
  # @api private
101
- # @private
102
101
  #
103
- # Returns the ActiveRecord scope being filtered
102
+ # Returns the current scope being filtered
104
103
  #
105
- # @return [ActiveRecord::Relation] the scope being filtered
104
+ # @return [ActiveRecord::Relation] the scope
106
105
 
107
106
  # @!method attribute
108
107
  # @api private
109
- # @private
110
108
  #
111
109
  # Returns the attribute name to filter by
112
110
  #
113
- # @return [Symbol] the attribute name to filter by
111
+ # @return [Symbol] the attribute name
114
112
 
115
113
  # @!method table_name
116
114
  # @api private
117
- # @private
118
115
  #
119
- # Returns the table name from the scope
116
+ # Returns the table name for the filter
120
117
  #
121
118
  # @return [String] the table name
122
119
 
123
- # @!method filter_value
120
+ # @!method value
124
121
  # @api private
125
- # @private
126
122
  #
127
- # Returns the filter value for the current filter's attribute
123
+ # Returns the filter value
128
124
  #
129
- # @return [Object, nil] the filter value or nil if not present
125
+ # @return [Object] the filter value
130
126
  end
131
127
  end
@@ -12,7 +12,12 @@ module Kiroshi
12
12
  #
13
13
  # @example Creating and running a filter
14
14
  # filter = Kiroshi::Filter.new(:name, match: :like)
15
- # runner = Kiroshi::FilterRunner.new(filter: filter, scope: User.all, filters: { name: 'John' })
15
+ # runner = Kiroshi::FilterRunner.new(filter: filter, scope: User.all, value: 'John')
16
+ # result = runner.apply
17
+ #
18
+ # @example Creating and running a filter with specific value
19
+ # filter = Kiroshi::Filter.new(:status)
20
+ # runner = Kiroshi::FilterRunner.new(filter: filter, scope: User.all, value: 'active')
16
21
  # result = runner.apply
17
22
  #
18
23
  # @since 0.1.0
@@ -21,13 +26,13 @@ module Kiroshi
21
26
  #
22
27
  # @param filter [Kiroshi::Filter] the filter configuration
23
28
  # @param scope [ActiveRecord::Relation] the scope to filter
24
- # @param filters [Hash] a hash containing filter values
29
+ # @param value [Object, nil] the specific value to use for filtering, defaults to nil
25
30
  #
26
- # @since 0.1.0
27
- def initialize(filter:, scope:, filters:)
31
+ # @since 0.2.0
32
+ def initialize(filter:, scope:, value: nil)
28
33
  @filter = filter
29
34
  @scope = scope
30
- @filters = filters
35
+ @value = value
31
36
  end
32
37
 
33
38
  # Applies the filter logic to the scope
@@ -38,41 +43,49 @@ module Kiroshi
38
43
  # @return [ActiveRecord::Relation] the filtered scope
39
44
  #
40
45
  # @example Applying exact match filter
41
- # runner = FilterRunner.new(filter: filter, scope: scope, filters: { name: 'John' })
46
+ # runner = FilterRunner.new(filter: filter, scope: scope, value: 'John')
42
47
  # runner.apply
43
48
  #
44
49
  # @example Applying LIKE filter
45
- # runner = FilterRunner.new(filter: filter, scope: scope, filters: { title: 'Ruby' })
50
+ # runner = FilterRunner.new(filter: filter, scope: scope, value: 'Ruby')
51
+ # runner.apply
52
+ #
53
+ # @example With specific value provided
54
+ # runner = FilterRunner.new(filter: filter, scope: scope, value: 'specific_value')
46
55
  # runner.apply
47
56
  #
48
- # @example With no matching value
49
- # runner = FilterRunner.new(filter: filter, scope: scope, filters: { name: nil })
57
+ # @example With no value (returns unchanged scope)
58
+ # runner = FilterRunner.new(filter: filter, scope: scope, value: nil)
50
59
  # runner.apply
51
60
  # # Returns the original scope unchanged
52
61
  #
53
62
  # @since 0.1.1
54
63
  def apply
55
- return scope unless filter_value.present?
64
+ return scope unless value.present?
56
65
 
57
66
  query_strategy = FilterQuery.for(filter.match).new(self)
58
67
  query_strategy.apply
59
68
  end
60
69
 
61
- # Returns the filter value for the current filter's attribute
70
+ attr_reader :scope, :value
71
+
72
+ # @!method scope
73
+ # @api private
62
74
  #
63
- # @return [Object, nil] the filter value or nil if not present
75
+ # Returns the current scope being filtered
64
76
  #
65
- # @since 0.1.1
66
- def filter_value
67
- filters[filter.attribute]
68
- end
77
+ # @return [ActiveRecord::Relation] the scope
78
+ #
79
+ # @since 0.1.1
69
80
 
70
- # Returns the current scope being filtered
81
+ # @!method value
82
+ # @api private
71
83
  #
72
- # @return [ActiveRecord::Relation] the scope
84
+ # Returns the filter value for the current filter
73
85
  #
74
- # @since 0.1.1
75
- attr_reader :scope
86
+ # @return [Object] the filter value or nil if not present
87
+ #
88
+ # @since 0.2.0
76
89
 
77
90
  # Returns the table name to use for the filter
78
91
  #
@@ -84,12 +97,12 @@ module Kiroshi
84
97
  #
85
98
  # @example With filter table_name specified
86
99
  # filter = Kiroshi::Filter.new(:name, table: 'tags')
87
- # runner = FilterRunner.new(filter: filter, scope: Document.joins(:tags), filters: {})
100
+ # runner = FilterRunner.new(filter: filter, scope: Document.joins(:tags), value: 'ruby')
88
101
  # runner.table_name # => 'tags'
89
102
  #
90
103
  # @example Without filter table_name (fallback to scope)
91
104
  # filter = Kiroshi::Filter.new(:name)
92
- # runner = FilterRunner.new(filter: filter, scope: Document.all, filters: {})
105
+ # runner = FilterRunner.new(filter: filter, scope: Document.all, value: 'test')
93
106
  # runner.table_name # => 'documents'
94
107
  #
95
108
  # @since 0.1.1
@@ -104,9 +117,16 @@ module Kiroshi
104
117
  #
105
118
  # @return [ActiveRecord::Relation] the scope
106
119
 
120
+ # @!method value
121
+ # @api private
122
+ #
123
+ # Returns the filter value for the current filter
124
+ #
125
+ # @return [Object, nil] the filter value or nil if not present
126
+
107
127
  private
108
128
 
109
- attr_reader :filter, :filters
129
+ attr_reader :filter
110
130
 
111
131
  # @!method filter
112
132
  # @api private
@@ -116,14 +136,6 @@ module Kiroshi
116
136
  #
117
137
  # @return [Kiroshi::Filter] the filter configuration
118
138
 
119
- # @!method filters
120
- # @api private
121
- # @private
122
- #
123
- # Returns the hash of filter values
124
- #
125
- # @return [Hash] the hash of filter values
126
-
127
139
  delegate :attribute, to: :filter
128
140
  delegate :table_name, to: :scope, prefix: true
129
141
  delegate :table_name, to: :filter, prefix: true
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiroshi
4
+ class Filters
5
+ # @api public
6
+ # Class-level methods for configuring filters in Kiroshi::Filters
7
+ #
8
+ # This module provides the DSL methods that allow filter classes to
9
+ # define their filtering behavior using class-level method calls.
10
+ # These methods are automatically available when extending Kiroshi::Filters.
11
+ #
12
+ # The primary interface is the {.filter_by} method, which registers
13
+ # filters that will be applied when {Filters#apply} is called on
14
+ # instances of the filter class.
15
+ #
16
+ # @example Basic filter configuration
17
+ # class DocumentFilters < Kiroshi::Filters
18
+ # filter_by :name, match: :like
19
+ # filter_by :status
20
+ # filter_by :category, table: :documents
21
+ # end
22
+ #
23
+ # @example Accessing filter configurations
24
+ # DocumentFilters.filter_configs.keys # => [:name, :status, :category]
25
+ # DocumentFilters.filter_configs[:name].match # => :like
26
+ #
27
+ # @since 0.2.0
28
+ # @author darthjee
29
+ module ClassMethods
30
+ # Defines a filter for the current filter class
31
+ #
32
+ # This method is used at the class level to configure filters that will
33
+ # be applied when {Filters#apply} is called. Each call creates a new {Filter}
34
+ # instance with the specified configuration and stores it in the class's
35
+ # filter registry for later use during filtering operations.
36
+ #
37
+ # The method supports various matching strategies and table qualification
38
+ # options to handle complex database queries with joins and ambiguous
39
+ # column names.
40
+ #
41
+ # @overload filter_by(attribute, **options)
42
+ # @param attribute [Symbol] the attribute name to filter by
43
+ # @param options [Hash] additional options passed to {Filter#initialize}
44
+ # @option options [Symbol] :match (:exact) the matching type
45
+ # - +:exact+ for exact matching (default)
46
+ # - +:like+ for partial matching using SQL LIKE with wildcards
47
+ # @option options [String, Symbol, nil] :table (nil) the table name to qualify the attribute
48
+ # when dealing with joined tables that have conflicting column names
49
+ #
50
+ # @return (see Filters.filter_by)
51
+ # @example (see Filters.filter_by)
52
+ # @note (see Filters.filter_by)
53
+ # @see (see Filters.filter_by)
54
+ # @since (see Filters.filter_by)
55
+ def filter_by(attribute, **)
56
+ Filter.new(attribute, **).tap do |filter|
57
+ filter_configs[attribute] = filter
58
+ end
59
+ end
60
+
61
+ # @api private
62
+ # Returns the filter configuration for a specific attribute
63
+ #
64
+ # This method provides a convenient way to retrieve a specific filter
65
+ # by its attribute name. It's a shorthand for accessing the filter_configs
66
+ # hash directly and is used internally by the filtering system.
67
+ #
68
+ # @param attribute [Symbol] the attribute name to look up
69
+ #
70
+ # @return [Filter, nil] the filter instance for the given attribute,
71
+ # or nil if no filter is configured for that attribute
72
+ #
73
+ # @example Retrieving a specific filter
74
+ # class MyFilters < Kiroshi::Filters
75
+ # filter_by :name, match: :like
76
+ # filter_by :status
77
+ # end
78
+ #
79
+ # MyFilters.filter_for(:name) # => #<Kiroshi::Filter:0x... @attribute=:name @match=:like>
80
+ # MyFilters.filter_for(:status) # => #<Kiroshi::Filter:0x... @attribute=:status @match=:exact>
81
+ # MyFilters.filter_for(:unknown) # => nil
82
+ #
83
+ # @see .filter_configs for accessing the complete filter registry
84
+ # @see Filters#apply for how this method is used during filtering
85
+ #
86
+ # @since 0.2.0
87
+ def filter_for(attribute)
88
+ filter_configs[attribute] || inherited_filter_for(attribute)
89
+ end
90
+
91
+ private
92
+
93
+ # @api private
94
+ # @private
95
+ #
96
+ # Searches for a filter in the inheritance chain
97
+ #
98
+ # This method looks up the inheritance chain to find a filter configuration
99
+ # for the given attribute. It only searches in superclasses that inherit
100
+ # from Kiroshi::Filters, stopping when it reaches a non-Filters class.
101
+ #
102
+ # @param attribute [Symbol] the attribute name to look up
103
+ # @return [Filter, nil] the filter instance from a parent class, or nil if not found
104
+ #
105
+ # @since 0.2.0
106
+ def inherited_filter_for(attribute)
107
+ return nil unless superclass < Kiroshi::Filters
108
+
109
+ superclass.filter_for(attribute)
110
+ end
111
+
112
+ # @api private
113
+ # @private
114
+ #
115
+ # Returns the hash of configured filters for this filter class
116
+ #
117
+ # This method provides access to the internal registry of filters
118
+ # that have been configured using {.filter_by}. The returned hash
119
+ # contains {Filter} instances keyed by their attribute names, allowing
120
+ # for efficient O(1) lookup during filter application.
121
+ #
122
+ # This method is primarily used internally by {Filters#apply} to
123
+ # iterate through and apply all configured filters to a scope.
124
+ # While marked as private API, it may be useful for introspection
125
+ # and testing purposes.
126
+ #
127
+ # @return [Hash<Symbol, Filter>] hash of {Filter} instances configured
128
+ # for this filter class, keyed by attribute name for efficient access
129
+ #
130
+ # @example Accessing configured filters for introspection
131
+ # class MyFilters < Kiroshi::Filters
132
+ # filter_by :name, match: :like
133
+ # filter_by :status
134
+ # filter_by :category, table: :categories
135
+ # end
136
+ #
137
+ # MyFilters.filter_configs.length # => 3
138
+ # MyFilters.filter_configs.keys # => [:name, :status, :category]
139
+ # MyFilters.filter_configs[:name].attribute # => :name
140
+ # MyFilters.filter_configs[:name].match # => :like
141
+ # MyFilters.filter_configs[:status].match # => :exact
142
+ # MyFilters.filter_configs[:category].table_name # => :categories
143
+ #
144
+ # @example Using in tests to verify filter configuration
145
+ # RSpec.describe ProductFilters do
146
+ # it 'configures the expected filters' do
147
+ # expect(described_class.filter_configs).to have_key(:name)
148
+ # expect(described_class.filter_configs[:name].match).to eq(:like)
149
+ # end
150
+ # end
151
+ #
152
+ # @note This method returns a reference to the actual internal hash.
153
+ # Modifying the returned hash directly will affect the filter class
154
+ # configuration. Use {.filter_by} for proper filter registration.
155
+ #
156
+ # @note The hash is lazily initialized on first access and persists
157
+ # for the lifetime of the class. Each filter class maintains its
158
+ # own separate filter_configs hash.
159
+ #
160
+ # @see .filter_by for adding filters to this configuration
161
+ # @see Filters#apply for how these configurations are used
162
+ #
163
+ # @since 0.2.0
164
+ def filter_configs
165
+ @filter_configs ||= {}
166
+ end
167
+ end
168
+ end
169
+ end
@@ -36,73 +36,69 @@ module Kiroshi
36
36
  #
37
37
  # @since 0.1.0
38
38
  class Filters
39
- class << self
40
- # Defines a filter for the current filter class
41
- #
42
- # This method is used at the class level to configure filters that will
43
- # be applied when {#apply} is called. Each call creates a new {Filter}
44
- # instance with the specified configuration.
45
- #
46
- # @overload filter_by(attribute, **options)
47
- # @param attribute [Symbol] the attribute name to filter by
48
- # @param options [Hash] additional options passed to {Filter#initialize}
49
- # @option options [Symbol] :match (:exact) the matching type
50
- # - +:exact+ for exact matching (default)
51
- # - +:like+ for partial matching using SQL LIKE
52
- # @option options [String, Symbol, nil] :table (nil) the table name to qualify the attribute
53
- #
54
- # @return [Filter] the new filter instance
55
- #
56
- # @example Defining exact match filters
57
- # class ProductFilters < Kiroshi::Filters
58
- # filter_by :category
59
- # filter_by :brand
60
- # end
61
- #
62
- # @example Defining partial match filters
63
- # class SearchFilters < Kiroshi::Filters
64
- # filter_by :title, match: :like
65
- # filter_by :description, match: :like
66
- # end
67
- #
68
- # @example Mixed filter types
69
- # class OrderFilters < Kiroshi::Filters
70
- # filter_by :customer_name, match: :like
71
- # filter_by :status, match: :exact
72
- # filter_by :payment_method
73
- # end
74
- #
75
- # @example Filter with table qualification
76
- # class DocumentTagFilters < Kiroshi::Filters
77
- # filter_by :name, table: :tags
78
- # end
79
- #
80
- # @since 0.1.0
81
- def filter_by(attribute, **)
82
- Filter.new(attribute, **).tap do |filter|
83
- filter_configs << filter
84
- end
85
- end
39
+ autoload :ClassMethods, 'kiroshi/filters/class_methods'
86
40
 
87
- # Returns the list of configured filters for this class
88
- #
89
- # @return [Array<Filter>] array of {Filter} instances configured
90
- # for this filter class
91
- #
92
- # @example Accessing configured filters
93
- # class MyFilters < Kiroshi::Filters
94
- # filter_by :name
95
- # filter_by :status, match: :like
96
- # end
97
- #
98
- # MyFilters.filter_configs.length # => 2
99
- # MyFilters.filter_configs.first.attribute # => :name
100
- #
101
- # @since 0.1.0
102
- def filter_configs
103
- @filter_configs ||= []
104
- end
105
- end
41
+ extend ClassMethods
42
+
43
+ # @method self.filter_by(attribute, **options)
44
+ # @api public
45
+ # @param attribute [Symbol] the attribute name to filter by
46
+ # @param options [Hash] additional options passed to {Filter#initialize}
47
+ # @option options [Symbol] :match (:exact) the matching type
48
+ # - +:exact+ for exact matching (default)
49
+ # - +:like+ for partial matching using SQL LIKE with wildcards
50
+ # @option options [String, Symbol, nil] :table (nil) the table name to qualify the attribute
51
+ # when dealing with joined tables that have conflicting column names
52
+ #
53
+ # @return [Filter] the new filter instance that was created and registered
54
+ #
55
+ # @example Defining exact match filters
56
+ # class ProductFilters < Kiroshi::Filters
57
+ # filter_by :category # Exact match on category
58
+ # filter_by :brand # Exact match on brand
59
+ # filter_by :active # Exact match on active status
60
+ # end
61
+ #
62
+ # @example Defining partial match filters
63
+ # class SearchFilters < Kiroshi::Filters
64
+ # filter_by :title, match: :like # Partial match on title
65
+ # filter_by :description, match: :like # Partial match on description
66
+ # filter_by :author_name, match: :like # Partial match on author name
67
+ # end
68
+ #
69
+ # @example Mixed filter types with different matching strategies
70
+ # class OrderFilters < Kiroshi::Filters
71
+ # filter_by :customer_name, match: :like # Partial match for customer search
72
+ # filter_by :status, match: :exact # Exact match for order status
73
+ # filter_by :payment_method # Exact match (default) for payment
74
+ # end
75
+ #
76
+ # @example Filters with table qualification for joined queries
77
+ # class DocumentTagFilters < Kiroshi::Filters
78
+ # filter_by :name, table: :documents # Filter by document name
79
+ # filter_by :tag_name, table: :tags # Filter by tag name
80
+ # filter_by :category, table: :categories # Filter by category name
81
+ # end
82
+ #
83
+ # @example Complex real-world filter class
84
+ # class ProductSearchFilters < Kiroshi::Filters
85
+ # filter_by :name, match: :like # Product name search
86
+ # filter_by :category_id # Exact category match
87
+ # filter_by :brand, match: :like # Brand name search
88
+ # filter_by :price_min # Minimum price
89
+ # filter_by :price_max # Maximum price
90
+ # filter_by :in_stock # Availability filter
91
+ # filter_by :category_name, table: :categories # Category name via join
92
+ # end
93
+ #
94
+ # @note When using table qualification, ensure that the specified table
95
+ # is properly joined in the scope being filtered. The filter will not
96
+ # automatically add joins - it only qualifies the column name.
97
+ #
98
+ # @see Filter#initialize for detailed information about filter options
99
+ # @see Filters#apply for how these filters are used during query execution
100
+ #
101
+ # @since 0.1.0
106
102
 
107
103
  # Creates a new Filters instance
108
104
  #
@@ -160,10 +156,13 @@ module Kiroshi
160
156
  # filtered_articles = filters.apply(Article.all)
161
157
  # # Generates: WHERE title LIKE '%Ruby%'
162
158
  #
163
- # @since 0.1.0
159
+ # @since 0.2.0
164
160
  def apply(scope)
165
- self.class.filter_configs.each do |filter|
166
- scope = filter.apply(scope, filters)
161
+ filters.compact.each do |attribute, value|
162
+ filter = self.class.filter_for(attribute)
163
+ next unless filter
164
+
165
+ scope = filter.apply(scope: scope, value: value)
167
166
  end
168
167
 
169
168
  scope
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kiroshi
4
- VERSION = '0.1.1'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/kiroshi.rb CHANGED
@@ -9,9 +9,10 @@
9
9
  # using a declarative DSL. It supports multiple matching strategies and can
10
10
  # be easily integrated into Rails controllers and other components.
11
11
  #
12
- # The gem is designed around two main concepts:
12
+ # The gem is designed around the main concept:
13
13
  # - {Filters}: A base class for creating reusable filter sets
14
- # - {Filter}: Individual filters that can be applied to scopes
14
+ #
15
+ # Individual filters are handled internally and don't require direct interaction.
15
16
  #
16
17
  # @example Basic filter class definition
17
18
  # class DocumentFilters < Kiroshi::Filters
@@ -68,25 +69,6 @@
68
69
  # filtered_users = filters.apply(User.includes(:department))
69
70
  # # Generates: WHERE email LIKE '%admin%' AND role = 'moderator' AND active = true
70
71
  #
71
- # @example Individual filter usage
72
- # # Create standalone filters
73
- # name_filter = Kiroshi::Filter.new(:name, match: :like)
74
- # status_filter = Kiroshi::Filter.new(:status)
75
- #
76
- # # Apply filters step by step
77
- # scope = Document.all
78
- # scope = name_filter.apply(scope, { name: 'annual' })
79
- # scope = status_filter.apply(scope, { status: 'published' })
80
- #
81
- # @example Filter matching types
82
- # # Exact matching (default)
83
- # Kiroshi::Filter.new(:status)
84
- # # Generates: WHERE status = 'value'
85
- #
86
- # # Partial matching with LIKE
87
- # Kiroshi::Filter.new(:title, match: :like)
88
- # # Generates: WHERE title LIKE '%value%'
89
- #
90
72
  # @example Empty value handling
91
73
  # filters = DocumentFilters.new(name: '', status: 'published')
92
74
  # result = filters.apply(Document.all)
@@ -148,7 +130,6 @@
148
130
  # end
149
131
  #
150
132
  # @see Filters Base class for creating filter sets
151
- # @see Filter Individual filter implementation
152
133
  # @see https://github.com/darthjee/kiroshi GitHub repository
153
134
  # @see https://www.rubydoc.info/gems/kiroshi YARD documentation
154
135
  #
@@ -6,11 +6,10 @@ RSpec.describe Kiroshi::FilterQuery::Exact, type: :model do
6
6
  describe '#apply' do
7
7
  subject(:query) { described_class.new(filter_runner) }
8
8
 
9
- let(:filter_runner) { Kiroshi::FilterRunner.new(filter: filter, scope: scope, filters: filters) }
9
+ let(:filter_runner) { Kiroshi::FilterRunner.new(filter: filter, scope: scope, value: filter_value) }
10
10
  let(:filter) { Kiroshi::Filter.new(:name, match: :exact) }
11
11
  let(:scope) { Document.all }
12
12
  let(:filter_value) { 'test_document' }
13
- let(:filters) { { name: filter_value } }
14
13
 
15
14
  let!(:matching_document) { create(:document, name: 'test_document') }
16
15
  let!(:non_matching_document) { create(:document, name: 'other_document') }
@@ -36,7 +35,6 @@ RSpec.describe Kiroshi::FilterQuery::Exact, type: :model do
36
35
  context 'when filtering by status attribute' do
37
36
  let(:filter) { Kiroshi::Filter.new(:status, match: :exact) }
38
37
  let(:filter_value) { 'published' }
39
- let(:filters) { { status: filter_value } }
40
38
 
41
39
  let!(:published_document) { create(:document, status: 'published') }
42
40
  let!(:draft_document) { create(:document, status: 'draft') }
@@ -63,7 +61,6 @@ RSpec.describe Kiroshi::FilterQuery::Exact, type: :model do
63
61
  context 'when filtering with numeric values' do
64
62
  let(:filter) { Kiroshi::Filter.new(:priority, match: :exact) }
65
63
  let(:filter_value) { 1 }
66
- let(:filters) { { priority: filter_value } }
67
64
 
68
65
  let!(:high_priority_document) { create(:document, priority: 1) }
69
66
  let!(:medium_priority_document) { create(:document, priority: 2) }
@@ -90,7 +87,6 @@ RSpec.describe Kiroshi::FilterQuery::Exact, type: :model do
90
87
  context 'when filtering with boolean values' do
91
88
  let(:filter) { Kiroshi::Filter.new(:active, match: :exact) }
92
89
  let(:filter_value) { true }
93
- let(:filters) { { active: filter_value } }
94
90
 
95
91
  let!(:active_document) { create(:document, active: true) }
96
92
  let!(:inactive_document) { create(:document, active: false) }
@@ -154,7 +150,6 @@ RSpec.describe Kiroshi::FilterQuery::Exact, type: :model do
154
150
  context 'when filter has table configured' do
155
151
  let(:scope) { Document.joins(:tags) }
156
152
  let(:filter_value) { 'ruby' }
157
- let(:filters) { { name: filter_value } }
158
153
 
159
154
  let!(:first_tag) { Tag.find_or_create_by(name: 'ruby') }
160
155
  let!(:second_tag) { Tag.find_or_create_by(name: 'javascript') }
@@ -252,7 +247,6 @@ RSpec.describe Kiroshi::FilterQuery::Exact, type: :model do
252
247
  context 'when filtering by different attributes with table qualification' do
253
248
  let(:filter) { Kiroshi::Filter.new(:id, match: :exact, table: :tags) }
254
249
  let(:filter_value) { first_tag.id }
255
- let(:filters) { { id: filter_value } }
256
250
 
257
251
  let(:expected_sql) do
258
252
  <<~SQL.squish
@@ -6,11 +6,10 @@ RSpec.describe Kiroshi::FilterQuery::Like, type: :model do
6
6
  describe '#apply' do
7
7
  subject(:query) { described_class.new(filter_runner) }
8
8
 
9
- let(:filter_runner) { Kiroshi::FilterRunner.new(filter: filter, scope: scope, filters: filters) }
9
+ let(:filter_runner) { Kiroshi::FilterRunner.new(filter: filter, scope: scope, value: filter_value) }
10
10
  let(:filter) { Kiroshi::Filter.new(:name, match: :like) }
11
11
  let(:scope) { Document.all }
12
12
  let(:filter_value) { 'test' }
13
- let(:filters) { { name: filter_value } }
14
13
 
15
14
  let!(:matching_document) { create(:document, name: 'test_document') }
16
15
  let!(:another_match) { create(:document, name: 'my_test_file') }
@@ -41,7 +40,6 @@ RSpec.describe Kiroshi::FilterQuery::Like, type: :model do
41
40
  context 'when filtering by status attribute' do
42
41
  let(:filter) { Kiroshi::Filter.new(:status, match: :like) }
43
42
  let(:filter_value) { 'pub' }
44
- let(:filters) { { status: filter_value } }
45
43
 
46
44
  let!(:published_document) { create(:document, status: 'published') }
47
45
  let!(:republished_document) { create(:document, status: 'republished') }
@@ -73,7 +71,6 @@ RSpec.describe Kiroshi::FilterQuery::Like, type: :model do
73
71
  context 'when filtering with numeric values as strings' do
74
72
  let(:filter) { Kiroshi::Filter.new(:version, match: :like) }
75
73
  let(:filter_value) { '1.2' }
76
- let(:filters) { { version: filter_value } }
77
74
 
78
75
  let!(:version_match) { create(:document, version: '1.2.3') }
79
76
  let!(:another_version) { create(:document, version: '2.1.2') }
@@ -190,7 +187,6 @@ RSpec.describe Kiroshi::FilterQuery::Like, type: :model do
190
187
  context 'when filter has table configured' do
191
188
  let(:scope) { Document.joins(:tags) }
192
189
  let(:filter_value) { 'ruby' }
193
- let(:filters) { { name: filter_value } }
194
190
 
195
191
  let!(:first_tag) { Tag.find_or_create_by(name: 'ruby') }
196
192
  let!(:second_tag) { Tag.find_or_create_by(name: 'ruby_on_rails') }
@@ -4,12 +4,12 @@ require 'spec_helper'
4
4
 
5
5
  RSpec.describe Kiroshi::FilterRunner, type: :model do
6
6
  describe '#apply' do
7
- subject(:runner) { described_class.new(filter: filter, scope: scope, filters: filters) }
7
+ subject(:runner) { described_class.new(filter: filter, scope: scope, value: filter_value) }
8
8
 
9
9
  let(:scope) { Document.all }
10
10
  let(:filter_value) { 'test_value' }
11
- let(:filters) { { name: filter_value } }
12
- let!(:matching_document) { create(:document, name: filter_value) }
11
+ let(:document_name) { filter_value }
12
+ let!(:matching_document) { create(:document, name: document_name) }
13
13
  let!(:non_matching_document) { create(:document, name: 'other_value') }
14
14
 
15
15
  context 'when filter match is :exact' do
@@ -57,8 +57,9 @@ RSpec.describe Kiroshi::FilterRunner, type: :model do
57
57
  end
58
58
 
59
59
  context 'when filter value is not present' do
60
- let(:filter) { Kiroshi::Filter.new(:name) }
61
- let(:filters) { { name: nil } }
60
+ let(:document_name) { 'Some name' }
61
+ let(:filter) { Kiroshi::Filter.new(:name) }
62
+ let(:filter_value) { nil }
62
63
 
63
64
  it 'returns the original scope unchanged' do
64
65
  expect(runner.apply).to eq(scope)
@@ -66,35 +67,18 @@ RSpec.describe Kiroshi::FilterRunner, type: :model do
66
67
  end
67
68
 
68
69
  context 'when filter value is empty string' do
69
- let(:filter) { Kiroshi::Filter.new(:name) }
70
- let(:filters) { { name: '' } }
71
-
72
- it 'returns the original scope unchanged' do
73
- expect(runner.apply).to eq(scope)
74
- end
75
- end
76
-
77
- context 'when filter attribute is not in filters hash' do
78
- let(:filter) { Kiroshi::Filter.new(:status) }
79
- let(:filters) { { name: 'test_value' } }
80
-
81
- it 'returns the original scope unchanged' do
82
- expect(runner.apply).to eq(scope)
83
- end
84
- end
85
-
86
- context 'when filters hash is empty' do
87
- let(:filter) { Kiroshi::Filter.new(:name) }
88
- let(:filters) { {} }
70
+ let(:document_name) { 'Some name' }
71
+ let(:filter) { Kiroshi::Filter.new(:name) }
72
+ let(:filter_value) { '' }
89
73
 
90
74
  it 'returns the original scope unchanged' do
91
75
  expect(runner.apply).to eq(scope)
92
76
  end
93
77
  end
94
78
 
95
- context 'with multiple attributes' do
96
- let(:filter) { Kiroshi::Filter.new(:status, match: :exact) }
97
- let(:filters) { { name: 'test_name', status: 'finished' } }
79
+ context 'with status filter' do
80
+ let(:filter) { Kiroshi::Filter.new(:status, match: :exact) }
81
+ let(:filter_value) { 'finished' }
98
82
  let!(:matching_document) { create(:document, name: 'test_name', status: 'finished') }
99
83
  let!(:non_matching_document) { create(:document, name: 'other_name', status: 'processing') }
100
84
 
@@ -6,19 +6,18 @@ RSpec.describe Kiroshi::Filter, type: :model do
6
6
  describe '#apply' do
7
7
  let(:scope) { Document.all }
8
8
  let(:filter_value) { 'test_value' }
9
- let(:filters) { { name: filter_value } }
10
9
  let!(:matching_document) { create(:document, name: filter_value) }
11
10
  let!(:non_matching_document) { create(:document, name: 'other_value') }
12
11
 
13
12
  context 'when match is :exact' do
14
13
  subject(:filter) { described_class.new(:name, match: :exact) }
15
14
 
16
- it 'returns exact matches' do
17
- expect(filter.apply(scope, filters)).to include(matching_document)
15
+ it 'returns documents matching the filter' do
16
+ expect(filter.apply(scope: scope, value: filter_value)).to include(matching_document)
18
17
  end
19
18
 
20
- it 'does not return non-matching records' do
21
- expect(filter.apply(scope, filters)).not_to include(non_matching_document)
19
+ it 'does not return documents not matching the filter' do
20
+ expect(filter.apply(scope: scope, value: filter_value)).not_to include(non_matching_document)
22
21
  end
23
22
  end
24
23
 
@@ -30,11 +29,11 @@ RSpec.describe Kiroshi::Filter, type: :model do
30
29
  let!(:non_matching_document) { create(:document, name: 'other_value') }
31
30
 
32
31
  it 'returns partial matches' do
33
- expect(filter.apply(scope, filters)).to include(matching_document)
32
+ expect(filter.apply(scope: scope, value: filter_value)).to include(matching_document)
34
33
  end
35
34
 
36
35
  it 'does not return non-matching records' do
37
- expect(filter.apply(scope, filters)).not_to include(non_matching_document)
36
+ expect(filter.apply(scope: scope, value: filter_value)).not_to include(non_matching_document)
38
37
  end
39
38
  end
40
39
 
@@ -42,21 +41,19 @@ RSpec.describe Kiroshi::Filter, type: :model do
42
41
  subject(:filter) { described_class.new(:name) }
43
42
 
44
43
  it 'defaults to exact match returning only exact matches' do
45
- expect(filter.apply(scope, filters)).to include(matching_document)
44
+ expect(filter.apply(scope: scope, value: filter_value)).to include(matching_document)
46
45
  end
47
46
 
48
47
  it 'defaults to exact match returning not returning when filtering by a non-matching value' do
49
- expect(filter.apply(scope, filters)).not_to include(non_matching_document)
48
+ expect(filter.apply(scope: scope, value: filter_value)).not_to include(non_matching_document)
50
49
  end
51
50
  end
52
51
 
53
52
  context 'when filter value is not present' do
54
53
  subject(:filter) { described_class.new(:name) }
55
54
 
56
- let(:filters) { { name: nil } }
57
-
58
55
  it 'returns the original scope unchanged' do
59
- expect(filter.apply(scope, filters)).to eq(scope)
56
+ expect(filter.apply(scope: scope, value: nil)).to eq(scope)
60
57
  end
61
58
  end
62
59
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Kiroshi::Filters::ClassMethods, type: :model do
6
+ subject(:filters_class) { Class.new(Kiroshi::Filters) }
7
+
8
+ let(:filter_instance) { filters_class.new(filters) }
9
+ let(:scope) { Document.all }
10
+ let(:filters) { {} }
11
+
12
+ describe '.filter_by' do
13
+ let(:scope) { Document.all }
14
+ let(:filters) { { name: name } }
15
+ let(:name) { 'test_name' }
16
+
17
+ context 'when adding a new filter' do
18
+ it do
19
+ expect { filters_class.filter_by :name }
20
+ .to change { filter_instance.apply(scope) }
21
+ .from(scope).to(scope.where(name: name))
22
+ end
23
+ end
24
+
25
+ context 'when adding a filter with table qualification' do
26
+ let(:scope) { Document.joins(:tags) }
27
+
28
+ it do
29
+ expect { filters_class.filter_by :name, table: :documents }
30
+ .to change { filter_instance.apply(scope) }
31
+ .from(scope).to(scope.where(documents: { name: name }))
32
+ end
33
+ end
34
+
35
+ context 'when adding a filter with different table' do
36
+ let(:scope) { Document.joins(:tags) }
37
+ let(:filters) { { name: 'ruby' } }
38
+ let(:name) { 'ruby' }
39
+
40
+ it do
41
+ expect { filters_class.filter_by :name, table: :tags }
42
+ .to change { filter_instance.apply(scope) }
43
+ .from(scope).to(scope.where(tags: { name: name }))
44
+ end
45
+ end
46
+
47
+ context 'when adding a like filter with table qualification' do
48
+ let(:scope) { Document.joins(:tags) }
49
+ let(:filters) { { name: 'test' } }
50
+ let(:name) { 'test' }
51
+
52
+ it do
53
+ expect { filters_class.filter_by :name, match: :like, table: :documents }
54
+ .to change { filter_instance.apply(scope) }
55
+ .from(scope).to(scope.where('documents.name LIKE ?', '%test%'))
56
+ end
57
+ end
58
+ end
59
+ end
@@ -3,16 +3,16 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe Kiroshi::Filters, type: :model do
6
- describe '#apply' do
7
- subject(:filter_instance) { filters_class.new(filters) }
6
+ subject(:filters_class) { Class.new(described_class) }
7
+
8
+ let(:filter_instance) { filters_class.new(filters) }
9
+ let(:scope) { Document.all }
10
+ let(:filters) { {} }
8
11
 
9
- let(:scope) { Document.all }
10
- let(:filters) { {} }
12
+ describe '#apply' do
11
13
  let!(:document) { create(:document, name: 'test_name', status: 'finished') }
12
14
  let!(:other_document) { create(:document, name: 'other_name', status: 'processing') }
13
15
 
14
- let(:filters_class) { Class.new(described_class) }
15
-
16
16
  context 'when no filters are configured' do
17
17
  context 'when no filters are provided' do
18
18
  it 'returns the original scope unchanged' do
@@ -92,7 +92,7 @@ RSpec.describe Kiroshi::Filters, type: :model do
92
92
  end
93
93
 
94
94
  context 'when scope has joined tables with clashing fields' do
95
- let(:scope) { Document.joins(:tags) }
95
+ let(:scope) { Document.joins(:tags) }
96
96
  let(:filters) { { name: 'test_name' } }
97
97
 
98
98
  let!(:first_tag) { Tag.find_or_create_by(name: 'ruby') }
@@ -128,7 +128,6 @@ RSpec.describe Kiroshi::Filters, type: :model do
128
128
  let(:filters) { { name: 'test' } }
129
129
 
130
130
  before do
131
- filters_class.instance_variable_set(:@filter_configs, [])
132
131
  filters_class.filter_by :name, match: :like
133
132
  end
134
133
 
@@ -153,5 +152,102 @@ RSpec.describe Kiroshi::Filters, type: :model do
153
152
  end
154
153
  end
155
154
  end
155
+
156
+ context 'when filter was defined in the superclass' do
157
+ subject(:filters_class) { Class.new(parent_class) }
158
+
159
+ let(:parent_class) { Class.new(described_class) }
160
+ let(:filters) { { name: 'test_name' } }
161
+
162
+ before do
163
+ parent_class.filter_by :name
164
+ end
165
+
166
+ it 'applies the filter defined in the parent class' do
167
+ expect(filter_instance.apply(scope)).to include(document)
168
+ end
169
+
170
+ it 'does not return documents not matching the inherited filter' do
171
+ expect(filter_instance.apply(scope)).not_to include(other_document)
172
+ end
173
+
174
+ it 'generates SQL that includes the filter value from parent class' do
175
+ result = filter_instance.apply(scope)
176
+ expect(result.to_sql).to include("'test_name'")
177
+ end
178
+
179
+ context 'when child class adds its own filter' do
180
+ let(:filters) { { name: 'test_name', status: 'finished' } }
181
+
182
+ before do
183
+ filters_class.filter_by :status
184
+ end
185
+
186
+ it 'applies both parent and child filters' do
187
+ expect(filter_instance.apply(scope)).to include(document)
188
+ end
189
+
190
+ it 'does not return documents not matching all filters' do
191
+ expect(filter_instance.apply(scope)).not_to include(other_document)
192
+ end
193
+ end
194
+
195
+ context 'when child class overrides parent filter' do
196
+ let(:filters) { { name: 'test' } }
197
+
198
+ before do
199
+ filters_class.filter_by :name, match: :like
200
+ end
201
+
202
+ it 'uses the child class filter configuration' do
203
+ expect(filter_instance.apply(scope)).to include(document)
204
+ end
205
+
206
+ it 'does not use the parent class filter configuration' do
207
+ expect(filter_instance.apply(scope).to_sql)
208
+ .to include('LIKE')
209
+ end
210
+
211
+ it 'generates SQL that includes LIKE operation with the filter value' do
212
+ expect(filter_instance.apply(scope).to_sql)
213
+ .to include("'%test%'")
214
+ end
215
+ end
216
+
217
+ context 'when child class overrides parent filter with table qualification' do
218
+ let(:scope) { Document.joins(:tags) }
219
+ let(:filters) { { name: 'ruby' } }
220
+
221
+ let!(:ruby_tag) { Tag.find_or_create_by(name: 'ruby') }
222
+ let!(:js_tag) { Tag.find_or_create_by(name: 'javascript') }
223
+
224
+ before do
225
+ filters_class.filter_by :name, table: :tags
226
+
227
+ document.tags << [ruby_tag]
228
+ other_document.tags << [js_tag]
229
+ end
230
+
231
+ it 'uses the child class table qualification (tags.name)' do
232
+ expect(filter_instance.apply(scope)).to include(document)
233
+ end
234
+
235
+ it 'does not return documents with different tag names' do
236
+ expect(filter_instance.apply(scope)).not_to include(other_document)
237
+ end
238
+
239
+ it 'generates SQL that filters by tags.name, not documents.name' do
240
+ expect(filter_instance.apply(scope).to_sql).to include('"tags"."name"')
241
+ end
242
+
243
+ it 'generates SQL that does not include documents.name' do
244
+ expect(filter_instance.apply(scope).to_sql).not_to include('"documents"."name"')
245
+ end
246
+
247
+ it 'generates SQL that includes the tag filter value' do
248
+ expect(filter_instance.apply(scope).to_sql).to include("'ruby'")
249
+ end
250
+ end
251
+ end
156
252
  end
157
253
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kiroshi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Darthjee
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-18 00:00:00.000000000 Z
11
+ date: 2025-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -70,6 +70,7 @@ files:
70
70
  - lib/kiroshi/filter_query/like.rb
71
71
  - lib/kiroshi/filter_runner.rb
72
72
  - lib/kiroshi/filters.rb
73
+ - lib/kiroshi/filters/class_methods.rb
73
74
  - lib/kiroshi/version.rb
74
75
  - spec/integration/readme/.keep
75
76
  - spec/integration/yard/.keep
@@ -78,6 +79,7 @@ files:
78
79
  - spec/lib/kiroshi/filter_query_spec.rb
79
80
  - spec/lib/kiroshi/filter_runner_spec.rb
80
81
  - spec/lib/kiroshi/filter_spec.rb
82
+ - spec/lib/kiroshi/filters/class_methods_spec.rb
81
83
  - spec/lib/kiroshi/filters_spec.rb
82
84
  - spec/lib/kiroshi_spec.rb
83
85
  - spec/spec_helper.rb