kiroshi 0.0.1 → 0.1.1

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,152 @@
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, filters: { name: 'John' })
16
+ # result = runner.apply
17
+ #
18
+ # @since 0.1.0
19
+ class FilterRunner
20
+ # Creates a new FilterRunner instance
21
+ #
22
+ # @param filter [Kiroshi::Filter] the filter configuration
23
+ # @param scope [ActiveRecord::Relation] the scope to filter
24
+ # @param filters [Hash] a hash containing filter values
25
+ #
26
+ # @since 0.1.0
27
+ def initialize(filter:, scope:, filters:)
28
+ @filter = filter
29
+ @scope = scope
30
+ @filters = filters
31
+ end
32
+
33
+ # Applies the filter logic to the scope
34
+ #
35
+ # This method contains the actual filtering logic, checking the filter's
36
+ # match type and applying the appropriate WHERE clause to the scope.
37
+ #
38
+ # @return [ActiveRecord::Relation] the filtered scope
39
+ #
40
+ # @example Applying exact match filter
41
+ # runner = FilterRunner.new(filter: filter, scope: scope, filters: { name: 'John' })
42
+ # runner.apply
43
+ #
44
+ # @example Applying LIKE filter
45
+ # runner = FilterRunner.new(filter: filter, scope: scope, filters: { title: 'Ruby' })
46
+ # runner.apply
47
+ #
48
+ # @example With no matching value
49
+ # runner = FilterRunner.new(filter: filter, scope: scope, filters: { name: nil })
50
+ # runner.apply
51
+ # # Returns the original scope unchanged
52
+ #
53
+ # @since 0.1.1
54
+ def apply
55
+ return scope unless filter_value.present?
56
+
57
+ query_strategy = FilterQuery.for(filter.match).new(self)
58
+ query_strategy.apply
59
+ end
60
+
61
+ # Returns the filter value for the current filter's attribute
62
+ #
63
+ # @return [Object, nil] the filter value or nil if not present
64
+ #
65
+ # @since 0.1.1
66
+ def filter_value
67
+ filters[filter.attribute]
68
+ end
69
+
70
+ # Returns the current scope being filtered
71
+ #
72
+ # @return [ActiveRecord::Relation] the scope
73
+ #
74
+ # @since 0.1.1
75
+ attr_reader :scope
76
+
77
+ # Returns the table name to use for the filter
78
+ #
79
+ # This method prioritizes the filter's table_name over the scope's table_name.
80
+ # If the filter has a specific table_name configured, it uses that;
81
+ # otherwise, it falls back to the scope's table_name.
82
+ #
83
+ # @return [String] the table name to use for filtering
84
+ #
85
+ # @example With filter table_name specified
86
+ # filter = Kiroshi::Filter.new(:name, table: 'tags')
87
+ # runner = FilterRunner.new(filter: filter, scope: Document.joins(:tags), filters: {})
88
+ # runner.table_name # => 'tags'
89
+ #
90
+ # @example Without filter table_name (fallback to scope)
91
+ # filter = Kiroshi::Filter.new(:name)
92
+ # runner = FilterRunner.new(filter: filter, scope: Document.all, filters: {})
93
+ # runner.table_name # => 'documents'
94
+ #
95
+ # @since 0.1.1
96
+ def table_name
97
+ filter_table_name || scope_table_name
98
+ end
99
+
100
+ # @!method scope
101
+ # @api private
102
+ #
103
+ # Returns the current scope being filtered
104
+ #
105
+ # @return [ActiveRecord::Relation] the scope
106
+
107
+ private
108
+
109
+ attr_reader :filter, :filters
110
+
111
+ # @!method filter
112
+ # @api private
113
+ # @private
114
+ #
115
+ # Returns the filter configuration
116
+ #
117
+ # @return [Kiroshi::Filter] the filter configuration
118
+
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
+ delegate :attribute, to: :filter
128
+ delegate :table_name, to: :scope, prefix: true
129
+ delegate :table_name, to: :filter, prefix: true
130
+
131
+ # @!method attribute
132
+ # @api private
133
+ #
134
+ # Returns the attribute name to filter by
135
+ #
136
+ # @return [Symbol] the attribute name to filter by
137
+
138
+ # @!method scope_table_name
139
+ # @api private
140
+ #
141
+ # Returns the table name from the scope
142
+ #
143
+ # @return [String] the table name from the scope
144
+
145
+ # @!method filter_table_name
146
+ # @api private
147
+ #
148
+ # Returns the table name from the filter configuration
149
+ #
150
+ # @return [String, nil] the table name from the filter or nil if not specified
151
+ end
152
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiroshi
4
+ # @api public
5
+ # Base class for implementing filter sets on ActiveRecord scopes
6
+ #
7
+ # This class provides a foundation for creating reusable filter collections
8
+ # that can be applied to ActiveRecord queries. It uses a class-level DSL
9
+ # to define filters and an instance-level interface to apply them.
10
+ #
11
+ # The class is designed to be inherited by specific filter implementations
12
+ # that define their own set of filters using the {.filter_by} method.
13
+ #
14
+ # @api public
15
+ # @author darthjee
16
+ #
17
+ # @example Basic usage with inheritance
18
+ # class DocumentFilters < Kiroshi::Filters
19
+ # filter_by :name, match: :like
20
+ # filter_by :status
21
+ # filter_by :created_at, match: :exact
22
+ # end
23
+ #
24
+ # filters = DocumentFilters.new(name: 'report', status: 'published')
25
+ # filtered_documents = filters.apply(Document.all)
26
+ #
27
+ # @example Multiple filter types
28
+ # class UserFilters < Kiroshi::Filters
29
+ # filter_by :email, match: :like
30
+ # filter_by :role
31
+ # filter_by :active, match: :exact
32
+ # end
33
+ #
34
+ # filters = UserFilters.new(email: 'admin', role: 'moderator')
35
+ # filtered_users = filters.apply(User.all)
36
+ #
37
+ # @since 0.1.0
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
86
+
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
106
+
107
+ # Creates a new Filters instance
108
+ #
109
+ # @param filters [Hash] a hash containing the filter values to be applied.
110
+ # Keys should correspond to attributes defined with {.filter_by}.
111
+ # Values will be used for filtering. Nil or blank values are ignored.
112
+ #
113
+ # @example Creating filters with values
114
+ # filters = DocumentFilters.new(
115
+ # name: 'annual report',
116
+ # status: 'published',
117
+ # category: 'finance'
118
+ # )
119
+ #
120
+ # @example Creating filters with partial values
121
+ # filters = UserFilters.new(email: 'admin') # Only email filter will be applied
122
+ #
123
+ # @example Creating empty filters
124
+ # filters = ProductFilters.new({}) # No filters will be applied
125
+ #
126
+ # @since 0.1.0
127
+ def initialize(filters = {})
128
+ @filters = filters || {}
129
+ end
130
+
131
+ # Applies all configured filters to the given scope
132
+ #
133
+ # This method iterates through all filters defined via {.filter_by}
134
+ # and applies each one sequentially to the scope. Filters with no
135
+ # corresponding value in the filters hash or with blank values are
136
+ # automatically skipped.
137
+ #
138
+ # @param scope [ActiveRecord::Relation] the ActiveRecord scope to filter
139
+ #
140
+ # @return [ActiveRecord::Relation] the filtered scope with all
141
+ # applicable filters applied
142
+ #
143
+ # @example Applying filters to a scope
144
+ # class ArticleFilters < Kiroshi::Filters
145
+ # filter_by :title, match: :like
146
+ # filter_by :published, match: :exact
147
+ # end
148
+ #
149
+ # filters = ArticleFilters.new(title: 'Ruby', published: true)
150
+ # filtered_articles = filters.apply(Article.all)
151
+ # # Generates: WHERE title LIKE '%Ruby%' AND published = true
152
+ #
153
+ # @example With empty filters
154
+ # filters = ArticleFilters.new({})
155
+ # filtered_articles = filters.apply(Article.all)
156
+ # # Returns the original scope unchanged
157
+ #
158
+ # @example With partial filters
159
+ # filters = ArticleFilters.new(title: 'Ruby') # published filter ignored
160
+ # filtered_articles = filters.apply(Article.all)
161
+ # # Generates: WHERE title LIKE '%Ruby%'
162
+ #
163
+ # @since 0.1.0
164
+ def apply(scope)
165
+ self.class.filter_configs.each do |filter|
166
+ scope = filter.apply(scope, filters)
167
+ end
168
+
169
+ scope
170
+ end
171
+
172
+ private
173
+
174
+ attr_reader :filters
175
+
176
+ # @!method filters
177
+ # @api private
178
+ # @private
179
+ #
180
+ # Returns the hash of filter values to be applied
181
+ #
182
+ # @return [Hash] the hash of filter values to be applied
183
+ end
184
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Kiroshi
4
- VERSION = '0.0.1'
3
+ module Kiroshi
4
+ VERSION = '0.1.1'
5
5
  end
