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,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
@@ -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