kiroshi 0.1.0 → 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.
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiroshi
4
+ # @api private
5
+ # @author darthjee
6
+ #
7
+ # A filter runner that applies filtering logic to ActiveRecord scopes
8
+ #
9
+ # This class handles the actual application of filter logic to database queries,
10
+ # supporting both exact matches and partial matches using SQL LIKE operations.
11
+ # It separates the filter configuration from the filter execution logic.
12
+ #
13
+ # @example Creating and running a filter
14
+ # filter = Kiroshi::Filter.new(:name, match: :like)
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')
21
+ # result = runner.apply
22
+ #
23
+ # @since 0.1.0
24
+ class FilterRunner
25
+ # Creates a new FilterRunner instance
26
+ #
27
+ # @param filter [Kiroshi::Filter] the filter configuration
28
+ # @param scope [ActiveRecord::Relation] the scope to filter
29
+ # @param value [Object, nil] the specific value to use for filtering, defaults to nil
30
+ #
31
+ # @since 0.2.0
32
+ def initialize(filter:, scope:, value: nil)
33
+ @filter = filter
34
+ @scope = scope
35
+ @value = value
36
+ end
37
+
38
+ # Applies the filter logic to the scope
39
+ #
40
+ # This method contains the actual filtering logic, checking the filter's
41
+ # match type and applying the appropriate WHERE clause to the scope.
42
+ #
43
+ # @return [ActiveRecord::Relation] the filtered scope
44
+ #
45
+ # @example Applying exact match filter
46
+ # runner = FilterRunner.new(filter: filter, scope: scope, value: 'John')
47
+ # runner.apply
48
+ #
49
+ # @example Applying LIKE filter
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')
55
+ # runner.apply
56
+ #
57
+ # @example With no value (returns unchanged scope)
58
+ # runner = FilterRunner.new(filter: filter, scope: scope, value: nil)
59
+ # runner.apply
60
+ # # Returns the original scope unchanged
61
+ #
62
+ # @since 0.1.1
63
+ def apply
64
+ return scope unless value.present?
65
+
66
+ query_strategy = FilterQuery.for(filter.match).new(self)
67
+ query_strategy.apply
68
+ end
69
+
70
+ attr_reader :scope, :value
71
+
72
+ # @!method scope
73
+ # @api private
74
+ #
75
+ # Returns the current scope being filtered
76
+ #
77
+ # @return [ActiveRecord::Relation] the scope
78
+ #
79
+ # @since 0.1.1
80
+
81
+ # @!method value
82
+ # @api private
83
+ #
84
+ # Returns the filter value for the current filter
85
+ #
86
+ # @return [Object] the filter value or nil if not present
87
+ #
88
+ # @since 0.2.0
89
+
90
+ # Returns the table name to use for the filter
91
+ #
92
+ # This method prioritizes the filter's table_name over the scope's table_name.
93
+ # If the filter has a specific table_name configured, it uses that;
94
+ # otherwise, it falls back to the scope's table_name.
95
+ #
96
+ # @return [String] the table name to use for filtering
97
+ #
98
+ # @example With filter table_name specified
99
+ # filter = Kiroshi::Filter.new(:name, table: 'tags')
100
+ # runner = FilterRunner.new(filter: filter, scope: Document.joins(:tags), value: 'ruby')
101
+ # runner.table_name # => 'tags'
102
+ #
103
+ # @example Without filter table_name (fallback to scope)
104
+ # filter = Kiroshi::Filter.new(:name)
105
+ # runner = FilterRunner.new(filter: filter, scope: Document.all, value: 'test')
106
+ # runner.table_name # => 'documents'
107
+ #
108
+ # @since 0.1.1
109
+ def table_name
110
+ filter_table_name || scope_table_name
111
+ end
112
+
113
+ # @!method scope
114
+ # @api private
115
+ #
116
+ # Returns the current scope being filtered
117
+ #
118
+ # @return [ActiveRecord::Relation] the scope
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
+
127
+ private
128
+
129
+ attr_reader :filter
130
+
131
+ # @!method filter
132
+ # @api private
133
+ # @private
134
+ #
135
+ # Returns the filter configuration
136
+ #
137
+ # @return [Kiroshi::Filter] the filter configuration
138
+
139
+ delegate :attribute, to: :filter
140
+ delegate :table_name, to: :scope, prefix: true
141
+ delegate :table_name, to: :filter, prefix: true
142
+
143
+ # @!method attribute
144
+ # @api private
145
+ #
146
+ # Returns the attribute name to filter by
147
+ #
148
+ # @return [Symbol] the attribute name to filter by
149
+
150
+ # @!method scope_table_name
151
+ # @api private
152
+ #
153
+ # Returns the table name from the scope
154
+ #
155
+ # @return [String] the table name from the scope
156
+
157
+ # @!method filter_table_name
158
+ # @api private
159
+ #
160
+ # Returns the table name from the filter configuration
161
+ #
162
+ # @return [String, nil] the table name from the filter or nil if not specified
163
+ end
164
+ end
@@ -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,66 +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
- # @param attribute [Symbol] the attribute name to filter by
47
- # @param options [Hash] additional options passed to {Filter#initialize}
48
- # @option options [Symbol] :match (:exact) the matching type
49
- # - +:exact+ for exact matching (default)
50
- # - +:like+ for partial matching using SQL LIKE
51
- #
52
- # @return [Filter] the new filter instance
53
- #
54
- # @example Defining exact match filters
55
- # class ProductFilters < Kiroshi::Filters
56
- # filter_by :category
57
- # filter_by :brand
58
- # end
59
- #
60
- # @example Defining partial match filters
61
- # class SearchFilters < Kiroshi::Filters
62
- # filter_by :title, match: :like
63
- # filter_by :description, match: :like
64
- # end
65
- #
66
- # @example Mixed filter types
67
- # class OrderFilters < Kiroshi::Filters
68
- # filter_by :customer_name, match: :like
69
- # filter_by :status, match: :exact
70
- # filter_by :payment_method
71
- # end
72
- #
73
- # @since 0.1.0
74
- def filter_by(attribute, **)
75
- Filter.new(attribute, **).tap do |filter|
76
- filter_configs << filter
77
- end
78
- end
39
+ autoload :ClassMethods, 'kiroshi/filters/class_methods'
79
40
 
