rails-surrender 0.1.0 → 8.0.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/LICENSE +674 -0
  4. data/README.md +240 -0
  5. data/lib/rails/surrender/config/locales/en.yml +3 -0
  6. data/lib/rails/surrender/controller_additions.rb +13 -73
  7. data/lib/rails/surrender/fields/asks.rb +50 -0
  8. data/lib/rails/surrender/fields/attribute.rb +37 -0
  9. data/lib/rails/surrender/fields/expand.rb +65 -0
  10. data/lib/rails/surrender/fields/field_tree.rb +71 -0
  11. data/lib/rails/surrender/fields/id_list.rb +38 -0
  12. data/lib/rails/surrender/fields/node.rb +86 -0
  13. data/lib/rails/surrender/menu/field.rb +15 -0
  14. data/lib/rails/surrender/menu/filter.rb +11 -0
  15. data/lib/rails/surrender/menu/menu.rb +55 -0
  16. data/lib/rails/surrender/model_additions.rb +47 -32
  17. data/lib/rails/surrender/params/list_param.rb +23 -0
  18. data/lib/rails/surrender/railtie.rb +23 -9
  19. data/lib/rails/surrender/render/resource.rb +11 -30
  20. data/lib/rails/surrender/selection/filtering.rb +144 -0
  21. data/lib/rails/surrender/selection/page_size.rb +33 -0
  22. data/lib/rails/surrender/selection/selected.rb +50 -0
  23. data/lib/rails/surrender/selection/selection.rb +70 -0
  24. data/lib/rails/surrender/selection/sorting.rb +61 -0
  25. data/lib/rails/surrender/version.rb +1 -1
  26. data/lib/rails/surrender.rb +13 -12
  27. metadata +97 -35
  28. data/lib/rails/surrender/config/initializers/active_record_associations_patch.rb +0 -17
  29. data/lib/rails/surrender/config/initializers/active_record_preloader_patch.rb +0 -20
  30. data/lib/rails/surrender/helpers/filter_builder.rb +0 -50
  31. data/lib/rails/surrender/helpers/pagination_builder.rb +0 -25
  32. data/lib/rails/surrender/helpers/query_param_parser.rb +0 -98
  33. data/lib/rails/surrender/helpers/sort_builder.rb +0 -56
  34. data/lib/rails/surrender/model_filter_scopes.rb +0 -56
  35. data/lib/rails/surrender/render/configuration/inclusion_mapper_logic.rb +0 -69
  36. data/lib/rails/surrender/render/configuration/instance_logic.rb +0 -88
  37. data/lib/rails/surrender/render/configuration.rb +0 -93
  38. data/lib/rails/surrender/render/count.rb +0 -21
  39. data/lib/rails/surrender/render/ids.rb +0 -21
  40. data/lib/rails/surrender/render/resource/collection.rb +0 -26
  41. data/lib/rails/surrender/render/resource/inclusion_mapper.rb +0 -48
  42. data/lib/rails/surrender/render/resource/instance.rb +0 -75
  43. data/test/surrender_test.rb +0 -9
  44. data/test/test_helper.rb +0 -21
