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.
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Kiroshi::FilterQuery::Like, 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: :like) }
11
+ let(:scope) { Document.all }
12
+ let(:filter_value) { 'test' }
13
+ let(:filters) { { name: filter_value } }
14
+
15
+ let!(:matching_document) { create(:document, name: 'test_document') }
16
+ let!(:another_match) { create(:document, name: 'my_test_file') }
17
+ let!(:non_matching_document) { create(:document, name: 'other_document') }
18
+
19
+ let(:expected_sql) do
20
+ <<~SQL.squish
21
+ SELECT "documents".* FROM "documents" WHERE (documents.name LIKE '%test%')
22
+ SQL
23
+ end
24
+
25
+ it 'returns records that partially match the filter value' do
26
+ expect(query.apply).to include(matching_document)
27
+ end
28
+
29
+ it 'returns multiple records that contain the filter value' do
30
+ expect(query.apply).to include(another_match)
31
+ end
32
+
33
+ it 'does not return records that do not contain the filter value' do
34
+ expect(query.apply).not_to include(non_matching_document)
35
+ end
36
+
37
+ it 'generates correct SQL with LIKE operation' do
38
+ expect(query.apply.to_sql).to eq(expected_sql)
39
+ end
40
+
41
+ context 'when filtering by status attribute' do
42
+ let(:filter) { Kiroshi::Filter.new(:status, match: :like) }
43
+ let(:filter_value) { 'pub' }
44
+ let(:filters) { { status: filter_value } }
45
+
46
+ let!(:published_document) { create(:document, status: 'published') }
47
+ let!(:republished_document) { create(:document, status: 'republished') }
48
+ let!(:draft_document) { create(:document, status: 'draft') }
49
+
50
+ let(:expected_sql) do
51
+ <<~SQL.squish
52
+ SELECT "documents".* FROM "documents" WHERE (documents.status LIKE '%pub%')
53
+ SQL
54
+ end
55
+
56
+ it 'returns documents with partial status match' do
57
+ expect(query.apply).to include(published_document)
58
+ end
59
+
60
+ it 'returns documents with partial match in different positions' do
61
+ expect(query.apply).to include(republished_document)
62
+ end
63
+
64
+ it 'does not return documents without partial status match' do
65
+ expect(query.apply).not_to include(draft_document)
66
+ end
67
+
68
+ it 'generates correct SQL for status filtering' do
69
+ expect(query.apply.to_sql).to eq(expected_sql)
70
+ end
71
+ end
72
+
73
+ context 'when filtering with numeric values as strings' do
74
+ let(:filter) { Kiroshi::Filter.new(:version, match: :like) }
75
+ let(:filter_value) { '1.2' }
76
+ let(:filters) { { version: filter_value } }
77
+
78
+ let!(:version_match) { create(:document, version: '1.2.3') }
79
+ let!(:another_version) { create(:document, version: '2.1.2') }
80
+ let!(:different_version) { create(:document, version: '3.0.0') }
81
+
82
+ let(:expected_sql) do
83
+ <<~SQL.squish
84
+ SELECT "documents".* FROM "documents" WHERE (documents.version LIKE '%1.2%')
85
+ SQL
86
+ end
87
+
88
+ it 'returns documents with partial numeric match' do
89
+ expect(query.apply).to include(version_match)
90
+ end
91
+
92
+ it 'returns documents with partial match in different positions' do
93
+ expect(query.apply).to include(another_version)
94
+ end
95
+
96
+ it 'does not return documents without partial numeric match' do
97
+ expect(query.apply).not_to include(different_version)
98
+ end
99
+
100
+ it 'generates correct SQL for numeric string filtering' do
101
+ expect(query.apply.to_sql).to eq(expected_sql)
102
+ end
103
+ end
104
+
105
+ context 'when no records match' do
106
+ let(:filter_value) { 'nonexistent' }
107
+
108
+ let(:expected_sql) do
109
+ <<~SQL.squish
110
+ SELECT "documents".* FROM "documents" WHERE (documents.name LIKE '%nonexistent%')
111
+ SQL
112
+ end
113
+
114
+ it 'returns empty relation' do
115
+ expect(query.apply).to be_empty
116
+ end
117
+
118
+ it 'still generates valid SQL' do
119
+ expect(query.apply.to_sql).to eq(expected_sql)
120
+ end
121
+ end
122
+
123
+ context 'with case sensitivity' do
124
+ let(:filter_value) { 'Test' }
125
+ let!(:lowercase_document) { create(:document, name: 'test_document') }
126
+ let!(:uppercase_document) { create(:document, name: 'TEST_FILE') }
127
+ let!(:mixedcase_document) { create(:document, name: 'Test_Document') }
128
+ let!(:no_match_document) { create(:document, name: 'example') }
129
+
130
+ it 'includes documents with exact case match' do
131
+ expect(query.apply).to include(mixedcase_document)
132
+ end
133
+
134
+ it 'includes documents with lowercase match' do
135
+ expect(query.apply).to include(lowercase_document)
136
+ end
137
+
138
+ it 'includes documents with uppercase match' do
139
+ expect(query.apply).to include(uppercase_document)
140
+ end
141
+
142
+ it 'excludes documents without any case match' do
143
+ expect(query.apply).not_to include(no_match_document)
144
+ end
145
+ end
146
+
147
+ context 'with special characters in filter value' do
148
+ let(:filter_value) { 'user@' }
149
+ let!(:email_document) { create(:document, name: 'user@example.com') }
150
+ let!(:partial_email_document) { create(:document, name: 'admin_user@test.org') }
151
+ let!(:no_match_document) { create(:document, name: 'username') }
152
+
153
+ it 'includes documents with special character match' do
154
+ expect(query.apply).to include(email_document)
155
+ end
156
+
157
+ it 'includes documents with partial special character match' do
158
+ expect(query.apply).to include(partial_email_document)
159
+ end
160
+
161
+ it 'excludes documents without special character match' do
162
+ expect(query.apply).not_to include(no_match_document)
163
+ end
164
+ end
165
+
166
+ context 'with single character filter' do
167
+ let(:filter_value) { 'a' }
168
+ let!(:start_match) { create(:document, name: 'apple') }
169
+ let!(:middle_match) { create(:document, name: 'banana') }
170
+ let!(:end_match) { create(:document, name: 'extra') }
171
+ let!(:no_match_document) { create(:document, name: 'test') }
172
+
173
+ it 'includes documents with character at start' do
174
+ expect(query.apply).to include(start_match)
175
+ end
176
+
177
+ it 'includes documents with character in middle' do
178
+ expect(query.apply).to include(middle_match)
179
+ end
180
+
181
+ it 'includes documents with character at end' do
182
+ expect(query.apply).to include(end_match)
183
+ end
184
+
185
+ it 'excludes documents without the character' do
186
+ expect(query.apply).not_to include(no_match_document)
187
+ end
188
+ end
189
+
190
+ context 'when filter has table configured' do
191
+ let(:scope) { Document.joins(:tags) }
192
+ let(:filter_value) { 'ruby' }
193
+ let(:filters) { { name: filter_value } }
194
+
195
+ let!(:first_tag) { Tag.find_or_create_by(name: 'ruby') }
196
+ let!(:second_tag) { Tag.find_or_create_by(name: 'ruby_on_rails') }
197
+
198
+ let!(:document_with_ruby_tag) { create(:document, name: 'My Document') }
199
+ let!(:document_with_rails_tag) { create(:document, name: 'Rails Guide') }
200
+ let!(:document_without_tag) { create(:document, name: 'Other Document') }
201
+
202
+ before do
203
+ Tag.find_or_create_by(name: 'programming')
204
+ document_with_ruby_tag.tags << [first_tag]
205
+ document_with_rails_tag.tags << [second_tag]
206
+ end
207
+
208
+ context 'when filtering by tags table' do
209
+ let(:filter) { Kiroshi::Filter.new(:name, match: :like, table: :tags) }
210
+
211
+ it 'returns documents with tags that partially match the filter value' do
212
+ expect(query.apply).to include(document_with_ruby_tag)
213
+ end
214
+
215
+ it 'returns documents with tags that contain the filter value' do
216
+ expect(query.apply).to include(document_with_rails_tag)
217
+ end
218
+
219
+ it 'does not return documents without matching tags' do
220
+ expect(query.apply).not_to include(document_without_tag)
221
+ end
222
+
223
+ it 'generates SQL with tags table qualification' do
224
+ result_sql = query.apply.to_sql
225
+ expect(result_sql).to include('tags.name LIKE')
226
+ end
227
+
228
+ it 'generates SQL with correct LIKE pattern for tag name' do
229
+ result_sql = query.apply.to_sql
230
+ expect(result_sql).to include("'%ruby%'")
231
+ end
232
+ end
233
+
234
+ context 'when filtering by documents table explicitly' do
235
+ let(:filter) { Kiroshi::Filter.new(:name, match: :like, table: :documents) }
236
+ let(:filter_value) { 'Guide' }
237
+
238
+ it 'returns documents that partially match the filter value in document name' do
239
+ expect(query.apply).to include(document_with_rails_tag)
240
+ end
241
+
242
+ it 'does not return documents that do not match document name' do
243
+ expect(query.apply).not_to include(document_with_ruby_tag)
244
+ end
245
+
246
+ it 'does not return documents without matching document name' do
247
+ expect(query.apply).not_to include(document_without_tag)
248
+ end
249
+
250
+ it 'generates SQL with documents table qualification' do
251
+ result_sql = query.apply.to_sql
252
+ expect(result_sql).to include('documents.name LIKE')
253
+ end
254
+
255
+ it 'generates SQL with correct LIKE pattern for document name' do
256
+ result_sql = query.apply.to_sql
257
+ expect(result_sql).to include("'%Guide%'")
258
+ end
259
+ end
260
+
261
+ context 'when table is specified as string' do
262
+ let(:filter) { Kiroshi::Filter.new(:name, match: :like, table: 'tags') }
263
+
264
+ it 'works the same as with symbol table name' do
265
+ expect(query.apply).to include(document_with_ruby_tag)
266
+ end
267
+
268
+ it 'generates SQL with string table qualification' do
269
+ result_sql = query.apply.to_sql
270
+ expect(result_sql).to include('tags.name LIKE')
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Kiroshi::FilterQuery, type: :model do
6
+ describe '.for' do
7
+ context 'when match is :exact' do
8
+ it 'returns the Exact class' do
9
+ expect(described_class.for(:exact)).to eq(Kiroshi::FilterQuery::Exact)
10
+ end
11
+ end
12
+
13
+ context 'when match is :like' do
14
+ it 'returns the Like class' do
15
+ expect(described_class.for(:like)).to eq(Kiroshi::FilterQuery::Like)
16
+ end
17
+ end
18
+
19
+ context 'when match is an unsupported type' do
20
+ it 'raises ArgumentError for unsupported match type' do
21
+ expect { described_class.for(:invalid) }.to raise_error(
22
+ ArgumentError, 'Unsupported match type: invalid'
23
+ )
24
+ end
25
+
26
+ it 'raises ArgumentError for nil match type' do
27
+ expect { described_class.for(nil) }.to raise_error(
28
+ ArgumentError, 'Unsupported match type: '
29
+ )
30
+ end
31
+
32
+ it 'raises ArgumentError for string match type' do
33
+ expect { described_class.for('exact') }.to raise_error(
34
+ ArgumentError, 'Unsupported match type: exact'
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Kiroshi::FilterRunner, type: :model do
6
+ describe '#apply' do
7
+ subject(:runner) { described_class.new(filter: filter, scope: scope, filters: filters) }
8
+
9
+ let(:scope) { Document.all }
10
+ let(:filter_value) { 'test_value' }
11
+ let(:filters) { { name: filter_value } }
12
+ let!(:matching_document) { create(:document, name: filter_value) }
13
+ let!(:non_matching_document) { create(:document, name: 'other_value') }
14
+
15
+ context 'when filter match is :exact' do
16
+ let(:filter) { Kiroshi::Filter.new(:name, match: :exact) }
17
+
18
+ it 'returns exact matches' do
19
+ expect(runner.apply).to include(matching_document)
20
+ end
21
+
22
+ it 'does not return non-matching records' do
23
+ expect(runner.apply).not_to include(non_matching_document)
24
+ end
25
+ end
26
+
27
+ context 'when filter match is :like' do
28
+ let(:filter) { Kiroshi::Filter.new(:name, match: :like) }
29
+ let(:filter_value) { 'test' }
30
+ let!(:matching_document) { create(:document, name: 'test_document') }
31
+ let!(:non_matching_document) { create(:document, name: 'other_value') }
32
+
33
+ it 'returns partial matches' do
34
+ expect(runner.apply).to include(matching_document)
35
+ end
36
+
37
+ it 'does not return non-matching records' do
38
+ expect(runner.apply).not_to include(non_matching_document)
39
+ end
40
+
41
+ it 'generates correct SQL with table name prefix' do
42
+ expected_sql = "SELECT \"documents\".* FROM \"documents\" WHERE (documents.name LIKE '%test%')"
43
+ expect(runner.apply.to_sql).to eq(expected_sql)
44
+ end
45
+ end
46
+
47
+ context 'when filter match is not specified (default)' do
48
+ let(:filter) { Kiroshi::Filter.new(:name) }
49
+
50
+ it 'defaults to exact match returning only exact matches' do
51
+ expect(runner.apply).to include(matching_document)
52
+ end
53
+
54
+ it 'defaults to exact match not returning non-matching records' do
55
+ expect(runner.apply).not_to include(non_matching_document)
56
+ end
57
+ end
58
+
59
+ context 'when filter value is not present' do
60
+ let(:filter) { Kiroshi::Filter.new(:name) }
61
+ let(:filters) { { name: nil } }
62
+
63
+ it 'returns the original scope unchanged' do
64
+ expect(runner.apply).to eq(scope)
65
+ end
66
+ end
67
+
68
+ context 'when filter value is empty string' do
69
+ let(:filter) { Kiroshi::Filter.new(:name) }
70
+ let(:filters) { { name: '' } }
71
+
72
+ it 'returns the original scope unchanged' do
73
+ expect(runner.apply).to eq(scope)
74
+ end
75
+ end
76
+
77
+ context 'when filter attribute is not in filters hash' do
78
+ let(:filter) { Kiroshi::Filter.new(:status) }
79
+ let(:filters) { { name: 'test_value' } }
80
+
81
+ it 'returns the original scope unchanged' do
82
+ expect(runner.apply).to eq(scope)
83
+ end
84
+ end
85
+
86
+ context 'when filters hash is empty' do
87
+ let(:filter) { Kiroshi::Filter.new(:name) }
88
+ let(:filters) { {} }
89
+
90
+ it 'returns the original scope unchanged' do
91
+ expect(runner.apply).to eq(scope)
92
+ end
93
+ end
94
+
95
+ context 'with multiple attributes' do
96
+ let(:filter) { Kiroshi::Filter.new(:status, match: :exact) }
97
+ let(:filters) { { name: 'test_name', status: 'finished' } }
98
+ let!(:matching_document) { create(:document, name: 'test_name', status: 'finished') }
99
+ let!(:non_matching_document) { create(:document, name: 'other_name', status: 'processing') }
100
+
101
+ it 'filters by the configured attribute only returning the matched' do
102
+ expect(runner.apply).to include(matching_document)
103
+ end
104
+
105
+ it 'does not return non-matching records' do
106
+ expect(runner.apply).not_to include(non_matching_document)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -90,5 +90,68 @@ 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.instance_variable_set(:@filter_configs, [])
132
+ filters_class.filter_by :name, match: :like
133
+ end
134
+
135
+ it 'filters by document name with LIKE operation' do
136
+ result = filter_instance.apply(scope)
137
+ expect(result).to include(document)
138
+ end
139
+
140
+ it 'does not return documents that do not match document name pattern' do
141
+ result = filter_instance.apply(scope)
142
+ expect(result).not_to include(other_document)
143
+ end
144
+
145
+ it 'generates SQL with table-qualified LIKE operation' do
146
+ result = filter_instance.apply(scope)
147
+ expect(result.to_sql).to include('documents.name LIKE')
148
+ end
149
+
150
+ it 'generates SQL with correct LIKE pattern' do
151
+ result = filter_instance.apply(scope)
152
+ expect(result.to_sql).to include("'%test%'")
153
+ end
154
+ end
155
+ end
93
156
  end
94
157
  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.1.1
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-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -65,19 +65,29 @@ 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
69
73
  - lib/kiroshi/version.rb
70
74
  - spec/integration/readme/.keep
71
75
  - spec/integration/yard/.keep
76
+ - spec/lib/kiroshi/filter_query/exact_spec.rb
77
+ - spec/lib/kiroshi/filter_query/like_spec.rb
78
+ - spec/lib/kiroshi/filter_query_spec.rb
79
+ - spec/lib/kiroshi/filter_runner_spec.rb
72
80
  - spec/lib/kiroshi/filter_spec.rb
73
81
  - spec/lib/kiroshi/filters_spec.rb
74
82
  - spec/lib/kiroshi_spec.rb
75
83
  - spec/spec_helper.rb
76
84
  - spec/support/db/schema.rb
77
85
  - spec/support/factories/document.rb
86
+ - spec/support/factories/tag.rb
78
87
  - spec/support/factory_bot.rb
79
88
  - spec/support/models/.keep
80
89
  - spec/support/models/document.rb
90
+ - spec/support/models/tag.rb
81
91
  - spec/support/shared_examples/.keep
82
92
  homepage: https://github.com/darthjee/kiroshi
83
93
  licenses: []