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.
@@ -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
@@ -90,5 +90,164 @@ RSpec.describe Kiroshi::Filters, type: :model do
90
90
  expect(filter_instance.apply(scope)).to eq(scope)
91
91
  end
92
92
  end
93
+
94
+ context 'when scope has joined tables with clashing fields' do
95
+ let(:scope) { Document.joins(:tags) }
96
+ let(:filters) { { name: 'test_name' } }
97
+
98
+ let!(:first_tag) { Tag.find_or_create_by(name: 'ruby') }
99
+ let!(:second_tag) { Tag.find_or_create_by(name: 'programming') }
100
+
101
+ before do
102
+ filters_class.filter_by :name
103
+ document.tags << [first_tag, second_tag]
104
+ other_document.tags << [first_tag]
105
+ end
106
+
107
+ it 'filters by document name, not tag name' do
108
+ result = filter_instance.apply(scope)
109
+ expect(result).to include(document)
110
+ end
111
+
112
+ it 'does not return documents that do not match document name' do
113
+ result = filter_instance.apply(scope)
114
+ expect(result).not_to include(other_document)
115
+ end
116
+
117
+ it 'generates SQL that includes documents table qualification for name field' do
118
+ result = filter_instance.apply(scope)
119
+ expect(result.to_sql).to include('"documents"."name"')
120
+ end
121
+
122
+ it 'generates SQL that includes the filter value' do
123
+ result = filter_instance.apply(scope)
124
+ expect(result.to_sql).to include("'test_name'")
125
+ end
126
+
127
+ context 'when using like filter' do
128
+ let(:filters) { { name: 'test' } }
129
+
130
+ before do
131
+ filters_class.filter_by :name, match: :like
132
+ end
133
+
134
+ it 'filters by document name with LIKE operation' do
135
+ result = filter_instance.apply(scope)
136
+ expect(result).to include(document)
137
+ end
138
+
139
+ it 'does not return documents that do not match document name pattern' do
140
+ result = filter_instance.apply(scope)
141
+ expect(result).not_to include(other_document)
142
+ end
143
+
144
+ it 'generates SQL with table-qualified LIKE operation' do
145
+ result = filter_instance.apply(scope)
146
+ expect(result.to_sql).to include('documents.name LIKE')
147
+ end
148
+
149
+ it 'generates SQL with correct LIKE pattern' do
150
+ result = filter_instance.apply(scope)
151
+ expect(result.to_sql).to include("'%test%'")
152
+ end
153
+ end
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
93
252
  end
94
253
  end
@@ -6,5 +6,19 @@ ActiveRecord::Schema.define do
6
6
  create_table :documents, force: true do |t|
7
7
  t.string :name
8
8
  t.string :status
9
+ t.boolean :active
10
+ t.integer :priority
11
+ t.string :version
12
+ end
13
+
14
+ create_table :tags, force: true do |t|
15
+ t.string :name, null: false
16
+ t.index :name, unique: true
17
+ end
18
+
19
+ create_table :documents_tags, force: true do |t|
20
+ t.references :document, null: false, foreign_key: true
21
+ t.references :tag, null: false, foreign_key: true
22
+ t.index %i[document_id tag_id], unique: true
9
23
  end
10
24
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :tag, class: '::Tag' do
5
+ sequence(:name) { |n| "tag-#{n}" }
6
+ end
7
+ end
@@ -2,4 +2,6 @@
2
2
 
3
3
  class Document < ActiveRecord::Base
4
4
  validates :name, presence: true
5
+
6
+ has_and_belongs_to_many :tags
5
7
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tag < ActiveRecord::Base
4
+ validates :name, presence: true, uniqueness: true
5
+
6
+ has_and_belongs_to_many :documents
7
+ 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.0
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-17 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
@@ -65,19 +65,31 @@ files:
65
65
  - kiroshi.jpg
66
66
  - lib/kiroshi.rb
67
67
  - lib/kiroshi/filter.rb
68
+ - lib/kiroshi/filter_query.rb
69
+ - lib/kiroshi/filter_query/exact.rb
70
+ - lib/kiroshi/filter_query/like.rb
71
+ - lib/kiroshi/filter_runner.rb
68
72
  - lib/kiroshi/filters.rb
73
+ - lib/kiroshi/filters/class_methods.rb
69
74
  - lib/kiroshi/version.rb
70
75
  - spec/integration/readme/.keep
71
76
  - spec/integration/yard/.keep
77
+ - spec/lib/kiroshi/filter_query/exact_spec.rb
78
+ - spec/lib/kiroshi/filter_query/like_spec.rb
79
+ - spec/lib/kiroshi/filter_query_spec.rb
80
+ - spec/lib/kiroshi/filter_runner_spec.rb
72
81
  - spec/lib/kiroshi/filter_spec.rb
82
+ - spec/lib/kiroshi/filters/class_methods_spec.rb
73
83
  - spec/lib/kiroshi/filters_spec.rb
74
84
  - spec/lib/kiroshi_spec.rb
75
85
  - spec/spec_helper.rb
76
86
  - spec/support/db/schema.rb
77
87
  - spec/support/factories/document.rb
88
+ - spec/support/factories/tag.rb
78
89
  - spec/support/factory_bot.rb
79
90
  - spec/support/models/.keep
80
91
  - spec/support/models/document.rb
92
+ - spec/support/models/tag.rb
81
93
  - spec/support/shared_examples/.keep
82
94
  homepage: https://github.com/darthjee/kiroshi
83
95
  licenses: []