shiboru 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/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +352 -0
- data/Rakefile +12 -0
- data/lib/generators/shiboru/filter/filter_generator.rb +22 -0
- data/lib/generators/shiboru/filter/templates/filter.rb.tt +21 -0
- data/lib/generators/shiboru/install/install_generator.rb +60 -0
- data/lib/generators/shiboru/install/templates/.keep +0 -0
- data/lib/generators/shiboru/install/templates/enable_pg_trgm.rb.tt +21 -0
- data/lib/generators/shiboru/install/templates/initializer.rb.tt +29 -0
- data/lib/shiboru/controller.rb +24 -0
- data/lib/shiboru/filter_set.rb +208 -0
- data/lib/shiboru/operators.rb +27 -0
- data/lib/shiboru/param_parser.rb +53 -0
- data/lib/shiboru/railtie.rb +13 -0
- data/lib/shiboru/registry.rb +49 -0
- data/lib/shiboru/version.rb +5 -0
- data/lib/shiboru.rb +18 -0
- data/sig/shiboru.rbs +4 -0
- metadata +66 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5aaa47f5a806eacf52959e93cbd7044542e513057ee09170ff7f3e1fed5ce833
|
|
4
|
+
data.tar.gz: 907a797eb38b6a24ebf5546ca282f16021de43df7b2c6f88c7e8f01324f76d08
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 356c936711fb4a648e516c2900365e8213c9e7fc2d4469cdd511e9e0ca41922cad1fde1f1f20e9f2c3fe61411272db08983d09bee47b67650e349a8ae5a86a63
|
|
7
|
+
data.tar.gz: 0d7af3c888668696ed0d219b60d43a1ea6582c8ddb16f64deb6f3fadd7949fccb09fc888c32b3f995d03ecf80fbd9030796bfa9a588b3dff21a626a488b903ac
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our
|
|
6
|
+
community a harassment-free experience for everyone, regardless of age, body
|
|
7
|
+
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
|
8
|
+
identity and expression, level of experience, education, socio-economic status,
|
|
9
|
+
nationality, personal appearance, race, caste, color, religion, or sexual
|
|
10
|
+
identity and orientation.
|
|
11
|
+
|
|
12
|
+
We pledge to act and interact in ways that contribute to an open, welcoming,
|
|
13
|
+
diverse, inclusive, and healthy community.
|
|
14
|
+
|
|
15
|
+
## Our Standards
|
|
16
|
+
|
|
17
|
+
Examples of behavior that contributes to a positive environment for our
|
|
18
|
+
community include:
|
|
19
|
+
|
|
20
|
+
* Demonstrating empathy and kindness toward other people
|
|
21
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
|
22
|
+
* Giving and gracefully accepting constructive feedback
|
|
23
|
+
* Accepting responsibility and apologizing to those affected by our mistakes,
|
|
24
|
+
and learning from the experience
|
|
25
|
+
* Focusing on what is best not just for us as individuals, but for the overall
|
|
26
|
+
community
|
|
27
|
+
|
|
28
|
+
Examples of unacceptable behavior include:
|
|
29
|
+
|
|
30
|
+
* The use of sexualized language or imagery, and sexual attention or advances of
|
|
31
|
+
any kind
|
|
32
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
|
33
|
+
* Public or private harassment
|
|
34
|
+
* Publishing others' private information, such as a physical or email address,
|
|
35
|
+
without their explicit permission
|
|
36
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
|
37
|
+
professional setting
|
|
38
|
+
|
|
39
|
+
## Enforcement Responsibilities
|
|
40
|
+
|
|
41
|
+
Community leaders are responsible for clarifying and enforcing our standards of
|
|
42
|
+
acceptable behavior and will take appropriate and fair corrective action in
|
|
43
|
+
response to any behavior that they deem inappropriate, threatening, offensive,
|
|
44
|
+
or harmful.
|
|
45
|
+
|
|
46
|
+
Community leaders have the right and responsibility to remove, edit, or reject
|
|
47
|
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
|
48
|
+
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
|
49
|
+
decisions when appropriate.
|
|
50
|
+
|
|
51
|
+
## Scope
|
|
52
|
+
|
|
53
|
+
This Code of Conduct applies within all community spaces, and also applies when
|
|
54
|
+
an individual is officially representing the community in public spaces.
|
|
55
|
+
Examples of representing our community include using an official email address,
|
|
56
|
+
posting via an official social media account, or acting as an appointed
|
|
57
|
+
representative at an online or offline event.
|
|
58
|
+
|
|
59
|
+
## Enforcement
|
|
60
|
+
|
|
61
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
62
|
+
reported to the community leaders responsible for enforcement at
|
|
63
|
+
[INSERT CONTACT METHOD].
|
|
64
|
+
All complaints will be reviewed and investigated promptly and fairly.
|
|
65
|
+
|
|
66
|
+
All community leaders are obligated to respect the privacy and security of the
|
|
67
|
+
reporter of any incident.
|
|
68
|
+
|
|
69
|
+
## Enforcement Guidelines
|
|
70
|
+
|
|
71
|
+
Community leaders will follow these Community Impact Guidelines in determining
|
|
72
|
+
the consequences for any action they deem in violation of this Code of Conduct:
|
|
73
|
+
|
|
74
|
+
### 1. Correction
|
|
75
|
+
|
|
76
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed
|
|
77
|
+
unprofessional or unwelcome in the community.
|
|
78
|
+
|
|
79
|
+
**Consequence**: A private, written warning from community leaders, providing
|
|
80
|
+
clarity around the nature of the violation and an explanation of why the
|
|
81
|
+
behavior was inappropriate. A public apology may be requested.
|
|
82
|
+
|
|
83
|
+
### 2. Warning
|
|
84
|
+
|
|
85
|
+
**Community Impact**: A violation through a single incident or series of
|
|
86
|
+
actions.
|
|
87
|
+
|
|
88
|
+
**Consequence**: A warning with consequences for continued behavior. No
|
|
89
|
+
interaction with the people involved, including unsolicited interaction with
|
|
90
|
+
those enforcing the Code of Conduct, for a specified period of time. This
|
|
91
|
+
includes avoiding interactions in community spaces as well as external channels
|
|
92
|
+
like social media. Violating these terms may lead to a temporary or permanent
|
|
93
|
+
ban.
|
|
94
|
+
|
|
95
|
+
### 3. Temporary Ban
|
|
96
|
+
|
|
97
|
+
**Community Impact**: A serious violation of community standards, including
|
|
98
|
+
sustained inappropriate behavior.
|
|
99
|
+
|
|
100
|
+
**Consequence**: A temporary ban from any sort of interaction or public
|
|
101
|
+
communication with the community for a specified period of time. No public or
|
|
102
|
+
private interaction with the people involved, including unsolicited interaction
|
|
103
|
+
with those enforcing the Code of Conduct, is allowed during this period.
|
|
104
|
+
Violating these terms may lead to a permanent ban.
|
|
105
|
+
|
|
106
|
+
### 4. Permanent Ban
|
|
107
|
+
|
|
108
|
+
**Community Impact**: Demonstrating a pattern of violation of community
|
|
109
|
+
standards, including sustained inappropriate behavior, harassment of an
|
|
110
|
+
individual, or aggression toward or disparagement of classes of individuals.
|
|
111
|
+
|
|
112
|
+
**Consequence**: A permanent ban from any sort of public interaction within the
|
|
113
|
+
community.
|
|
114
|
+
|
|
115
|
+
## Attribution
|
|
116
|
+
|
|
117
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
|
118
|
+
version 2.1, available at
|
|
119
|
+
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
|
120
|
+
|
|
121
|
+
Community Impact Guidelines were inspired by
|
|
122
|
+
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
|
123
|
+
|
|
124
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
|
125
|
+
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
|
126
|
+
[https://www.contributor-covenant.org/translations][translations].
|
|
127
|
+
|
|
128
|
+
[homepage]: https://www.contributor-covenant.org
|
|
129
|
+
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
|
130
|
+
[Mozilla CoC]: https://github.com/mozilla/diversity
|
|
131
|
+
[FAQ]: https://www.contributor-covenant.org/faq
|
|
132
|
+
[translations]: https://www.contributor-covenant.org/translations
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Rishi Banerjee
|
|
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,352 @@
|
|
|
1
|
+
# Shiboru
|
|
2
|
+
|
|
3
|
+
DRF-style filtering for Rails APIs that feels like home to anyone coming from Django. If you’ve used [DjangoFilter](https://django-filter.readthedocs.io/), this will click instantly.
|
|
4
|
+
|
|
5
|
+
Ransack exists and is great for many Rails apps, but for teams steeped in Django/DRF conventions the mental model never quite felt native. Shiboru was built to bring the DjangoFilter ergonomics to Rails: `field__op=value` params, association paths like `user__email__icontains=...`, first-class ordering and pagination, and per-model `FilterSet` classes with a sweet, explicit DSL.
|
|
6
|
+
|
|
7
|
+
Maintainers: Rishi Banerjee, Pratik Jain, Nikhil Anand, Dhruv Bhargava
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
* Per-model `FilterSet` classes: `UserFilter`, `Profiles::UserFilter`, inferred from class name.
|
|
12
|
+
* DRF/DjangoFilter-style operators:
|
|
13
|
+
|
|
14
|
+
* `__eq, __ne, __gt, __gte, __lt, __lte`
|
|
15
|
+
* `__contains, __icontains, __startswith, __istartswith, __endswith, __iendswith`
|
|
16
|
+
* `__in, __nin, __isnull, __range`
|
|
17
|
+
* Nested association paths: `profile__city__icontains=...`, `company__name__eq=...`.
|
|
18
|
+
* Ordering: `?ordering=-created_at,name`.
|
|
19
|
+
* Pagination: `page/page_size` or `limit/offset`.
|
|
20
|
+
* Whitelist DSL: `fields`, `related_fields`, `orderable_fields`.
|
|
21
|
+
* Custom filters per resource: `filter :q { |scope, v| ... }`.
|
|
22
|
+
* Postgres-friendly case-insensitive ops via `ILIKE`. Optional pg\_trgm migration for performance.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
Add to your Rails app:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# Gemfile
|
|
30
|
+
gem "shiboru", git: "https://github.com/your-org/shiboru" # or path: "../shiboru"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Install and run the installer:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bundle install
|
|
37
|
+
bin/spring stop
|
|
38
|
+
bin/rails g shiboru:install --pgtrgm # --pgtrgm is safe; no-ops on non-Postgres adapters
|
|
39
|
+
bin/rails db:migrate
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The installer will create:
|
|
43
|
+
|
|
44
|
+
* `config/initializers/shiboru.rb`
|
|
45
|
+
* `app/filters/.keep`
|
|
46
|
+
* Optionally a pg\_trgm migration guarded to run only on Postgres
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
Create a model:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
bin/rails g model User name:string email:string:index age:integer active:boolean company:string signed_up_at:datetime
|
|
54
|
+
bin/rails db:migrate
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Generate a filter:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
bin/rails g shiboru:filter UserFilter
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Edit `app/filters/user_filter.rb`:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# frozen_string_literal: true
|
|
67
|
+
class UserFilter < Shiboru::FilterSet
|
|
68
|
+
# Model is inferred: User
|
|
69
|
+
|
|
70
|
+
fields :id, :name, :email, :age, :active, :company, :signed_up_at, :created_at, :updated_at
|
|
71
|
+
orderable_fields :name, :age, :company, :signed_up_at, :created_at
|
|
72
|
+
|
|
73
|
+
# Optional quick search (?q=...)
|
|
74
|
+
filter :q do |scope, value, context:|
|
|
75
|
+
v = "%#{value}%"
|
|
76
|
+
scope.where("users.name ILIKE ? OR users.email ILIKE ?", v, v)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Controller and routes:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
# app/controllers/users_controller.rb
|
|
85
|
+
class UsersController < ApplicationController
|
|
86
|
+
include Shiboru::Controller
|
|
87
|
+
|
|
88
|
+
def index
|
|
89
|
+
render json: api_index(User, params) # returns {count,next,previous,results}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# config/routes.rb
|
|
96
|
+
Rails.application.routes.draw do
|
|
97
|
+
resources :users, only: [:index]
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Seed a little data (optional):
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# db/seeds.rb
|
|
105
|
+
%w[Opmaint Acme Omnix Finlyt NovaLabs].each do |co|
|
|
106
|
+
5.times do |i|
|
|
107
|
+
User.create!(
|
|
108
|
+
name: ["Rishi Banerjee", "Pratik Jain", "Nikhil Anand", "Dhruv Bhargava"].sample + " #{i}",
|
|
109
|
+
email: "user#{i}+#{co.downcase}@example.com",
|
|
110
|
+
age: rand(18..60),
|
|
111
|
+
active: [true, false].sample,
|
|
112
|
+
company: co,
|
|
113
|
+
signed_up_at: rand(180).days.ago + rand(0..86_400)
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
bin/rails db:seed
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Try it:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
GET /users?name__icontains=rishi&age__gte=20&ordering=-created_at&page=1&page_size=10
|
|
127
|
+
GET /users?q=bhargava
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## The Filter DSL
|
|
131
|
+
|
|
132
|
+
Shiboru looks for a `FilterSet` class named after the model: `UserFilter` for `User`, `Profiles::UserFilter` for `Profiles::User`. Place filters in `app/filters/**`.
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
class ArticleFilter < Shiboru::FilterSet
|
|
136
|
+
fields :id, :title, :status, :views, :published_at, :category, :created_at
|
|
137
|
+
related_fields :user__name, :user__email, :user__company, :user__active
|
|
138
|
+
orderable_fields :published_at, :views, :created_at, :title
|
|
139
|
+
|
|
140
|
+
filter :q do |scope, value, context:|
|
|
141
|
+
v = "%#{value}%"
|
|
142
|
+
scope.where("articles.title ILIKE ? OR articles.body ILIKE ?", v, v)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Notes:
|
|
148
|
+
|
|
149
|
+
* `fields` whitelists filterable base-table columns.
|
|
150
|
+
* `related_fields` whitelists association fields via `assoc__field` paths.
|
|
151
|
+
* `orderable_fields` whitelists fields that can appear in `?ordering=...`.
|
|
152
|
+
|
|
153
|
+
## Query language
|
|
154
|
+
|
|
155
|
+
Any query param that matches `path__operator=value` becomes a filter.
|
|
156
|
+
|
|
157
|
+
### Operators
|
|
158
|
+
|
|
159
|
+
* Equality and comparisons: `__eq`, `__ne`, `__gt`, `__gte`, `__lt`, `__lte`
|
|
160
|
+
* String matching: `__contains`, `__startswith`, `__endswith` (case sensitive)
|
|
161
|
+
* Case-insensitive variants (Postgres): `__icontains`, `__istartswith`, `__iendswith`
|
|
162
|
+
* Membership: `__in=1,2,3`, `__nin=4,5`
|
|
163
|
+
* Null checks: `__isnull=true|false`
|
|
164
|
+
* Ranges: `__range=from,to` or `from..to`
|
|
165
|
+
|
|
166
|
+
Examples:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
/users?age__gte=25&age__lt=40
|
|
170
|
+
/users?email__icontains=example.com
|
|
171
|
+
/users?id__in=1,2,3
|
|
172
|
+
/users?signed_up_at__range=2025-01-01,2025-06-30
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Associations
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
/articles?user__company__eq=Acme
|
|
179
|
+
/articles?user__active__eq=true&ordering=-published_at,title
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Shiboru builds `LEFT OUTER JOIN`s for association chains. If you filter a `has_many` chain on the “one” side and see duplicates, add `distinct` at the controller level or we can enable a built-in `distinct_on_root` toggle later.
|
|
183
|
+
|
|
184
|
+
## Ordering
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
?ordering=-created_at,name
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Multiple fields are comma-separated. Use `-` for descending. Association paths are supported:
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
/articles?ordering=-user__name,views
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Pagination
|
|
197
|
+
|
|
198
|
+
Two modes, both supported:
|
|
199
|
+
|
|
200
|
+
* Page-based: `?page=2&page_size=50`
|
|
201
|
+
* Offset-based: `?limit=50&offset=100`
|
|
202
|
+
|
|
203
|
+
Response envelope matches DRF:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"count": 421,
|
|
208
|
+
"next": 3, // page number (or next offset in offset mode)
|
|
209
|
+
"previous": 1, // previous page (or previous offset)
|
|
210
|
+
"results": [ ... ]
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`ENV["API_MAX_LIMIT"]` caps maximum page size and limit (default 100).
|
|
215
|
+
|
|
216
|
+
## Controllers
|
|
217
|
+
|
|
218
|
+
Include the helper and call `api_index(Model, params)`:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
class ArticlesController < ApplicationController
|
|
222
|
+
include Shiboru::Controller
|
|
223
|
+
|
|
224
|
+
def index
|
|
225
|
+
render json: api_index(Article, params)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Optionally add a serializer:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
render json: api_index(Article, params, serializer: ArticleSerializer)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Generators
|
|
237
|
+
|
|
238
|
+
Install:
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
bin/rails g shiboru:install --pgtrgm
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Create a filter:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
bin/rails g shiboru:filter UserFilter
|
|
248
|
+
bin/rails g shiboru:filter Articles::PostFilter # for Articles::Post model
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Templates live in `lib/generators/shiboru/...` inside the gem.
|
|
252
|
+
|
|
253
|
+
## Performance
|
|
254
|
+
|
|
255
|
+
* Add btree indexes on equality/ordering fields (`created_at`, foreign keys, etc.).
|
|
256
|
+
* For `__icontains` and other case-insensitive matchers on Postgres:
|
|
257
|
+
|
|
258
|
+
* enable pg\_trgm (`--pgtrgm` in installer),
|
|
259
|
+
* create trigram indexes:
|
|
260
|
+
|
|
261
|
+
```sql
|
|
262
|
+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
263
|
+
CREATE INDEX CONCURRENTLY idx_users_name_trgm ON users USING gin (name gin_trgm_ops);
|
|
264
|
+
CREATE INDEX CONCURRENTLY idx_users_email_trgm ON users USING gin (email gin_trgm_ops);
|
|
265
|
+
CREATE INDEX CONCURRENTLY idx_articles_title_trgm ON articles USING gin (title gin_trgm_ops);
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Security
|
|
269
|
+
|
|
270
|
+
* The DSL is a whitelist. Only fields you declare are filterable/orderable.
|
|
271
|
+
* If `related_fields` is omitted, Shiboru allows all columns on joined targets. For strict security, always declare `related_fields` explicitly.
|
|
272
|
+
|
|
273
|
+
## Example: Users and Articles
|
|
274
|
+
|
|
275
|
+
Models:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
class User < ApplicationRecord
|
|
279
|
+
has_many :articles, dependent: :destroy
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
class Article < ApplicationRecord
|
|
283
|
+
belongs_to :user
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Filters:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
class UserFilter < Shiboru::FilterSet
|
|
291
|
+
fields :id, :name, :email, :age, :active, :company, :signed_up_at, :created_at
|
|
292
|
+
orderable_fields :name, :age, :company, :signed_up_at, :created_at
|
|
293
|
+
filter :q do |scope, v, context:|
|
|
294
|
+
like = "%#{v}%"
|
|
295
|
+
scope.where("users.name ILIKE ? OR users.email ILIKE ?", like, like)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
class ArticleFilter < Shiboru::FilterSet
|
|
300
|
+
fields :id, :title, :status, :views, :published_at, :category, :created_at
|
|
301
|
+
related_fields :user__name, :user__email, :user__company, :user__active
|
|
302
|
+
orderable_fields :published_at, :views, :title, :created_at
|
|
303
|
+
filter :q do |scope, v, context:|
|
|
304
|
+
like = "%#{v}%"
|
|
305
|
+
scope.where("articles.title ILIKE ? OR articles.body ILIKE ?", like, like)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Requests:
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
/users?age__gte=25&company__eq=Acme&ordering=-signed_up_at
|
|
314
|
+
/users?q=Anand&page=1&page_size=20
|
|
315
|
+
|
|
316
|
+
/articles?status__in=published,archived&user__company__eq=Opmaint
|
|
317
|
+
/articles?views__gte=1000&ordering=-views,title&limit=30&offset=60
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## How it works
|
|
321
|
+
|
|
322
|
+
* `Shiboru::Registry` maps `Model` → `ModelFilter` (namespaced aware).
|
|
323
|
+
* `FilterSet`:
|
|
324
|
+
|
|
325
|
+
* Parses params into filter instructions (`path`, `op`, `value`).
|
|
326
|
+
* Validates against whitelists.
|
|
327
|
+
* Builds `LEFT OUTER JOIN`s for association paths.
|
|
328
|
+
* Applies `WHERE` based on operators with bind parameters.
|
|
329
|
+
* Applies `ORDER BY` and pagination.
|
|
330
|
+
* Returns a DRF-like envelope.
|
|
331
|
+
|
|
332
|
+
## Why not just use Ransack?
|
|
333
|
+
|
|
334
|
+
Ransack is powerful, battle-tested, and a fine choice for many Rails apps. Teams coming from Django/DRF often prefer the `field__op=value` grammar and the mental model of `FilterSet` classes that mirror the resource. Shiboru embraces that exact style so Rails APIs can feel like DRF without translation overhead.
|
|
335
|
+
|
|
336
|
+
Reference: DjangoFilter documentation
|
|
337
|
+
[https://django-filter.readthedocs.io/](https://django-filter.readthedocs.io/)
|
|
338
|
+
|
|
339
|
+
## Development
|
|
340
|
+
|
|
341
|
+
* Ruby 3.1+
|
|
342
|
+
* Rails 6.1+ (tested on 7/8)
|
|
343
|
+
|
|
344
|
+
Run tests:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
bundle exec rspec
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## License
|
|
351
|
+
|
|
352
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Shiboru
|
|
6
|
+
module Generators
|
|
7
|
+
class FilterGenerator < Rails::Generators::NamedBase
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
# Usage:
|
|
10
|
+
# rails g shiboru:filter UserFilter
|
|
11
|
+
# rails g shiboru:filter Profiles::UserFilter
|
|
12
|
+
|
|
13
|
+
def create_filter
|
|
14
|
+
template "filter.rb.tt", File.join("app/filters", class_path, "#{file_name}.rb")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def file_name = name.demodulize.underscore
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
class <%= name %> < Shiboru::FilterSet
|
|
3
|
+
# Model is inferred from class name:
|
|
4
|
+
# UserFilter -> User
|
|
5
|
+
# Profiles::UserFilter -> Profiles::User
|
|
6
|
+
|
|
7
|
+
# Base-table fields:
|
|
8
|
+
fields :id, :created_at, :updated_at
|
|
9
|
+
# fields :name, :age, ...
|
|
10
|
+
|
|
11
|
+
# Association fields as "assoc__field":
|
|
12
|
+
# related_fields :profile__city, :profile__state, :company__name
|
|
13
|
+
|
|
14
|
+
# Orderable fields:
|
|
15
|
+
orderable_fields :id, :created_at
|
|
16
|
+
|
|
17
|
+
# Custom named filter example (?active=true):
|
|
18
|
+
# filter :active do |scope, value, context:|
|
|
19
|
+
# value.to_s == "true" ? scope.where(active: true) : scope
|
|
20
|
+
# end
|
|
21
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Shiboru
|
|
6
|
+
module Generators
|
|
7
|
+
# Usage:
|
|
8
|
+
# rails g shiboru:install
|
|
9
|
+
# rails g shiboru:install --pgtrgm
|
|
10
|
+
#
|
|
11
|
+
# Creates:
|
|
12
|
+
# config/initializers/shiboru.rb
|
|
13
|
+
# app/filters/.keep
|
|
14
|
+
# Optionally:
|
|
15
|
+
# db/migrate/<timestamp>_enable_pg_trgm_extension.rb (when --pgtrgm)
|
|
16
|
+
class InstallGenerator < Rails::Generators::Base
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
class_option :pgtrgm, type: :boolean, default: false,
|
|
20
|
+
desc: "Create a migration to enable pg_trgm (Postgres text search accel)"
|
|
21
|
+
|
|
22
|
+
def create_initializer
|
|
23
|
+
template "initializer.rb.tt", "config/initializers/shiboru.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ensure_filters_dir
|
|
27
|
+
empty_directory "app/filters"
|
|
28
|
+
create_file "app/filters/.keep" unless File.exist?("app/filters/.keep")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create_pg_trgm_migration
|
|
32
|
+
return unless options[:pgtrgm]
|
|
33
|
+
|
|
34
|
+
unless defined?(ActiveRecord::Base)
|
|
35
|
+
say_status :warn, "ActiveRecord not found; skipping pg_trgm migration", :yellow
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless ActiveRecord::Base.connection.adapter_name.match?(/postg/i)
|
|
40
|
+
say_status :warn, "Non-Postgres adapter detected; skipping pg_trgm migration", :yellow
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
migration_template "enable_pg_trgm.rb.tt", "db/migrate/#{timestamp}_enable_pg_trgm_extension.rb"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Basic timestamp helper for migration filenames
|
|
50
|
+
def timestamp
|
|
51
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Provide Rails' migration_template without inheriting from ActiveRecord::Generators::Base
|
|
55
|
+
def migration_template(source, destination)
|
|
56
|
+
template source, destination
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
class EnablePgTrgmExtension < ActiveRecord::Migration[7.0]
|
|
3
|
+
def up
|
|
4
|
+
return unless postgres?
|
|
5
|
+
|
|
6
|
+
enable_extension "pg_trgm" unless extension_enabled?("pg_trgm")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def down
|
|
10
|
+
return unless postgres?
|
|
11
|
+
|
|
12
|
+
disable_extension "pg_trgm" if extension_enabled?("pg_trgm")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def postgres?
|
|
18
|
+
# Works for Rails 6/7/8, and for multiple DBs (use connection for this DB)
|
|
19
|
+
connection.adapter_name.to_s.downcase.include?("postgres")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shiboru initializer
|
|
4
|
+
#
|
|
5
|
+
# Shiboru exposes a DRF-like FilterSet for Rails APIs.
|
|
6
|
+
# You typically:
|
|
7
|
+
# - Create per-model filters under app/filters, e.g. UserFilter, Profiles::UserFilter
|
|
8
|
+
# - In controllers, call: render json: api_index(User, params)
|
|
9
|
+
#
|
|
10
|
+
# Configuration notes:
|
|
11
|
+
# - Pagination limits: Shiboru reads ENV["API_MAX_LIMIT"] (default 100) to clamp page_size/limit.
|
|
12
|
+
# - Postgres icontains/istartswith/iendswith use ILIKE. For fast search, consider pg_trgm.
|
|
13
|
+
# - To enable pg_trgm, run installer with --pgtrgm to generate the extension migration.
|
|
14
|
+
#
|
|
15
|
+
# Example per-model filter:
|
|
16
|
+
# class UserFilter < Shiboru::FilterSet
|
|
17
|
+
# fields :id, :name, :age, :created_at, :updated_at
|
|
18
|
+
# related_fields :profile__city, :company__name
|
|
19
|
+
# orderable_fields :name, :age, :created_at
|
|
20
|
+
# # filter :active { |scope, v, context:| v.to_s == "true" ? scope.where(active: true) : scope }
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Optional ENV overrides (uncomment and tune as needed):
|
|
24
|
+
# ENV["API_MAX_LIMIT"] ||= "200" # Upper cap for page_size/limit (default 100)
|
|
25
|
+
|
|
26
|
+
Rails.application.config.to_prepare do
|
|
27
|
+
# Autoload filters from app/filters with Zeitwerk (Rails does this automatically if the folder exists).
|
|
28
|
+
# This block ensures any reloads pick up new filter classes in dev.
|
|
29
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shiboru
|
|
4
|
+
module Controller
|
|
5
|
+
# Usage: render json: api_index(User, params, serializer: UserSerializer)
|
|
6
|
+
def api_index(model, params, serializer: nil, serializer_opts: {})
|
|
7
|
+
filter_klass = Shiboru::Registry.for_model(model)
|
|
8
|
+
raise ArgumentError, "No FilterSet found for #{model.name} (expected #{model.name}Filter)" unless filter_klass
|
|
9
|
+
|
|
10
|
+
payload = filter_klass.new(model.all, params).call
|
|
11
|
+
records = payload["results"]
|
|
12
|
+
|
|
13
|
+
payload["results"] =
|
|
14
|
+
if serializer
|
|
15
|
+
ActiveModelSerializers::SerializableResource.new(records, each_serializer: serializer,
|
|
16
|
+
**serializer_opts).as_json
|
|
17
|
+
else
|
|
18
|
+
records.as_json
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
payload
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shiboru
|
|
4
|
+
class FilterSet
|
|
5
|
+
class << self
|
|
6
|
+
attr_reader :_fields, :_related_fields, :_orderable, :_custom_filters
|
|
7
|
+
|
|
8
|
+
# --- Sweet DSL ---
|
|
9
|
+
def fields(*names) = (@_fields ||= []).concat(names.flatten.map(&:to_s))
|
|
10
|
+
def related_fields(*names) = (@_related_fields ||= []).concat(names.flatten.map(&:to_s))
|
|
11
|
+
def orderable_fields(*names)= (@_orderable ||= []).concat(names.flatten.map(&:to_s))
|
|
12
|
+
def filter(name, &blk) = (@_custom_filters ||= {}).store(name.to_s, blk)
|
|
13
|
+
|
|
14
|
+
# Infer model from Filter class: Profiles::UserFilter -> Profiles::User
|
|
15
|
+
def model
|
|
16
|
+
@__inferred_model ||= begin
|
|
17
|
+
base = name.sub(/Filter\z/, "")
|
|
18
|
+
raise ArgumentError, "Filter class name must end with 'Filter' (got #{name})" if base == name
|
|
19
|
+
|
|
20
|
+
constantize_chain(base)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fields_set = @_fields&.to_set || model.column_names.to_set
|
|
25
|
+
def related_set = @_related_fields&.to_set || Set.new
|
|
26
|
+
def orderable_set = @_orderable&.to_set || fields_set
|
|
27
|
+
def custom_filters = @_custom_filters || {}
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def constantize_chain(str)
|
|
32
|
+
names = str.split("::")
|
|
33
|
+
names.shift if names.first && names.first.empty?
|
|
34
|
+
constant = Object
|
|
35
|
+
names.each do |n|
|
|
36
|
+
constant = constant.const_get(n)
|
|
37
|
+
end
|
|
38
|
+
constant
|
|
39
|
+
rescue NameError => e
|
|
40
|
+
raise ArgumentError, "Cannot infer model for #{name} -> #{str}: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
DEFAULT_OPERATORS = Shiboru::Operators::DEFAULT
|
|
45
|
+
|
|
46
|
+
def initialize(scope, params)
|
|
47
|
+
@scope = scope
|
|
48
|
+
@params = Shiboru::ParamParser.new(params)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call
|
|
52
|
+
rel = apply_filters(@scope)
|
|
53
|
+
rel = apply_ordering(rel)
|
|
54
|
+
rel, meta = apply_pagination(rel)
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
"count" => meta[:count],
|
|
58
|
+
"next" => meta[:next],
|
|
59
|
+
"previous" => meta[:previous],
|
|
60
|
+
"results" => rel
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def apply_filters(scope)
|
|
67
|
+
rel = scope
|
|
68
|
+
|
|
69
|
+
@params.filters.each do |f|
|
|
70
|
+
# custom named filter (?active=true)
|
|
71
|
+
if self.class.custom_filters.key?(f[:field]) && f[:assoc].empty?
|
|
72
|
+
rel = self.class.custom_filters[f[:field]].call(rel, f[:value], context: self)
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if f[:assoc].empty?
|
|
77
|
+
next unless self.class.fields_set.include?(f[:field])
|
|
78
|
+
|
|
79
|
+
qualified = "#{self.class.model.table_name}.#{f[:field]}"
|
|
80
|
+
column = self.class.model.columns_hash[f[:field]]
|
|
81
|
+
value = cast_value(f[:op], f[:value], column)
|
|
82
|
+
sql, *b = operator_for(f[:op]).call(qualified, value)
|
|
83
|
+
rel = rel.where([sql, *b])
|
|
84
|
+
else
|
|
85
|
+
rel = rel.left_outer_joins(f[:assoc].map(&:to_sym))
|
|
86
|
+
target_klass = traverse_klass(self.class.model, f[:assoc])
|
|
87
|
+
next unless related_allowed?(f[:assoc], f[:field], target_klass)
|
|
88
|
+
|
|
89
|
+
qualified = "#{target_klass.table_name}.#{f[:field]}"
|
|
90
|
+
column = target_klass.columns_hash[f[:field]]
|
|
91
|
+
value = cast_value(f[:op], f[:value], column)
|
|
92
|
+
sql, *b = operator_for(f[:op]).call(qualified, value)
|
|
93
|
+
rel = rel.where([sql, *b])
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
rel
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def apply_ordering(scope)
|
|
101
|
+
orders = @params.ordering
|
|
102
|
+
return scope if orders.empty?
|
|
103
|
+
|
|
104
|
+
rel = scope
|
|
105
|
+
orders.each do |token|
|
|
106
|
+
dir = token.start_with?("-") ? "DESC" : "ASC"
|
|
107
|
+
token = token.delete_prefix("-")
|
|
108
|
+
path = token.split("__")
|
|
109
|
+
field = path.pop
|
|
110
|
+
assoc = path
|
|
111
|
+
|
|
112
|
+
if assoc.empty?
|
|
113
|
+
next unless self.class.orderable_set.include?(field)
|
|
114
|
+
|
|
115
|
+
qualified = "#{self.class.model.table_name}.#{field}"
|
|
116
|
+
rel = rel.order(Arel.sql("#{qualified} #{dir}"))
|
|
117
|
+
else
|
|
118
|
+
rel = rel.left_outer_joins(assoc.map(&:to_sym))
|
|
119
|
+
target_klass = traverse_klass(self.class.model, assoc)
|
|
120
|
+
next unless related_allowed?(assoc, field, target_klass)
|
|
121
|
+
|
|
122
|
+
qualified = "#{target_klass.table_name}.#{field}"
|
|
123
|
+
rel = rel.order(Arel.sql("#{qualified} #{dir}"))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
rel
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def apply_pagination(scope)
|
|
130
|
+
total = scope.except(:order).count(:all)
|
|
131
|
+
pg = @params.pagination
|
|
132
|
+
|
|
133
|
+
if pg[:mode] == :page
|
|
134
|
+
page = pg[:page]
|
|
135
|
+
size = pg[:page_size]
|
|
136
|
+
offset = (page - 1) * size
|
|
137
|
+
next_page = (offset + size) < total ? page + 1 : nil
|
|
138
|
+
prev_page = page > 1 ? page - 1 : nil
|
|
139
|
+
[scope.limit(size).offset(offset), { count: total, next: next_page, previous: prev_page }]
|
|
140
|
+
else
|
|
141
|
+
limit = pg[:limit]
|
|
142
|
+
offset = pg[:offset]
|
|
143
|
+
next_offset = (offset + limit) < total ? (offset + limit) : nil
|
|
144
|
+
prev_offset = offset > 0 ? [offset - limit, 0].max : nil
|
|
145
|
+
[scope.limit(limit).offset(offset), { count: total, next: next_offset, previous: prev_offset }]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# --- helpers ---
|
|
150
|
+
def operator_for(op) = DEFAULT_OPERATORS[op] || DEFAULT_OPERATORS["eq"]
|
|
151
|
+
|
|
152
|
+
def cast_value(op, raw, column)
|
|
153
|
+
return raw if column.nil?
|
|
154
|
+
|
|
155
|
+
case op
|
|
156
|
+
when "in", "nin"
|
|
157
|
+
arr = raw.is_a?(Array) ? raw : raw.to_s.split(",")
|
|
158
|
+
arr.map { |v| cast_scalar(v, column) }
|
|
159
|
+
when "range"
|
|
160
|
+
a, b = if raw.is_a?(Array)
|
|
161
|
+
raw
|
|
162
|
+
else
|
|
163
|
+
(raw.to_s.include?("..") ? raw.split("..", 2) : raw.split(",", 2))
|
|
164
|
+
end
|
|
165
|
+
[cast_scalar(a, column), cast_scalar(b, column)]
|
|
166
|
+
else
|
|
167
|
+
cast_scalar(raw, column)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def cast_scalar(v, col)
|
|
172
|
+
return v if v.nil?
|
|
173
|
+
|
|
174
|
+
case col.type
|
|
175
|
+
when :integer then v.to_i
|
|
176
|
+
when :float, :decimal then v.to_f
|
|
177
|
+
when :boolean then ActiveModel::Type::Boolean.new.cast(v)
|
|
178
|
+
when :datetime, :timestamp then begin
|
|
179
|
+
Time.zone.parse(v)
|
|
180
|
+
rescue StandardError
|
|
181
|
+
v
|
|
182
|
+
end
|
|
183
|
+
when :date then begin
|
|
184
|
+
Date.parse(v)
|
|
185
|
+
rescue StandardError
|
|
186
|
+
v
|
|
187
|
+
end
|
|
188
|
+
else v
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def traverse_klass(root, assoc_chain)
|
|
193
|
+
assoc_chain.reduce(root) do |klass, name|
|
|
194
|
+
refl = klass.reflect_on_association(name.to_sym)
|
|
195
|
+
raise ArgumentError, "Unknown association #{name} on #{klass.name}" unless refl
|
|
196
|
+
|
|
197
|
+
refl.klass
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def related_allowed?(assoc_chain, field, target_klass)
|
|
202
|
+
return target_klass.column_names.include?(field) if self.class.related_set.empty?
|
|
203
|
+
|
|
204
|
+
token = (assoc_chain + [field]).join("__")
|
|
205
|
+
self.class.related_set.include?(token)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shiboru
|
|
4
|
+
module Operators
|
|
5
|
+
DEFAULT = {
|
|
6
|
+
"eq" => ->(col, v) { ["#{col} = ?", v] },
|
|
7
|
+
"ne" => ->(col, v) { ["#{col} <> ?", v] },
|
|
8
|
+
"gt" => ->(col, v) { ["#{col} > ?", v] },
|
|
9
|
+
"gte" => ->(col, v) { ["#{col} >= ?", v] },
|
|
10
|
+
"lt" => ->(col, v) { ["#{col} < ?", v] },
|
|
11
|
+
"lte" => ->(col, v) { ["#{col} <= ?", v] },
|
|
12
|
+
"contains" => ->(col, v) { ["#{col} LIKE ?", "%#{v}%"] },
|
|
13
|
+
"startswith" => ->(col, v) { ["#{col} LIKE ?", "#{v}%"] },
|
|
14
|
+
"endswith" => ->(col, v) { ["#{col} LIKE ?", "%#{v}"] },
|
|
15
|
+
"icontains" => ->(col, v) { ["#{col} ILIKE ?", "%#{v}%"] },
|
|
16
|
+
"istartswith" => ->(col, v) { ["#{col} ILIKE ?", "#{v}%"] },
|
|
17
|
+
"iendswith" => ->(col, v) { ["#{col} ILIKE ?", "%#{v}"] },
|
|
18
|
+
"in" => ->(col, v) { ["#{col} IN (?)", v] },
|
|
19
|
+
"nin" => ->(col, v) { ["#{col} NOT IN (?)", v] },
|
|
20
|
+
"isnull" => ->(col, v) { v.to_s == "true" ? ["#{col} IS NULL"] : ["#{col} IS NOT NULL"] },
|
|
21
|
+
"range" => lambda { |col, v|
|
|
22
|
+
a, b = v
|
|
23
|
+
["#{col} BETWEEN ? AND ?", a, b]
|
|
24
|
+
}
|
|
25
|
+
}.freeze
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shiboru
|
|
4
|
+
class ParamParser
|
|
5
|
+
RESERVED = %w[ordering page page_size limit offset].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(params)
|
|
8
|
+
@raw = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def filters
|
|
12
|
+
@raw.each_with_object([]) do |(k, v), acc|
|
|
13
|
+
next if RESERVED.include?(k.to_s)
|
|
14
|
+
next if v.nil? || (v.respond_to?(:empty?) && v.empty?)
|
|
15
|
+
|
|
16
|
+
path = k.to_s.split("__")
|
|
17
|
+
op = Shiboru::Operators::DEFAULT.key?(path.last) ? path.pop : "eq"
|
|
18
|
+
field = path.pop
|
|
19
|
+
assoc = path
|
|
20
|
+
|
|
21
|
+
acc << { assoc: assoc, field: field, op: op, value: v }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ordering
|
|
26
|
+
(@raw["ordering"] || "").to_s.split(",").map(&:strip).reject(&:blank?)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def pagination
|
|
30
|
+
if @raw.key?("page") || @raw.key?("page_size")
|
|
31
|
+
{
|
|
32
|
+
mode: :page,
|
|
33
|
+
page: [@raw["page"].to_i, 1].max,
|
|
34
|
+
page_size: clamp((@raw["page_size"] || @raw["limit"] || 25).to_i)
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
{
|
|
38
|
+
mode: :offset,
|
|
39
|
+
limit: clamp((@raw["limit"] || 25).to_i),
|
|
40
|
+
offset: [(@raw["offset"] || 0).to_i, 0].max
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def clamp(n)
|
|
48
|
+
max = (ENV["API_MAX_LIMIT"] || 100).to_i
|
|
49
|
+
n = 25 if n <= 0
|
|
50
|
+
[n, max].min
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# lib/shiboru/railtie.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "rails/railtie"
|
|
5
|
+
|
|
6
|
+
module Shiboru
|
|
7
|
+
class Railtie < Rails::Railtie
|
|
8
|
+
generators do
|
|
9
|
+
require_relative "../generators/shiboru/install/install_generator"
|
|
10
|
+
require_relative "../generators/shiboru/filter/filter_generator"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A registry to map models to their corresponding FilterSet classes.
|
|
4
|
+
module Shiboru
|
|
5
|
+
# Usage:
|
|
6
|
+
# Shiboru::Registry.for_model(User) # => UserFilter (if UserFilter exists)
|
|
7
|
+
# Shiboru::Registry.for_model(Post) # => PostFilter (if PostFilter exists)
|
|
8
|
+
# Shiboru::Registry.for_model(Admin::User) # => Admin::UserFilter (if it exists)
|
|
9
|
+
# Shiboru::Registry.for_model(NonExistentModel) # => nil (if NonExistentModelFilter doesn't exist)
|
|
10
|
+
class Registry
|
|
11
|
+
@cache = {}
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Given a model class, find its corresponding FilterSet class.
|
|
15
|
+
# Automatically maps model names to filter class names by appending "Filter".
|
|
16
|
+
# Results are cached to improve performance on subsequent calls.
|
|
17
|
+
#
|
|
18
|
+
# @param klass [Class] The model class to find a FilterSet for
|
|
19
|
+
# @return [Class, nil] The corresponding FilterSet class, or nil if not found
|
|
20
|
+
#
|
|
21
|
+
# Examples:
|
|
22
|
+
# User -> UserFilter
|
|
23
|
+
# Profiles::User -> Profiles::UserFilter
|
|
24
|
+
def for_model(klass)
|
|
25
|
+
return @cache[klass] if @cache.key?(klass)
|
|
26
|
+
|
|
27
|
+
filter_const_name = "#{klass.name}Filter"
|
|
28
|
+
filter_klass = constantize_safe(filter_const_name)
|
|
29
|
+
@cache[klass] = filter_klass
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def constantize_safe(str)
|
|
35
|
+
names = str.split("::")
|
|
36
|
+
names.shift if names.first && names.first.empty?
|
|
37
|
+
constant = Object
|
|
38
|
+
names.each do |name|
|
|
39
|
+
return nil unless constant.const_defined?(name, false)
|
|
40
|
+
|
|
41
|
+
constant = constant.const_get(name, false)
|
|
42
|
+
end
|
|
43
|
+
constant
|
|
44
|
+
rescue NameError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/shiboru.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "shiboru/version"
|
|
4
|
+
|
|
5
|
+
require "active_support"
|
|
6
|
+
require "active_support/core_ext"
|
|
7
|
+
require "active_record"
|
|
8
|
+
|
|
9
|
+
require "shiboru/version"
|
|
10
|
+
require "shiboru/operators"
|
|
11
|
+
require "shiboru/param_parser"
|
|
12
|
+
require "shiboru/registry"
|
|
13
|
+
require "shiboru/filter_set"
|
|
14
|
+
require "shiboru/controller"
|
|
15
|
+
require "shiboru/railtie"
|
|
16
|
+
|
|
17
|
+
module Shiboru
|
|
18
|
+
end
|
data/sig/shiboru.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shiboru
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Rishi Banerjee
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |-
|
|
13
|
+
Shiboru provides a clean and intuitive way to handle filtering, ordering, and pagination in Ruby applications.
|
|
14
|
+
It automatically maps models to filter classes, parses HTTP parameters, and supports complex nested associations with various operators.
|
|
15
|
+
email:
|
|
16
|
+
- rishieric91@gmail.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- CODE_OF_CONDUCT.md
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- README.md
|
|
25
|
+
- Rakefile
|
|
26
|
+
- lib/generators/shiboru/filter/filter_generator.rb
|
|
27
|
+
- lib/generators/shiboru/filter/templates/filter.rb.tt
|
|
28
|
+
- lib/generators/shiboru/install/install_generator.rb
|
|
29
|
+
- lib/generators/shiboru/install/templates/.keep
|
|
30
|
+
- lib/generators/shiboru/install/templates/enable_pg_trgm.rb.tt
|
|
31
|
+
- lib/generators/shiboru/install/templates/initializer.rb.tt
|
|
32
|
+
- lib/shiboru.rb
|
|
33
|
+
- lib/shiboru/controller.rb
|
|
34
|
+
- lib/shiboru/filter_set.rb
|
|
35
|
+
- lib/shiboru/operators.rb
|
|
36
|
+
- lib/shiboru/param_parser.rb
|
|
37
|
+
- lib/shiboru/railtie.rb
|
|
38
|
+
- lib/shiboru/registry.rb
|
|
39
|
+
- lib/shiboru/version.rb
|
|
40
|
+
- sig/shiboru.rbs
|
|
41
|
+
homepage: https://github.com/rshrc/shiboru
|
|
42
|
+
licenses:
|
|
43
|
+
- MIT
|
|
44
|
+
metadata:
|
|
45
|
+
allowed_push_host: https://rubygems.org
|
|
46
|
+
homepage_uri: https://github.com/rshrc/shiboru
|
|
47
|
+
source_code_uri: https://github.com/rshrc/shiboru
|
|
48
|
+
changelog_uri: https://github.com/rshrc/shiboru/blob/main/CHANGELOG.md
|
|
49
|
+
rdoc_options: []
|
|
50
|
+
require_paths:
|
|
51
|
+
- lib
|
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: 3.2.0
|
|
57
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0'
|
|
62
|
+
requirements: []
|
|
63
|
+
rubygems_version: 3.7.1
|
|
64
|
+
specification_version: 4
|
|
65
|
+
summary: A flexible filtering and pagination library for Ruby applications
|
|
66
|
+
test_files: []
|