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 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-09-28
4
+
5
+ - Initial release
@@ -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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shiboru
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,4 @@
1
+ module Shiboru
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []