actionquery 0.0.0 → 1.0.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.
- checksums.yaml +4 -4
- data/.mise.toml +2 -0
- data/.rubocop.yml +23 -9
- data/.standard.yml +10 -0
- data/Appraisals +10 -0
- data/CHANGELOG.md +10 -2
- data/Gemfile +5 -4
- data/Gemfile.lock +258 -34
- data/README.md +236 -17
- data/Rakefile +6 -1
- data/actionquery.gemspec +5 -8
- data/docs/decisions/001-subclass-active-record-relation.md +41 -0
- data/gemfiles/rails_7.2.gemfile +12 -0
- data/gemfiles/rails_8.0.gemfile +12 -0
- data/gemfiles/rails_8.1.gemfile +12 -0
- data/lib/actionquery/base.rb +138 -0
- data/lib/actionquery/error.rb +3 -0
- data/lib/actionquery/search_query.rb +120 -0
- data/lib/actionquery/version.rb +4 -2
- data/lib/actionquery.rb +9 -4
- data/sig/actionquery.rbs +33 -2
- metadata +32 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3bd844fec818667d207722f7397c1a8778cb11eb205fa046ee4b39e66c0d556f
|
|
4
|
+
data.tar.gz: 321043d8240fa89717cdb73c834832146b399e71e8fdbec8fd442030727946ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 61109f5a2d0aede2847a6858bd1b7d639705b592d8782f7c3e35553fcd0beba205d3c2b7b95cddcd316e8c83b8bbd324e4cf1b9382dfbdd2e025f15a6d9b781b
|
|
7
|
+
data.tar.gz: 0ce3a0122c7496a310424db8ad7dad75066d30cf3741b48ecd3ffc356f84e0c92280c30b281f8861d53c56a69e48adadd82366cc787937449bf77d431bed5246
|
data/.mise.toml
ADDED
data/.rubocop.yml
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
+
require:
|
|
2
|
+
- standard
|
|
3
|
+
|
|
4
|
+
inherit_gem:
|
|
5
|
+
standard: config/base.yml
|
|
6
|
+
|
|
1
7
|
AllCops:
|
|
2
|
-
TargetRubyVersion:
|
|
8
|
+
TargetRubyVersion: 3.1
|
|
9
|
+
SuggestExtensions: false
|
|
10
|
+
NewCops: enable
|
|
11
|
+
Exclude:
|
|
12
|
+
- "gemfiles/**/*"
|
|
13
|
+
- "vendor/**/*"
|
|
3
14
|
|
|
4
|
-
Style/
|
|
5
|
-
|
|
6
|
-
EnforcedStyle: double_quotes
|
|
15
|
+
Style/ClassAndModuleChildren:
|
|
16
|
+
EnforcedStyle: compact
|
|
7
17
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
18
|
+
# Trailing commas make multiline diffs smaller when entries are added or reordered.
|
|
19
|
+
Style/TrailingCommaInArrayLiteral:
|
|
20
|
+
EnforcedStyleForMultiline: comma
|
|
21
|
+
Style/TrailingCommaInHashLiteral:
|
|
22
|
+
EnforcedStyleForMultiline: comma
|
|
23
|
+
Style/TrailingCommaInArguments:
|
|
24
|
+
EnforcedStyleForMultiline: comma
|
|
11
25
|
|
|
12
|
-
Layout/
|
|
13
|
-
|
|
26
|
+
Layout/AccessModifierIndentation:
|
|
27
|
+
EnforcedStyle: outdent
|
data/.standard.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Standard's built-in styles conflict with the project conventions below.
|
|
2
|
+
# RuboCop inherits Standard's full ruleset and enforces these overrides.
|
|
3
|
+
ruby_version: 3.1
|
|
4
|
+
ignore:
|
|
5
|
+
- "**/*":
|
|
6
|
+
- Layout/AccessModifierIndentation
|
|
7
|
+
- Layout/CommentIndentation
|
|
8
|
+
- Style/TrailingCommaInArguments
|
|
9
|
+
- Style/TrailingCommaInArrayLiteral
|
|
10
|
+
- Style/TrailingCommaInHashLiteral
|
data/Appraisals
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
1
3
|
## [Unreleased]
|
|
2
4
|
|
|
3
|
-
## [
|
|
5
|
+
## [1.0.0] - 2026-09-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
4
8
|
|
|
5
|
-
-
|
|
9
|
+
- Relation-backed `ActionQuery::Base` query objects.
|
|
10
|
+
- Model inference, explicit model/relation sources, and relation wrapping.
|
|
11
|
+
- Conditional scopes, pagination, distinct-column values, and SQL unions.
|
|
12
|
+
- Type-aware `ActionQuery::SearchQuery` filters and custom filter/sort DSLs.
|
|
13
|
+
- Rails 7.2, 8.0, and 8.1 compatibility coverage.
|
data/Gemfile
CHANGED
|
@@ -5,8 +5,9 @@ source "https://rubygems.org"
|
|
|
5
5
|
# Specify your gem's dependencies in actionquery.gemspec
|
|
6
6
|
gemspec
|
|
7
7
|
|
|
8
|
+
gem "appraisal", "~> 2.5"
|
|
9
|
+
gem "rails", "~> 8.1.0"
|
|
8
10
|
gem "rake", "~> 13.0"
|
|
9
|
-
|
|
10
|
-
gem "
|
|
11
|
-
|
|
12
|
-
gem "rubocop", "~> 1.21"
|
|
11
|
+
gem "rspec", "~> 3.13"
|
|
12
|
+
gem "sqlite3", "~> 2.8"
|
|
13
|
+
gem "standard", "~> 1.56", require: false
|
data/Gemfile.lock
CHANGED
|
@@ -1,57 +1,281 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
actionquery (
|
|
4
|
+
actionquery (1.0.0)
|
|
5
|
+
activerecord (>= 7.2)
|
|
5
6
|
|
|
6
7
|
GEM
|
|
7
8
|
remote: https://rubygems.org/
|
|
8
9
|
specs:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
action_text-trix (2.1.19)
|
|
11
|
+
railties
|
|
12
|
+
actioncable (8.1.3.1)
|
|
13
|
+
actionpack (= 8.1.3.1)
|
|
14
|
+
activesupport (= 8.1.3.1)
|
|
15
|
+
nio4r (~> 2.0)
|
|
16
|
+
websocket-driver (>= 0.6.1)
|
|
17
|
+
zeitwerk (~> 2.6)
|
|
18
|
+
actionmailbox (8.1.3.1)
|
|
19
|
+
actionpack (= 8.1.3.1)
|
|
20
|
+
activejob (= 8.1.3.1)
|
|
21
|
+
activerecord (= 8.1.3.1)
|
|
22
|
+
activestorage (= 8.1.3.1)
|
|
23
|
+
activesupport (= 8.1.3.1)
|
|
24
|
+
mail (>= 2.8.0)
|
|
25
|
+
actionmailer (8.1.3.1)
|
|
26
|
+
actionpack (= 8.1.3.1)
|
|
27
|
+
actionview (= 8.1.3.1)
|
|
28
|
+
activejob (= 8.1.3.1)
|
|
29
|
+
activesupport (= 8.1.3.1)
|
|
30
|
+
mail (>= 2.8.0)
|
|
31
|
+
rails-dom-testing (~> 2.2)
|
|
32
|
+
actionpack (8.1.3.1)
|
|
33
|
+
actionview (= 8.1.3.1)
|
|
34
|
+
activesupport (= 8.1.3.1)
|
|
35
|
+
nokogiri (>= 1.8.5)
|
|
36
|
+
rack (>= 2.2.4)
|
|
37
|
+
rack-session (>= 1.0.1)
|
|
38
|
+
rack-test (>= 0.6.3)
|
|
39
|
+
rails-dom-testing (~> 2.2)
|
|
40
|
+
rails-html-sanitizer (~> 1.6)
|
|
41
|
+
useragent (~> 0.16)
|
|
42
|
+
actiontext (8.1.3.1)
|
|
43
|
+
action_text-trix (~> 2.1.15)
|
|
44
|
+
actionpack (= 8.1.3.1)
|
|
45
|
+
activerecord (= 8.1.3.1)
|
|
46
|
+
activestorage (= 8.1.3.1)
|
|
47
|
+
activesupport (= 8.1.3.1)
|
|
48
|
+
globalid (>= 0.6.0)
|
|
49
|
+
nokogiri (>= 1.8.5)
|
|
50
|
+
actionview (8.1.3.1)
|
|
51
|
+
activesupport (= 8.1.3.1)
|
|
52
|
+
builder (~> 3.1)
|
|
53
|
+
erubi (~> 1.11)
|
|
54
|
+
rails-dom-testing (~> 2.2)
|
|
55
|
+
rails-html-sanitizer (~> 1.6)
|
|
56
|
+
activejob (8.1.3.1)
|
|
57
|
+
activesupport (= 8.1.3.1)
|
|
58
|
+
globalid (>= 0.3.6)
|
|
59
|
+
activemodel (8.1.3.1)
|
|
60
|
+
activesupport (= 8.1.3.1)
|
|
61
|
+
activerecord (8.1.3.1)
|
|
62
|
+
activemodel (= 8.1.3.1)
|
|
63
|
+
activesupport (= 8.1.3.1)
|
|
64
|
+
timeout (>= 0.4.0)
|
|
65
|
+
activestorage (8.1.3.1)
|
|
66
|
+
actionpack (= 8.1.3.1)
|
|
67
|
+
activejob (= 8.1.3.1)
|
|
68
|
+
activerecord (= 8.1.3.1)
|
|
69
|
+
activesupport (= 8.1.3.1)
|
|
70
|
+
marcel (~> 1.0)
|
|
71
|
+
activesupport (8.1.3.1)
|
|
72
|
+
base64
|
|
73
|
+
bigdecimal
|
|
74
|
+
concurrent-ruby (~> 1.0, >= 1.3.1)
|
|
75
|
+
connection_pool (>= 2.2.5)
|
|
76
|
+
drb
|
|
77
|
+
i18n (>= 1.6, < 2)
|
|
78
|
+
json
|
|
79
|
+
logger (>= 1.4.2)
|
|
80
|
+
minitest (>= 5.1)
|
|
81
|
+
securerandom (>= 0.3)
|
|
82
|
+
tzinfo (~> 2.0, >= 2.0.5)
|
|
83
|
+
uri (>= 0.13.1)
|
|
84
|
+
appraisal (2.5.0)
|
|
85
|
+
bundler
|
|
86
|
+
rake
|
|
87
|
+
thor (>= 0.14.0)
|
|
88
|
+
ast (2.4.3)
|
|
89
|
+
base64 (0.3.0)
|
|
90
|
+
bigdecimal (4.1.3)
|
|
91
|
+
builder (3.3.0)
|
|
92
|
+
concurrent-ruby (1.3.8)
|
|
93
|
+
connection_pool (3.0.2)
|
|
94
|
+
crass (1.0.7)
|
|
95
|
+
date (3.5.1)
|
|
96
|
+
diff-lcs (1.6.2)
|
|
97
|
+
drb (2.2.3)
|
|
98
|
+
erb (6.0.7)
|
|
99
|
+
erubi (1.13.1)
|
|
100
|
+
globalid (1.4.0)
|
|
101
|
+
activesupport (>= 6.1)
|
|
102
|
+
i18n (1.15.2)
|
|
103
|
+
concurrent-ruby (~> 1.0)
|
|
104
|
+
io-console (0.9.4)
|
|
105
|
+
irb (1.18.0)
|
|
106
|
+
pp (>= 0.6.0)
|
|
107
|
+
prism (>= 1.3.0)
|
|
108
|
+
rdoc (>= 4.0.0)
|
|
109
|
+
reline (>= 0.4.2)
|
|
110
|
+
json (2.21.2)
|
|
111
|
+
language_server-protocol (3.17.0.6)
|
|
112
|
+
lint_roller (1.1.0)
|
|
113
|
+
logger (1.7.0)
|
|
114
|
+
loofah (2.25.2)
|
|
115
|
+
crass (~> 1.0.2)
|
|
116
|
+
nokogiri (>= 1.12.0)
|
|
117
|
+
mail (2.9.1)
|
|
118
|
+
logger
|
|
119
|
+
mini_mime (>= 0.1.1)
|
|
120
|
+
net-imap
|
|
121
|
+
net-pop
|
|
122
|
+
net-smtp
|
|
123
|
+
marcel (1.2.1)
|
|
124
|
+
mini_mime (1.1.5)
|
|
125
|
+
minitest (6.0.6)
|
|
126
|
+
drb (~> 2.0)
|
|
127
|
+
prism (~> 1.5)
|
|
128
|
+
net-imap (0.6.7)
|
|
129
|
+
date
|
|
130
|
+
net-protocol
|
|
131
|
+
net-pop (0.1.2)
|
|
132
|
+
net-protocol
|
|
133
|
+
net-protocol (0.4.0)
|
|
134
|
+
timeout
|
|
135
|
+
net-smtp (0.5.1)
|
|
136
|
+
net-protocol
|
|
137
|
+
nio4r (2.7.5)
|
|
138
|
+
nokogiri (1.19.4-arm64-darwin)
|
|
139
|
+
racc (~> 1.4)
|
|
140
|
+
nokogiri (1.19.4-x86_64-linux-gnu)
|
|
141
|
+
racc (~> 1.4)
|
|
142
|
+
parallel (2.2.0)
|
|
143
|
+
parser (3.3.12.0)
|
|
14
144
|
ast (~> 2.4.1)
|
|
145
|
+
racc
|
|
146
|
+
pp (0.6.4)
|
|
147
|
+
prettyprint
|
|
148
|
+
prettyprint (0.2.0)
|
|
149
|
+
prism (1.9.0)
|
|
150
|
+
racc (1.8.1)
|
|
151
|
+
rack (3.2.7)
|
|
152
|
+
rack-session (2.1.2)
|
|
153
|
+
base64 (>= 0.1.0)
|
|
154
|
+
rack (>= 3.0.0)
|
|
155
|
+
rack-test (2.2.0)
|
|
156
|
+
rack (>= 1.3)
|
|
157
|
+
rackup (2.3.1)
|
|
158
|
+
rack (>= 3)
|
|
159
|
+
rails (8.1.3.1)
|
|
160
|
+
actioncable (= 8.1.3.1)
|
|
161
|
+
actionmailbox (= 8.1.3.1)
|
|
162
|
+
actionmailer (= 8.1.3.1)
|
|
163
|
+
actionpack (= 8.1.3.1)
|
|
164
|
+
actiontext (= 8.1.3.1)
|
|
165
|
+
actionview (= 8.1.3.1)
|
|
166
|
+
activejob (= 8.1.3.1)
|
|
167
|
+
activemodel (= 8.1.3.1)
|
|
168
|
+
activerecord (= 8.1.3.1)
|
|
169
|
+
activestorage (= 8.1.3.1)
|
|
170
|
+
activesupport (= 8.1.3.1)
|
|
171
|
+
bundler (>= 1.15.0)
|
|
172
|
+
railties (= 8.1.3.1)
|
|
173
|
+
rails-dom-testing (2.3.0)
|
|
174
|
+
activesupport (>= 5.0.0)
|
|
175
|
+
minitest
|
|
176
|
+
nokogiri (>= 1.6)
|
|
177
|
+
rails-html-sanitizer (1.7.1)
|
|
178
|
+
loofah (~> 2.25, >= 2.25.2)
|
|
179
|
+
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
|
180
|
+
railties (8.1.3.1)
|
|
181
|
+
actionpack (= 8.1.3.1)
|
|
182
|
+
activesupport (= 8.1.3.1)
|
|
183
|
+
irb (~> 1.13)
|
|
184
|
+
rackup (>= 1.0.0)
|
|
185
|
+
rake (>= 12.2)
|
|
186
|
+
thor (~> 1.0, >= 1.2.2)
|
|
187
|
+
tsort (>= 0.2)
|
|
188
|
+
zeitwerk (~> 2.6)
|
|
15
189
|
rainbow (3.1.1)
|
|
16
|
-
rake (13.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
190
|
+
rake (13.4.2)
|
|
191
|
+
rbs (4.2.0)
|
|
192
|
+
logger
|
|
193
|
+
prism (>= 1.6.0)
|
|
194
|
+
tsort
|
|
195
|
+
rdoc (8.0.0)
|
|
196
|
+
erb
|
|
197
|
+
prism (>= 1.6.0)
|
|
198
|
+
rbs (>= 4.0.0)
|
|
199
|
+
tsort
|
|
200
|
+
regexp_parser (2.12.0)
|
|
201
|
+
reline (0.7.0)
|
|
202
|
+
io-console (~> 0.5)
|
|
203
|
+
rspec (3.13.2)
|
|
204
|
+
rspec-core (~> 3.13.0)
|
|
205
|
+
rspec-expectations (~> 3.13.0)
|
|
206
|
+
rspec-mocks (~> 3.13.0)
|
|
207
|
+
rspec-core (3.13.6)
|
|
208
|
+
rspec-support (~> 3.13.0)
|
|
209
|
+
rspec-expectations (3.13.5)
|
|
26
210
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
27
|
-
rspec-support (~> 3.
|
|
28
|
-
rspec-mocks (3.
|
|
211
|
+
rspec-support (~> 3.13.0)
|
|
212
|
+
rspec-mocks (3.13.8)
|
|
29
213
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
30
|
-
rspec-support (~> 3.
|
|
31
|
-
rspec-support (3.
|
|
32
|
-
rubocop (1.
|
|
214
|
+
rspec-support (~> 3.13.0)
|
|
215
|
+
rspec-support (3.13.7)
|
|
216
|
+
rubocop (1.88.2)
|
|
33
217
|
json (~> 2.3)
|
|
34
|
-
|
|
35
|
-
|
|
218
|
+
language_server-protocol (~> 3.17.0.2)
|
|
219
|
+
lint_roller (~> 1.1.0)
|
|
220
|
+
parallel (>= 1.10)
|
|
221
|
+
parser (>= 3.3.0.2)
|
|
36
222
|
rainbow (>= 2.2.2, < 4.0)
|
|
37
|
-
regexp_parser (>=
|
|
38
|
-
|
|
39
|
-
rubocop-ast (>= 1.18.0, < 2.0)
|
|
223
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
|
224
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
40
225
|
ruby-progressbar (~> 1.7)
|
|
41
|
-
unicode-display_width (>=
|
|
42
|
-
rubocop-ast (1.
|
|
43
|
-
parser (>= 3.
|
|
44
|
-
|
|
45
|
-
|
|
226
|
+
unicode-display_width (>= 2.4.0, < 4.0)
|
|
227
|
+
rubocop-ast (1.50.0)
|
|
228
|
+
parser (>= 3.3.7.2)
|
|
229
|
+
prism (~> 1.7)
|
|
230
|
+
rubocop-performance (1.26.1)
|
|
231
|
+
lint_roller (~> 1.1)
|
|
232
|
+
rubocop (>= 1.75.0, < 2.0)
|
|
233
|
+
rubocop-ast (>= 1.47.1, < 2.0)
|
|
234
|
+
ruby-progressbar (1.13.0)
|
|
235
|
+
securerandom (0.4.1)
|
|
236
|
+
sqlite3 (2.9.6-arm64-darwin)
|
|
237
|
+
sqlite3 (2.9.6-x86_64-linux-gnu)
|
|
238
|
+
standard (1.56.0)
|
|
239
|
+
language_server-protocol (~> 3.17.0.2)
|
|
240
|
+
lint_roller (~> 1.0)
|
|
241
|
+
rubocop (~> 1.88.0)
|
|
242
|
+
standard-custom (~> 1.0.0)
|
|
243
|
+
standard-performance (~> 1.8)
|
|
244
|
+
standard-custom (1.0.2)
|
|
245
|
+
lint_roller (~> 1.0)
|
|
246
|
+
rubocop (~> 1.50)
|
|
247
|
+
standard-performance (1.9.0)
|
|
248
|
+
lint_roller (~> 1.1)
|
|
249
|
+
rubocop-performance (~> 1.26.0)
|
|
250
|
+
thor (1.5.0)
|
|
251
|
+
timeout (0.6.1)
|
|
252
|
+
tsort (0.2.0)
|
|
253
|
+
tzinfo (2.0.6)
|
|
254
|
+
concurrent-ruby (~> 1.0)
|
|
255
|
+
unicode-display_width (3.3.0)
|
|
256
|
+
unicode-emoji (~> 4.3)
|
|
257
|
+
unicode-emoji (4.3.0)
|
|
258
|
+
uri (1.1.1)
|
|
259
|
+
useragent (0.16.11)
|
|
260
|
+
websocket-driver (0.8.2)
|
|
261
|
+
base64
|
|
262
|
+
websocket-extensions (>= 0.1.0)
|
|
263
|
+
websocket-extensions (0.1.5)
|
|
264
|
+
zeitwerk (2.8.3)
|
|
46
265
|
|
|
47
266
|
PLATFORMS
|
|
48
267
|
arm64-darwin-21
|
|
268
|
+
arm64-darwin-23
|
|
269
|
+
x86_64-linux
|
|
49
270
|
|
|
50
271
|
DEPENDENCIES
|
|
51
272
|
actionquery!
|
|
273
|
+
appraisal (~> 2.5)
|
|
274
|
+
rails (~> 8.1.0)
|
|
52
275
|
rake (~> 13.0)
|
|
53
|
-
rspec (~> 3.
|
|
54
|
-
|
|
276
|
+
rspec (~> 3.13)
|
|
277
|
+
sqlite3 (~> 2.8)
|
|
278
|
+
standard (~> 1.56)
|
|
55
279
|
|
|
56
280
|
BUNDLED WITH
|
|
57
|
-
2.
|
|
281
|
+
2.6.9
|
data/README.md
CHANGED
|
@@ -1,37 +1,256 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ActionQuery
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/actionquery)
|
|
4
|
+
[](https://github.com/ElMassimo/actionquery/actions/workflows/main.yml)
|
|
5
|
+
[](https://github.com/standardrb/standard)
|
|
6
|
+
[](https://www.ruby-lang.org/)
|
|
7
|
+
[](https://rubyonrails.org/)
|
|
8
|
+
[](LICENSE.txt)
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
ActionQuery lets you define query objects with ordinary Ruby methods while retaining the full behavior of `ActiveRecord::Relation`.
|
|
11
|
+
|
|
12
|
+
It follows the spirit of [Queryable](https://github.com/ElMassimo/queryable), but is designed specifically for modern Active Record. Instead of wrapping a relation and maintaining lists of delegated and chainable methods, an ActionQuery **is** a relation.
|
|
13
|
+
|
|
14
|
+
## Query methods
|
|
15
|
+
|
|
16
|
+
Define reusable query behavior as instance methods:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
class CustomersQuery < ActionQuery::Base
|
|
20
|
+
def active
|
|
21
|
+
where(status: "active")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def recently_logged_in
|
|
25
|
+
where(logged_in_at: 1.week.ago..)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def favourite_brand(product, brand)
|
|
29
|
+
where(favourite_product: product, favourite_brand: brand)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
CustomersQuery.new
|
|
34
|
+
.active
|
|
35
|
+
.recently_logged_in
|
|
36
|
+
.favourite_brand(:beer, "Miller")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Relation methods such as `where`, `joins`, `includes`, `order`, `limit`, calculations, and record loading work normally. Chained relations retain the query class and its methods—there is no `scope`, `chain`, or `delegate_and_chain` declaration.
|
|
40
|
+
|
|
41
|
+
## Model scopes and delegation
|
|
42
|
+
|
|
43
|
+
Model scopes and public model query methods are available from a query object. They execute inside the current relation scope:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class Customer < ApplicationRecord
|
|
47
|
+
scope :verified, -> { where(verified: true) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
CustomersQuery.new
|
|
51
|
+
.where(shop: current_shop)
|
|
52
|
+
.verified
|
|
53
|
+
.recently_logged_in
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
ActionQuery also delegates the model metadata, query-cache, logging, and connection methods required by Active Record internals, including `adapter_class`, `connection_pool`, `primary_key`, `lease_connection`, and `with_connection`.
|
|
57
|
+
|
|
58
|
+
## Advantages
|
|
59
|
+
|
|
60
|
+
- Query objects use plain Ruby methods, inheritance, and modules.
|
|
61
|
+
- Queries compose with model scopes and standard Active Record relations.
|
|
62
|
+
- Chaining does not depend on maintaining a list of Relation methods.
|
|
63
|
+
- Query behavior stays out of models without introducing a repository abstraction.
|
|
64
|
+
- Search filters and sorting strategies can be inherited and extended safely.
|
|
65
|
+
- Compatibility is tested against every supported Rails minor line.
|
|
6
66
|
|
|
7
67
|
## Installation
|
|
8
68
|
|
|
9
|
-
|
|
69
|
+
Add ActionQuery to your bundle:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
gem "actionquery"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then run:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
bundle install
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
ActionQuery requires Ruby 3.1 or newer and Active Record 7.2 or newer. It depends on `activerecord`, not the full `rails` gem.
|
|
82
|
+
|
|
83
|
+
## Basic usage
|
|
84
|
+
|
|
85
|
+
### Model inference
|
|
86
|
+
|
|
87
|
+
ActionQuery removes `Query` or `SearchQuery`, singularizes the remainder, and resolves the model:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class CustomersQuery < ActionQuery::Base
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
CustomersQuery.new.model # => Customer
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Namespaced models are preferred, with a top-level fallback:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class Admin::CustomersQuery < ActionQuery::Base
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
Admin::CustomersQuery.new.model
|
|
103
|
+
# => Admin::Customer when defined, otherwise Customer
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Explicit query sources
|
|
107
|
+
|
|
108
|
+
Use `query_from` when naming inference is not appropriate. It accepts a model or an existing relation:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class RecentCustomersQuery < ActionQuery::Base
|
|
112
|
+
query_from Customer.where(created_at: 30.days.ago..).order(created_at: :desc)
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A relation source retains its clauses while keeping `Customer` as the Active Record model. The source relation is not mutated.
|
|
117
|
+
|
|
118
|
+
### Wrapping a relation
|
|
10
119
|
|
|
11
|
-
|
|
120
|
+
Use `.wrap` to convert an existing relation into a particular query class:
|
|
12
121
|
|
|
13
|
-
|
|
122
|
+
```ruby
|
|
123
|
+
RecentCustomersQuery.wrap(shop.customers.where(status: "active"))
|
|
124
|
+
```
|
|
14
125
|
|
|
15
|
-
|
|
126
|
+
## Search queries
|
|
16
127
|
|
|
17
|
-
|
|
128
|
+
`ActionQuery::SearchQuery` adds conventions for common search forms:
|
|
18
129
|
|
|
19
|
-
|
|
130
|
+
```ruby
|
|
131
|
+
class CustomersSearchQuery < ActionQuery::SearchQuery
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
CustomersSearchQuery.new.search(
|
|
135
|
+
name: "miller",
|
|
136
|
+
active: true,
|
|
137
|
+
status: :invited
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Default filtering is based on the column type:
|
|
142
|
+
|
|
143
|
+
| Column type | Behavior |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| string, text | Escaped, case-insensitive partial match |
|
|
146
|
+
| integer, decimal, boolean, date | Exact Active Record condition |
|
|
147
|
+
| enum | Exact enum match |
|
|
148
|
+
| datetime + `Date` | Entire calendar day |
|
|
149
|
+
| datetime + `Time` or `DateTime` | Exact value |
|
|
150
|
+
|
|
151
|
+
Nil conditions and `sort` metadata are ignored. Unsupported or unknown fields raise `NotImplementedError` instead of silently producing an unintended query.
|
|
152
|
+
|
|
153
|
+
### Custom filters
|
|
154
|
+
|
|
155
|
+
Define a filter with a callable executed in the query context:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
class CustomersSearchQuery < ActionQuery::SearchQuery
|
|
159
|
+
filter :minimum_age, ->(age) { where(age: age..) }
|
|
160
|
+
filter :email, :eq
|
|
161
|
+
filter :older_than, :gt
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`:eq` uses Active Record hash conditions, so it supports a scalar or an array. Other symbols must name an `Arel::Attributes::Attribute` operator.
|
|
166
|
+
|
|
167
|
+
Use `matches_any_of` for escaped partial matching across columns:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
class CustomersSearchQuery < ActionQuery::SearchQuery
|
|
171
|
+
filter :general, ->(value) {
|
|
172
|
+
matches_any_of(%i[first_name last_name email], value)
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Filter definitions are inherited. Adding a filter to a child query does not modify its parent or sibling classes.
|
|
178
|
+
|
|
179
|
+
### Sorting
|
|
180
|
+
|
|
181
|
+
`apply_sorting` accepts any object responding to `strategy` and `direction`:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
SortOption = Struct.new(:strategy, :direction, keyword_init: true)
|
|
185
|
+
option = SortOption.new(strategy: :created_at, direction: :desc)
|
|
186
|
+
|
|
187
|
+
CustomersSearchQuery.new.apply_sorting(option)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Unconfigured strategies use Active Record's hash-based `order`. Define a custom strategy for computed or joined values:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
class CustomersSearchQuery < ActionQuery::SearchQuery
|
|
194
|
+
sort_by :seniority, ->(direction) { order(hired_at: direction) }
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Active Record validates default sort directions. Custom strategies are responsible for safely handling any SQL fragments they introduce.
|
|
199
|
+
|
|
200
|
+
## Query helpers
|
|
201
|
+
|
|
202
|
+
`ActionQuery::Base` includes a small set of composable helpers:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
query.any_of(Customer.arel_table[:role].eq("admin"), Customer.arel_table[:owner].eq(true))
|
|
206
|
+
query.scope_if(include_archived) { |relation| relation.unscope(where: :archived_at) }
|
|
207
|
+
query.paginate(page_number: 2, page_size: 25)
|
|
208
|
+
query.unsorted
|
|
209
|
+
query.union(Customer.where(invited: true))
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`distinct` keeps Active Record's normal behavior unless given a column symbol:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
query.distinct # a SELECT DISTINCT relation
|
|
216
|
+
query.distinct(false) # a relation without SELECT DISTINCT
|
|
217
|
+
query.distinct(:country_code) # an eager array of unique values
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Search queries also provide `with_ids(ids)` and `all_attributes` for primary-key filtering and resetting a previous `select`.
|
|
221
|
+
|
|
222
|
+
## Compatibility notes
|
|
223
|
+
|
|
224
|
+
Subclassing `ActiveRecord::Relation` provides native composition but intentionally couples ActionQuery to Relation initialization and delegation behavior. The suite exercises real database queries on Rails 7.2, 8.0, and 8.1, including connection methods and query-cache semantics.
|
|
225
|
+
|
|
226
|
+
See [ADR-001](docs/decisions/001-subclass-active-record-relation.md) for the rationale and alternatives considered.
|
|
20
227
|
|
|
21
228
|
## Development
|
|
22
229
|
|
|
23
|
-
|
|
230
|
+
The repository uses [mise](https://mise.jdx.dev/) for Ruby and Bundler for dependencies. Local development targets Ruby 4 and Rails 8.1:
|
|
24
231
|
|
|
25
|
-
|
|
232
|
+
```sh
|
|
233
|
+
mise install
|
|
234
|
+
bin/setup
|
|
235
|
+
bundle exec rake
|
|
236
|
+
bundle exec standardrb
|
|
237
|
+
bundle exec rubocop
|
|
238
|
+
```
|
|
26
239
|
|
|
27
|
-
|
|
240
|
+
The RuboCop configuration inherits Standard Ruby and adds trailing commas for multiline constructs plus outdented access modifiers.
|
|
28
241
|
|
|
29
|
-
|
|
242
|
+
Run an individual Rails appraisal with:
|
|
30
243
|
|
|
31
|
-
|
|
244
|
+
```sh
|
|
245
|
+
bundle exec appraisal rails-7.2 rspec
|
|
246
|
+
bundle exec appraisal rails-8.0 rspec
|
|
247
|
+
bundle exec appraisal rails-8.1 rspec
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Contributing
|
|
32
251
|
|
|
33
|
-
|
|
252
|
+
Bug reports and pull requests are welcome at https://github.com/ElMassimo/actionquery. Behavioral changes should include regression tests against real Active Record relations.
|
|
34
253
|
|
|
35
|
-
##
|
|
254
|
+
## License
|
|
36
255
|
|
|
37
|
-
|
|
256
|
+
ActionQuery is available under the [MIT License](LICENSE.txt).
|
data/Rakefile
CHANGED
data/actionquery.gemspec
CHANGED
|
@@ -4,16 +4,17 @@ require_relative "lib/actionquery/version"
|
|
|
4
4
|
|
|
5
5
|
Gem::Specification.new do |spec|
|
|
6
6
|
spec.name = "actionquery"
|
|
7
|
-
spec.version =
|
|
7
|
+
spec.version = ActionQuery::VERSION
|
|
8
8
|
spec.authors = ["Maximo Mussini"]
|
|
9
9
|
spec.email = ["maximomussini@gmail.com"]
|
|
10
10
|
|
|
11
11
|
spec.summary = "Keep your scopes and queries flexible by using Ruby"
|
|
12
|
-
spec.description = "ActionQuery
|
|
12
|
+
spec.description = "ActionQuery encapsulates query building in relation-backed objects instead of model scopes."
|
|
13
13
|
spec.homepage = "https://github.com/ElMassimo/actionquery"
|
|
14
14
|
spec.license = "MIT"
|
|
15
|
-
spec.required_ruby_version = ">=
|
|
15
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
16
16
|
|
|
17
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
17
18
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
19
|
spec.metadata["source_code_uri"] = "https://github.com/ElMassimo/actionquery/tree/main"
|
|
19
20
|
spec.metadata["changelog_uri"] = "https://github.com/ElMassimo/actionquery/blob/main/CHANGELOG.md"
|
|
@@ -29,9 +30,5 @@ Gem::Specification.new do |spec|
|
|
|
29
30
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
30
31
|
spec.require_paths = ["lib"]
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
# spec.add_dependency "example-gem", "~> 1.0"
|
|
34
|
-
|
|
35
|
-
# For more information and examples about making a new gem, check out our
|
|
36
|
-
# guide at: https://bundler.io/guides/creating_gem.html
|
|
33
|
+
spec.add_dependency "activerecord", ">= 7.2"
|
|
37
34
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# ADR-001: Subclass ActiveRecord::Relation
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted
|
|
6
|
+
|
|
7
|
+
## Date
|
|
8
|
+
|
|
9
|
+
2026-09-21
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
Queryable-style query objects usually wrap a relation and delegate selected methods. That requires maintaining lists of chainable and terminal methods, and a wrapper can stop behaving like a native relation when Active Record adds APIs.
|
|
14
|
+
|
|
15
|
+
ActionQuery is intended specifically for Active Record. Its query objects need to compose with scopes, association queries, calculations, connection management, and third-party instrumentation as relations rather than relation-like wrappers.
|
|
16
|
+
|
|
17
|
+
## Decision
|
|
18
|
+
|
|
19
|
+
`ActionQuery::Base` subclasses `ActiveRecord::Relation`. Query classes add behavior with ordinary instance methods, and Relation cloning preserves the query subclass through normal chains.
|
|
20
|
+
|
|
21
|
+
A configured relation source is decomposed into its model, table, predicate builder, and relation values. The relation itself is never installed as Active Record's model.
|
|
22
|
+
|
|
23
|
+
ActionQuery explicitly delegates the model and connection methods used by supported Relation versions. A real-database test suite runs against the minimum Rails version and each currently supported minor line.
|
|
24
|
+
|
|
25
|
+
## Alternatives considered
|
|
26
|
+
|
|
27
|
+
### Wrap and delegate an internal relation
|
|
28
|
+
|
|
29
|
+
This is the approach used by Queryable. It has a smaller dependency on Relation initialization internals, but it requires classifying Active Record methods as chainable or terminal and can return wrapper-breaking values when that classification becomes stale.
|
|
30
|
+
|
|
31
|
+
### Extend each relation with a module
|
|
32
|
+
|
|
33
|
+
`relation.extending(Module)` avoids subclass construction but makes named query inheritance, model inference, and class-level filter configuration less direct. It also changes the API from constructing a query object to decorating every source relation.
|
|
34
|
+
|
|
35
|
+
## Consequences
|
|
36
|
+
|
|
37
|
+
- Query objects pass `is_a?(ActiveRecord::Relation)` and work with native relation APIs.
|
|
38
|
+
- Relation methods naturally retain custom query methods after chaining.
|
|
39
|
+
- ActionQuery is intentionally coupled to supported Active Record Relation internals.
|
|
40
|
+
- New Rails minor versions must be added to CI before being claimed as supported.
|
|
41
|
+
- Connection, query-cache, scope delegation, and relation-source initialization need dedicated regression tests.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal", "~> 2.5"
|
|
6
|
+
gem "rails", "~> 7.2.0"
|
|
7
|
+
gem "rake", "~> 13.0"
|
|
8
|
+
gem "rspec", "~> 3.13"
|
|
9
|
+
gem "sqlite3", "~> 2.8"
|
|
10
|
+
gem "standard", "~> 1.56", require: false
|
|
11
|
+
|
|
12
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal", "~> 2.5"
|
|
6
|
+
gem "rails", "~> 8.0.0"
|
|
7
|
+
gem "rake", "~> 13.0"
|
|
8
|
+
gem "rspec", "~> 3.13"
|
|
9
|
+
gem "sqlite3", "~> 2.8"
|
|
10
|
+
gem "standard", "~> 1.56", require: false
|
|
11
|
+
|
|
12
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal", "~> 2.5"
|
|
6
|
+
gem "rails", "~> 8.1.0"
|
|
7
|
+
gem "rake", "~> 13.0"
|
|
8
|
+
gem "rspec", "~> 3.13"
|
|
9
|
+
gem "sqlite3", "~> 2.8"
|
|
10
|
+
gem "standard", "~> 1.56", require: false
|
|
11
|
+
|
|
12
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# An Active Record relation that can be extended with ordinary instance methods.
|
|
4
|
+
class ActionQuery::Base < ActiveRecord::Relation
|
|
5
|
+
class << self
|
|
6
|
+
# Sets the model or relation used when constructing this query.
|
|
7
|
+
def query_from(source)
|
|
8
|
+
@query_source = source
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Wraps an existing relation in this query class.
|
|
12
|
+
def wrap(relation)
|
|
13
|
+
new.merge(relation)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns the Active Record model queried by this class.
|
|
17
|
+
def model_class
|
|
18
|
+
source = query_source
|
|
19
|
+
source.is_a?(ActiveRecord::Relation) ? source.model : source
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def query_source
|
|
25
|
+
return @query_source if instance_variable_defined?(:@query_source)
|
|
26
|
+
|
|
27
|
+
@query_source = inherited_query_source || infer_model_class
|
|
28
|
+
rescue NameError
|
|
29
|
+
message = "A model could not be inferred for #{name || self}. Follow the naming convention " \
|
|
30
|
+
"or specify one with `query_from`."
|
|
31
|
+
raise NotImplementedError, message
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def inherited_query_source
|
|
35
|
+
return unless superclass.respond_to?(:query_source, true)
|
|
36
|
+
|
|
37
|
+
superclass.send(:query_source)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def infer_model_class
|
|
41
|
+
model_name = name.sub(/(Search)?Query\z/, "").singularize
|
|
42
|
+
model_name.safe_constantize || model_name.demodulize.constantize
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
query_from false
|
|
47
|
+
|
|
48
|
+
delegate :name, :model_class, to: :class
|
|
49
|
+
delegate :attribute_types, :columns_hash, :logger, :sanitize_sql_like, :uncached, to: :model_class
|
|
50
|
+
delegate :adapter_class, :connection, :connection_pool, :lease_connection, :primary_key, :with_connection,
|
|
51
|
+
to: :model_class
|
|
52
|
+
|
|
53
|
+
def initialize
|
|
54
|
+
source = self.class.send(:query_source)
|
|
55
|
+
raise NotImplementedError, abstract_query_message unless source
|
|
56
|
+
|
|
57
|
+
model, options = initialization_attributes(source)
|
|
58
|
+
super(model, **options)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Applies an OR expression composed from the supplied Arel conditions.
|
|
62
|
+
def any_of(*conditions)
|
|
63
|
+
return self if conditions.empty?
|
|
64
|
+
|
|
65
|
+
first_condition, *remaining_conditions = conditions
|
|
66
|
+
where(remaining_conditions.reduce(first_condition, &:or))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def paginate(page_number:, page_size:)
|
|
70
|
+
return self unless page_size
|
|
71
|
+
|
|
72
|
+
offset((page_number - 1) * page_size).limit(page_size)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def scope_if(condition)
|
|
76
|
+
condition ? yield(self) : self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def unsorted
|
|
80
|
+
except(:order)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# With a column, returns its unique values. Otherwise retains Active Record behavior.
|
|
84
|
+
# The boolean default intentionally matches ActiveRecord::Relation#distinct.
|
|
85
|
+
# rubocop:disable Style/OptionalBooleanParameter
|
|
86
|
+
def distinct(column = true)
|
|
87
|
+
return super unless column.is_a?(Symbol)
|
|
88
|
+
|
|
89
|
+
super().select(column).pluck(column).flatten.uniq
|
|
90
|
+
end
|
|
91
|
+
# rubocop:enable Style/OptionalBooleanParameter
|
|
92
|
+
|
|
93
|
+
def union(relation)
|
|
94
|
+
unscope(:where).from(arel.union(relation.arel).as(table.name))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
protected
|
|
98
|
+
|
|
99
|
+
def column_type(column_name)
|
|
100
|
+
name = column_name.to_s
|
|
101
|
+
return :enum if model_class.respond_to?(:defined_enums) && model_class.defined_enums.key?(name)
|
|
102
|
+
|
|
103
|
+
attribute_types[name]&.type
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# rubocop:disable Naming/PredicatePrefix
|
|
107
|
+
def has_column?(column_name)
|
|
108
|
+
columns_hash.key?(column_name.to_s)
|
|
109
|
+
end
|
|
110
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
115
|
+
model_class.respond_to?(method_name, include_private) || super
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def method_missing(method_name, ...)
|
|
119
|
+
return super unless model_class.respond_to?(method_name)
|
|
120
|
+
|
|
121
|
+
scoping { model_class.public_send(method_name, ...) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def abstract_query_message
|
|
125
|
+
"#{self.class.name || self.class} is an abstract query, create a subclass instead."
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def initialization_attributes(source)
|
|
129
|
+
return [source, {}] unless source.is_a?(ActiveRecord::Relation)
|
|
130
|
+
|
|
131
|
+
options = {
|
|
132
|
+
table: source.table,
|
|
133
|
+
predicate_builder: source.predicate_builder,
|
|
134
|
+
values: source.values.dup,
|
|
135
|
+
}
|
|
136
|
+
[source.model, options]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A query with conventions for filtering and sorting search results.
|
|
4
|
+
class ActionQuery::SearchQuery < ActionQuery::Base
|
|
5
|
+
class << self
|
|
6
|
+
def filter(field, processor)
|
|
7
|
+
field = field.to_sym
|
|
8
|
+
search_filters[field] = build_filter_processor(field, processor)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def sort_by(field, processor)
|
|
12
|
+
sort_options[field.to_sym] = processor
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def search_filters
|
|
16
|
+
@search_filters ||= inherited_configuration(:search_filters)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def sort_options
|
|
20
|
+
@sort_options ||= inherited_configuration(:sort_options)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def build_filter_processor(field, processor)
|
|
26
|
+
return processor unless processor.is_a?(Symbol)
|
|
27
|
+
|
|
28
|
+
unless Arel::Attributes::Attribute.method_defined?(processor)
|
|
29
|
+
raise ArgumentError, "Unknown operator for an Arel attribute: #{processor}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
return ->(value) { where(field => value) } if processor == :eq
|
|
33
|
+
|
|
34
|
+
->(value) { where(table[field].public_send(processor, value)) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inherited_configuration(name)
|
|
38
|
+
return {} unless superclass.respond_to?(name)
|
|
39
|
+
|
|
40
|
+
superclass.public_send(name).dup
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
query_from false
|
|
45
|
+
|
|
46
|
+
def search(**conditions)
|
|
47
|
+
apply_search_filters(conditions)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def apply_search_filters(conditions)
|
|
51
|
+
conditions.reduce(self) do |query, (field, value)|
|
|
52
|
+
query.send(:apply_search_filter, field, value)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def apply_sorting(sort_option)
|
|
57
|
+
return self unless sort_option
|
|
58
|
+
|
|
59
|
+
field = sort_option.strategy
|
|
60
|
+
direction = sort_option.direction
|
|
61
|
+
processor = self.class.sort_options[field.to_sym]
|
|
62
|
+
|
|
63
|
+
processor ? instance_exec(direction, &processor) : order(field => direction)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def matches_any_of(fields, value)
|
|
67
|
+
any_of(*fields.map { |field| matches(field, value) })
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def with_ids(ids)
|
|
71
|
+
where(primary_key => ids)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def all_attributes
|
|
75
|
+
reselect(table[Arel.star])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
protected
|
|
79
|
+
|
|
80
|
+
def matches(field, value)
|
|
81
|
+
attribute = field.respond_to?(:matches) ? field : table[field]
|
|
82
|
+
attribute.matches("%#{sanitize_sql_like(value.to_s)}%", "\\")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def apply_search_filter(field, value)
|
|
86
|
+
return self if field.to_s == "sort" || value.nil?
|
|
87
|
+
|
|
88
|
+
processor = self.class.search_filters[field.to_sym]
|
|
89
|
+
return instance_exec(value, &processor) if processor
|
|
90
|
+
|
|
91
|
+
apply_automatic_filter(field, value)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def apply_automatic_filter(field, value)
|
|
97
|
+
case column_type(field)
|
|
98
|
+
when :string, :text
|
|
99
|
+
where(matches(field, value))
|
|
100
|
+
when :datetime
|
|
101
|
+
where(field => datetime_filter_value(value))
|
|
102
|
+
when :boolean, :date, :decimal, :integer, :enum
|
|
103
|
+
where(field => value)
|
|
104
|
+
else
|
|
105
|
+
raise_unsupported_filter(field)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def datetime_filter_value(value)
|
|
110
|
+
return value unless value.is_a?(Date) && !value.is_a?(DateTime)
|
|
111
|
+
|
|
112
|
+
value.all_day
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def raise_unsupported_filter(field)
|
|
116
|
+
message = "Please implement `search` in #{self.class} to handle conditions on #{field.inspect}, " \
|
|
117
|
+
"or define a handler using `filter :#{field}`."
|
|
118
|
+
raise NotImplementedError, message
|
|
119
|
+
end
|
|
120
|
+
end
|
data/lib/actionquery/version.rb
CHANGED
data/lib/actionquery.rb
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
# Your code goes here...
|
|
6
|
+
# Relation-backed query objects for Active Record.
|
|
7
|
+
module ActionQuery
|
|
8
8
|
end
|
|
9
|
+
|
|
10
|
+
require_relative "actionquery/error"
|
|
11
|
+
require_relative "actionquery/version"
|
|
12
|
+
require_relative "actionquery/base"
|
|
13
|
+
require_relative "actionquery/search_query"
|
data/sig/actionquery.rbs
CHANGED
|
@@ -1,4 +1,35 @@
|
|
|
1
|
-
module
|
|
1
|
+
module ActionQuery
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class Base < ActiveRecord::Relation
|
|
8
|
+
def self.query_from: (untyped source) -> untyped
|
|
9
|
+
def self.wrap: (ActiveRecord::Relation relation) -> Base
|
|
10
|
+
def self.model_class: () -> singleton(ActiveRecord::Base)
|
|
11
|
+
|
|
12
|
+
def initialize: () -> void
|
|
13
|
+
def model_class: () -> singleton(ActiveRecord::Base)
|
|
14
|
+
def any_of: (*untyped conditions) -> Base
|
|
15
|
+
def paginate: (page_number: Integer, page_size: Integer?) -> Base
|
|
16
|
+
def scope_if: [T] (untyped condition) { (Base) -> T } -> (T | Base)
|
|
17
|
+
def unsorted: () -> Base
|
|
18
|
+
def distinct: (?untyped column) -> untyped
|
|
19
|
+
def union: (ActiveRecord::Relation relation) -> Base
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class SearchQuery < Base
|
|
23
|
+
def self.filter: (String | Symbol field, untyped processor) -> untyped
|
|
24
|
+
def self.sort_by: (String | Symbol field, untyped processor) -> untyped
|
|
25
|
+
def self.search_filters: () -> Hash[Symbol, untyped]
|
|
26
|
+
def self.sort_options: () -> Hash[Symbol, untyped]
|
|
27
|
+
|
|
28
|
+
def search: (**untyped conditions) -> SearchQuery
|
|
29
|
+
def apply_search_filters: (Hash[untyped, untyped] conditions) -> SearchQuery
|
|
30
|
+
def apply_sorting: (untyped sort_option) -> SearchQuery
|
|
31
|
+
def matches_any_of: (Array[untyped] fields, untyped value) -> SearchQuery
|
|
32
|
+
def with_ids: (untyped ids) -> SearchQuery
|
|
33
|
+
def all_attributes: () -> SearchQuery
|
|
34
|
+
end
|
|
4
35
|
end
|
metadata
CHANGED
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: actionquery
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Maximo Mussini
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
12
|
-
dependencies:
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.2'
|
|
26
|
+
description: ActionQuery encapsulates query building in relation-backed objects instead
|
|
27
|
+
of model scopes.
|
|
15
28
|
email:
|
|
16
29
|
- maximomussini@gmail.com
|
|
17
30
|
executables: []
|
|
18
31
|
extensions: []
|
|
19
32
|
extra_rdoc_files: []
|
|
20
33
|
files:
|
|
34
|
+
- ".mise.toml"
|
|
21
35
|
- ".rspec"
|
|
22
36
|
- ".rubocop.yml"
|
|
37
|
+
- ".standard.yml"
|
|
38
|
+
- Appraisals
|
|
23
39
|
- CHANGELOG.md
|
|
24
40
|
- CODE_OF_CONDUCT.md
|
|
25
41
|
- Gemfile
|
|
@@ -28,17 +44,24 @@ files:
|
|
|
28
44
|
- README.md
|
|
29
45
|
- Rakefile
|
|
30
46
|
- actionquery.gemspec
|
|
47
|
+
- docs/decisions/001-subclass-active-record-relation.md
|
|
48
|
+
- gemfiles/rails_7.2.gemfile
|
|
49
|
+
- gemfiles/rails_8.0.gemfile
|
|
50
|
+
- gemfiles/rails_8.1.gemfile
|
|
31
51
|
- lib/actionquery.rb
|
|
52
|
+
- lib/actionquery/base.rb
|
|
53
|
+
- lib/actionquery/error.rb
|
|
54
|
+
- lib/actionquery/search_query.rb
|
|
32
55
|
- lib/actionquery/version.rb
|
|
33
56
|
- sig/actionquery.rbs
|
|
34
57
|
homepage: https://github.com/ElMassimo/actionquery
|
|
35
58
|
licenses:
|
|
36
59
|
- MIT
|
|
37
60
|
metadata:
|
|
61
|
+
rubygems_mfa_required: 'true'
|
|
38
62
|
homepage_uri: https://github.com/ElMassimo/actionquery
|
|
39
63
|
source_code_uri: https://github.com/ElMassimo/actionquery/tree/main
|
|
40
64
|
changelog_uri: https://github.com/ElMassimo/actionquery/blob/main/CHANGELOG.md
|
|
41
|
-
post_install_message:
|
|
42
65
|
rdoc_options: []
|
|
43
66
|
require_paths:
|
|
44
67
|
- lib
|
|
@@ -46,15 +69,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
46
69
|
requirements:
|
|
47
70
|
- - ">="
|
|
48
71
|
- !ruby/object:Gem::Version
|
|
49
|
-
version:
|
|
72
|
+
version: 3.1.0
|
|
50
73
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
74
|
requirements:
|
|
52
75
|
- - ">="
|
|
53
76
|
- !ruby/object:Gem::Version
|
|
54
77
|
version: '0'
|
|
55
78
|
requirements: []
|
|
56
|
-
rubygems_version:
|
|
57
|
-
signing_key:
|
|
79
|
+
rubygems_version: 4.0.20
|
|
58
80
|
specification_version: 4
|
|
59
81
|
summary: Keep your scopes and queries flexible by using Ruby
|
|
60
82
|
test_files: []
|