prato 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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +938 -0
  5. data/lib/prato/configuration.rb +99 -0
  6. data/lib/prato/internal/active_record_version.rb +24 -0
  7. data/lib/prato/internal/join_helper.rb +48 -0
  8. data/lib/prato/internal/join_helper_legacy.rb +171 -0
  9. data/lib/prato/internal/lazy_loader_cache.rb +25 -0
  10. data/lib/prato/internal/pipeline/filtering.rb +277 -0
  11. data/lib/prato/internal/pipeline/pagination.rb +30 -0
  12. data/lib/prato/internal/pipeline/serializer.rb +87 -0
  13. data/lib/prato/internal/pipeline/sorting.rb +78 -0
  14. data/lib/prato/internal/query_executor.rb +105 -0
  15. data/lib/prato/internal/query_state.rb +90 -0
  16. data/lib/prato/internal/specification.rb +101 -0
  17. data/lib/prato/internal/specification_builder.rb +361 -0
  18. data/lib/prato/internal/sql_support.rb +118 -0
  19. data/lib/prato/query/and_filter.rb +13 -0
  20. data/lib/prato/query/default_parser.rb +148 -0
  21. data/lib/prato/query/field_resolver.rb +23 -0
  22. data/lib/prato/query/filter.rb +15 -0
  23. data/lib/prato/query/or_filter.rb +13 -0
  24. data/lib/prato/query/parameters.rb +17 -0
  25. data/lib/prato/query/sort.rb +14 -0
  26. data/lib/prato/table.rb +39 -0
  27. data/lib/prato/table_builder.rb +40 -0
  28. data/lib/prato/types/aggregate_column.rb +93 -0
  29. data/lib/prato/types/association_column.rb +37 -0
  30. data/lib/prato/types/direct_column.rb +27 -0
  31. data/lib/prato/types/expression_column.rb +38 -0
  32. data/lib/prato/types/ruby_column.rb +31 -0
  33. data/lib/prato/version.rb +5 -0
  34. data/lib/prato.rb +66 -0
  35. metadata +96 -0
