Advanced Querying
Every Rhino endpoint supports filtering, sorting, search, pagination, field selection, and eager loading — all via query parameters. Powered by a custom Rhino::QueryBuilder that wraps ActiveRecord.
Model Configuration
Define what's queryable on your model:
class Post < ApplicationRecord
include Rhino::HasRhino
# Fields that can be filtered
rhino_filters :status, :user_id, :category_id
# Fields that can be sorted
rhino_sorts :created_at, :title, :updated_at, :published_at
# Default sort when none specified (prefix with - for descending)
rhino_default_sort '-created_at'
# Fields that can be selected
rhino_fields :id, :title, :content, :status, :created_at
# Relationships that can be eager loaded
rhino_includes :user, :comments, :tags, :category
# Fields searched with ?search= parameter
rhino_search :title, :content, 'user.name'
end
Fields not listed in these DSL calls are silently ignored. This is a security feature — users can't filter or sort by columns you haven't explicitly allowed.
Attribute permissions apply to queries too
The DSL calls above are global: they say which columns are queryable at all. The policy's attribute permissions then say which of those this user may query.
An attribute the policy hides is refused as a filter or a sort:
GET /api/employees?filter[salary]=300000
# → 403 { "message": "Filter 'salary' is not allowed" }
GET /api/employees?sort=-salary
# → 403 { "message": "Sort 'salary' is not allowed" }
Without this, a hidden column stayed usable as a predicate: the response never printed a salary, but narrowing the list by it told the caller what the value was, and sorting by it leaked the whole ordering.
Three related rules:
?search=skips hidden columns. The client names a term, not a column, so there is nothing to refuse. The term reaches only the searchable columns this user may see. If every one of them is hidden, the search returns nothing rather than the unnarrowed list.- A column the model never allowlisted is still ignored, not refused. Refusing it would tell the caller the column exists.
- Sorting is deny by default. A model that declares no
rhino_sortsaccepts no?sortat all. It used to accept any column.
The declared rhino_default_sort is the server's own choice, so it applies regardless.
Filtering
Filter records by field values:
# Single filter
GET /api/posts?filter[status]=published
# Multiple filters (AND)
GET /api/posts?filter[status]=published&filter[user_id]=1
# Multiple values for one field (OR)
GET /api/posts?filter[status]=draft,published
Only fields listed in rhino_filters can be filtered.
Examples
# Posts by a specific user
GET /api/posts?filter[user_id]=42
# Published posts in a category
GET /api/posts?filter[status]=published&filter[category_id]=5
# Posts that are either draft or published (excludes archived)
GET /api/posts?filter[status]=draft,published
Sorting
Sort records by one or more fields:
# Ascending
GET /api/posts?sort=title
# Descending (prefix with -)
GET /api/posts?sort=-created_at
# Multiple sorts (first by status ascending, then by date descending)
GET /api/posts?sort=status,-created_at
Only fields listed in rhino_sorts can be sorted. If no sort is specified, rhino_default_sort is used.
Search
Full-text search across configured fields:
GET /api/posts?search=rails
Searches across all fields listed in rhino_search. You can search across relationships too:
rhino_search :title, :content, 'user.name'
# This searches in post.title, post.content, AND user.name
GET /api/posts?search=john
Rhino performs a case-insensitive LIKE search. For relationship fields (dot-notation), it automatically applies left_outer_joins to include the related table.
# Search for "rails" only in published posts
GET /api/posts?search=rails&filter[status]=published
Named Scopes
Named scopes let the client select a model-whitelisted query scope by name via ?scope=. Unlike filters and sorts (which the client composes freely from allowed columns), a named scope is a reusable, server-defined query fragment — ideal for complex joins or user-specific constraints you don't want to express in the URL.
Declare the whitelist and a default with rhino_scopes and rhino_default_scope. Scope names are camelCase on the wire (?scope=availableForDrivers) and underscored internally:
class Route < Rhino::RhinoModel
# Scopes the client may select by name. A plain AR scope can be referenced
# by symbol; a complex scope points at a Rhino::ResourceScope subclass.
rhino_scopes :active, available_for_drivers: Scopes::AvailableForDriversScope
# Applied automatically when no ?scope= is given (wire name: 'active')
rhino_default_scope :active
# Simple scopes can be plain ActiveRecord scopes referenced by symbol
scope :active, -> { where(status: "active") }
end
Simple vs complex scopes
A simple scope is a plain AR scope referenced by symbol (rhino_scopes :active with scope :active, ...). A complex scope subclasses Rhino::ResourceScope and implements apply(relation), giving you access to the current user, organization, and role (backed by RequestStore):
module Scopes
class AvailableForDriversScope < Rhino::ResourceScope
def apply(relation)
return relation.none unless user
relation
.where(status: "active")
.joins(region: :driver_qualifications)
.where(driver_qualifications: { driver_id: user.id })
.where("driver_qualifications.expires_at > ?", Time.current)
.distinct
end
end
end
The user helper resolves the current authenticated user server-side — the client never sends user identity, only the scope name. Always fail closed (relation.none) when user is nil.
Scopes::<ModelName>ScopeThat name is auto-applied globally by HasAutoScope (see Models) and would run on every query. Give named-scope classes a distinct name (e.g. AvailableForDriversScope) and always derive from the relation argument.
Selecting a scope
# Apply the availableForDrivers scope (camelCase on the wire)
GET /api/routes?scope=availableForDrivers
# No ?scope= → the model's rhino_default_scope (:active) is applied automatically
GET /api/routes
Scope parameters
A scope can take arguments the client fills in. Declare the parameter names in the order the scope takes them, then send them in the bracket form:
class Route < Rhino::RhinoModel
rhino_scopes :available_for_drivers, # no parameters
since: { params: [:date] }, # one parameter
window: { params: %i[from to] }, # two, both required
titled: { params: %i[title status], optional: [:status] },
mine: { params: [:status],
with: ->(relation, user, status) { relation.where(user: user, status: status) } }
scope :since, ->(date) { where("created_at >= ?", date) }
scope :window, ->(from, to) { where(created_at: from..to) }
end
# One parameter: a bare value binds to the single declared name
GET /api/routes?scope[since]=2026-01-01
# Several: every argument is named
GET /api/routes?scope[window][from]=2026-01-01&scope[window][to]=2026-02-01
# An optional parameter may simply be left out
GET /api/routes?scope[titled][title]=Night+shift
Arguments bind by name, never by position, so the order of the keys in the URL does not matter. A positional list (?scope[window][]=a) is refused, and so is a bare value for a scope with more than one parameter: two arguments are never guessed at from one value. Parameter names are camelCase on the wire and underscored internally, exactly like scope names.
The value true or false reaches the scope as a real boolean, so a check inside the scope body cannot be fooled by the string "false".
A scope that declares no parameters never receives client input. Sending any is a 403, which means a scope written without arguments can never be handed some later by a URL. A Rhino::ResourceScope subclass receives them as extra arguments to apply:
class WindowScope < Rhino::ResourceScope
def apply(relation, from, to)
relation.where(created_at: from..to)
end
end
Computed attributes take parameters the same way — the same bracket forms, the same binding by name, and the same 403 wording — so an aggregate over a date window is a declaration rather than a filter. One difference worth knowing: computed-attribute parameter names are matched verbatim, not underscored.
Combining scopes
Several scopes may be combined — three by default, set by max_scopes_per_request — and they apply in the order the URL lists them:
GET /api/routes?scope[archived]=&scope[window][from]=2026-01-01&scope[window][to]=2026-02-01
The two forms cannot be mixed in one request, because they share the single scope query key. That is what the empty value on archived is for: it is how a no-argument scope joins a request that also carries one with arguments. On its own, ?scope=archived is still the way to write it.
Each scope narrows the set, so they compose like filters — with one caveat worth reading before you rely on it.
The cap is about blast radius, not about a number anyone hits. Every scope in a request adds its fragment to the same relation, and scopes are written in isolation, so two that are each correct alone can be wrong together.
The usual way this bites is a joins:
scope :with_open_assignments, -> { joins(:assignments).where(assignments: { completed_at: nil }) }
scope :in_region, ->(region_id) { joins(:region).where(regions: { id: region_id }) }
Each one returns the right routes on its own. Asked for together:
GET /api/routes?scope[withOpenAssignments]=&scope[inRegion]=4
a route with three open assignments comes back three times, the pagination total counts those duplicates, and ?sort= is ambiguous because two tables now carry a created_at. Nothing errors; the numbers are just wrong.
Write scopes that survive being combined:
- Prefer
where(<relation>: ...)predicates orEXISTSsubqueries overjoins. They narrow without multiplying rows, so any number of them compose. - Do not set ordering or limits inside a scope.
orderfights?sort=, and alimittruncates the set before the other scopes have narrowed it. - Avoid
distinctas a patch. It hides duplicate rows but interacts badly with?fields=and makes the count query more expensive, not less. - Watch the cost. Three fragments is three more chances to scan a table that has no index for the column you filtered on.
The cap is max_scopes_per_request in the initializer:
Rhino.configure do |config|
config.max_scopes_per_request = 3
end
Three covers the shapes that come up in practice: a base scope, a window, and one more predicate. Raise it if your clients legitimately compose more. A value below 1 is ignored, so a typo cannot lock every scope out of every request.
Restricting scopes per user
The model says which scopes exist on the wire. The policy says which of them this user may select:
class RoutePolicy < Rhino::ResourcePolicy
def permitted_scopes(user)
return ["*"] if has_role?(user, "dispatcher")
["available_for_drivers"]
end
end
The default is ["*"], so a policy written before this existed keeps allowing every declared scope. A denied scope and an undeclared one return the same message, so the endpoint never reveals which scopes a model has.
The model's rhino_default_scope is applied by the server when the client sends no scope at all, so it is not subject to this list. Requesting it by name is.
Best practices for complex scopes
Once a scope grows past a couple of clauses — joins, subqueries, per-user or per-role logic — move it out of the model and into its own class. A scope is a pure query transformation: (relation, context) -> narrowed relation, and keeping it that way is what makes it safe to run on every list request.
Put it in a scope class, not a service
A complex scope is a Rhino::ResourceScope subclass in app/models/scopes/, implementing apply(relation). The whitelist entry on the model points at the class — keep that line thin:
rhino_scopes available_for_drivers: Scopes::AvailableForDriversScope
module Scopes
class AvailableForDriversScope < Rhino::ResourceScope
def apply(relation)
return relation.none unless user
relation
.where(status: "active")
.where(
region_id: Region.joins(:driver_qualifications).where(
driver_qualifications: { driver_id: user.id }
).where("driver_qualifications.expires_at > ?", Time.current)
)
end
end
end
user, organization, and role come from the base class (backed by RequestStore) — the client never sends user identity, only the scope name.
Scopes::<ModelName>ScopeThat exact name (e.g. Scopes::RouteScope for Route) is auto-discovered by HasAutoScope (see Models) and applied as an always-on global scope on every query. Name a named-scope class for its purpose — AvailableForDriversScope, not RouteScope.
Always derive from the relation you are handed
apply receives a relation that already carries organization scoping and every global scope. Narrow that — never start from a fresh Model.where(...). A fresh query silently drops tenant isolation, turning a scoped list into a data leak:
# Good — builds on the org-scoped, globally-scoped relation
relation.where(status: "active")
# Bad — starts fresh, drops org scoping → cross-tenant leak
Route.where(status: "active")
Fail closed when there is no user
When user is nil, return relation.none — an empty set, never the full relation. A user-specific scope with no user must resolve to nothing, not everything.
return relation.none unless user
Prefer relation predicates over raw joins
A raw joins against a has-many duplicates parent rows, which breaks pagination counts and makes ?sort ambiguous. Prefer a subquery / where(id: ...) predicate (as above) so each parent row appears once. If you genuinely need joins(...).distinct, do not combine that scope with ?fields — COUNT(DISTINCT ...) over a projected column set misbehaves. Add DB indexes on the joined predicate columns (driver_qualifications.driver_id, driver_qualifications.expires_at).
A named scope only narrows, never widens
A named scope runs on top of organization scoping and every global scope; it can only shrink the already-authorized set. Never put mandatory restrictions — tenancy, visibility — inside a named scope. Those belong in an always-on global scope (BelongsToOrganization, HasAutoScope, or a manual default_scope), which no ?scope= value can bypass. See the warning below: the default scope is a convenience, not a security boundary.
Offload external or expensive work to a service
If the scope needs an API call, a permission-graph lookup, or heavy computation, don't do it inline — the scope runs on every list request. Put that work in a service that returns raw material (a set of ids or a subquery), cache it, and have the scope apply the result:
module Scopes
class VisibleProjectsScope < Rhino::ResourceScope
def apply(relation)
return relation.none unless user
ids = PermissionGraph.new(user).visible_project_ids # cached inside the service
relation.where(id: ids)
end
end
end
The scope stays a cheap, pure query transform; the expensive part lives behind a cacheable service.
Test the class in isolation
Because it's a plain Ruby class, unit-test apply directly with a stubbed user — no HTTP round-trip needed:
it "returns nothing when there is no user" do
allow_any_instance_of(Scopes::AvailableForDriversScope).to receive(:user).and_return(nil)
expect(Scopes::AvailableForDriversScope.new.apply(Route.all)).to eq(Route.none)
end
The 403 contract
Unlike filters and sorts, an unknown or non-whitelisted scope name is not silently ignored — it returns a 403, mirroring the include-authorization behavior:
# 'archived' is not whitelisted, or the policy does not permit it:
GET /api/routes?scope=archived
# → 403 { "message": "Scope 'archived' is not allowed" }
Argument mistakes are refused the same way, and these messages do name the parameter, because they are only ever reached after the scope itself was allowed for this user:
GET /api/routes?scope[window][from]=2026-01-01
# → 403 { "message": "Scope 'window' requires parameter 'to'" }
GET /api/routes?scope[window][nope]=1
# → 403 { "message": "Scope 'window' does not accept parameter 'nope'" }
GET /api/routes?scope[window]=a,b
# → 403 { "message": "Scope 'window' requires named parameters" }
Requesting the declared default scope by name (?scope=active) is always allowed.
rhino_default_scope is a listing default that a client replaces the moment it selects another scope. Do not put mandatory row restrictions (tenancy, visibility) in it — those belong in an always-on global scope (BelongsToOrganization, HasAutoScope, or a manual default scope), which no ?scope= value can bypass. Note the two meanings of "scope" in Rhino: a named scope selects a client-chosen subset, while a global scope enforces a subset on every query.
A named scope can only narrow the already-authorized, organization-scoped set — it is applied on top of global scoping and can never widen it.
Composition and where it applies
Named scopes compose with everything else — filter, sort, search, fields, include, and pagination all apply on top of the selected scope:
GET /api/routes?scope=availableForDrivers&sort=-created_at&include=region&page=1&per_page=20
Scoping applies to the index listing and the trashed (soft-delete) listing. A single-record show is not scoped.
Pagination
Control page size and navigate through results:
# Page 1 with 20 items per page
GET /api/posts?page=1&per_page=20
# Page 3
GET /api/posts?page=3&per_page=20
Pagination Headers
Pagination metadata is returned in response headers, not the body:
X-Current-Page: 2
X-Last-Page: 10
X-Per-Page: 20
X-Total: 195
The response body contains only the data array:
[
{ "id": 21, "title": "Post 21", ... },
{ "id": 22, "title": "Post 22", ... },
...
]
Enabling Pagination
Pagination is controlled at the model level:
class Post < ApplicationRecord
include Rhino::HasRhino
rhino_pagination_enabled true
end
Disabling Pagination
To return all results without pagination:
class Tag < ApplicationRecord
include Rhino::HasRhino
rhino_pagination_enabled false # or simply omit it (false by default)
end
Changing Default Page Size
class Post < ApplicationRecord
include Rhino::HasRhino
rhino_per_page 25 # Default items per page
end
Per-page values are clamped between 1 and 100 to prevent abuse.
Field Selection
Select only specific fields to reduce payload size:
# Select specific fields
GET /api/posts?fields[posts]=id,title,status
# Select fields on included relationships too
GET /api/posts?fields[posts]=id,title&fields[users]=id,name&include=user
Only fields listed in rhino_fields can be selected. The primary key (id) is always included automatically.
The table name is used as the key in the fields parameter. For a posts table, use fields[posts]=....
Eager Loading (Includes)
Load related models in a single request:
# Load single relationship
GET /api/posts?include=user
# Load multiple relationships
GET /api/posts?include=user,comments,tags
# Load nested relationships
GET /api/posts?include=comments.user
Only relationships listed in rhino_includes can be loaded.
Count and Exists
You can get relationship counts or existence checks:
# Get the count of comments for each post
GET /api/posts?include=commentsCount
# Check if comments exist (boolean)
GET /api/posts?include=commentsExists
Response:
{
"id": 1,
"title": "My Post",
"comments_count": 15,
"comments_exists": true
}
Include Authorization
When loading includes, Rhino checks if the user has index permission on the included resource. If not, a 403 is returned:
# If user doesn't have 'comments.index' permission:
GET /api/posts?include=comments
# → 403 { "message": "You do not have permission to include comments." }
This prevents users from bypassing permissions through eager loading.
Combined Example
GET /api/posts?scope=active&filter[status]=published&sort=-created_at&include=user,comments&fields[posts]=id,title,excerpt&search=rails&page=1&per_page=20
This single request:
- Selects the
activenamed scope (narrowing the base result set) - Filters to published posts only
- Sorts newest first
- Eager loads user and comments
- Returns only id, title, and excerpt fields
- Searches for "rails" in searchable columns
- Returns page 1 with 20 items per page
Response
Headers:
X-Current-Page: 1
X-Last-Page: 3
X-Per-Page: 20
X-Total: 47
Body:
[
{
"id": 42,
"title": "Getting Started with Rails",
"excerpt": "A beginner's guide to Rails...",
"user": {
"id": 1,
"name": "John Doe"
},
"comments": [
{ "id": 1, "content": "Great article!", "user_id": 2 }
]
},
...
]