data/lib/kiroshi.rb CHANGED
@@ -2,6 +2,162 @@
2
2
 
3
3
  # @api public
4
4
  # @author darthjee
5
- class Kiroshi
6
- autoload :VERSION, 'kiroshi/version'
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 two main concepts:
13
+ # - {Filters}: A base class for creating reusable filter sets
14
+ # - {Filter}: Individual filters that can be applied to scopes
15
+ #
16
+ # @example Basic filter class definition
17
+ # class DocumentFilters < Kiroshi::Filters
18
+ # filter_by :name, match: :like
19
+ # filter_by :status
20
+ # filter_by :category
21
+ # end
22
+ #
23
+ # # Usage
24
+ # filters = DocumentFilters.new(name: 'report', status: 'published')
25
+ # filtered_documents = filters.apply(Document.all)
26
+ # # Generates: WHERE name LIKE '%report%' AND status = 'published'
27
+ #
28
+ # @example Controller integration
29
+ # # URL: /articles?filter[title]=ruby&filter[author]=john&filter[category]=tech
30
+ # class ArticlesController < ApplicationController
31
+ # def index
32
+ # @articles = article_filters.apply(Article.published)
33
+ # render json: @articles
34
+ # end
35
+ #
36
+ # private
37
+ #
38
+ # def article_filters
39
+ # ArticleFilters.new(filter_params)
40
+ # end
41
+ #
42
+ # def filter_params
43
+ # params[:filter]&.permit(:title, :author, :category, :tag)
44
+ # end
45
+ # end
46
+ #
47
+ # class ArticleFilters < Kiroshi::Filters
48
+ # filter_by :title, match: :like
49
+ # filter_by :author, match: :like
50
+ # filter_by :category
51
+ # filter_by :tag
52
+ # end
53
+ #
54
+ # @example Advanced filtering scenarios
55
+ # class UserFilters < Kiroshi::Filters
56
+ # filter_by :email, match: :like
57
+ # filter_by :role
58
+ # filter_by :active, match: :exact
59
+ # filter_by :department
60
+ # end
61
+ #
62
+ # # Apply multiple filters
63
+ # filters = UserFilters.new(
64
+ # email: 'admin',
65
+ # role: 'moderator',
66
+ # active: true
67
+ # )
68
+ # filtered_users = filters.apply(User.includes(:department))
69
+ # # Generates: WHERE email LIKE '%admin%' AND role = 'moderator' AND active = true
70
+ #
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
+ # @example Empty value handling
91
+ # filters = DocumentFilters.new(name: '', status: 'published')
92
+ # result = filters.apply(Document.all)
93
+ # # Only status filter is applied, name is ignored due to empty value
94
+ #
95
+ # @example Chaining with existing scopes
96
+ # # URL: /orders?filter[status]=completed&filter[customer_name]=john
97
+ # class OrderFilters < Kiroshi::Filters
98
+ # filter_by :customer_name, match: :like
99
+ # filter_by :status
100
+ # filter_by :payment_method
101
+ # end
102
+ #
103
+ # # Apply to pre-filtered scope
104
+ # recent_orders = Order.where('created_at > ?', 1.month.ago)
105
+ # filters = OrderFilters.new(status: 'completed', customer_name: 'john')
106
+ # filtered_orders = filters.apply(recent_orders)
107
+ #
108
+ # @example Complex controller with pagination
109
+ # # URL: /products?filter[name]=laptop&filter[category]=electronics&filter[in_stock]=true&page=2
110
+ # class ProductsController < ApplicationController
111
+ # def index
112
+ # @products = filtered_products.page(params[:page])
113
+ # render json: {
114
+ # products: @products,
115
+ # total: filtered_products.count,
116
+ # filters_applied: applied_filter_count
117
+ # }
118
+ # end
119
+ #
120
+ # private
121
+ #
122
+ # def filtered_products
123
+ # @filtered_products ||= product_filters.apply(base_scope)
124
+ # end
125
+ #
126
+ # def base_scope
127
+ # Product.includes(:category, :brand).available
128
+ # end
129
+ #
130
+ # def product_filters
131
+ # ProductFilters.new(filter_params)
132
+ # end
133
+ #
134
+ # def filter_params
135
+ # params[:filter]&.permit(:name, :category, :brand, :price_range, :in_stock)
136
+ # end
137
+ #
138
+ # def applied_filter_count
139
+ # filter_params.compact.count
140
+ # end
141
+ # end
142
+ #
143
+ # class ProductFilters < Kiroshi::Filters
144
+ # filter_by :name, match: :like
145
+ # filter_by :category
146
+ # filter_by :brand
147
+ # filter_by :in_stock, match: :exact
148
+ # end
149
+ #
150
+ # @see Filters Base class for creating filter sets
151
+ # @see Filter Individual filter implementation
152
+ # @see https://github.com/darthjee/kiroshi GitHub repository
153
+ # @see https://www.rubydoc.info/gems/kiroshi YARD documentation
154
+ #
155
+ # @since 0.1.0
156
+ module Kiroshi
157
+ autoload :VERSION, 'kiroshi/version'
158
+
159
+ autoload :Filters, 'kiroshi/filters'
160
+ autoload :Filter, 'kiroshi/filter'
161
+ autoload :FilterRunner, 'kiroshi/filter_runner'
162
+ autoload :FilterQuery, 'kiroshi/filter_query'
7
163
  end