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.
- checksums.yaml +4 -4
- data/README.md +94 -5
- data/lib/kiroshi/filter.rb +41 -30
- data/lib/kiroshi/filter_query/exact.rb +38 -0
- data/lib/kiroshi/filter_query/like.rb +42 -0
- data/lib/kiroshi/filter_query.rb +131 -0
- data/lib/kiroshi/filter_runner.rb +152 -0
- data/lib/kiroshi/filters.rb +12 -5
- data/lib/kiroshi/version.rb +1 -1
- data/lib/kiroshi.rb +157 -3
- data/spec/lib/kiroshi/filter_query/exact_spec.rb +280 -0
- data/spec/lib/kiroshi/filter_query/like_spec.rb +275 -0
- data/spec/lib/kiroshi/filter_query_spec.rb +39 -0
- data/spec/lib/kiroshi/filter_runner_spec.rb +110 -0
- data/spec/lib/kiroshi/filters_spec.rb +63 -0
- data/spec/support/db/schema.rb +14 -0
- data/spec/support/factories/tag.rb +7 -0
- data/spec/support/models/document.rb +2 -0
- data/spec/support/models/tag.rb +7 -0
- metadata +12 -2
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,
|
7
|
-
|
8
|
-
autoload :
|
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
|