80
- # Returns the list of configured filters for this class
81
- #
82
- # @return [Array<Filter>] array of {Filter} instances configured
83
- # for this filter class
84
- #
85
- # @example Accessing configured filters
86
- # class MyFilters < Kiroshi::Filters
87
- # filter_by :name
88
- # filter_by :status, match: :like
89
- # end
90
- #
91
- # MyFilters.filter_configs.length # => 2
92
- # MyFilters.filter_configs.first.attribute # => :name
93
- #
94
- # @since 0.1.0
95
- def filter_configs
96
- @filter_configs ||= []
97
- end
98
- 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
99
102
 
100
103
  # Creates a new Filters instance
101
104
  #
@@ -153,10 +156,13 @@ module Kiroshi
153
156
  # filtered_articles = filters.apply(Article.all)
154
157
  # # Generates: WHERE title LIKE '%Ruby%'
155
158
  #
156
- # @since 0.1.0
159
+ # @since 0.2.0
157
160
  def apply(scope)
158
- self.class.filter_configs.each do |filter|
159
- 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)
160
166
  end
161
167
 
162
168
  scope
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kiroshi
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/kiroshi.rb CHANGED
@@ -2,8 +2,143 @@
2
2
 
3
3
  # @api public
4
4
  # @author darthjee