data/README.md ADDED
@@ -0,0 +1,240 @@
1
+ # Surrender
2
+ A JSON rendering platform for Rails APIs. Provides many controls through URL query parameters for clients to craft
3
+ API responses from your resources.
4
+
5
+ ## Requirements
6
+ * Ruby 3.3 or newer
7
+ * Rails 8.0 or 8.1
8
+
9
+ The gem's version names the Rails version it supports. Use 8.x for Rails 8.0 and 8.1, and 7.0.0 (on the
10
+ `7-0-stable` branch) for Rails 7.0.
11
+
12
+ Rails 8.0 is not compatible with version 3 of the `json` gem, so Rails 8.0 apps should keep `json` below 3:
13
+
14
+ ```ruby
15
+ gem 'json', '< 3'
16
+ ```
17
+
18
+ ## Features
19
+ Surrender adds a number of helpful features to API requests, through URL query parameters to craft the structure of the
20
+ response:
21
+
22
+ ### Sorting
23
+ Most attributes on the requested resource can be targeted for sorting. Sorting is provided for to the API with the
24
+ 'sort' query parameter.
25
+
26
+ | example query param | description |
27
+ |---------------------|-----------------------------------------------------------|
28
+ | `sort=name` | Sort the results by the `name` attribute |
29
+ | `sort=-created_at` | Sort the results in reverse by the `created_at` attribute |
30
+ | `sort=author.name` | Sort the results by the `name` of each result's `author` |
31
+
32
+ Sorting by a column of an association works for associations that hold one record, such as `belongs_to` and `has_one`.
33
+ An association that holds many would repeat each result once per associated record, so it is rejected.
34
+
35
+ A model can define its own sorts as scopes named `sort_by_<name>`, which take the direction, `'ASC'` or `'DESC'`. A
36
+ sort the model defines wins over a column of the same name, so `sort_by_title` can sort titles without regard to case:
37
+
38
+ ```ruby
39
+ scope :sort_by_title, ->(direction) { order(Arel.sql("LOWER(title) #{direction}")) }
40
+ ```
41
+
42
+
43
+ ### Pagination
44
+ Pagination is provided internally using the 'kaminari' gem. Access is provided to the API with the `page` and `per`
45
+ query parameters
46
+
47
+ | example query param | description |
48
+ |---------------------|------------------------------------|
49
+ | `per=20` | Request 20 results per page |
50
+ | `page=2` | Request the second page of results |
51
+
52
+ A page holds 50 resources unless the request sets `per`, and never more than 100, whatever `per` asks for. Both numbers
53
+ can be changed in the app's configuration (see Setup). `page` and `per` take positive whole numbers; anything else is
54
+ rejected with a `Rails::Surrender::Error`.
55
+
56
+ Paginated results will populate an `X-Pagination` header in the result set that provides a hash of the paginated
57
+ details. The hash is structured like:
58
+
59
+ ```json
60
+ {
61
+ "total": 70,
62
+ "page_total": 3,
63
+ "page": 2,
64
+ "previous_page": 1,
65
+ "next_page": 3,
66
+ "last_page": 3,
67
+ "per_page": 25,
68
+ "offset": 25
69
+ }
70
+ ```
71
+
72
+ ### Filtering
73
+ `rails-surrender` adds a number of scopes to your models for filtering, and provides access to the filters with the
74
+ `filter` query parameter. Most columns are provided with an equality filter and can be filtered on matching values.
75
+ Date and Time columns are also provided with `_before` and `_after` filters, named after the column without its `_at`
76
+ suffix, which select records strictly before or after the given date or time. Examples are provided below:
77
+
78
+ Note: Time-based filters take an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) date or time, such as
79
+ `'2022-01-01'`, `'2022-01-01T13:30:00Z'` or `'2022-01-01T13:30:00-05:00'`, surrounded by single or double quotes. A time
80
+ without an offset is in the app's time zone. Any other value is rejected with a `Rails::Surrender::Error`.
81
+
82
+ | example query param | description |
83
+ |-----------------------------------------------|-----------------------------------------------------------------------------------|
84
+ | `filter=active:false` | filter for resources with the `active` attribute set to false |
85
+ | `filter=state:TX` | filter for resources with the `state` attribute of `TX` |
86
+ | `filter=created_before:'2022-01-01'` | filter for resources with a `created_at` value _before_ Jan 01, 2022 |
87
+ | `filter=created_after:'2022-01-01'` | filter for resources with a `created_at` value _after_ Jan 01, 2022 |
88
+ | `filter=created_after:'2022-01-01T13:30:00Z'` | filter for resources with a `created_at` value _after_ 1:30pm UTC on Jan 01, 2022 |
89
+
90
+
91
+ ### Counting
92
+ Instead of rendering a full list of resources, `rails-surrender` can return only the _count_ of the number of resources
93
+ that _would_ be returned. A count ignores pagination: it counts every resource the filters select, not just one page. This can be useful when combined with filters for basic reporting. Access is provided with
94
+ the `count` query parameter.
95
+
96
+ | example query param | description |
97
+ |-----------------------------------------------------------------|------------------------------------------------------|
98
+ | `count` | count the number of resources |
99
+ | `filter=state:TX&count` | count the number of resources in the `state` of 'TX' |
100
+ | `filter=state:TX&count` | count the number of resources in the `state` of 'TX' |
101
+ | `filter=created_before:'2022-01-01'&count` | count the number of resources created before 2022 |
102
+
103
+
104
+ ### Including
105
+ the `include` (and `exclude` described below) directive provides an easy but powerful mechanism for crafting the shape
106
+ of the response. For models that provide additional attributes or associations that are not rendered by default (see
107
+ setup below) the `include` query parameter can be used to request those attributes and associations be rendered in the
108
+ request. The `include` parameter can request multiple attributes and associations, and for associations can include
109
+ additional values on nested resources. Examples below:
110
+
111
+ | example query param | description |
112
+ |---------------------------------------------------------------------------|-------------------------------------------------------------------------|
113
+ | `include=created_at, updated_at` | include the `created_at` and `updated at` attributes of the resource(s) |
114
+ | `include=work_orders` | include the `work_orders` association of the resource(s) |
115
+ | `include=work_orders:[activities]` | include the `work_orders` and it's `activities` association |
116
+ | `include=created_at, work_orders:[created_at, activities:[created_at ]]` | combine various attributes and associations and nested results |
117
+
118
+ Each resource renders its `id` first, then its other attributes, then its `*_ids` lists, then its associations. Within
119
+ each group, the model's default fields come first, then those the controller includes, then those the request includes.
120
+
121
+
122
+ ### Excluding
123
+ Exclude (and exclude below) provide an easy but powerful mechanism for crafting the shape of the response. Excluding
124
+ works like the reverse of include. Models may define attributes or associations that are rendered by default, but
125
+ exclude can be used to prevent rendering of those attributes. Examples below:
126
+
127
+ | example query param | description |
128
+ |----------------------------------|------------------------------------------------------------------------------------------------------|
129
+ | `exclude=created_at, updated_at` | exclude the `created_at` and `updated at` attributes of the resource(s) that are rendered by default |
130
+ | `exclude=work_orders` | include the `work_orders` association of the resource(s) that is rendered by default |
131
+ | `exclude=activities:[creator]` | keep the default `activities` association, but leave out each activity's `creator` |
132
+
133
+ To list only the ids of the resources, exclude every other field they render by default, such as
134
+ `exclude=title,genre,author`.
135
+
136
+
137
+ ## Example Project
138
+ A full working example project is provided in the [rails-surrender-demo](https://github.com/smmcbride/rails-surrender-demo)
139
+ repository. It provides several models and endpoints demonstrating a working configuration of `rails-surrender`. The
140
+ project provides seed data, a browser explorer where each query parameter is a checkbox and the response is shown as
141
+ you send it, and a `bin/http` command for trying each query parameter from the terminal.
142
+
143
+
144
+ ## Installation
145
+
146
+ This project is intended to be used in a Ruby on Rails project. To install, simply add this your Gemfile:
147
+
148
+ ```ruby
149
+ gem 'rails-surrender', '~> 8.0'
150
+ ```
151
+
152
+ and bundle:
153
+
154
+ ```sh
155
+ % bundle
156
+ ```
157
+
158
+ ## Setup
159
+ Surrender setup begins in your models. To enable rendering of a model with this gem, call `surrenders` on it with the
160
+ fields to render by default, and optionally `available:` with the fields API clients can add with the `include` query
161
+ parameter. A field can be any model attribute or method, a `*_ids` attribute such as `chapter_ids`, or an association
162
+ such as one defined with `has_many` or `belongs_to`. Surrender renders associations as nested resources, using the
163
+ associated model's own `surrenders` fields, and renders every other field as an attribute.
164
+
165
+ An example invocation of the `surrenders` method is provided below:
166
+ ```ruby
167
+ surrenders :id, :title, :description, :status, :activities,
168
+ available: [:created_at, :updated_at, :user]
169
+ ```
170
+
171
+ A model that doesn't call `surrenders` renders its `id` and `created_at`. A subclass that calls `surrenders` adds its
172
+ fields to those of its superclass, and its records render them wherever they appear, including in a collection of the
173
+ superclass.
174
+
175
+ Surrender can't expand a polymorphic `belongs_to`, because it works out the fields to render before any record loads,
176
+ when the association has no class yet. Listing one as a field raises `ArgumentError`.
177
+
178
+ To invoke surrender in your controllers, simply end your controller action with a call to `surrender` with the
179
+ resource you wish to render. An example controller action is provided below:
180
+
181
+ ```ruby
182
+ def index
183
+ books = Book.all
184
+ surrender books
185
+ end
186
+ ```
187
+
188
+
189
+ ### Configuration
190
+
191
+ Surrender's page sizes are set in `config/application.rb`, or per environment in `config/environments/*.rb`:
192
+
193
+ ```ruby
194
+ config.surrender.default_per_page = 50 # the page size when a request doesn't set `per`
195
+ config.surrender.max_per_page = 100 # the most a page holds, whatever `per` asks for; nil removes the limit
196
+ ```
197
+
198
+ The app won't boot if the default is larger than the limit, or either isn't a positive whole number. Kaminari's own
199
+ page size settings don't apply to surrender's pages.
200
+
201
+ ### Describing what a request may ask
202
+
203
+ `surrender_menu` lists everything a request may ask of a model, read from the same rules that check requests, for
204
+ documentation or an API explorer like the demo's:
205
+
206
+ ```ruby
207
+ menu = Book.surrender_menu
208
+ menu.fields # each with its name, kind (:attribute, :id_list or :expand), default?, and an expand's own menu
209
+ menu.filters # each with its name and the column it compares, or nil for a filter the model defines
210
+ menu.sorts # ['id', 'title', ..., 'author.name', 'title_length']
211
+ menu.page_size.default, menu.page_size.max # 50 and 100 unless configured
212
+ ```
213
+
214
+ ### Additional Notes
215
+
216
+ Surrender prevents circular renderings. If a `Book` model, for example, expands its `Author`, and `Author` expands its
217
+ `books`, then surrender leaves out the `books` of an author rendered inside a book. It never expands an association
218
+ into a model already rendered above it, and it treats an STI subclass as its base class when checking.
219
+
220
+ Surrender prevents un-authorized access to associated resources by invoking the `current_ability` rule-set from the
221
+ CanCanCan gem, if it is used in your Rails project. Any controller method named `current_ability`, public or private,
222
+ that returns an object responding to `can?(:read, record)` is used the same way. If neither is present it will allow
223
+ full access to associated resources.
224
+
225
+ ## Development
226
+
227
+ The test suite boots a small Rails app (`spec/dummy`) against an in-memory SQLite database. Run it against the
228
+ latest Rails with:
229
+
230
+ ```sh
231
+ bundle install
232
+ bundle exec rake
233
+ ```
234
+
235
+ `rake` runs both the specs and RuboCop. To run the specs against each supported Rails version:
236
+
237
+ ```sh
238
+ bundle exec appraisal install
239
+ bundle exec appraisal rspec
240
+ ```
@@ -6,7 +6,10 @@ en:
6
6
  incorrect_format: "The %{param} parameter was improperly formatted."
7
7
  filter:
8
8
  not_available: "%{param} is not a valid filter parameter."
9
+ invalid_time: "%{param} needs an ISO 8601 date or time, such as '2026-01-01' or '2026-01-01T13:30:00Z', not '%{value}'."
9
10
  include:
10
11
  not_available: "%{param} is not a valid include parameter."
12
+ pagination:
13
+ invalid: "%{param} needs a positive whole number, not '%{value}'."
11
14
  sort:
12
15
  invalid_column: "%{param} is not a valid sort parameter."
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'helpers/query_param_parser'
4
- require_relative 'helpers/filter_builder'
5
- require_relative 'helpers/pagination_builder'
6
- require_relative 'helpers/sort_builder'
7
-
8
3
  module Rails
9
4
  module Surrender
10
5
  # Additions to the Rails ActionController to allow surrender's rendering.
@@ -14,36 +9,17 @@ module Rails
14
9
  super
15
10
  end
16
11
 
17
- def surrender(resource, status: 200, reload: true, include: [], exclude: [])
18
- resource = filter resource if parsed_query_params.filter?
19
-
20
- if parsed_query_params.sort?
21
- resource = sort resource
22
- response.headers['X-Sort'] = parsed_query_params.sort.request
23
- end
24
-
25
- if paginate?(resource)
26
- resource = paginate resource
27
- response.headers['X-Pagination'] = pagination_headers(resource)
28
- end
12
+ def surrender(resource, status: 200, include: [], exclude: [])
13
+ selection = Selection.new(query_params, paginate: @will_paginate)
14
+ selected = selection.apply(resource)
15
+ response.headers.merge!(selected.headers)
29
16
 
30
- surrender_response = if parsed_query_params.count?
31
- Render::Count.new(resource).parse
32
- elsif parsed_query_params.ids?
33
- Render::Ids.new(resource).parse
17
+ surrender_response = if selection.count?
18
+ Response.new(data: { count: selected.count })
34
19
  else
35
- config = Render::Configuration.new(
36
- resource_class: resource_class(resource),
37
- reload_resource: reload,
38
- user_exclude: parsed_query_params.exclude,
39
- user_include: parsed_query_params.include,
40
- ctrl_exclude: exclude,
41
- ctrl_include: include
42
- )
43
-
44
- Render::Resource.new(resource: resource,
45
- ability: ability,
46
- config: config).parse
20
+ asks = { include: query_params[:include], exclude: query_params[:exclude],
21
+ controller_include: include, controller_exclude: exclude }
22
+ Render::Resource.new(resource: selected.records, ability: ability, asks: asks).parse
47
23
  end
48
24
 
49
25
  # Allows the calling method to decorate the response data before returning the result
@@ -55,54 +31,18 @@ module Rails
55
31
  private
56
32
 
57
33
  def ability
58
- return current_ability if respond_to?(:current_ability)
34
+ # Apps commonly override CanCanCan's current_ability as a private method.
35
+ return current_ability if respond_to?(:current_ability, true)
59
36
 
60
37
  DefaultAbility.new
61
38
  end
62
39
 
63
- def resource_class(resource)
64
- resource.try(:klass) || resource.class
65
- end
66
-
67
40
  def skip_pagination
68
41
  @will_paginate = false
69
42
  end
70
43
 
71
- def filter(resource)
72
- FilterBuilder.new(resource: resource, filter: parsed_query_params.filter).build!
73
- end
74
-
75
- def paginate?(resource)
76
- pagination(resource).paginatable? && @will_paginate
77
- end
78
-
79
- def paginate(resource)
80
- pagination(resource).build!
81
- end
82
-
83
- def pagination(resource)
84
- PaginationBuilder.new(resource: resource, pagination: parsed_query_params.pagination)
85
- end
86
-
87
- def pagination_headers(resource)
88
- {
89
- total: resource.total_count,
90
- page_total: resource.count,
91
- page: resource.current_page,
92
- previous_page: resource.prev_page,
93
- next_page: resource.next_page,
94
- last_page: resource.total_pages,
95
- per_page: parsed_query_params.pagination.per,
96
- offset: resource.offset_value
97
- }.to_json
98
- end
99
-
100
- def sort(resource)
101
- SortBuilder.new(resource: resource, sort: parsed_query_params.sort).build!
102
- end
103
-
104
- def parsed_query_params
105
- @parsed_query_params ||= QueryParamParser.new(request.query_parameters.symbolize_keys)
44
+ def query_params
45
+ request.query_parameters.symbolize_keys
106
46
  end
107
47
  end
108
48
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Fields
6
+ # What a request and a controller ask one level of the tree to include and exclude. Each list holds bare names,
7
+ # and hashes that carry the asks for the level below, such as { 'author' => ['name'] }.
8
+ Asks = Data.define(:request_include, :request_exclude, :controller_include, :controller_exclude) do
9
+ def initialize(request_include: [], request_exclude: [], controller_include: [], controller_exclude: [])
10
+ super(request_include: listed(request_include), request_exclude: listed(request_exclude),
11
+ controller_include: listed(controller_include), controller_exclude: listed(controller_exclude))
12
+ end
13
+
14
+ def request_includes
15
+ names_in(request_include)
16
+ end
17
+
18
+ def controller_includes
19
+ names_in(controller_include)
20
+ end
21
+
22
+ # A request's include wins over any exclude. `author:[name]` excludes the author's name, not the author, so
23
+ # only bare names exclude at this level.
24
+ def excludes?(name)
25
+ return false if request_includes.include?(name)
26
+
27
+ (request_exclude + controller_exclude).grep_v(Hash).map(&:to_sym).include?(name)
28
+ end
29
+
30
+ def below(name)
31
+ self.class.new(**to_h.transform_values { |list| asks_below(list)[name] })
32
+ end
33
+
34
+ private
35
+
36
+ def listed(list)
37
+ (list.is_a?(Array) ? list : [list]).compact
38
+ end
39
+
40
+ def names_in(list)
41
+ list.flat_map { |item| item.is_a?(Hash) ? item.keys : item }.map(&:to_sym).uniq
42
+ end
43
+
44
+ def asks_below(list)
45
+ list.grep(Hash).reduce({}, :merge).transform_keys(&:to_sym)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Fields
6
+ # A field rendered as the value its record returns for it.
7
+ Attribute = Data.define(:name) do
8
+ # Every record leads with its id, wherever the model or the asks list it.
9
+ def rank
10
+ name == :id ? 0 : 1
11
+ end
12
+
13
+ def kind
14
+ :attribute
15
+ end
16
+
17
+ def preload; end
18
+
19
+ def circular?(_above)
20
+ false
21
+ end
22
+
23
+ def branch(**); end
24
+
25
+ def menu(**); end
26
+
27
+ def write(record, into:, **)
28
+ into[name] = record.public_send(name)
29
+ end
30
+
31
+ def describe(_branch)
32
+ :attribute
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Fields
6
+ # A field that names an association and renders as the nested resource, with the fields of its branch.
7
+ Expand = Data.define(:name, :reflection) do
8
+ def rank
9
+ 3
10
+ end
11
+
12
+ def kind
13
+ :expand
14
+ end
15
+
16
+ def preload
17
+ name
18
+ end
19
+
20
+ # Surrender never renders a model inside itself, and STI subclasses count as their base class.
21
+ def circular?(above)
22
+ target.base_class.in?(above)
23
+ end
24
+
25
+ def branch(asks:, above:)
26
+ Node.new(target, asks.below(name), above + [target.base_class])
27
+ end
28
+
29
+ def menu(above:)
30
+ Menu.new(target, above: above + [target.base_class])
31
+ end
32
+
33
+ # The record at the top is never checked against the ability, only the records nested in it.
34
+ def write(record, into:, ability:, branch:)
35
+ value = record.public_send(name)
36
+
37
+ if reflection.collection?
38
+ into[name] = value.select { |nested| ability.can?(:read, nested) }
39
+ .map { |nested| branch.for(nested.class).render(nested, ability) }
40
+ elsif value.nil?
41
+ into[name] = nil
42
+ elsif ability.can?(:read, value)
43
+ into[name] = branch.for(value.class).render(value, ability)
44
+ end
45
+ end
46
+
47
+ def describe(branch)
48
+ branch.to_h
49
+ end
50
+
51
+ private
52
+
53
+ # A polymorphic association has no class until a record loads, but the tree is worked out before any do.
54
+ def target
55
+ if reflection.polymorphic?
56
+ raise ArgumentError,
57
+ "surrender can't expand the polymorphic association #{reflection.active_record}##{name}"
58
+ end
59
+
60
+ reflection.klass
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../params/list_param'
4
+ require_relative 'node'
5
+
6
+ module Rails
7
+ module Surrender
8
+ # The fields one request renders, nested by expand, worked out from the model and the asks of the request and the
9
+ # controller before any record loads. Preloading and rendering both read it, so they always agree.
10
+ class FieldTree
11
+ # `include:` and `exclude:` are the request's asks, as the query string gives them: `'books:[pages], author'`.
12
+ # A controller's asks are lists: `[:pages, { author: [:name] }]`.
13
+ def initialize(model, include: '', exclude: '', controller_include: [], controller_exclude: [])
14
+ asks = Fields::Asks.new(request_include: Params::ListParam.parse(include, param: :include),
15
+ request_exclude: Params::ListParam.parse(exclude, param: :exclude),
16
+ controller_include: controller_include, controller_exclude: controller_exclude)
17
+ @root = Fields::Node.new(model, asks, [model.base_class])
18
+ end
19
+
20
+ # Renders a record as a hash, or a relation as an array of them.
21
+ def render(resource, ability:)
22
+ records = resource.is_a?(ActiveRecord::Relation) ? resource.to_a : [resource]
23
+ preload(records)
24
+ rendered = records.map { |record| @root.for(record.class).render(record, ability) }
25
+ resource.is_a?(ActiveRecord::Relation) ? rendered : rendered.first
26
+ end
27
+
28
+ def to_h
29
+ @root.to_h
30
+ end
31
+
32
+ private
33
+
34
+ # Level by level, each association loads once for every record that renders it, whatever the record's class.
35
+ # STI subclasses then share a query for an association they inherit, and one only a subclass renders loads only
36
+ # for that subclass's records, which a nested `includes` on the parent's relation can't name.
37
+ def preload(records)
38
+ level = { @root => records }
39
+ until level.empty?
40
+ variants = variants_in(level)
41
+ load_associations(variants)
42
+ level = level_below(variants)
43
+ end
44
+ end
45
+
46
+ def variants_in(level)
47
+ level.flat_map do |node, records|
48
+ records.group_by(&:class).map { |klass, group| [node.for(klass), group] }
49
+ end
50
+ end
51
+
52
+ def load_associations(variants)
53
+ loading = Hash.new { |by_association, association| by_association[association] = [] }
54
+ variants.each { |node, records| node.preloads.each { |association| loading[association].concat(records) } }
55
+ loading.each do |association, records|
56
+ ActiveRecord::Associations::Preloader.new(records: records, associations: association).call
57
+ end
58
+ end
59
+
60
+ def level_below(variants)
61
+ level = Hash.new { |records_by_node, node| records_by_node[node] = [] }
62
+ variants.each do |node, records|
63
+ node.branches.each do |name, branch|
64
+ level[branch].concat(records.flat_map { |record| Array(record.public_send(name)) })
65
+ end
66
+ end
67
+ level.transform_values { |records| records.compact.uniq }.reject { |_, records| records.empty? }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Fields
6
+ # A field that names an association by its ids, such as chapter_ids, and renders them as a flat list.
7
+ IdList = Data.define(:name, :association) do
8
+ def rank
9
+ 2
10
+ end
11
+
12
+ def kind
13
+ :id_list
14
+ end
15
+
16
+ def preload
17
+ association
18
+ end
19
+
20
+ def circular?(_above)
21
+ false
22
+ end
23
+
24
+ def branch(**); end
25
+
26
+ def menu(**); end
27
+
28
+ def write(record, into:, **)
29
+ into[name] = record.public_send(name)
30
+ end
31
+
32
+ def describe(_branch)
33
+ :id_list
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end