Click here to see the interactive demo!
Prato is a library that simplifies the backend code required to support queryable data,
by mapping parameters onto a table structure,
allowing Prato to invoke Active Record methods like .where, .order, .joins, .pluck and others.
The immediate use case for this is fetching data for tables in the frontend, and with a simple Prato table, it becomes trivial to provide any kind of filtering / sorting / pagination operations over an Active Record relation.
A quick example of this in action:
class BooksController < ApplicationController
def index
table = Prato.table(Book) do
column(:title)
section(:people) do
column(author_name: [:author, :name])
column(editor_name: [:editor, :name])
end
column(:review_count, count: :reviews)
column(:avg_review_score, avg: [:reviews, :score])
end
render json: table.page(Book.all, params)
end
end
Assuming Book has an association to author, editor, and reviews, this will generate the following result:
```json lines
{
"entries": [
{
"title": "Practical Object Conversations",
"people": {
"authorName": "Sandi Metz",
"editorName": "Martin Fowler"
},
"reviewCount": 2,
"avgReviewScore": 2.5
},
// ... 9 entries omitted
],
"totalCount": 24
}
That's it! Even if the request contains parameters (filters, ordering, field selection),
we don't have to change any of the backend code.
## Table of Contents
- [Why Prato](#why-prato)
- [Requirements](#requirements)
- [Installation](#installation)
- [Technical Overview](#technical-overview)
- [Usage](#usage)
- [Defining a Prato table](#defining-a-prato-table)
- [column](#column)
- [query_column](#query_column)
- [section](#section)
- [configuration](#configuration)
- [ruby_column (Advanced)](#ruby_column-advanced)
- [Materializing a scope](#materializing-a-scope)
- [Parameters / Request Details](#parameters--request-details)
- [Pagination](#pagination)
- [Filters](#filters)
- [Sorting](#sorting)
- [Fields](#fields)
- [Development](#development-todo)
- [Contributing](#contributing)
- [License](#license)
## Why Prato
Prato was born as a way to tackle complexity at scale.
It's common for applications to have some web pages that display data in a tabular style. The default approach to solve this
is to write an Active Record scope, add any necessary `.where` or `.or` statements, add `.includes` for any relations
and then serialize the result into model objects, as this is more ergonomic than just using `.pluck`.
This has some downsides:
- The request can overfetch data from the database.
- (which is problematic when new columns are added, and we don't know how much data they might have!)
- The relation is materialized with model objects, which may invoke any number of callbacks that we are not aware of (`after_find` or `after_initialize`).
- The business requirements may change, requiring data from different models which causes association and serializaiton code to be revisited.
- It's necessary to write *a lot* of code.
For applications being worked on with multiple developers and with hundreds of database tables, it becomes tricky to ensure
that all code is performant and correct.
Prato's table structure offers a way of ensuring that all the problems above stop being a concern.
## Requirements
Prato requires Ruby 2.4 or later, Active Record 5.0 or later, and MySql, Sqlite or Postgres.
The library is actively tested against the following matrix:
| Ruby | Active Record |
|-------|---------------|
| 2.4.x | 5.0 |
| 2.5.x | 5.1 |
| 2.6.x | 5.2 |
| 2.7.x | 6.0, 6.1 |
| 3.0.x | 7.0 |
| 3.1.x | 7.1 |
| 3.2.x | 7.2, 8.0, 8.1 |
| 3.3.x | 7.2, 8.0, 8.1 |
| 3.4.x | 7.2, 8.0, 8.1 |
| 4.0.x | 7.2, 8.0, 8.1 |
## Installation
Install the gem and add it to your application's Gemfile by running:
```bash
bundle add prato
Technical Overview
Prato's guiding philosophy is that Active Record (AR) is already great at building SQL so Prato relies on it and Arel for generating SQL.
A Prato table specification uses :symbols to describe the fields that can be displayed, filtered, and sorted.
These symbols correspond to the method calls that otherwise would have to be written.
For example, column(author_name: [:author, :name]) provides the same result as <object>.author.name.
By letting the request define what is required, Prato can decide at runtime which Active Record methods should be invoked.
Filters map to .where clauses, sorts map to .order clauses, association paths add the required joins, pagination adds .limit and .offset
and finally .pluck materializes any data that the request requires.
This lets application offer more functionality while having less code.
Usage
Prato relies on two steps:
- Defining a Prato table.
- Use an Active Record relation on that table with
.page(scope, params),.full(scope, params)or.batches(scope, params, ...).
Defining a Prato table
A Prato table consists of columns and may also include sections and configuration. The example below demonstrates many of the available features:
table = Prato.table(Book) do
column(:title)
column("Display Title" => :title)
column(:author_name, [:author, :name])
column(:city, [:publisher, :address, :city])
column(:review_count, count: :reviews)
column(:review_sum, sum: [:reviews, :score])
column(:review_avg, avg: [:reviews, :score])
column(:review_min, min: [:reviews, :score])
column(:review_max, max: [:reviews, :score])
column(:title_upper, expression: "UPPER(books.title)")
column(:formatted_title, :title, format: ->(v) { v.downcase })
column(:status, filter: [:eq, :in])
column(:internal_id, :id, queryable: :filter)
section(:author) do
column(:name, [:author, :name])
column(:email, [:author, :email])
end
query_column(:author_id, [:author, :id])
configure(
key_transformation: :camelCase,
on_invalid_input: :raise,
parameter_parser: Prato::Query::DefaultParser,
default_page_size: 25,
maximum_page_size: 100,
default_queryable: :all,
default_ruby_column_queryable: :none
)
end
Invoking table.page(Book.all) will output the following structure:
{
entries: [
{
title: "Practical Object Conversations",
"Display Title" => "Practical Object Conversations",
authorName: "Sandi Metz",
city: "Raleigh",
reviewCount: 4,
reviewSum: 18,
reviewAvg: 4.5,
reviewMin: 3,
reviewMax: 5,
titleUpper: "PRACTICAL OBJECT CONVERSATIONS",
formattedTitle: "practical object conversations",
status: "published",
author: {
name: "Sandi Metz",
email: "sandi@example.com"
}
},
# ... up to 24 more entries omitted (default_page_size: 25)
],
totalCount: 34
}
column
A column is backed by SQL and its values are obtained via .pluck, unless ruby_columns are used (see more below).
Filters and sorts applied to a column will generate SQL via Arel or Active Record methods.
The source of a column's value can be defined in different ways:
- A column on the base model, referenced by name.
- A column on an associated model, reached through an association path.
- An aggregate expression (
:count,:avg,:sum,:min,:max). - A custom SQL expression.
In the following subsections, every example will use the configuration key_transformation: :camelCase.
Basic Columns
Use a symbol to expose a model column directly:
| Example | Output field | SQL source |
|---|---|---|
column(:release_year) |
:releaseYear |
release_year |
column(:runtime_minutes) |
:runtimeMinutes |
runtime_minutes |
column(:published_at) |
:publishedAt |
published_at |
Use a hash when the output field should differ from the source column:
| Example | Output field | SQL source |
|---|---|---|
column(display_name: :name) |
:displayName |
name |
column(released_on: :release_date) |
:releasedOn |
release_date |
column("Box Office" => :box_office_total) |
"Box Office" |
box_office_total |
Use an association path to read from a joined model:
| Example | Output field | SQL source |
|---|---|---|
column(studio_name: [:studio, :name]) |
:studioName |
studios.name |
column(director_country: [:director, :country]) |
:directorCountry |
directors.country |
column("Genre Label" => [:genre, :label]) |
"Genre Label" |
genres.label |
column(:publisher_city, [:publisher, :city]) |
:publisherCity |
publishers.city |
Aggregate Columns
Use an aggregate keyword to compute a value in SQL:
| Example | Output field | SQL source |
|---|---|---|
column(:review_count, count: :reviews) |
:reviewCount |
COUNT(reviews.*) |
column(:average_rating, avg: [:reviews, :rating]) |
:averageRating |
AVG(reviews.rating) |
column(:total_sales, sum: [:orders, :total_cents]) |
:totalSales |
SUM(orders.total_cents) |
column(:first_showtime, min: [:screenings, :starts_at]) |
:firstShowtime |
MIN(screenings.starts_at) |
column(:latest_purchase, max: [:purchases, :created_at]) |
:latestPurchase |
MAX(purchases.created_at) |
Expression Columns
Use expression: when the value should come from custom SQL:
| Example | Output field | SQL source |
|---|---|---|
column(:lowercase_name, expression: "LOWER(name)") |
:normalizedName |
LOWER(name) |
column(:release_decade, expression: "FLOOR(release_year / 10) * 10") |
:releaseDecade |
FLOOR(release_year / 10) * 10 |
column("Short Code", expression: "SUBSTRING(code, 1, 3)") |
"Short Code" |
SUBSTRING(code, 1, 3) |
Options
format
Use format: to transform the raw SQL value before it is serialized.
column(:title_length, :title, format: ->(value) { value.length })
If the database value for title is "Book title", the serialized value for titleLength will be 10.
filter
column(:title, filter: [:eq])
This column can only be filtered with the :eq operator.
A query that attempts to filter this column with another operator will be treated as invalid input:
by default it returns an empty result, or raises ArgumentError when on_invalid_input: :raise is configured.
It is also possible to override the filtering behavior. The proc receives the current relation, the requested operator, and the filter value. It must return a relation, or nil to use the default filtering mechanism.
column(:age, filter: lambda { |scope, operator, value|
case operator
when :eq
scope.where(age: 10 * value)
end
})
In the example above, only the "equals" operator is overridden. Any other operator will still use the default implementation. To override filtering and reject all remaining operators, return an empty relation:
column(:age, filter: lambda { |scope, operator, value|
case operator
when :eq then scope.where(age: 10 * value)
else scope.none
end
})
queryable
Use queryable to control whether a column can be filtered or sorted.
column(:currency, queryable: :all) # Can be displayed, filtered and sorted
column(:title, queryable: :none) # Can only be displayed
column(:status, queryable: :filter) # Can be displayed and filtered, but not sorted
column(:created_at, queryable: :sort) # Can be displayed and sorted, but not filtered
query_column
A query_column behaves like a column, but it's not included in the serialized output.
Use it when a field should be available for filtering or sorting, but should not be rendered in the response.
query_column(:author_id, [:author, :id])
query_column(:status, filter: [:eq])
query_column(:created_at, queryable: :sort)
For query_column, valid queryable: values are :all, :filter, and :sort.
section
Use section to group fields under a nested object in the serialized output.
table = Prato.table(Book) do
column(:title)
section(:author) do
column(:name, [:author, :name])
column(:email, [:author, :email])
end
end
Invoking table.page(Book.all) produces:
{
entries: [
{
title: "Practical Object Conversations",
author: {
name: "Sandi Metz",
email: "sandi@example.com"
}
}
],
totalCount: 1
}
Sections only affect the output shape, nesting together some columns. They can also be nested themselves:
section(:publisher) do
section(:address) do
column(:city, %i[publisher address city])
end
end
When using the default parser, nested fields are referenced with dotted paths when filtering, sorting or selecting fields:
table.page(
Book.all,
{
filters: [{ field: "author.name", operator: "eq", value: "Sandi Metz" }],
sorts: [{ field: "author.email", order: "asc" }],
fields: ["title", "author.name"]
}
)
Section names with symbols are transformed using key_transformation.
configure(key_transformation: :snake_case)
section(:authorProfile) do
column(:displayName, %i[author name])
end
This serializes as:
{
author_profile: {
display_name: "Sandi Metz"
}
}
configuration
Use configure inside a table definition to override the application-level settings.
table = Prato.table(Book) do
column(:title)
column(:published_at)
configure(
key_transformation: :camelCase,
on_invalid_input: :empty,
parameter_parser: Prato::Query::DefaultParser.new,
default_page_size: 20,
maximum_page_size: 100,
default_queryable: :all,
default_ruby_column_queryable: :none
)
end
| Option | Default | Values |
|---|---|---|
key_transformation |
:camelCase |
:camelCase, :snake_case, :none |
on_invalid_input |
:empty |
:empty, :raise |
parameter_parser |
Prato::Query::DefaultParser.new |
Any object responding to parse_parameters(input, field_lookup) |
default_page_size |
20 |
Integer |
maximum_page_size |
100 |
Integer |
default_queryable |
:all |
:all, :none, :filter, :sort |
default_ruby_column_queryable |
:none |
:all, :none, :filter, :sort |
key_transformation
Controls how output keys are transformed.
configure(key_transformation: :camelCase)
column(:published_at)
# => :publishedAt
configure(key_transformation: :snake_case)
column(:publishedAt, :published_at)
# => :published_at
configure(key_transformation: :none)
column(:published_at)
# => :published_at
This applies to both column names and section names that use :symbols. Strings are not affected by the key transformation.
on_invalid_input
Controls what happens when parsed request parameters reference fields or operators that are not allowed.
configure(on_invalid_input: :empty) # returns an empty result
configure(on_invalid_input: :raise) # raises an `ArgumentError`
parameter_parser
Controls how incoming request parameters are converted into Prato query parameters.
configure(parameter_parser: MyCustomParser.new)
A custom parser must respond to:
parse_parameters(input, field_lookup)
It should return a Prato::Query::Parameters object.
To define your own Parser, look at how to implement a request parser
default_page_size and maximum_page_size
Controls pagination defaults and limits.
configure(
default_page_size: 25,
maximum_page_size: 100
)
If the request does not provide per_page, Prato uses default_page_size.
If the request asks for more than maximum_page_size, Prato caps the page size.
default_queryable
Sets the default queryable: behavior for columns that do not specify it explicitly.
configure(default_queryable: :none)
column(:title)
column(:status, queryable: :filter)
column(:currency, queryable: :all)
In this example, title can not be filtered or sorted, while status is allowed to filter. currency can be filtered and sorted.
default_ruby_column_queryable
Same as default_queryable, but applied to ruby_column.
Global configuration
Use Prato.setup with a block to configure application-level defaults.
These defaults are used by tables that do not override them.
Prato.setup do |config|
config.key_transformation = :snake_case
config.default_page_size = 50
end
table = Prato.table(Book) do
column(:published_at)
end
# Output key:
# => :published_at
Shared configuration
Use Prato.setup without a block to create a reusable configuration object.
That object can then be passed to one or more tables.
config = Prato.setup
config.key_transformation = :snake_case
config.default_page_size = 50
books_table = Prato.table(Book) do
configure(config, maximum_page_size: 200)
column(:published_at)
end
= Prato.table(Author) do
configure(config)
column(:created_at)
end
Options passed directly to configure override the shared configuration object for that table.
ruby_column (Advanced)
Warning!
Requests that use ruby_columns requires Active Record objects to be materialized.
This disables some SQL-only optimizations, such as serializing directly with .pluck.
Use ruby_column when a value cannot be expressed as a SQL-backed column, or when the value should be loaded through Ruby code.
table = Prato.table(Book) do
column(:title)
ruby_column(:title_length, key: :id) do |books, _loaders|
books.to_h do |book|
[book.id, book.title.length]
end
end
end
The idea behind a ruby_column is that sometimes, we need to have some values that cannot be calculated in the database.
(The example above can actually be computed in the database, but let's pretend it cannot!)
The way the ruby_column works is that it receives two arguments: an array of model objects and an hash of loaders.
- The array of model objects represent the data that is going to be displayed in the frontend
- The hash of loaders is useful when a
ruby_columnuses data from anotherruby_column.
Separate Loaders
A loader can also be defined separately with ruby_loader. This is useful when multiple Ruby columns need to share the same loading logic, or when you want to keep the column declaration compact.
table = Prato.table(Book) do
column(:title)
ruby_column(:review_summary, key: :id)
ruby_loader(:review_summary) do |books, _cache|
# This prevents a n+1 issue
review_counts = Review.where(book_id: books.map(&:id)).group(:book_id).count
books.to_h do |book|
count = review_counts.fetch(book.id, 0)
[book.id, "#{count} reviews"]
end
end
end
The name passed to ruby_column is used as both the output field and the loader name. To use a different output field and loader name, pass both:
ruby_column(:summary, :review_summary, key: :id)
ruby_loader(:review_summary) do |books, _cache|
# ...
end
key
By default, ruby_column uses the record's id.
ruby_column(:availability) do |books, _cache|
books.to_h { |book| [book.id, "available"] }
end
Use a symbol to read a different attribute:
ruby_column(:availability, key: :isbn) do |books, _cache|
Inventory.lookup(books.map(&:isbn))
end
Use a proc when the lookup key needs custom logic:
ruby_column(:company_status, key: ->(book) { book.publisher&.company_id }) do |books, _cache|
# ...
end
includes
Use includes: when the loader needs associations from the materialized records.
ruby_column(:publisher_name, key: :id, includes: :publisher) do |books, _cache|
books.to_h do |book|
[book.id, book.publisher&.name]
end
end
The association loading can also be declared on a separate loader:
ruby_column(:publisher_name, key: :id)
ruby_loader(:publisher_name, includes: :publisher) do |books, _cache|
books.to_h { |book| [book.id, book.publisher&.name] }
end
cache
The second block argument is a loader cache. It can be used when one Ruby loader depends on another Ruby loader.
table = Prato.table(Book) do
ruby_column(:review_count, key: :id)
ruby_loader(:review_count) do |books, _cache|
Review.where(book_id: books.map(&:id)).group(:book_id).count
end
ruby_column(:review_summary, key: :id) do |_books, cache|
counts = cache[:review_count]
counts.transform_values do |count|
"#{count} reviews"
end
end
end
Loader results are memoized, so referencing cache[:review_count] multiple times does not run that loader multiple times. Additionally, the loaders are lazy loaded, so they can be declared in any order.
** Filtering and Sorting **
Filtering and sorting on ruby_column values should be enabled carefully, because they can be expensive.
When a ruby_column is only displayed, Prato can still apply SQL-backed filtering, sorting, and pagination before materializing records.
This keeps the amount of Ruby work limited to the records that are actually being returned.
Filtering or sorting by a ruby_column is different.
Since the value only exists in Ruby, Prato must load the matching records, compute the Ruby value for each one, and then apply the filter or sort in memory.
For large tables, this can mean materializing many records before pagination can be applied.
For this reason, Ruby columns should be treated as display-only by default, and filtering or sorting should only be enabled when the candidate result set is known to be small enough.
Materializing a scope
There are three ways of materializing a scope - page, full and batches.
All 3 method calls receive the same two main arguments:
- scope: An Active Record relation.
- params: A user-provided object parsed by the configured parameter parser.
- By default, it's expected that
paramsis anActionController::Parameters, but it is not mandatory. - This field can be omitted.
- By default, it's expected that
table = Prato.table(Book) do
column(:title)
column(:published_at)
end
page
Use page when returning data for a paginated UI.
page applies filters, sorting, field selection, and pagination.
It returns a hash containing the serialized entries and the total number of matching records before pagination:
table.page(Book.order(:id), params)
# returns:
{
entries: [
{
title: "Practical Object Conversations",
publishedAt: "2026-01-01"
}
],
totalCount: 42
}
If no pagination parameters are provided, Prato uses default_page_size.
full
Use full when the entire matching result should be returned.
full applies filters, sorting, and field selection, but does not apply pagination and does not return totalCount.
table.full(Book.order(:id), params)
# returns:
[
{
title: "Practical Object Conversations",
publishedAt: "2026-01-01"
},
{
title: "Eloquent Ruby",
publishedAt: "2025-06-15"
}
]
batches
Use batches when processing large result sets without loading the whole result into memory at once.
table.batches(Book.order(:id), params, batch_size: 1_000) do |batch|
batch.each do |entry|
# Process each serialized entry
end
end
# Each yielded batch is an array of serialized entries:
[
{
title: "Practical Object Conversations",
publishedAt: "2026-01-01"
}
]
If no block is given, batches returns an enumerator:
enum = table.batches(Book.order(:id), params, batch_size: 1_000)
enum.each do |batch|
# Process batch
end
batches applies the same filters, sorting, and field selection as full, but yields the serialized records in chunks instead of returning a single array.
Parameters / Request details
Prato receives request data through the params argument passed to .page, .full, or .batches.
By default, params are parsed by Prato::Query::DefaultParser, which supports pagination, filters, sorting, and field selection.
A custom parser can be configured with parameter_parser:, which allows the application to use requests with different parameters and formats.
Pagination
Pagination in prato works by using Active Record's .offset and .limit.
The default parser reads two optional parameters:
| Parameter | Meaning |
|---|---|
page |
The page number to return |
per_page |
The number of records per page |
If page is not present, then the default page is 1.
If per_page is not present, then the used page size is the one in Configuration.default_page_size.
If per_page is greater than Configuration.maximum_page_size, Prato caps it to the configured maximum.
Example Request: ```http request https://prato.trecitano.com/reviews.json?query="page":2,"per_page":20
Example params:
```ruby
{
page: 2,
per_page: 20
}
Filters
The following filters are supported:
| Filter | Meaning |
|---|---|
:eq |
Equals |
:not_eq |
Not equals |
:lt |
Less than |
:lte |
Less than or equals |
:gt |
Greater than |
:gte |
Greater than or equals |
:present |
Is not nil |
:not_present |
Is nil |
:in |
Included in a list |
:not_in |
Not included in a list |
:contains |
Contains, case sensitive |
:not_contains |
Not contains, case sensitive |
:icontains |
Contains, case insensitive |
:not_icontains |
Not contains, case insensitive |
:between |
Between, inclusive |
:not_between |
Not between, inclusive |
:between_exclusive |
Between, exclusive |
:not_between_exclusive |
Not between, exclusive |
These work by invoking the underlying Arel methods; see the filter operator implementation.
The default parser assumes that the request contains a parameter called filters
that contains an array of:
{
"field": "<name of the field>",
"operator": "<one of the operators above>",
"value": "<any value>"
}
If filters is not present, then no filtering is applied.
Example request:
```http request
http://prato.trecitano.com/nested-relations.json?query={"filters":[{"field":"title","operator":"contains","value":"test2"}]}
Filters can also be nested with and and or:
{
filters: [
{
or: [
{ field: "title", operator: "contains", value: "ruby" },
{ field: "author.name", operator: "eq", value: "Sandi Metz" }
]
}
]
}
Nested fields are referenced with dotted paths, matching the serialized output path.
Sorting
Prato's Sort objects are composed by 2 parameters:
- :field, the internal name of a field
- :is_desc
The default parser assumes that the request contains a parameter called "sorts" that contains an array of:
{
"field": <name of the field>,
"order": asc | desc
}
If "sorts" is not present, then no sorting is applied.
Example request: ```http request http://localhost:3000/nested-relations.json?query="sorts":[{"field":"title","order":"asc"]}
Nested fields can be sorted with dotted paths:
```ruby
{
sorts: [
{
field: "author.name",
order: "asc"
}
]
}
Fields
Field selection controls which fields are included in the serialized response.
The default parser expects fields to contain an array of field names:
{
fields: ["title", "author.name", "avgReviewScore"]
}
If fields is not present, every displayable field is included in the response.
Fields inside sections are referenced with dotted paths:
{
fields: ["title", "classification.categoryName", "avgReviewScore"]
}
Field selection only affects the serialized output. Fields declared with query_column can still be used for filtering or sorting, but are never rendered.
Example request: ```http request http://localhost:3000/nested-relations.json?query="fields":["title","classification"fields":["title","classification.categoryName","avgReviewScore"]
## Development (TODO)
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bin/run-test-matrix` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/trecitano/prato.
## License
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
