kiroshi 0.1.0 → 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.
data/lib/kiroshi.rb CHANGED
@@ -2,8 +2,162 @@
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 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
5
156
  module Kiroshi
6
- autoload :VERSION, 'kiroshi/version'
7
- autoload :Filters, 'kiroshi/filters'
8
- autoload :Filter, 'kiroshi/filter'
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'
9
163
  end
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Kiroshi::FilterQuery::Exact, type: :model do
6
+ describe '#apply' do
7
+ subject(:query) { described_class.new(filter_runner) }
8
+
9
+ let(:filter_runner) { Kiroshi::FilterRunner.new(filter: filter, scope: scope, filters: filters) }
10
+ let(:filter) { Kiroshi::Filter.new(:name, match: :exact) }
11
+ let(:scope) { Document.all }
12
+ let(:filter_value) { 'test_document' }
13
+ let(:filters) { { name: filter_value } }
14
+
15
+ let!(:matching_document) { create(:document, name: 'test_document') }
16
+ let!(:non_matching_document) { create(:document, name: 'other_document') }
17
+
18
+ let(:expected_sql) do
19
+ <<~SQL.squish
20
+ SELECT "documents".* FROM "documents" WHERE "documents"."name" = 'test_document'
21
+ SQL
22
+ end
23
+
24
+ it 'returns records that exactly match the filter value' do
25
+ expect(query.apply).to include(matching_document)
26
+ end
27
+
28
+ it 'does not return records that do not exactly match' do
29
+ expect(query.apply).not_to include(non_matching_document)
30
+ end
31
+
32
+ it 'generates correct SQL with exact equality' do
33
+ expect(query.apply.to_sql).to eq(expected_sql)
34
+ end
35
+
36
+ context 'when filtering by status attribute' do
37
+ let(:filter) { Kiroshi::Filter.new(:status, match: :exact) }
38
+ let(:filter_value) { 'published' }
39
+ let(:filters) { { status: filter_value } }
40
+
41
+ let!(:published_document) { create(:document, status: 'published') }
42
+ let!(:draft_document) { create(:document, status: 'draft') }
43
+
44
+ let(:expected_sql) do
45
+ <<~SQL.squish
46
+ SELECT "documents".* FROM "documents" WHERE "documents"."status" = 'published'
47
+ SQL
48
+ end
49
+
50
+ it 'returns documents with exact status match' do
51
+ expect(query.apply).to include(published_document)
52
+ end
53
+
54
+ it 'does not return documents without exact status match' do
55
+ expect(query.apply).not_to include(draft_document)
56
+ end
57
+
58
+ it 'generates correct SQL for status filtering' do
59
+ expect(query.apply.to_sql).to eq(expected_sql)
60
+ end
61
+ end
62
+
63
+ context 'when filtering with numeric values' do
64
+ let(:filter) { Kiroshi::Filter.new(:priority, match: :exact) }
65
+ let(:filter_value) { 1 }
66
+ let(:filters) { { priority: filter_value } }
67
+
68
+ let!(:high_priority_document) { create(:document, priority: 1) }
69
+ let!(:medium_priority_document) { create(:document, priority: 2) }
70
+
71
+ let(:expected_sql) do
72
+ <<~SQL.squish
73
+ SELECT "documents".* FROM "documents" WHERE "documents"."priority" = 1
74
+ SQL
75
+ end
76
+
77
+ it 'returns documents with exact numeric match' do
78
+ expect(query.apply).to include(high_priority_document)
79
+ end
80
+
81
+ it 'does not return documents without exact numeric match' do
82
+ expect(query.apply).not_to include(medium_priority_document)
83
+ end
84
+
85
+ it 'generates correct SQL for numeric filtering' do
86
+ expect(query.apply.to_sql).to eq(expected_sql)
87
+ end
88
+ end
89
+
90
+ context 'when filtering with boolean values' do
91
+ let(:filter) { Kiroshi::Filter.new(:active, match: :exact) }
92
+ let(:filter_value) { true }
93
+ let(:filters) { { active: filter_value } }
94
+
95
+ let!(:active_document) { create(:document, active: true) }
96
+ let!(:inactive_document) { create(:document, active: false) }
97
+
98
+ let(:expected_sql) do
99
+ <<~SQL.squish
100
+ SELECT "documents".* FROM "documents" WHERE "documents"."active" = 1
101
+ SQL
102
+ end
103
+
104
+ it 'returns documents with exact boolean match' do
105
+ expect(query.apply).to include(active_document)
106
+ end
107
+
108
+ it 'does not return documents without exact boolean match' do
109
+ expect(query.apply).not_to include(inactive_document)
110
+ end
111
+
112
+ it 'generates correct SQL for boolean filtering' do
113
+ expect(query.apply.to_sql).to eq(expected_sql)
114
+ end
115
+ end
116
+
117
+ context 'when no records match' do
118
+ let(:filter_value) { 'nonexistent_value' }
119
+
120
+ let(:expected_sql) do
121
+ <<~SQL.squish
122
+ SELECT "documents".* FROM "documents" WHERE "documents"."name" = 'nonexistent_value'
123
+ SQL
124
+ end
125
+
126
+ it 'returns empty relation' do
127
+ expect(query.apply).to be_empty
128
+ end
129
+
130
+ it 'still generates valid SQL' do
131
+ expect(query.apply.to_sql).to eq(expected_sql)
132
+ end
133
+ end
134
+
135
+ context 'with case sensitivity' do
136
+ let(:filter_value) { 'Test_Document' }
137
+ let!(:lowercase_document) { create(:document, name: 'test_document') }
138
+ let!(:uppercase_document) { create(:document, name: 'TEST_DOCUMENT') }
139
+ let!(:mixedcase_document) { create(:document, name: 'Test_Document') }
140
+
141
+ it 'includes documents with exact case match' do
142
+ expect(query.apply).to include(mixedcase_document)
143
+ end
144
+
145
+ it 'excludes documents with lowercase' do
146
+ expect(query.apply).not_to include(lowercase_document)
147
+ end
148
+
149
+ it 'excludes documents with upcase' do
150
+ expect(query.apply).not_to include(uppercase_document)
151
+ end
152
+ end
153
+
154
+ context 'when filter has table configured' do
155
+ let(:scope) { Document.joins(:tags) }
156
+ let(:filter_value) { 'ruby' }
157
+ let(:filters) { { name: filter_value } }
158
+
159
+ let!(:first_tag) { Tag.find_or_create_by(name: 'ruby') }
160
+ let!(:second_tag) { Tag.find_or_create_by(name: 'javascript') }
161
+
162
+ let!(:document_with_ruby_tag) { create(:document, name: 'My Document') }
163
+ let!(:document_with_js_tag) { create(:document, name: 'JS Guide') }
164
+ let!(:document_without_tag) { create(:document, name: 'Other Document') }
165
+
166
+ before do
167
+ Tag.find_or_create_by(name: 'programming')
168
+ document_with_ruby_tag.tags << [first_tag]
169
+ document_with_js_tag.tags << [second_tag]
170
+ end
171
+
172
+ context 'when filtering by tags table' do
173
+ let(:filter) { Kiroshi::Filter.new(:name, match: :exact, table: :tags) }
174
+
175
+ let(:expected_sql) do
176
+ <<~SQL.squish
177
+ SELECT "documents".* FROM "documents"#{' '}
178
+ INNER JOIN "documents_tags" ON "documents_tags"."document_id" = "documents"."id"#{' '}
179
+ INNER JOIN "tags" ON "tags"."id" = "documents_tags"."tag_id"#{' '}
180
+ WHERE "tags"."name" = 'ruby'
181
+ SQL
182
+ end
183
+
184
+ it 'returns documents with tags that exactly match the filter value' do
185
+ expect(query.apply).to include(document_with_ruby_tag)
186
+ end
187
+
188
+ it 'does not return documents with tags that do not exactly match' do
189
+ expect(query.apply).not_to include(document_with_js_tag)
190
+ end
191
+
192
+ it 'does not return documents without matching tags' do
193
+ expect(query.apply).not_to include(document_without_tag)
194
+ end
195
+
196
+ it 'generates SQL with tags table qualification' do
197
+ expect(query.apply.to_sql).to eq(expected_sql)
198
+ end
199
+ end
200
+
201
+ context 'when filtering by documents table explicitly' do
202
+ let(:filter) { Kiroshi::Filter.new(:name, match: :exact, table: :documents) }
203
+ let(:filter_value) { 'JS Guide' }
204
+
205
+ let(:expected_sql) do
206
+ <<~SQL.squish
207
+ SELECT "documents".* FROM "documents"#{' '}
208
+ INNER JOIN "documents_tags" ON "documents_tags"."document_id" = "documents"."id"#{' '}
209
+ INNER JOIN "tags" ON "tags"."id" = "documents_tags"."tag_id"#{' '}
210
+ WHERE "documents"."name" = 'JS Guide'
211
+ SQL
212
+ end
213
+
214
+ it 'returns documents that exactly match the filter value in document name' do
215
+ expect(query.apply).to include(document_with_js_tag)
216
+ end
217
+
218
+ it 'does not return documents that do not exactly match document name' do
219
+ expect(query.apply).not_to include(document_with_ruby_tag)
220
+ end
221
+
222
+ it 'does not return documents without exact document name match' do
223
+ expect(query.apply).not_to include(document_without_tag)
224
+ end
225
+
226
+ it 'generates SQL with documents table qualification' do
227
+ expect(query.apply.to_sql).to eq(expected_sql)
228
+ end
229
+ end
230
+
231
+ context 'when table is specified as string' do
232
+ let(:filter) { Kiroshi::Filter.new(:name, match: :exact, table: 'tags') }
233
+
234
+ let(:expected_sql) do
235
+ <<~SQL.squish
236
+ SELECT "documents".* FROM "documents"#{' '}
237
+ INNER JOIN "documents_tags" ON "documents_tags"."document_id" = "documents"."id"#{' '}
238
+ INNER JOIN "tags" ON "tags"."id" = "documents_tags"."tag_id"#{' '}
239
+ WHERE "tags"."name" = 'ruby'
240
+ SQL
241
+ end
242
+
243
+ it 'works the same as with symbol table name' do
244
+ expect(query.apply).to include(document_with_ruby_tag)
245
+ end
246
+
247
+ it 'generates SQL with string table qualification' do
248
+ expect(query.apply.to_sql).to eq(expected_sql)
249
+ end
250
+ end
251
+
252
+ context 'when filtering by different attributes with table qualification' do
253
+ let(:filter) { Kiroshi::Filter.new(:id, match: :exact, table: :tags) }
254
+ let(:filter_value) { first_tag.id }
255
+ let(:filters) { { id: filter_value } }
256
+
257
+ let(:expected_sql) do
258
+ <<~SQL.squish
259
+ SELECT "documents".* FROM "documents"#{' '}
260
+ INNER JOIN "documents_tags" ON "documents_tags"."document_id" = "documents"."id"#{' '}
261
+ INNER JOIN "tags" ON "tags"."id" = "documents_tags"."tag_id"#{' '}
262
+ WHERE "tags"."id" = #{first_tag.id}
263
+ SQL
264
+ end
265
+
266
+ it 'returns documents with tags that match the tag id' do
267
+ expect(query.apply).to include(document_with_ruby_tag)
268
+ end
269
+
270
+ it 'does not return documents without the matching tag id' do
271
+ expect(query.apply).not_to include(document_with_js_tag)
272
+ end
273
+
274
+ it 'generates SQL with tags table qualification for id attribute' do
275
+ expect(query.apply.to_sql).to eq(expected_sql)
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end