action_scope 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +348 -0
- data/Rakefile +12 -0
- data/lib/action_scope/associations.rb +56 -0
- data/lib/action_scope/basic.rb +37 -0
- data/lib/action_scope/matchable.rb +39 -0
- data/lib/action_scope/multi_searchable.rb +75 -0
- data/lib/action_scope/rangeable.rb +48 -0
- data/lib/action_scope/scopable.rb +50 -0
- data/lib/action_scope/sortable.rb +41 -0
- data/lib/action_scope/version.rb +5 -0
- data/lib/action_scope.rb +64 -0
- data/sig/action_scope.rbs +4 -0
- metadata +134 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 43bbde8d9aa5ef6333ba5c917da1e09527174e56ed97e21f7ca3672088ed311b
|
4
|
+
data.tar.gz: e1935b3f2c97f025e2578a4cc0125acd9239bb181ac1158fd0d282f6fc89d63a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: fe694f26484fb9e44c6033c5b87e5760b7ec409d54223d5304bc17739351256e2dce06d5b43d1122e6b465eb06283229f44cf22afe40d23f5e8c71122af8e910
|
7
|
+
data.tar.gz: e04c2a45d939089277dc57a2f53efff52a58dadfb83958e76ef48f3296bea1c9850c4db64739b1ad0dd0168f554ed6b7d6891569ce8b094cf98678b189673aaf
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2025 OrestF
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,348 @@
|
|
1
|
+
# ActionScope
|
2
|
+
|
3
|
+
ActionScope provides a comprehensive set of dynamic scopes for ActiveRecord models, including basic column filtering, text matching, range queries, association scopes, multi-column search, and sorting capabilities. Automatically generates scopes based on model attributes and associations.
|
4
|
+
|
5
|
+
## Features
|
6
|
+
|
7
|
+
- **Basic Scopes**: Automatic `by_column_name` scopes for all model attributes
|
8
|
+
- **Text Matching**: Fuzzy search scopes for string/text columns
|
9
|
+
- **Range Queries**: Greater than, less than, and between scopes for numeric/date columns
|
10
|
+
- **Association Scopes**: Automatic scopes for model associations
|
11
|
+
- **Multi-Column Search**: Search across multiple columns with a single query
|
12
|
+
- **Sorting**: Dynamic sorting scopes for all columns
|
13
|
+
- **Type-Aware**: Automatically detects column types and generates appropriate scopes
|
14
|
+
- **Rails 7+ Compatible**: Works with modern Rails versions
|
15
|
+
|
16
|
+
## Installation
|
17
|
+
|
18
|
+
Add this line to your application's Gemfile:
|
19
|
+
|
20
|
+
```ruby
|
21
|
+
gem 'action_scope'
|
22
|
+
```
|
23
|
+
|
24
|
+
And then execute:
|
25
|
+
|
26
|
+
```bash
|
27
|
+
bundle install
|
28
|
+
```
|
29
|
+
|
30
|
+
Or install it yourself as:
|
31
|
+
|
32
|
+
```bash
|
33
|
+
gem install action_scope
|
34
|
+
```
|
35
|
+
|
36
|
+
## Usage
|
37
|
+
|
38
|
+
### Basic Setup
|
39
|
+
|
40
|
+
Include ActionScope in your ActiveRecord models and call `action_scope`:
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
class User < ApplicationRecord
|
44
|
+
include ActionScope
|
45
|
+
|
46
|
+
action_scope
|
47
|
+
|
48
|
+
# Your model code here...
|
49
|
+
end
|
50
|
+
```
|
51
|
+
|
52
|
+
### Example Model
|
53
|
+
|
54
|
+
Let's say you have a User model with the following structure:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
# db/migrate/xxx_create_users.rb
|
58
|
+
class CreateUsers < ActiveRecord::Migration[7.0]
|
59
|
+
def change
|
60
|
+
create_table :users do |t|
|
61
|
+
t.string :name
|
62
|
+
t.string :email
|
63
|
+
t.text :bio
|
64
|
+
t.integer :age
|
65
|
+
t.decimal :salary, precision: 10, scale: 2
|
66
|
+
t.datetime :last_login_at
|
67
|
+
t.date :birth_date
|
68
|
+
t.boolean :active
|
69
|
+
t.references :company, null: false, foreign_key: true
|
70
|
+
|
71
|
+
t.timestamps
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# app/models/user.rb
|
77
|
+
class User < ApplicationRecord
|
78
|
+
include ActionScope
|
79
|
+
|
80
|
+
belongs_to :company
|
81
|
+
has_many :posts
|
82
|
+
|
83
|
+
action_scope
|
84
|
+
end
|
85
|
+
|
86
|
+
# app/models/company.rb
|
87
|
+
class Company < ApplicationRecord
|
88
|
+
include ActionScope
|
89
|
+
|
90
|
+
has_many :users
|
91
|
+
|
92
|
+
action_scope
|
93
|
+
end
|
94
|
+
```
|
95
|
+
|
96
|
+
### Generated Scopes
|
97
|
+
|
98
|
+
ActionScope automatically generates the following types of scopes:
|
99
|
+
|
100
|
+
#### 1. Basic Scopes (`by_column_name`)
|
101
|
+
|
102
|
+
For every column in your model:
|
103
|
+
|
104
|
+
```ruby
|
105
|
+
# Filter by exact values
|
106
|
+
User.by_name("John Doe")
|
107
|
+
User.by_email("john@example.com")
|
108
|
+
User.by_age(30)
|
109
|
+
User.by_active(true)
|
110
|
+
|
111
|
+
# Chain multiple filters
|
112
|
+
User.by_name("John").by_active(true).by_age(30)
|
113
|
+
```
|
114
|
+
|
115
|
+
#### 2. Text Matching Scopes (`by_column_name_match`)
|
116
|
+
|
117
|
+
For string and text columns:
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
# Fuzzy search (uses LIKE %query%)
|
121
|
+
User.by_name_match("john") # Finds "John Doe", "Johnny", etc.
|
122
|
+
User.by_email_match("gmail") # Finds emails containing "gmail"
|
123
|
+
User.by_bio_match("developer") # Searches bio text
|
124
|
+
```
|
125
|
+
|
126
|
+
#### 3. Range Scopes
|
127
|
+
|
128
|
+
For numeric, date, and datetime columns:
|
129
|
+
|
130
|
+
```ruby
|
131
|
+
# Greater than / Less than
|
132
|
+
User.by_age_gt(25) # age > 25
|
133
|
+
User.by_age_gte(25) # age >= 25
|
134
|
+
User.by_age_lt(65) # age < 65
|
135
|
+
User.by_age_lte(65) # age <= 65
|
136
|
+
|
137
|
+
# Date ranges
|
138
|
+
User.by_birth_date_gte(Date.new(1990, 1, 1))
|
139
|
+
User.by_last_login_at_lt(1.week.ago)
|
140
|
+
|
141
|
+
# Salary ranges
|
142
|
+
User.by_salary_gte(50000)
|
143
|
+
User.by_salary_lte(100000)
|
144
|
+
|
145
|
+
# Chain range conditions
|
146
|
+
User.by_age_gte(25).by_age_lte(65) # Between 25 and 65
|
147
|
+
```
|
148
|
+
|
149
|
+
#### 4. Association Scopes
|
150
|
+
|
151
|
+
For belongs_to and has_many associations:
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
# Filter by association ID
|
155
|
+
User.by_company(1) # Users belonging to company with ID 1
|
156
|
+
User.by_company(company) # Users belonging to company object
|
157
|
+
|
158
|
+
# Works with company model too
|
159
|
+
Company.by_user(user) # Companies associated with user
|
160
|
+
```
|
161
|
+
|
162
|
+
#### 5. Multi-Column Search
|
163
|
+
|
164
|
+
Search across multiple columns simultaneously:
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
# Searches name, email, bio, and id columns
|
168
|
+
User.by_search_query("john")
|
169
|
+
|
170
|
+
# Finds users where any searchable column contains "john"
|
171
|
+
User.by_search_query("developer") # Matches name, email, or bio
|
172
|
+
```
|
173
|
+
|
174
|
+
#### 6. Sorting Scopes
|
175
|
+
|
176
|
+
For all columns:
|
177
|
+
|
178
|
+
```ruby
|
179
|
+
# Sort ascending (default)
|
180
|
+
User.sort_by_name
|
181
|
+
User.sort_by_age
|
182
|
+
User.sort_by_created_at
|
183
|
+
|
184
|
+
# Sort descending
|
185
|
+
User.sort_by_name('desc')
|
186
|
+
User.sort_by_salary('desc')
|
187
|
+
|
188
|
+
# Generic sorting method
|
189
|
+
User.sorted_by(:name, 'asc')
|
190
|
+
User.sorted_by(:age, 'desc')
|
191
|
+
```
|
192
|
+
|
193
|
+
### Advanced Usage
|
194
|
+
|
195
|
+
#### Selective Scope Generation
|
196
|
+
|
197
|
+
Generate scopes only for specific columns:
|
198
|
+
|
199
|
+
```ruby
|
200
|
+
class User < ApplicationRecord
|
201
|
+
include ActionScope
|
202
|
+
|
203
|
+
# Only generate scopes for name and email
|
204
|
+
action_scope :name, :email
|
205
|
+
end
|
206
|
+
```
|
207
|
+
|
208
|
+
#### Excluding Columns
|
209
|
+
|
210
|
+
Exclude certain columns from scope generation:
|
211
|
+
|
212
|
+
```ruby
|
213
|
+
class User < ApplicationRecord
|
214
|
+
include ActionScope
|
215
|
+
|
216
|
+
# Generate scopes for all columns except password_digest
|
217
|
+
action_scope except: [:password_digest, :secret_token]
|
218
|
+
end
|
219
|
+
```
|
220
|
+
|
221
|
+
#### Custom Configuration
|
222
|
+
|
223
|
+
You can also provide a block for custom configuration:
|
224
|
+
|
225
|
+
```ruby
|
226
|
+
class User < ApplicationRecord
|
227
|
+
include ActionScope
|
228
|
+
|
229
|
+
action_scope do |model|
|
230
|
+
# Custom logic here
|
231
|
+
puts "Generating scopes for #{model.name}"
|
232
|
+
end
|
233
|
+
end
|
234
|
+
```
|
235
|
+
|
236
|
+
### Real-World Examples
|
237
|
+
|
238
|
+
#### Building Complex Queries
|
239
|
+
|
240
|
+
```ruby
|
241
|
+
# Find active adult users from a specific company with recent activity
|
242
|
+
User.by_active(true)
|
243
|
+
.by_age_gte(18)
|
244
|
+
.by_company(company)
|
245
|
+
.by_last_login_at_gte(1.month.ago)
|
246
|
+
.sort_by_name
|
247
|
+
|
248
|
+
# Search for users and sort by salary
|
249
|
+
User.by_search_query("engineer")
|
250
|
+
.by_salary_gte(70000)
|
251
|
+
.sort_by_salary('desc')
|
252
|
+
|
253
|
+
# Filter by date ranges
|
254
|
+
User.by_birth_date_gte(Date.new(1980, 1, 1))
|
255
|
+
.by_birth_date_lte(Date.new(2000, 12, 31))
|
256
|
+
.by_active(true)
|
257
|
+
```
|
258
|
+
|
259
|
+
### Scope Introspection
|
260
|
+
|
261
|
+
ActionScope provides methods to inspect generated scopes:
|
262
|
+
|
263
|
+
#### `action_scope_options` - Complete Scope Overview
|
264
|
+
|
265
|
+
The `action_scope_options` method (aliased as `options_for_search`) returns a comprehensive hash of all available scopes with their types and options:
|
266
|
+
|
267
|
+
```ruby
|
268
|
+
User.action_scope_options
|
269
|
+
# Returns a hash like this:
|
270
|
+
|
271
|
+
{
|
272
|
+
# Basic scopes with column types
|
273
|
+
:by_name => nil,
|
274
|
+
:by_email => nil,
|
275
|
+
:by_bio => nil,
|
276
|
+
:by_age => nil,
|
277
|
+
:by_salary => nil,
|
278
|
+
:by_last_login_at => nil,
|
279
|
+
:by_birth_date => nil,
|
280
|
+
:by_active => nil,
|
281
|
+
:by_company_id => nil,
|
282
|
+
|
283
|
+
# Text matching scopes for string/text columns
|
284
|
+
:by_name_match => nil,
|
285
|
+
:by_email_match => nil,
|
286
|
+
:by_bio_match => nil,
|
287
|
+
|
288
|
+
# Multi-column search with searchable columns
|
289
|
+
:by_search_query => [
|
290
|
+
"id", "name", "email", "bio", "age", "salary",
|
291
|
+
"last_login_at", "birth_date", "active", "company_id"
|
292
|
+
],
|
293
|
+
|
294
|
+
# Range scopes for numeric/date columns
|
295
|
+
:by_age_gte => :integer,
|
296
|
+
:by_age_lte => :integer,
|
297
|
+
:by_age_gt => :integer,
|
298
|
+
:by_age_lt => :integer,
|
299
|
+
:by_salary_gte => :decimal,
|
300
|
+
:by_salary_lte => :decimal,
|
301
|
+
:by_salary_gt => :decimal,
|
302
|
+
:by_salary_lt => :decimal,
|
303
|
+
:by_last_login_at_gte => :datetime,
|
304
|
+
:by_last_login_at_lte => :datetime,
|
305
|
+
:by_last_login_at_gt => :datetime,
|
306
|
+
:by_last_login_at_lt => :datetime,
|
307
|
+
:by_birth_date_gte => :date,
|
308
|
+
:by_birth_date_lte => :date,
|
309
|
+
:by_birth_date_gt => :date,
|
310
|
+
:by_birth_date_lt => :date,
|
311
|
+
|
312
|
+
# Association scopes
|
313
|
+
:by_company => "id",
|
314
|
+
|
315
|
+
# Sorting scopes
|
316
|
+
:sort_by_name => ["asc", "desc"],
|
317
|
+
:sort_by_email => ["asc", "desc"],
|
318
|
+
:sort_by_age => ["asc", "desc"],
|
319
|
+
:sort_by_salary => ["asc", "desc"],
|
320
|
+
:sort_by_created_at => ["asc", "desc"]
|
321
|
+
}
|
322
|
+
```
|
323
|
+
|
324
|
+
#### Individual Introspection Methods
|
325
|
+
|
326
|
+
You can also access specific scope types individually:
|
327
|
+
|
328
|
+
```ruby
|
329
|
+
# Basic scopes with their column types
|
330
|
+
User.basic_scopes_with_types
|
331
|
+
|
332
|
+
# Text matching scopes
|
333
|
+
User.matchable_scopes_with_types
|
334
|
+
|
335
|
+
# Range scopes with their column types
|
336
|
+
User.rangeable_scopes_scopes_with_types
|
337
|
+
|
338
|
+
# Sorting scopes with available directions
|
339
|
+
User.sortable_scopes_with_directions
|
340
|
+
|
341
|
+
# Association scopes with their primary keys
|
342
|
+
User.association_scopes_with_primary_keys
|
343
|
+
|
344
|
+
# Multi-search scope with searchable column names
|
345
|
+
User.multi_searchable_scopes_with_column_names
|
346
|
+
```
|
347
|
+
|
348
|
+
This introspection is particularly useful for building dynamic UIs, API documentation, or debugging scope availability.
|
data/Rakefile
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionScope::Associations
|
4
|
+
extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
|
5
|
+
|
6
|
+
EXCLUDED_ASSOCIATION_SCOPES_CLASSES = %w[ActiveStorage::Attachment ActiveStorage::Blob].freeze
|
7
|
+
|
8
|
+
class_methods do
|
9
|
+
def load_association_scopes
|
10
|
+
yield(self) if block_given?
|
11
|
+
|
12
|
+
action_scope_association_scopes
|
13
|
+
end
|
14
|
+
|
15
|
+
def action_scope_association_scopes(*association_names, except: [])
|
16
|
+
return unless db_ready?
|
17
|
+
|
18
|
+
association_names = association_names.presence || relation_names_for_association_scopes
|
19
|
+
assoc_to_define = associations_for_scopes.select do |afs|
|
20
|
+
afs.name.in?(association_names.map(&:to_sym)) && !afs.name.in?(except.map(&:to_sym))
|
21
|
+
end
|
22
|
+
assoc_to_define.each do |association|
|
23
|
+
scope "by_#{association.name.to_s.singularize}", lambda { |value|
|
24
|
+
fltrs = [{ association.name.to_s => { association.association_primary_key => { 'is' => value } } }]
|
25
|
+
ActiveRecord::PredicateBuilder.filter_joins(klass, fltrs).flatten.reduce(self) do |_acc, j|
|
26
|
+
if j.is_a?(String) || j.is_a?(Arel::Nodes::Join)
|
27
|
+
joins(j)
|
28
|
+
elsif j.present?
|
29
|
+
left_outer_joins(j)
|
30
|
+
else
|
31
|
+
self
|
32
|
+
end
|
33
|
+
end.where(association.name.to_s => { association.association_primary_key => value })
|
34
|
+
}
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def relation_names_for_association_scopes
|
39
|
+
associations_for_scopes.map(&:name)
|
40
|
+
end
|
41
|
+
|
42
|
+
def associations_for_scopes
|
43
|
+
return [] unless table_exists?
|
44
|
+
|
45
|
+
reflect_on_all_associations.reject { |a| a.polymorphic? || a.class_name.in?(EXCLUDED_ASSOCIATION_SCOPES_CLASSES) }
|
46
|
+
end
|
47
|
+
|
48
|
+
def association_scopes_with_primary_keys
|
49
|
+
associations_for_scopes.each_with_object({}) do |association, h|
|
50
|
+
if defined_scopes.include?(:"by_#{association.name.to_s.singularize}")
|
51
|
+
h[:"by_#{association.name.to_s.singularize}"] = association.association_primary_key
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionScope::Basic
|
4
|
+
extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
|
5
|
+
|
6
|
+
BASIC_SCOPES_COLUMN_TYPES = %i[string text integer float decimal datetime date time].freeze
|
7
|
+
|
8
|
+
class_methods do
|
9
|
+
def action_scope_basic_scopes(*col_names, except: [])
|
10
|
+
return unless db_ready?
|
11
|
+
|
12
|
+
col_names = col_names.presence || column_names_for_basic_scopes
|
13
|
+
col_names -= Array.wrap(except).map(&:to_s)
|
14
|
+
Array.wrap(col_names).each do |column_name|
|
15
|
+
scope "by_#{column_name}", ->(value) { where(column_name => value) }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def columns_types_basic_scopes
|
20
|
+
return {} unless db_ready?
|
21
|
+
|
22
|
+
columns_hash.transform_values(&:type).select do |column, type|
|
23
|
+
BASIC_SCOPES_COLUMN_TYPES.include?(type) && !reflect_on_all_associations.map(&:foreign_key).include?(column)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def column_names_for_basic_scopes = columns_types_basic_scopes.keys
|
28
|
+
|
29
|
+
def basic_scopes_with_types
|
30
|
+
column_names_for_basic_scopes.each_with_object({}) do |(column, type), h|
|
31
|
+
defined = defined_scopes.include?(:"by_#{column}")
|
32
|
+
h[:"by_#{column}"] = type if defined
|
33
|
+
h[:"by_#{column}"] = defined_enums[column.to_s].keys if defined && defined_enums.keys.include?(column.to_s)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionScope::Matchable
|
4
|
+
extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
|
5
|
+
|
6
|
+
MATCHABLE_COLUMN_TYPES = %i[string text].freeze
|
7
|
+
|
8
|
+
included do
|
9
|
+
scope :match, ->(column_name, query) { where(arel_table[column_name].matches("%#{query}%")) }
|
10
|
+
end
|
11
|
+
|
12
|
+
class_methods do
|
13
|
+
def action_scope_matchable_scopes(*col_names, except: [])
|
14
|
+
return unless db_ready?
|
15
|
+
|
16
|
+
col_names = col_names.presence || column_names_for_matchable_scopes
|
17
|
+
col_names -= Array.wrap(except).map(&:to_s)
|
18
|
+
Array.wrap(col_names).each do |column_name|
|
19
|
+
scope "by_#{column_name}_match", ->(query) { match(column_name, query) }
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def columns_types_for_matchable_scopes
|
24
|
+
return {} unless db_ready?
|
25
|
+
|
26
|
+
columns_hash.transform_values(&:type).select do |column, type|
|
27
|
+
MATCHABLE_COLUMN_TYPES.include?(type) && !reflect_on_all_associations.map(&:foreign_key).include?(column)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def column_names_for_matchable_scopes = columns_types_for_matchable_scopes.keys
|
32
|
+
|
33
|
+
def matchable_scopes_with_types
|
34
|
+
column_names_for_matchable_scopes.each_with_object({}) do |(column, type), h|
|
35
|
+
h[:"by_#{column}_match"] = type if defined_scopes.include?(:"by_#{column}_match")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionScope::MultiSearchable
|
4
|
+
extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
|
5
|
+
|
6
|
+
SEARCHABLE_STRING_COLUMN_TYPES = %i[text string citext].freeze
|
7
|
+
SEARCHABLE_NUMBER_COLUMN_TYPES = %i[integer float].freeze
|
8
|
+
SEARCHABLE_COLUMN_TYPES = SEARCHABLE_STRING_COLUMN_TYPES + SEARCHABLE_NUMBER_COLUMN_TYPES
|
9
|
+
|
10
|
+
class_methods do
|
11
|
+
def action_scope_multi_searchable_scope(*col_names, except: [])
|
12
|
+
return unless db_ready?
|
13
|
+
|
14
|
+
col_names = col_names.presence || define_multi_searchable_columns
|
15
|
+
col_names -= Array.wrap(except).map(&:to_s)
|
16
|
+
invalid_column = Array.wrap(col_names).map(&:to_s) - column_names
|
17
|
+
raise ArgumentError, "Invalid column names: #{invalid_column.join(',')}" if invalid_column.present?
|
18
|
+
|
19
|
+
define_singleton_method(:column_names_for_multi_search_query) { col_names }
|
20
|
+
|
21
|
+
scope :by_search_query, lambda { |keyword|
|
22
|
+
where(or_join(build_multicolumn_search_filters(klass, col_names, keyword)))
|
23
|
+
}
|
24
|
+
end
|
25
|
+
alias_method :action_scope_multi_searchable_scopes, :action_scope_multi_searchable_scope
|
26
|
+
|
27
|
+
def define_multi_searchable_columns
|
28
|
+
return [] unless table_exists?
|
29
|
+
|
30
|
+
(columns.map { |c| c.type.in?(SEARCHABLE_COLUMN_TYPES) ? c.name : nil }.compact << primary_key).uniq
|
31
|
+
end
|
32
|
+
|
33
|
+
def column_names
|
34
|
+
return [] unless table_exists?
|
35
|
+
|
36
|
+
super
|
37
|
+
end
|
38
|
+
|
39
|
+
def column_names_for_multi_search_query
|
40
|
+
define_multi_searchable_columns
|
41
|
+
end
|
42
|
+
|
43
|
+
def multi_searchable_scopes_with_column_names
|
44
|
+
{ by_search_query: column_names_for_multi_search_query }
|
45
|
+
end
|
46
|
+
|
47
|
+
def build_multicolumn_search_filters(model, column_names, keyword)
|
48
|
+
arel_table = model.arel_table
|
49
|
+
|
50
|
+
column_names = (Array.wrap(column_names).map(&:to_s) << model.primary_key) & model.column_names
|
51
|
+
|
52
|
+
column_names.map do |name|
|
53
|
+
column_type = model.columns_hash[name.to_s]&.type
|
54
|
+
|
55
|
+
next unless column_type
|
56
|
+
|
57
|
+
if SEARCHABLE_STRING_COLUMN_TYPES.include?(column_type)
|
58
|
+
arel_table[name].matches("%#{keyword}%")
|
59
|
+
elsif SEARCHABLE_NUMBER_COLUMN_TYPES.include?(column_type)
|
60
|
+
arel_table[name].eq(keyword.to_f) if keyword.to_s.match?(/\A\d/)
|
61
|
+
else
|
62
|
+
arel_table[name].eq(keyword)
|
63
|
+
end
|
64
|
+
end.compact
|
65
|
+
end
|
66
|
+
|
67
|
+
def or_join(filter_array)
|
68
|
+
filter_array.reduce(nil) do |acc, filter|
|
69
|
+
next acc = filter unless acc
|
70
|
+
|
71
|
+
acc.or(filter)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionScope::Rangeable
|
4
|
+
extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
|
5
|
+
|
6
|
+
RANGEABLE_COLUMN_TYPES = %i[integer float decimal datetime date time].freeze
|
7
|
+
|
8
|
+
included do
|
9
|
+
scope :gt, ->(column, value) { where(arel_table[column].gt(value)) }
|
10
|
+
scope :lt, ->(column, value) { where(arel_table[column].lt(value)) }
|
11
|
+
scope :gte, ->(column, value) { where(arel_table[column].gteq(value)) }
|
12
|
+
scope :lte, ->(column, value) { where(arel_table[column].lteq(value)) }
|
13
|
+
end
|
14
|
+
|
15
|
+
class_methods do
|
16
|
+
def action_scope_rangeable_scopes(*col_names, except: [])
|
17
|
+
return unless db_ready?
|
18
|
+
|
19
|
+
col_names = col_names.presence || column_names_for_rangeable_scopes
|
20
|
+
col_names -= Array.wrap(except).map(&:to_s)
|
21
|
+
col_names.each do |column_name|
|
22
|
+
scope "by_#{column_name}_gte", ->(value) { gte(column_name, value) }
|
23
|
+
scope "by_#{column_name}_lte", ->(value) { lte(column_name, value) }
|
24
|
+
scope "by_#{column_name}_gt", ->(value) { gt(column_name, value) }
|
25
|
+
scope "by_#{column_name}_lt", ->(value) { lt(column_name, value) }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def column_names_for_rangeable_scopes = columns_types_for_rangeable_scopes.keys
|
30
|
+
|
31
|
+
def columns_types_for_rangeable_scopes
|
32
|
+
return {} unless db_ready?
|
33
|
+
|
34
|
+
columns_hash.transform_values(&:type).select do |column, type|
|
35
|
+
RANGEABLE_COLUMN_TYPES.include?(type) && !reflect_on_all_associations.map(&:foreign_key).include?(column)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def rangeable_scopes_scopes_with_types
|
40
|
+
columns_types_for_rangeable_scopes.each_with_object({}) do |(column, type), h|
|
41
|
+
h[:"by_#{column}_gte"] = type if defined_scopes.include?(:"by_#{column}_gte")
|
42
|
+
h[:"by_#{column}_lte"] = type if defined_scopes.include?(:"by_#{column}_lte")
|
43
|
+
h[:"by_#{column}_gt"] = type if defined_scopes.include?(:"by_#{column}_gt")
|
44
|
+
h[:"by_#{column}_lt"] = type if defined_scopes.include?(:"by_#{column}_lt")
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionScope::Scopable
|
4
|
+
# extend ActiveSupport::Concern
|
5
|
+
#
|
6
|
+
# include ActionScope::Associations
|
7
|
+
# include ActionScope::Basic
|
8
|
+
# include ActionScope::Matchable
|
9
|
+
# include ActionScope::MultiSearchable
|
10
|
+
# include ActionScope::Rangeable
|
11
|
+
# include ActionScope::Sortable
|
12
|
+
#
|
13
|
+
# class_methods do
|
14
|
+
# def db_ready?
|
15
|
+
# need_migration =
|
16
|
+
# if Rails.version.to_f >= 7.2
|
17
|
+
# ActiveRecord::Tasks::DatabaseTasks.migration_connection_pool.migration_context.needs_migration?
|
18
|
+
# else
|
19
|
+
# ActiveRecord::Base.connection.migration_context.needs_migration?
|
20
|
+
# end
|
21
|
+
# ActiveRecord::Base.connection && ActiveRecord::Base.connected? && !need_migration
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# def define_action_scope_scopes(*col_names, except: [])
|
25
|
+
# return unless db_ready?
|
26
|
+
#
|
27
|
+
# yield(self) if block_given?
|
28
|
+
#
|
29
|
+
# define_association_scopes(*col_names, except: except)
|
30
|
+
# define_basic_scopes(*col_names, except: except)
|
31
|
+
# define_matchable_scopes(*col_names, except: except)
|
32
|
+
# define_multi_searchable_scopes(*col_names, except: except)
|
33
|
+
# define_rangeable_scopes(*col_names, except: except)
|
34
|
+
# define_sortable_scopes(*col_names, except: except)
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# def options_for_search
|
38
|
+
# %i[
|
39
|
+
# association_scopes_with_primary_keys
|
40
|
+
# basic_scopes_with_types
|
41
|
+
# matchable_scopes_with_types
|
42
|
+
# multi_searchable_scopes_with_column_names
|
43
|
+
# rangeable_scopes_scopes_with_types
|
44
|
+
# sortable_scopes_with_directions
|
45
|
+
# ].each_with_object({}) do |scope, options|
|
46
|
+
# options.merge!(send(scope)) if defined?(scope)
|
47
|
+
# end
|
48
|
+
# end
|
49
|
+
# end
|
50
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionScope::Sortable
|
4
|
+
extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
|
5
|
+
|
6
|
+
SORTABLE_SCOPES_PREFIX = 'sort_by'
|
7
|
+
|
8
|
+
included do
|
9
|
+
scope :sorted_by, lambda { |col_names, direction = 'asc'|
|
10
|
+
order(direction.to_s.downcase == 'asc' ? arel_table[col_names].asc : arel_table[col_names].desc)
|
11
|
+
}
|
12
|
+
end
|
13
|
+
|
14
|
+
class_methods do
|
15
|
+
def action_scope_sortable_scopes(*col_names, except: [])
|
16
|
+
return unless db_ready?
|
17
|
+
|
18
|
+
col_names = column_names_for_sortable_scopes if col_names.blank?
|
19
|
+
col_names -= Array.wrap(except).map(&:to_s)
|
20
|
+
col_names.each do |col_name|
|
21
|
+
scope "#{SORTABLE_SCOPES_PREFIX}_#{col_name}", ->(direction = 'asc') { sorted_by(col_name, direction) }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def column_names_without_associations
|
26
|
+
return [] unless db_ready?
|
27
|
+
|
28
|
+
column_names - reflect_on_all_associations.map(&:foreign_key)
|
29
|
+
end
|
30
|
+
|
31
|
+
def column_names_for_sortable_scopes
|
32
|
+
column_names_without_associations
|
33
|
+
end
|
34
|
+
|
35
|
+
def sortable_scopes_with_directions
|
36
|
+
column_names_for_sortable_scopes.each_with_object({}) do |column, h|
|
37
|
+
h[:"#{SORTABLE_SCOPES_PREFIX}_#{column}"] = %w[asc desc] if defined_scopes.include?(:"#{SORTABLE_SCOPES_PREFIX}_#{column}")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/lib/action_scope.rb
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record'
|
4
|
+
|
5
|
+
require_relative "action_scope/version"
|
6
|
+
require_relative "action_scope/associations"
|
7
|
+
require_relative "action_scope/basic"
|
8
|
+
require_relative "action_scope/matchable"
|
9
|
+
require_relative "action_scope/multi_searchable"
|
10
|
+
require_relative "action_scope/rangeable"
|
11
|
+
require_relative "action_scope/sortable"
|
12
|
+
|
13
|
+
module ActionScope
|
14
|
+
class Error < StandardError; end
|
15
|
+
|
16
|
+
extend ActiveSupport::Concern if defined?(ActiveSupport::Concern)
|
17
|
+
|
18
|
+
include ActionScope::Associations
|
19
|
+
include ActionScope::Basic
|
20
|
+
include ActionScope::Matchable
|
21
|
+
include ActionScope::MultiSearchable
|
22
|
+
include ActionScope::Rangeable
|
23
|
+
include ActionScope::Sortable
|
24
|
+
|
25
|
+
class_methods do
|
26
|
+
def db_ready?
|
27
|
+
need_migration =
|
28
|
+
if Rails.version.to_f >= 7.2
|
29
|
+
ActiveRecord::Tasks::DatabaseTasks.migration_connection_pool.migration_context.needs_migration?
|
30
|
+
else
|
31
|
+
ActiveRecord::Base.connection.migration_context.needs_migration?
|
32
|
+
end
|
33
|
+
ActiveRecord::Base.connection && ActiveRecord::Base.connected? && !need_migration
|
34
|
+
end
|
35
|
+
|
36
|
+
def action_scope(*col_names, except: [])
|
37
|
+
return unless db_ready?
|
38
|
+
|
39
|
+
yield(self) if block_given?
|
40
|
+
|
41
|
+
action_scope_association_scopes(*col_names, except: except)
|
42
|
+
action_scope_basic_scopes(*col_names, except: except)
|
43
|
+
action_scope_matchable_scopes(*col_names, except: except)
|
44
|
+
action_scope_multi_searchable_scopes(*col_names, except: except)
|
45
|
+
action_scope_rangeable_scopes(*col_names, except: except)
|
46
|
+
action_scope_sortable_scopes(*col_names, except: except)
|
47
|
+
end
|
48
|
+
alias action_scopes action_scope
|
49
|
+
|
50
|
+
def action_scope_options
|
51
|
+
%i[
|
52
|
+
association_scopes_with_primary_keys
|
53
|
+
basic_scopes_with_types
|
54
|
+
matchable_scopes_with_types
|
55
|
+
multi_searchable_scopes_with_column_names
|
56
|
+
rangeable_scopes_scopes_with_types
|
57
|
+
sortable_scopes_with_directions
|
58
|
+
].each_with_object({}) do |scope, options|
|
59
|
+
options.merge!(send(scope)) if defined?(scope)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
alias options_for_search action_scope_options
|
63
|
+
end
|
64
|
+
end
|
metadata
ADDED
@@ -0,0 +1,134 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: action_scope
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- OrestF
|
8
|
+
bindir: exe
|
9
|
+
cert_chain: []
|
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: '0'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - ">="
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: activesupport
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
40
|
+
- !ruby/object:Gem::Dependency
|
41
|
+
name: rake
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '13.0'
|
47
|
+
type: :development
|
48
|
+
prerelease: false
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '13.0'
|
54
|
+
- !ruby/object:Gem::Dependency
|
55
|
+
name: rspec
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '3.0'
|
61
|
+
type: :development
|
62
|
+
prerelease: false
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '3.0'
|
68
|
+
- !ruby/object:Gem::Dependency
|
69
|
+
name: rubocop
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - "~>"
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '1.0'
|
75
|
+
type: :development
|
76
|
+
prerelease: false
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - "~>"
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '1.0'
|
82
|
+
description: ActionScope provides a comprehensive set of dynamic scopes for ActiveRecord
|
83
|
+
models, including basic column filtering, text matching, range queries, association
|
84
|
+
scopes, multi-column search, and sorting capabilities. Automatically generates scopes
|
85
|
+
based on model attributes and associations.
|
86
|
+
email:
|
87
|
+
- falchuko@gmail.com
|
88
|
+
executables: []
|
89
|
+
extensions: []
|
90
|
+
extra_rdoc_files: []
|
91
|
+
files:
|
92
|
+
- ".rspec"
|
93
|
+
- ".rubocop.yml"
|
94
|
+
- CHANGELOG.md
|
95
|
+
- LICENSE.txt
|
96
|
+
- README.md
|
97
|
+
- Rakefile
|
98
|
+
- lib/action_scope.rb
|
99
|
+
- lib/action_scope/associations.rb
|
100
|
+
- lib/action_scope/basic.rb
|
101
|
+
- lib/action_scope/matchable.rb
|
102
|
+
- lib/action_scope/multi_searchable.rb
|
103
|
+
- lib/action_scope/rangeable.rb
|
104
|
+
- lib/action_scope/scopable.rb
|
105
|
+
- lib/action_scope/sortable.rb
|
106
|
+
- lib/action_scope/version.rb
|
107
|
+
- sig/action_scope.rbs
|
108
|
+
homepage: https://github.com/OrestF/action_scope
|
109
|
+
licenses:
|
110
|
+
- MIT
|
111
|
+
metadata:
|
112
|
+
homepage_uri: https://github.com/OrestF/action_scope
|
113
|
+
source_code_uri: https://github.com/OrestF/action_scope
|
114
|
+
changelog_uri: https://github.com/OrestF/action_scope/blob/main/CHANGELOG.md
|
115
|
+
documentation_uri: https://github.com/OrestF/action_scope/blob/main/README.md
|
116
|
+
rubygems_mfa_required: 'true'
|
117
|
+
rdoc_options: []
|
118
|
+
require_paths:
|
119
|
+
- lib
|
120
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: 3.1.0
|
125
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
126
|
+
requirements:
|
127
|
+
- - ">="
|
128
|
+
- !ruby/object:Gem::Version
|
129
|
+
version: '0'
|
130
|
+
requirements: []
|
131
|
+
rubygems_version: 3.6.7
|
132
|
+
specification_version: 4
|
133
|
+
summary: Dynamic scoping for ActiveRecord models with automatic scope generation
|
134
|
+
test_files: []
|