data/README.md ADDED
@@ -0,0 +1,938 @@
1
+ # ![Prato](docs/prato_logo.webp)
2
+
3
+ [Click here to see the interactive demo!](https://prato.trecitano.com/)
4
+
5
+ Prato is a library that simplifies the backend code required to support queryable data,
6
+ by mapping parameters onto a table structure,
7
+ allowing Prato to invoke Active Record methods like `.where`, `.order`, `.joins`, `.pluck` and others.
8
+
9
+ The immediate use case for this is fetching data for tables in the frontend,
10
+ and with a simple *Prato* table, it becomes trivial to provide any kind of filtering / sorting / pagination operations
11
+ over an Active Record relation.
12
+
13
+ A quick example of this in action:
14
+
15
+ ```ruby
16
+ class BooksController < ApplicationController
17
+ def index
18
+ table = Prato.table(Book) do
19
+ column(:title)
20
+
21
+ section(:people) do
22
+ column(author_name: [:author, :name])
23
+ column(editor_name: [:editor, :name])
24
+ end
25
+
26
+ column(:review_count, count: :reviews)
27
+ column(:avg_review_score, avg: [:reviews, :score])
28
+ end
29
+
30
+ render json: table.page(Book.all, params)
31
+ end
32
+ end
33
+ ```
34
+
35
+ Assuming Book has an association to `author`, `editor`, and `reviews`, this will generate the following result:
36
+ ```json lines
37
+ {
38
+ "entries": [
39
+ {
40
+ "title": "Practical Object Conversations",
41
+ "people": {
42
+ "authorName": "Sandi Metz",
43
+ "editorName": "Martin Fowler"
44
+ },
45
+ "reviewCount": 2,
46
+ "avgReviewScore": 2.5
47
+ },
48
+ // ... 9 entries omitted
49
+ ],
50
+ "totalCount": 24
51
+ }
52
+ ```
53
+
54
+ That's it! Even if the request contains parameters (filters, ordering, field selection),
55
+ we don't have to change any of the backend code.
56
+
57
+ ## Table of Contents
58
+
59
+ - [Why Prato](#why-prato)
60
+ - [Requirements](#requirements)
61
+ - [Installation](#installation)
62
+ - [Technical Overview](#technical-overview)
63
+ - [Usage](#usage)
64
+ - [Defining a Prato table](#defining-a-prato-table)
65
+ - [column](#column)
66
+ - [query_column](#query_column)
67
+ - [section](#section)
68
+ - [configuration](#configuration)
69
+ - [ruby_column (Advanced)](#ruby_column-advanced)
70
+ - [Materializing a scope](#materializing-a-scope)
71
+ - [Parameters / Request Details](#parameters--request-details)
72
+ - [Pagination](#pagination)
73
+ - [Filters](#filters)
74
+ - [Sorting](#sorting)
75
+ - [Fields](#fields)
76
+ - [Development](#development-todo)
77
+ - [Contributing](#contributing)
78
+ - [License](#license)
79
+
80
+
81
+ ## Why Prato
82
+
83
+ Prato was born as a way to tackle complexity at scale.
84
+
85
+ It's common for applications to have some web pages that display data in a tabular style. The default approach to solve this
86
+ is to write an Active Record scope, add any necessary `.where` or `.or` statements, add `.includes` for any relations
87
+ and then serialize the result into model objects, as this is more ergonomic than just using `.pluck`.
88
+
89
+ This has some downsides:
90
+ - The request can overfetch data from the database.
91
+ - (which is problematic when new columns are added, and we don't know how much data they might have!)
92
+ - 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`).
93
+ - The business requirements may change, requiring data from different models which causes association and serializaiton code to be revisited.
94
+ - It's necessary to write *a lot* of code.
95
+
96
+ For applications being worked on with multiple developers and with hundreds of database tables, it becomes tricky to ensure
97
+ that all code is performant and correct.
98
+
99
+ Prato's table structure offers a way of ensuring that all the problems above stop being a concern.
100
+
101
+ ## Requirements
102
+
103
+ Prato requires Ruby 2.4 or later, Active Record 5.0 or later, and MySql, Sqlite or Postgres.
104
+
105
+ The library is actively tested against the following matrix:
106
+
107
+ | Ruby | Active Record |
108
+ |-------|---------------|
109
+ | 2.4.x | 5.0 |
110
+ | 2.5.x | 5.1 |
111
+ | 2.6.x | 5.2 |
112
+ | 2.7.x | 6.0, 6.1 |
113
+ | 3.0.x | 7.0 |
114
+ | 3.1.x | 7.1 |
115
+ | 3.2.x | 7.2, 8.0, 8.1 |
116
+ | 3.3.x | 7.2, 8.0, 8.1 |
117
+ | 3.4.x | 7.2, 8.0, 8.1 |
118
+ | 4.0.x | 7.2, 8.0, 8.1 |
119
+
120
+ ## Installation
121
+
122
+ Install the gem and add it to your application's Gemfile by running:
123
+
124
+ ```bash
125
+ bundle add prato
126
+ ```
127
+
128
+ ## Technical Overview
129
+
130
+ 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.
131
+
132
+ A Prato table specification uses `:symbols` to describe the fields that can be displayed, filtered, and sorted.
133
+ These symbols correspond to the method calls that otherwise would have to be written.
134
+ For example, `column(author_name: [:author, :name])` provides the same result as `<object>.author.name`.
135
+
136
+ By letting the request define what is required, Prato can decide at runtime which Active Record methods should be invoked.
137
+ Filters map to `.where` clauses, sorts map to `.order` clauses, association paths add the required joins, pagination adds `.limit` and `.offset`
138
+ and finally `.pluck` materializes any data that the request requires.
139
+
140
+ This lets application offer more functionality while having less code.
141
+
142
+ ## Usage
143
+
144
+ Prato relies on two steps:
145
+ - Defining a Prato table.
146
+ - Use an Active Record relation on that table with `.page(scope, params)`, `.full(scope, params)` or `.batches(scope, params, ...)`.
147
+
148
+ ### Defining a Prato table
149
+
150
+ A Prato table consists of columns and may also include sections and configuration.
151
+ The example below demonstrates many of the available features:
152
+
153
+ ```ruby
154
+ table = Prato.table(Book) do
155
+ column(:title)
156
+ column("Display Title" => :title)
157
+ column(:author_name, [:author, :name])
158
+ column(:city, [:publisher, :address, :city])
159
+
160
+ column(:review_count, count: :reviews)
161
+ column(:review_sum, sum: [:reviews, :score])
162
+ column(:review_avg, avg: [:reviews, :score])
163
+ column(:review_min, min: [:reviews, :score])
164
+ column(:review_max, max: [:reviews, :score])
165
+
166
+ column(:title_upper, expression: "UPPER(books.title)")
167
+
168
+ column(:formatted_title, :title, format: ->(v) { v.downcase })
169
+ column(:status, filter: [:eq, :in])
170
+ column(:internal_id, :id, queryable: :filter)
171
+
172
+ section(:author) do
173
+ column(:name, [:author, :name])
174
+ column(:email, [:author, :email])
175
+ end
176
+
177
+ query_column(:author_id, [:author, :id])
178
+
179
+ configure(
180
+ key_transformation: :camelCase,
181
+ on_invalid_input: :raise,
182
+ parameter_parser: Prato::Query::DefaultParser,
183
+ default_page_size: 25,
184
+ maximum_page_size: 100,
185
+ default_queryable: :all,
186
+ default_ruby_column_queryable: :none
187
+ )
188
+ end
189
+ ```
190
+
191
+ Invoking `table.page(Book.all)` will output the following structure:
192
+
193
+ ```ruby
194
+ {
195
+ entries: [
196
+ {
197
+ title: "Practical Object Conversations",
198
+ "Display Title" => "Practical Object Conversations",
199
+ authorName: "Sandi Metz",
200
+ city: "Raleigh",
201
+ reviewCount: 4,
202
+ reviewSum: 18,
203
+ reviewAvg: 4.5,
204
+ reviewMin: 3,
205
+ reviewMax: 5,
206
+ titleUpper: "PRACTICAL OBJECT CONVERSATIONS",
207
+ formattedTitle: "practical object conversations",
208
+ status: "published",
209
+ author: {
210
+ name: "Sandi Metz",
211
+ email: "sandi@example.com"
212
+ }
213
+ },
214
+ # ... up to 24 more entries omitted (default_page_size: 25)
215
+ ],
216
+ totalCount: 34
217
+ }
218
+ ```
219
+
220
+ #### column
221
+
222
+ A `column` is backed by SQL and its values are obtained via `.pluck`, unless `ruby_columns` are used (see more below).
223
+ Filters and sorts applied to a `column` will generate SQL via Arel or Active Record methods.
224
+
225
+ The source of a column's value can be defined in different ways:
226
+ - A column on the base model, referenced by name.
227
+ - A column on an associated model, reached through an association path.
228
+ - An aggregate expression (`:count`, `:avg`, `:sum`, `:min`, `:max`).
229
+ - A custom SQL expression.
230
+
231
+ In the following subsections, every example will use the configuration `key_transformation: :camelCase`.
232
+
233
+ ##### Basic Columns
234
+
235
+ Use a symbol to expose a model column directly:
236
+
237
+ | Example | Output field | SQL source |
238
+ |----------------------------|------------------|-------------------|
239
+ | `column(:release_year)` | `:releaseYear` | `release_year` |
240
+ | `column(:runtime_minutes)` | `:runtimeMinutes` | `runtime_minutes` |
241
+ | `column(:published_at)` | `:publishedAt` | `published_at` |
242
+
243
+ Use a hash when the output field should differ from the source column:
244
+
245
+ | Example | Output field | SQL source |
246
+ |---------------------------------------------|----------------|--------------------|
247
+ | `column(display_name: :name)` | `:displayName` | `name` |
248
+ | `column(released_on: :release_date)` | `:releasedOn` | `release_date` |
249
+ | `column("Box Office" => :box_office_total)` | `"Box Office"` | `box_office_total` |
250
+
251
+ Use an association path to read from a joined model:
252
+
253
+ | Example | Output field | SQL source |
254
+ |---------------------------------------------------|--------------------|---------------------|
255
+ | `column(studio_name: [:studio, :name])` | `:studioName` | `studios.name` |
256
+ | `column(director_country: [:director, :country])` | `:directorCountry` | `directors.country` |
257
+ | `column("Genre Label" => [:genre, :label])` | `"Genre Label"` | `genres.label` |
258
+ | `column(:publisher_city, [:publisher, :city])` | `:publisherCity` | `publishers.city` |
259
+
260
+ ##### Aggregate Columns
261
+
262
+ Use an aggregate keyword to compute a value in SQL:
263
+
264
+ | Example | Output field | SQL source |
265
+ |------------------------------------------------------------|-------------------|-----------------------------|
266
+ | `column(:review_count, count: :reviews)` | `:reviewCount` | `COUNT(reviews.*)` |
267
+ | `column(:average_rating, avg: [:reviews, :rating])` | `:averageRating` | `AVG(reviews.rating)` |
268
+ | `column(:total_sales, sum: [:orders, :total_cents])` | `:totalSales` | `SUM(orders.total_cents)` |
269
+ | `column(:first_showtime, min: [:screenings, :starts_at])` | `:firstShowtime` | `MIN(screenings.starts_at)` |
270
+ | `column(:latest_purchase, max: [:purchases, :created_at])` | `:latestPurchase` | `MAX(purchases.created_at)` |
271
+
272
+ ##### Expression Columns
273
+
274
+ Use `expression:` when the value should come from custom SQL:
275
+
276
+ | Example | Output field | SQL source |
277
+ |------------------------------------------------------------------------|-------------------|---------------------------------|
278
+ | `column(:lowercase_name, expression: "LOWER(name)")` | `:normalizedName` | `LOWER(name)` |
279
+ | `column(:release_decade, expression: "FLOOR(release_year / 10) * 10")` | `:releaseDecade` | `FLOOR(release_year / 10) * 10` |
280
+ | `column("Short Code", expression: "SUBSTRING(code, 1, 3)")` | `"Short Code"` | `SUBSTRING(code, 1, 3)` |
281
+
282
+ ##### Options
283
+
284
+ **format**
285
+
286
+ Use `format:` to transform the raw SQL value before it is serialized.
287
+ ```ruby
288
+ column(:title_length, :title, format: ->(value) { value.length })
289
+ ```
290
+ If the database value for title is "Book title", the serialized value for titleLength will be 10.
291
+
292
+ **filter**
293
+ ```ruby
294
+ column(:title, filter: [:eq])
295
+ ```
296
+ This column can only be filtered with the `:eq` operator.
297
+ A query that attempts to filter this column with another operator will be treated as invalid input:
298
+ by default it returns an empty result, or raises ArgumentError when on_invalid_input: :raise is configured.
299
+
300
+ It is also possible to override the filtering behavior.
301
+ The proc receives the current relation, the requested operator, and the filter value.
302
+ It must return a relation, or nil to use the default filtering mechanism.
303
+ ```ruby
304
+ column(:age, filter: lambda { |scope, operator, value|
305
+ case operator
306
+ when :eq
307
+ scope.where(age: 10 * value)
308
+ end
309
+ })
310
+ ```
311
+ In the example above, only the "equals" operator is overridden. Any other operator will still use the default implementation.
312
+ To override filtering and reject all remaining operators, return an empty relation:
313
+ ```ruby
314
+ column(:age, filter: lambda { |scope, operator, value|
315
+ case operator
316
+ when :eq then scope.where(age: 10 * value)
317
+ else scope.none
318
+ end
319
+ })
320
+ ```
321
+
322
+ ##### queryable
323
+ Use `queryable` to control whether a column can be filtered or sorted.
324
+ ```ruby
325
+ column(:currency, queryable: :all) # Can be displayed, filtered and sorted
326
+ column(:title, queryable: :none) # Can only be displayed
327
+ column(:status, queryable: :filter) # Can be displayed and filtered, but not sorted
328
+ column(:created_at, queryable: :sort) # Can be displayed and sorted, but not filtered
329
+ ```
330
+
331
+ #### query_column
332
+
333
+ A `query_column` behaves like a `column`, but it's not included in the serialized output.
334
+
335
+ Use it when a field should be available for filtering or sorting, but should not be rendered in the response.
336
+
337
+ ```ruby
338
+ query_column(:author_id, [:author, :id])
339
+ query_column(:status, filter: [:eq])
340
+ query_column(:created_at, queryable: :sort)
341
+ ```
342
+ For `query_column`, valid `queryable:` values are `:all`, `:filter`, and `:sort`.
343
+
344
+ #### section
345
+
346
+ Use `section` to group fields under a nested object in the serialized output.
347
+
348
+ ```ruby
349
+ table = Prato.table(Book) do
350
+ column(:title)
351
+ section(:author) do
352
+ column(:name, [:author, :name])
353
+ column(:email, [:author, :email])
354
+ end
355
+ end
356
+ ```
357
+ Invoking `table.page(Book.all)` produces:
358
+ ```ruby
359
+ {
360
+ entries: [
361
+ {
362
+ title: "Practical Object Conversations",
363
+ author: {
364
+ name: "Sandi Metz",
365
+ email: "sandi@example.com"
366
+ }
367
+ }
368
+ ],
369
+ totalCount: 1
370
+ }
371
+ ```
372
+ Sections only affect the output shape, nesting together some columns. They can also be nested themselves:
373
+ ```ruby
374
+ section(:publisher) do
375
+ section(:address) do
376
+ column(:city, %i[publisher address city])
377
+ end
378
+ end
379
+ ```
380
+ When using the [default parser](lib/prato/query/default_parser.rb),
381
+ nested fields are referenced with dotted paths when filtering, sorting or selecting fields:
382
+ ```ruby
383
+ table.page(
384
+ Book.all,
385
+ {
386
+ filters: [{ field: "author.name", operator: "eq", value: "Sandi Metz" }],
387
+ sorts: [{ field: "author.email", order: "asc" }],
388
+ fields: ["title", "author.name"]
389
+ }
390
+ )
391
+ ```
392
+ Section names with symbols are transformed using `key_transformation`.
393
+ ```ruby
394
+ configure(key_transformation: :snake_case)
395
+ section(:authorProfile) do
396
+ column(:displayName, %i[author name])
397
+ end
398
+ ```
399
+ This serializes as:
400
+ ```ruby
401
+ {
402
+ author_profile: {
403
+ display_name: "Sandi Metz"
404
+ }
405
+ }
406
+ ```
407
+
408
+ #### configuration
409
+
410
+ Use `configure` inside a table definition to override the application-level settings.
411
+
412
+ ```ruby
413
+ table = Prato.table(Book) do
414
+ column(:title)
415
+ column(:published_at)
416
+
417
+ configure(
418
+ key_transformation: :camelCase,
419
+ on_invalid_input: :empty,
420
+ parameter_parser: Prato::Query::DefaultParser.new,
421
+ default_page_size: 20,
422
+ maximum_page_size: 100,
423
+ default_queryable: :all,
424
+ default_ruby_column_queryable: :none
425
+ )
426
+ end
427
+ ```
428
+ | Option | Default | Values |
429
+ |-----------------------------------|-----------------------------------|------------------------------------------------------------------|
430
+ | `key_transformation` | `:camelCase` | `:camelCase`, `:snake_case`, `:none` |
431
+ | `on_invalid_input` | `:empty` | `:empty`, `:raise` |
432
+ | `parameter_parser` | `Prato::Query::DefaultParser.new` | Any object responding to `parse_parameters(input, field_lookup)` |
433
+ | `default_page_size` | `20` | Integer |
434
+ | `maximum_page_size` | `100` | Integer |
435
+ | `default_queryable` | `:all` | `:all`, `:none`, `:filter`, `:sort` |
436
+ | `default_ruby_column_queryable` | `:none` | `:all`, `:none`, `:filter`, `:sort` |
437
+
438
+ ##### `key_transformation`
439
+ Controls how output keys are transformed.
440
+
441
+ ```ruby
442
+ configure(key_transformation: :camelCase)
443
+ column(:published_at)
444
+ # => :publishedAt
445
+ configure(key_transformation: :snake_case)
446
+ column(:publishedAt, :published_at)
447
+ # => :published_at
448
+ configure(key_transformation: :none)
449
+ column(:published_at)
450
+ # => :published_at
451
+ ```
452
+ This applies to both column names and section names that use `:symbols`. Strings are not affected by the key transformation.
453
+
454
+ ##### `on_invalid_input`
455
+
456
+ Controls what happens when parsed request parameters reference fields or operators that are not allowed.
457
+ ```ruby
458
+ configure(on_invalid_input: :empty) # returns an empty result
459
+ configure(on_invalid_input: :raise) # raises an `ArgumentError`
460
+ ```
461
+
462
+ ##### `parameter_parser`
463
+ Controls how incoming request parameters are converted into Prato query parameters.
464
+ ```ruby
465
+ configure(parameter_parser: MyCustomParser.new)
466
+ ```
467
+ A custom parser must respond to:
468
+ ```ruby
469
+ parse_parameters(input, field_lookup)
470
+ ```
471
+ It should return a `Prato::Query::Parameters` object.
472
+
473
+ To define your own Parser, look at [how to implement a request parser](docs/implementing_a_parser.md)
474
+
475
+ ##### `default_page_size` and `maximum_page_size`
476
+ Controls pagination defaults and limits.
477
+ ```ruby
478
+ configure(
479
+ default_page_size: 25,
480
+ maximum_page_size: 100
481
+ )
482
+ ```
483
+ If the request does not provide `per_page`, Prato uses `default_page_size`.
484
+ If the request asks for more than `maximum_page_size`, Prato caps the page size.
485
+
486
+ ##### `default_queryable`
487
+ Sets the default `queryable:` behavior for columns that do not specify it explicitly.
488
+ ```ruby
489
+ configure(default_queryable: :none)
490
+ column(:title)
491
+ column(:status, queryable: :filter)
492
+ column(:currency, queryable: :all)
493
+ ```
494
+ In this example, `title` can not be filtered or sorted, while `status` is allowed to filter. `currency` can be filtered and sorted.
495
+
496
+ ##### `default_ruby_column_queryable`
497
+ Same as `default_queryable`, but applied to `ruby_column`.
498
+
499
+ ##### Global configuration
500
+
501
+ Use `Prato.setup` with a block to configure application-level defaults.
502
+ These defaults are used by tables that do not override them.
503
+
504
+ ```ruby
505
+ Prato.setup do |config|
506
+ config.key_transformation = :snake_case
507
+ config.default_page_size = 50
508
+ end
509
+
510
+ table = Prato.table(Book) do
511
+ column(:published_at)
512
+ end
513
+ # Output key:
514
+ # => :published_at
515
+ ````
516
+
517
+ ##### Shared configuration
518
+
519
+ Use `Prato.setup` without a block to create a reusable configuration object.
520
+ That object can then be passed to one or more tables.
521
+
522
+ ```ruby
523
+ config = Prato.setup
524
+ config.key_transformation = :snake_case
525
+ config.default_page_size = 50
526
+
527
+ books_table = Prato.table(Book) do
528
+ configure(config, maximum_page_size: 200)
529
+ column(:published_at)
530
+ end
531
+
532
+ authors_table = Prato.table(Author) do
533
+ configure(config)
534
+ column(:created_at)
535
+ end
536
+ ```
537
+
538
+ Options passed directly to configure override the shared configuration object for that table.
539
+
540
+ #### ruby_column (Advanced)
541
+
542
+ **Warning!**
543
+ Requests that use `ruby_columns` requires Active Record objects to be materialized.
544
+ This disables some SQL-only optimizations, such as serializing directly with `.pluck`.
545
+
546
+ Use `ruby_column` when a value cannot be expressed as a SQL-backed `column`, or when the value should be loaded through Ruby code.
547
+
548
+ ```ruby
549
+ table = Prato.table(Book) do
550
+ column(:title)
551
+ ruby_column(:title_length, key: :id) do |books, _loaders|
552
+ books.to_h do |book|
553
+ [book.id, book.title.length]
554
+ end
555
+ end
556
+ end
557
+ ```
558
+
559
+ The idea behind a `ruby_column` is that sometimes, we need to have some values that cannot be calculated in the database.
560
+ (The example above can actually be computed in the database, but let's pretend it cannot!)
561
+
562
+ The way the `ruby_column` works is that it receives two arguments: an array of model objects and an hash of loaders.
563
+ - The array of model objects represent the data that is going to be displayed in the frontend
564
+ - The hash of loaders is useful when a `ruby_column` uses data from another `ruby_column`.
565
+
566
+ **Separate Loaders**
567
+
568
+ A loader can also be defined separately with ruby_loader.
569
+ This is useful when multiple Ruby columns need to share the same loading logic,
570
+ or when you want to keep the column declaration compact.
571
+
572
+ ```ruby
573
+ table = Prato.table(Book) do
574
+ column(:title)
575
+ ruby_column(:review_summary, key: :id)
576
+
577
+ ruby_loader(:review_summary) do |books, _cache|
578
+ # This prevents a n+1 issue
579
+ review_counts = Review.where(book_id: books.map(&:id)).group(:book_id).count
580
+ books.to_h do |book|
581
+ count = review_counts.fetch(book.id, 0)
582
+ [book.id, "#{count} reviews"]
583
+ end
584
+ end
585
+ end
586
+ ```
587
+
588
+ The name passed to ruby_column is used as both the output field and the loader name.
589
+ To use a different output field and loader name, pass both:
590
+ ```ruby
591
+ ruby_column(:summary, :review_summary, key: :id)
592
+ ruby_loader(:review_summary) do |books, _cache|
593
+ # ...
594
+ end
595
+ ```
596
+
597
+ **key**
598
+
599
+ By default, `ruby_column` uses the record's id.
600
+ ```ruby
601
+ ruby_column(:availability) do |books, _cache|
602
+ books.to_h { |book| [book.id, "available"] }
603
+ end
604
+ ```
605
+
606
+ Use a symbol to read a different attribute:
607
+ ```ruby
608
+ ruby_column(:availability, key: :isbn) do |books, _cache|
609
+ Inventory.lookup(books.map(&:isbn))
610
+ end
611
+ ```
612
+
613
+ Use a proc when the lookup key needs custom logic:
614
+ ```ruby
615
+ ruby_column(:company_status, key: ->(book) { book.publisher&.company_id }) do |books, _cache|
616
+ # ...
617
+ end
618
+ ```
619
+
620
+ **includes**
621
+
622
+ Use includes: when the loader needs associations from the materialized records.
623
+ ```ruby
624
+ ruby_column(:publisher_name, key: :id, includes: :publisher) do |books, _cache|
625
+ books.to_h do |book|
626
+ [book.id, book.publisher&.name]
627
+ end
628
+ end
629
+ ```
630
+
631
+ The association loading can also be declared on a separate loader:
632
+ ```ruby
633
+ ruby_column(:publisher_name, key: :id)
634
+ ruby_loader(:publisher_name, includes: :publisher) do |books, _cache|
635
+ books.to_h { |book| [book.id, book.publisher&.name] }
636
+ end
637
+ ```
638
+
639
+
640
+ **cache**
641
+
642
+ The second block argument is a loader cache. It can be used when one Ruby loader depends on another Ruby loader.
643
+ ```ruby
644
+ table = Prato.table(Book) do
645
+ ruby_column(:review_count, key: :id)
646
+
647
+ ruby_loader(:review_count) do |books, _cache|
648
+ Review.where(book_id: books.map(&:id)).group(:book_id).count
649
+ end
650
+ ruby_column(:review_summary, key: :id) do |_books, cache|
651
+ counts = cache[:review_count]
652
+ counts.transform_values do |count|
653
+ "#{count} reviews"
654
+ end
655
+ end
656
+ end
657
+ ```
658
+ Loader results are memoized, so referencing cache[:review_count] multiple times does not run that loader multiple times.
659
+ Additionally, the loaders are lazy loaded, so they can be declared in any order.
660
+
661
+ ** Filtering and Sorting **
662
+
663
+ Filtering and sorting on `ruby_column` values should be enabled carefully, because they can be expensive.
664
+
665
+ When a `ruby_column` is only displayed, Prato can still apply SQL-backed filtering, sorting, and pagination before materializing records.
666
+ This keeps the amount of Ruby work limited to the records that are actually being returned.
667
+ Filtering or sorting by a `ruby_column` is different.
668
+ 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.
669
+ For large tables, this can mean materializing many records before pagination can be applied.
670
+
671
+ 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.
672
+
673
+ ### Materializing a scope
674
+
675
+ There are three ways of materializing a scope - `page`, `full` and `batches`.
676
+ All 3 method calls receive the same two main arguments:
677
+ - scope: An Active Record relation.
678
+ - params: A user-provided object parsed by the configured parameter parser.
679
+ - By default, it's expected that `params` is an `ActionController::Parameters`, but it is not mandatory.
680
+ - This field can be omitted.
681
+
682
+ ```ruby
683
+ table = Prato.table(Book) do
684
+ column(:title)
685
+ column(:published_at)
686
+ end
687
+ ```
688
+
689
+ #### page
690
+
691
+ Use `page` when returning data for a paginated UI.
692
+ `page` applies filters, sorting, field selection, and pagination.
693
+ It returns a hash containing the serialized entries and the total number of matching records before pagination:
694
+ ```ruby
695
+ table.page(Book.order(:id), params)
696
+ # returns:
697
+
698
+ {
699
+ entries: [
700
+ {
701
+ title: "Practical Object Conversations",
702
+ publishedAt: "2026-01-01"
703
+ }
704
+ ],
705
+ totalCount: 42
706
+ }
707
+ ```
708
+
709
+ If no pagination parameters are provided, Prato uses `default_page_size`.
710
+
711
+ #### full
712
+
713
+ Use `full` when the entire matching result should be returned.
714
+ `full` applies filters, sorting, and field selection, but does not apply pagination and does not return totalCount.
715
+ ```ruby
716
+ table.full(Book.order(:id), params)
717
+ # returns:
718
+
719
+ [
720
+ {
721
+ title: "Practical Object Conversations",
722
+ publishedAt: "2026-01-01"
723
+ },
724
+ {
725
+ title: "Eloquent Ruby",
726
+ publishedAt: "2025-06-15"
727
+ }
728
+ ]
729
+ ```
730
+
731
+ #### batches
732
+
733
+ Use batches when processing large result sets without loading the whole result into memory at once.
734
+ ```ruby
735
+ table.batches(Book.order(:id), params, batch_size: 1_000) do |batch|
736
+ batch.each do |entry|
737
+ # Process each serialized entry
738
+ end
739
+ end
740
+ # Each yielded batch is an array of serialized entries:
741
+ [
742
+ {
743
+ title: "Practical Object Conversations",
744
+ publishedAt: "2026-01-01"
745
+ }
746
+ ]
747
+ ```
748
+
749
+ If no block is given, batches returns an enumerator:
750
+ ```ruby
751
+ enum = table.batches(Book.order(:id), params, batch_size: 1_000)
752
+ enum.each do |batch|
753
+ # Process batch
754
+ end
755
+ ```
756
+
757
+ batches applies the same filters, sorting, and field selection as full,
758
+ but yields the serialized records in chunks instead of returning a single array.
759
+
760
+ ### Parameters / Request details
761
+
762
+ Prato receives request data through the `params` argument passed to `.page`, `.full`, or `.batches`.
763
+
764
+ By default, params are parsed by `Prato::Query::DefaultParser`, which supports pagination, filters, sorting, and field selection.
765
+
766
+ A custom parser can be configured with `parameter_parser:`, which allows the application to use requests with different parameters and formats.
767
+
768
+ #### Pagination
769
+
770
+ - [parameters.rb](lib/prato/query/parameters.rb)
771
+ - [parameters.rbs](sig/prato/query/parameters.rbs)
772
+
773
+ Pagination in prato works by using Active Record's `.offset` and `.limit`.
774
+
775
+ The [default parser](lib/prato/query/default_parser.rb) reads two optional parameters:
776
+
777
+ | Parameter | Meaning |
778
+ |------------|----------------------------------|
779
+ | `page` | The page number to return |
780
+ | `per_page` | The number of records per page |
781
+
782
+
783
+ If `page` is not present, then the default page is 1.
784
+ If `per_page` is not present, then the used page size is the one in `Configuration.default_page_size`.
785
+ If `per_page` is greater than `Configuration.maximum_page_size`, Prato caps it to the configured maximum.
786
+
787
+ Example Request:
788
+ ```http request
789
+ https://prato.trecitano.com/reviews.json?query={"page":2,"per_page":20}
790
+ ```
791
+ Example params:
792
+ ```ruby
793
+ {
794
+ page: 2,
795
+ per_page: 20
796
+ }
797
+ ```
798
+
799
+ #### Filters
800
+
801
+ - [filter.rb](lib/prato/query/filter.rb)
802
+ - [filter.rbs](sig/prato/query/filter.rbs)
803
+
804
+ The following filters are supported:
805
+
806
+ | Filter | Meaning |
807
+ |---------------------------|--------------------------------|
808
+ | `:eq` | Equals |
809
+ | `:not_eq` | Not equals |
810
+ | `:lt` | Less than |
811
+ | `:lte` | Less than or equals |
812
+ | `:gt` | Greater than |
813
+ | `:gte` | Greater than or equals |
814
+ | `:present` | Is not nil |
815
+ | `:not_present` | Is nil |
816
+ | `:in` | Included in a list |
817
+ | `:not_in` | Not included in a list |
818
+ | `:contains` | Contains, case sensitive |
819
+ | `:not_contains` | Not contains, case sensitive |
820
+ | `:icontains` | Contains, case insensitive |
821
+ | `:not_icontains` | Not contains, case insensitive |
822
+ | `:between` | Between, inclusive |
823
+ | `:not_between` | Not between, inclusive |
824
+ | `:between_exclusive` | Between, exclusive |
825
+ | `:not_between_exclusive` | Not between, exclusive |
826
+
827
+ These work by invoking the underlying Arel methods; see the
828
+ [filter operator implementation](lib/prato/internal/pipeline/filtering.rb#L138-L160).
829
+
830
+ The [default parser](lib/prato/query/default_parser.rb) assumes that the request contains a parameter called `filters`
831
+ that contains an array of:
832
+ ```json
833
+ {
834
+ "field": "<name of the field>",
835
+ "operator": "<one of the operators above>",
836
+ "value": "<any value>"
837
+ }
838
+ ```
839
+
840
+ If `filters` is not present, then no filtering is applied.
841
+
842
+ ```
843
+ Example request:
844
+ ```http request
845
+ http://prato.trecitano.com/nested-relations.json?query={"filters":[{"field":"title","operator":"contains","value":"test2"}]}
846
+ ```
847
+
848
+ Filters can also be nested with and and or:
849
+ ```ruby
850
+ {
851
+ filters: [
852
+ {
853
+ or: [
854
+ { field: "title", operator: "contains", value: "ruby" },
855
+ { field: "author.name", operator: "eq", value: "Sandi Metz" }
856
+ ]
857
+ }
858
+ ]
859
+ }
860
+ ```
861
+ Nested fields are referenced with dotted paths, matching the serialized output path.
862
+
863
+ #### Sorting
864
+
865
+ - [sort.rb](lib/prato/query/sort.rb)
866
+ - [sort.rbs](sig/prato/query/sort.rbs)
867
+
868
+ Prato's Sort objects are composed by 2 parameters:
869
+ - :field, the internal name of a field
870
+ - :is_desc
871
+
872
+ The default parser assumes that the request contains a parameter called "sorts" that contains an array of:
873
+ ```
874
+ {
875
+ "field": <name of the field>,
876
+ "order": asc | desc
877
+ }
878
+ ```
879
+
880
+ If "sorts" is not present, then no sorting is applied.
881
+
882
+ Example request:
883
+ ```http request
884
+ http://localhost:3000/nested-relations.json?query={"sorts":[{"field":"title","order":"asc"}]}
885
+ ```
886
+
887
+ Nested fields can be sorted with dotted paths:
888
+ ```ruby
889
+ {
890
+ sorts: [
891
+ {
892
+ field: "author.name",
893
+ order: "asc"
894
+ }
895
+ ]
896
+ }
897
+ ```
898
+
899
+ #### Fields
900
+
901
+ - [parameters.rb](lib/prato/query/parameters.rb)
902
+ - [parameters.rbs](sig/prato/query/parameters.rbs)
903
+
904
+ Field selection controls which fields are included in the serialized response.
905
+
906
+ The default parser expects fields to contain an array of field names:
907
+ ```ruby
908
+ {
909
+ fields: ["title", "author.name", "avgReviewScore"]
910
+ }
911
+ ```
912
+
913
+ If fields is not present, every displayable field is included in the response.
914
+
915
+ Fields inside sections are referenced with dotted paths:
916
+ ```ruby
917
+ {
918
+ fields: ["title", "classification.categoryName", "avgReviewScore"]
919
+ }
920
+ ```
921
+ Field selection only affects the serialized output. Fields declared with query_column can still be used for filtering or sorting, but are never rendered.
922
+
923
+ Example request:
924
+ ```http request
925
+ http://localhost:3000/nested-relations.json?query={"fields":["title","classification.categoryName","avgReviewScore"]}
926
+ ```
927
+
928
+ ## Development (TODO)
929
+
930
+ 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.
931
+
932
+ ## Contributing
933
+
934
+ Bug reports and pull requests are welcome on GitHub at https://github.com/trecitano/prato.
935
+
936
+ ## License
937
+
938
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).