5
+ #
6
+ # Kiroshi - Flexible ActiveRecord Query Filtering
7
+ #
8
+ # Kiroshi provides a clean and extensible way to filter ActiveRecord queries
9
+ # using a declarative DSL. It supports multiple matching strategies and can
10
+ # be easily integrated into Rails controllers and other components.
11
+ #
12
+ # The gem is designed around the main concept:
13
+ # - {Filters}: A base class for creating reusable filter sets
14
+ #
15
+ # Individual filters are handled internally and don't require direct interaction.
16
+ #
17
+ # @example Basic filter class definition
18
+ # class DocumentFilters < Kiroshi::Filters
19
+ # filter_by :name, match: :like
20
+ # filter_by :status
21
+ # filter_by :category
22
+ # end
23
+ #
24
+ # # Usage
25
+ # filters = DocumentFilters.new(name: 'report', status: 'published')
26
+ # filtered_documents = filters.apply(Document.all)
27
+ # # Generates: WHERE name LIKE '%report%' AND status = 'published'
28
+ #
29
+ # @example Controller integration
30
+ # # URL: /articles?filter[title]=ruby&filter[author]=john&filter[category]=tech
31
+ # class ArticlesController < ApplicationController
32
+ # def index
33
+ # @articles = article_filters.apply(Article.published)
34
+ # render json: @articles
35
+ # end
36
+ #
37
+ # private
38
+ #
39
+ # def article_filters
40
+ # ArticleFilters.new(filter_params)
41
+ # end
42
+ #
43
+ # def filter_params
44
+ # params[:filter]&.permit(:title, :author, :category, :tag)
45
+ # end
46
+ # end
47
+ #
48
+ # class ArticleFilters < Kiroshi::Filters
49
+ # filter_by :title, match: :like
50
+ # filter_by :author, match: :like
51
+ # filter_by :category
52
+ # filter_by :tag
53
+ # end
54
+ #
55
+ # @example Advanced filtering scenarios
56
+ # class UserFilters < Kiroshi::Filters
57
+ # filter_by :email, match: :like
58
+ # filter_by :role
59
+ # filter_by :active, match: :exact
60
+ # filter_by :department
61
+ # end
62
+ #
63
+ # # Apply multiple filters
64
+ # filters = UserFilters.new(
65
+ # email: 'admin',
66
+ # role: 'moderator',
67
+ # active: true
68
+ # )
69
+ # filtered_users = filters.apply(User.includes(:department))
70
+ # # Generates: WHERE email LIKE '%admin%' AND role = 'moderator' AND active = true
71
+ #
72
+ # @example Empty value handling
73
+ # filters = DocumentFilters.new(name: '', status: 'published')
74
+ # result = filters.apply(Document.all)
75
+ # # Only status filter is applied, name is ignored due to empty value
76
+ #
77
+ # @example Chaining with existing scopes
78
+ # # URL: /orders?filter[status]=completed&filter[customer_name]=john
79
+ # class OrderFilters < Kiroshi::Filters
80
+ # filter_by :customer_name, match: :like
81
+ # filter_by :status
82
+ # filter_by :payment_method
83
+ # end
84
+ #
85
+ # # Apply to pre-filtered scope
86
+ # recent_orders = Order.where('created_at > ?', 1.month.ago)
87
+ # filters = OrderFilters.new(status: 'completed', customer_name: 'john')
88
+ # filtered_orders = filters.apply(recent_orders)
89
+ #
90
+ # @example Complex controller with pagination
91
+ # # URL: /products?filter[name]=laptop&filter[category]=electronics&filter[in_stock]=true&page=2
92
+ # class ProductsController < ApplicationController
93
+ # def index
94
+ # @products = filtered_products.page(params[:page])
95
+ # render json: {
96
+ # products: @products,
97
+ # total: filtered_products.count,
98
+ # filters_applied: applied_filter_count
99
+ # }
100
+ # end
101
+ #
102
+ # private
103
+ #
104
+ # def filtered_products
105
+ # @filtered_products ||= product_filters.apply(base_scope)
106
+ # end
107
+ #
108
+ # def base_scope
109
+ # Product.includes(:category, :brand).available
110
+ # end
111
+ #
112
+ # def product_filters
113
+ # ProductFilters.new(filter_params)
114
+ # end
115
+ #
116
+ # def filter_params
117
+ # params[:filter]&.permit(:name, :category, :brand, :price_range, :in_stock)
118
+ # end
119
+ #
120
+ # def applied_filter_count
121
+ # filter_params.compact.count
122
+ # end
123
+ # end
124
+ #
125
+ # class ProductFilters < Kiroshi::Filters
126
+ # filter_by :name, match: :like
127
+ # filter_by :category
128
+ # filter_by :brand
129
+ # filter_by :in_stock, match: :exact
130
+ # end
131
+ #
132
+ # @see Filters Base class for creating filter sets
133
+ # @see https://github.com/darthjee/kiroshi GitHub repository
134
+ # @see https://www.rubydoc.info/gems/kiroshi YARD documentation
135
+ #
136
+ # @since 0.1.0
5
137
  module Kiroshi
6
- autoload :VERSION, 'kiroshi/version'
7
- autoload :Filters, 'kiroshi/filters'
8
- autoload :Filter, 'kiroshi/filter'
138
+ autoload :VERSION, 'kiroshi/version'
139
+
140
+ autoload :Filters, 'kiroshi/filters'
141
+ autoload :Filter, 'kiroshi/filter'
142
+ autoload :FilterRunner, 'kiroshi/filter_runner'
143
+ autoload :FilterQuery, 'kiroshi/filter_query'
9
144
  end