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
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'asks'
4
+ require_relative 'attribute'
5
+ require_relative 'expand'
6
+ require_relative 'id_list'
7
+
8
+ module Rails
9
+ module Surrender
10
+ module Fields
11
+ # The fields one model renders at one place in the field tree, with a branch below each expand.
12
+ class Node
13
+ attr_reader :branches
14
+
15
+ # `above` holds the base class of every model from the top of the tree down to this one.
16
+ def initialize(model, asks, above)
17
+ @model = model
18
+ @asks = asks
19
+ @above = above
20
+ reject_unavailable_request_includes
21
+ @fields = ordered(rendered_fields)
22
+ @branches = @fields.to_h { |field| [field.name, field.branch(asks: asks, above: above)] }.compact
23
+ @variants = { model => self }
24
+ end
25
+
26
+ # An STI subclass renders its own fields. Its variant is built when one of its records first appears,
27
+ # because subclasses aren't known before they load in development.
28
+ def for(klass)
29
+ @variants[klass] ||= Node.new(klass, @asks, @above)
30
+ end
31
+
32
+ def preloads
33
+ @fields.filter_map(&:preload)
34
+ end
35
+
36
+ def render(record, ability)
37
+ @fields.each_with_object({}) do |field, rendered|
38
+ field.write(record, into: rendered, ability: ability, branch: @branches[field.name])
39
+ end
40
+ end
41
+
42
+ def to_h
43
+ @fields.to_h { |field| [field.name, field.describe(@branches[field.name])] }
44
+ end
45
+
46
+ # Nodes are equal when they render the same fields, so a level loads each association once for all of them.
47
+ def eql?(other)
48
+ other.is_a?(Node) && identity == other.identity
49
+ end
50
+
51
+ alias == eql?
52
+
53
+ def hash
54
+ identity.hash
55
+ end
56
+
57
+ protected
58
+
59
+ def identity
60
+ [@model, @asks, @above]
61
+ end
62
+
63
+ private
64
+
65
+ def reject_unavailable_request_includes
66
+ unavailable = @asks.request_includes.reject { |name| @model.surrenders?(name) }
67
+ return if unavailable.empty?
68
+
69
+ raise Error, I18n.t('surrender.error.query_string.include.not_available', param: unavailable)
70
+ end
71
+
72
+ def rendered_fields
73
+ (@model.surrender_fields + @asks.controller_includes + @asks.request_includes)
74
+ .uniq
75
+ .reject { |name| @asks.excludes?(name) }
76
+ .map { |name| @model.surrender_field(name) }
77
+ .reject { |field| field.circular?(@above) }
78
+ end
79
+
80
+ def ordered(fields)
81
+ fields.each_with_index.sort_by { |field, position| [field.rank, position] }.map(&:first)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ class Menu
6
+ # A field a request may ask a model for: its kind (:attribute, :id_list or :expand), whether it renders by
7
+ # default, and, for an expand, the menu of the model it leads to.
8
+ Field = Data.define(:name, :kind, :default, :menu) do
9
+ def default?
10
+ default
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ class Menu
6
+ # A filter a request may name, and the column it compares, or nil for a filter the model defines under a name of
7
+ # its own.
8
+ Filter = Data.define(:name, :column)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'field'
4
+ require_relative 'filter'
5
+
6
+ module Rails
7
+ module Surrender
8
+ # Everything a request may ask of one model: its default and available fields, nested by expand, its filters and
9
+ # sorts, and the size of its pages. It is read from the same rules that check requests, so it lists exactly what a
10
+ # request may ask.
11
+ class Menu
12
+ # `above` holds the base class of every model from the top of the menu down to this one.
13
+ def initialize(model, above: [model.base_class])
14
+ @model = model
15
+ @above = above
16
+ end
17
+
18
+ # In the order fields render, with defaults before available fields within each kind. Expands back into a model
19
+ # above are left out, as they never render.
20
+ def fields
21
+ @fields ||= offered_fields.map do |field|
22
+ Field.new(name: field.name, kind: field.kind, default: default?(field), menu: field.menu(above: @above))
23
+ end
24
+ end
25
+
26
+ def filters
27
+ @model.surrender_filter_columns.map { |name, column| Filter.new(name: name, column: column) }
28
+ end
29
+
30
+ def sorts
31
+ @model.surrender_sort_names
32
+ end
33
+
34
+ def page_size
35
+ Surrender.page_size
36
+ end
37
+
38
+ private
39
+
40
+ def offered_fields
41
+ (@model.surrender_fields + @model.surrender_available_fields)
42
+ .uniq
43
+ .map { |name| @model.surrender_field(name) }
44
+ .reject { |field| field.circular?(@above) }
45
+ .each_with_index
46
+ .sort_by { |field, position| [field.rank, default?(field) ? 0 : 1, position] }
47
+ .map(&:first)
48
+ end
49
+
50
+ def default?(field)
51
+ @model.surrender_fields.include?(field.name)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -2,56 +2,71 @@
2
2
 
3
3
  module Rails
4
4
  module Surrender
5
- # Aad methods to the model class to describe how the model renders.
5
+ # Adds methods to the model class to describe how the model renders.
6
6
  module ModelAdditions
7
7
  def self.included(base)
8
- attr_accessor :surrender_attributes
9
- attr_accessor :surrender_expands
10
- attr_accessor :surrender_available_attributes
11
- attr_accessor :surrender_available_expands
12
-
13
8
  base.extend(ClassMethods)
14
9
  end
15
10
 
11
+ # A model lists the fields it renders by default, and those a request may add with `include`. Fields that name
12
+ # an association expand into the associated resource; every other field is rendered as an attribute.
16
13
  module ClassMethods
17
- def surrenders(*args)
18
- directives = args.extract_options!
19
-
20
- # Run through the various lists of attributes and assign them to the rendering context
21
- # If the superclass has attributes then consume those as well.
22
- %w[attributes expands available_attributes available_expands].each do |directive|
23
- surrender_directive = "surrender_#{directive}"
24
- list = []
25
- if superclass.instance_variable_defined?("@#{surrender_directive}")
26
- list << superclass.instance_variable_get("@#{surrender_directive}")
27
- end
28
- list << directives[directive.to_sym] if directives.key?(directive.to_sym)
29
- instance_variable_set("@#{surrender_directive}", list.flatten.uniq)
30
- end
14
+ DEFAULT_FIELDS = %i[id created_at].freeze
15
+
16
+ # Fields are sorted into attributes and associations when read, not here, because models often call
17
+ # `surrenders` before declaring the associations it names.
18
+ def surrenders(*fields, available: [])
19
+ @surrender_fields = (inherited_surrender(:@surrender_fields) + fields.map(&:to_sym)).uniq
20
+ @surrender_available_fields =
21
+ (inherited_surrender(:@surrender_available_fields) + Array(available).map(&:to_sym)).uniq
22
+ end
23
+
24
+ def surrender_fields
25
+ @surrender_fields || DEFAULT_FIELDS
31
26
  end
32
27
 
33
- def surrender_attributes
34
- @surrender_attributes ||= %i[id created_at]
28
+ def surrender_available_fields
29
+ @surrender_available_fields || []
35
30
  end
36
31
 
37
- def surrender_available_attributes
38
- @surrender_available_attributes ||= []
32
+ # Everything a request may ask of the model: its fields, filters and sorts.
33
+ def surrender_menu
34
+ Menu.new(self)
39
35
  end
40
36
 
41
- def surrender_expands
42
- @surrender_expands ||= []
37
+ # Whether a request may include the field: it renders by default or is available.
38
+ def surrenders?(name)
39
+ surrender_callable_fields.include?(name.to_sym)
43
40
  end
44
41
 
45
- def surrender_available_expands
46
- @surrender_available_expands ||= []
42
+ # An association expands, a *_ids name of an association is an id list, and anything else is an attribute.
43
+ def surrender_field(name)
44
+ name = name.to_sym
45
+ if (reflection = reflect_on_association(name))
46
+ Fields::Expand.new(name: name, reflection: reflection)
47
+ elsif (association = id_list_association(name))
48
+ Fields::IdList.new(name: name, association: association)
49
+ else
50
+ Fields::Attribute.new(name: name)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def id_list_association(name)
57
+ return unless name.end_with?('_ids')
58
+
59
+ association = name.to_s.delete_suffix('_ids').pluralize.to_sym
60
+ association if reflect_on_association(association)
47
61
  end
48
62
 
49
- def surrender_callable_attributes
50
- @surrender_callable_attributes ||= (surrender_attributes + surrender_available_attributes).flatten
63
+ # A subclass may make a default of a field its superclass only made available.
64
+ def surrender_callable_fields
65
+ (surrender_fields + surrender_available_fields).uniq
51
66
  end
52
67
 
53
- def surrender_callable_expands
54
- @surrender_callable_expands ||= (surrender_expands + surrender_available_expands).flatten
68
+ def inherited_surrender(variable)
69
+ superclass.instance_variable_defined?(variable) ? superclass.instance_variable_get(variable) : []
55
70
  end
56
71
  end
57
72
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ module Params
6
+ # A query param that lists names, names with values, and nested lists, such as `genre:fantasy, author:[name]`.
7
+ # It is read as the YAML of a flow sequence, so values come back typed: `long:true` holds true, not 'true'.
8
+ class ListParam
9
+ # YAML needs a space after each comma and colon that separates entries, but not inside quoted values, where it
10
+ # would change the value: '13:30' must stay '13:30'.
11
+ SEPARATORS_OUTSIDE_QUOTES = /'[^']*'|"[^"]*"|[,:]/
12
+
13
+ # `param` names the query param in the error a malformed value raises.
14
+ def self.parse(text, param:)
15
+ spaced = text.to_s.gsub(SEPARATORS_OUTSIDE_QUOTES) { |match| match.length == 1 ? "#{match} " : match }
16
+ Psych.safe_load("[#{spaced}]")
17
+ rescue Psych::Exception
18
+ raise Error, I18n.t('surrender.error.query_string.incorrect_format', param: param)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -2,19 +2,33 @@
2
2
 
3
3
  module Rails
4
4
  module Surrender
5
+ # Hooks surrender into Rails as each framework loads. Using load hooks rather than after_initialize matters
6
+ # in eager loaded environments, where models call `surrenders` before after_initialize runs.
5
7
  class Railtie < ::Rails::Railtie
6
- config.after_initialize do
7
- ActionController::Base.class_eval do
8
- include Rails::Surrender::ControllerAdditions
9
- end
8
+ config.surrender = ActiveSupport::OrderedOptions.new
9
+ config.surrender.default_per_page = 50
10
+ config.surrender.max_per_page = 100
10
11
 
11
- ActionController::API.class_eval do
12
- include Rails::Surrender::ControllerAdditions
13
- end
12
+ # Built once at boot, so a page size setting that makes no sense stops the app from starting.
13
+ initializer 'rails_surrender.page_size' do |app|
14
+ Rails::Surrender.page_size = Rails::Surrender::Selection::PageSize.new(
15
+ default: app.config.surrender.default_per_page, max: app.config.surrender.max_per_page
16
+ )
17
+ end
18
+
19
+ initializer 'rails_surrender.locales' do |app|
20
+ app.config.i18n.load_path += Dir[File.expand_path('config/locales/*.yml', __dir__)]
21
+ end
22
+
23
+ initializer 'rails_surrender.action_controller' do
24
+ ActiveSupport.on_load(:action_controller) { include Rails::Surrender::ControllerAdditions }
25
+ end
14
26
 
15
- ActiveRecord::Base.class_eval do
27
+ initializer 'rails_surrender.active_record' do
28
+ ActiveSupport.on_load(:active_record) do
16
29
  include Rails::Surrender::ModelAdditions
17
- include Rails::Surrender::ModelFilterScopes
30
+ include Rails::Surrender::Selection::Filtering
31
+ include Rails::Surrender::Selection::Sorting
18
32
  end
19
33
  end
20
34
  end
@@ -1,52 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'resource/inclusion_mapper'
4
- require_relative 'resource/collection'
5
- require_relative 'resource/instance'
6
-
7
3
  module Rails
8
4
  module Surrender
9
5
  module Render
10
- # Rendering a resource, and it's various nested components, according to the given config params.
6
+ # Renders what a controller surrenders. Records and relations render through a field tree; nil, hashes and
7
+ # arrays have no model to shape them, so they render as given.
11
8
  class Resource
12
- attr_reader :resource, :ability, :config
9
+ attr_reader :resource, :ability, :asks
13
10
 
14
- def initialize(resource:, ability:, config:)
11
+ # `asks` holds the keyword arguments of FieldTree.new other than the model.
12
+ def initialize(resource:, ability:, asks: {})
15
13
  @resource = resource
16
14
  @ability = ability
17
- @config = config
15
+ @asks = asks
18
16
  end
19
17
 
20
18
  def parse
21
19
  data = case resource
22
- when nil? then {}
23
- when Hash || Array then resource
24
- when ActiveRecord::Relation then collection_data
25
- else instance_data
20
+ when nil then {}
21
+ when Hash, Array then resource
22
+ else FieldTree.new(model, **asks).render(resource, ability: ability)
26
23
  end
27
24
  Response.new(data: data)
28
25
  end
29
26
 
30
27
  private
31
28
 
32
- def collection_data
33
- includes = InclusionMapper.new(resource_class: resource.klass, config: config).parse
34
- data = @resource.includes(includes)
35
- Collection.new(resource: data, config: config, ability: ability).render
36
- end
37
-
38
- def instance_data
39
- # Reloading the instance here allows us to take advantage of the eager loading
40
- # capabilities of ActiveRecord with our 'includes' hash to prevent N+1 queries.
41
- # This can save a TON of response time when the data sets begin to get large.
42
- data = if config.reload_resource?
43
- includes = InclusionMapper.new(resource_class: resource.class, config: config).parse
44
- @resource = resource.class.includes(includes).find_by_id(resource.id)
45
- else
46
- resource
47
- end
48
-
49
- Instance.new(resource: data, config: config, ability: ability).render
29
+ def model
30
+ resource.try(:klass) || resource.class
50
31
  end
51
32
  end
52
33
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ class Selection
6
+ # Adds a filter_by_<column> scope for every column, and filter_by_<name>_before/after scopes for date and
7
+ # time columns, where <name> drops any _at suffix. The before/after scopes take a Time or Date, or an ISO 8601
8
+ # string, and raise Rails::Surrender::Error for anything else.
9
+ #
10
+ # The scopes are defined the first time any filter_by_ method is looked up, rather than when the class is
11
+ # defined, so that loading a model never touches the database.
12
+ module Filtering
13
+ extend ActiveSupport::Concern
14
+
15
+ FILTER_PREFIX = 'filter_by_'
16
+
17
+ # Serializes first-time scope definition across threads, so a concurrent lookup waits for the scopes rather
18
+ # than reporting them missing. It is shared by all models because scopes are defined once per model.
19
+ SCOPES_LOCK = Monitor.new
20
+
21
+ # Applies a request's filters, and generates the filter scopes on first lookup.
22
+ module ClassMethods
23
+ # Applies the filter_by_ scope a request's filter names, trying an association's foreign key (`author` for
24
+ # filter_by_author_id) when the name alone has no scope. Only filter_by_ scopes are reachable this way.
25
+ def surrender_filter(name, value)
26
+ scope = surrender_filter_scopes_for(name).find { |candidate| respond_to?(candidate) }
27
+ raise Error, I18n.t('surrender.error.query_string.filter.not_available', param: name) unless scope
28
+
29
+ public_send(scope, value)
30
+ end
31
+
32
+ # The filters a request may name, each with the column it compares, or nil for a filter the model defines
33
+ # under a name of its own.
34
+ def surrender_filter_columns
35
+ generated = surrender_filterable? ? surrender_generated_filters : {}
36
+ defined = methods.grep(/\A#{FILTER_PREFIX}/o).map { |scope| scope.to_s.delete_prefix(FILTER_PREFIX) }.sort
37
+ generated.merge((defined - generated.keys).index_with(nil))
38
+ end
39
+
40
+ def respond_to_missing?(name, include_private = false)
41
+ return super unless surrender_filter_name?(name) && surrender_filter_scopes_pending?
42
+
43
+ define_surrender_filter_scopes
44
+ surrender_filter_scopes_defined? ? respond_to?(name, include_private) : super
45
+ end
46
+
47
+ def method_missing(name, ...)
48
+ return super unless surrender_filter_name?(name) && surrender_filter_scopes_pending?
49
+
50
+ define_surrender_filter_scopes
51
+ surrender_filter_scopes_defined? ? public_send(name, ...) : super
52
+ end
53
+
54
+ private
55
+
56
+ def surrender_filter_scopes_for(name)
57
+ scope = :"#{FILTER_PREFIX}#{name.to_s.tr('.', '_')}"
58
+ [scope, :"#{scope}_id"]
59
+ end
60
+
61
+ def surrender_filter_name?(name)
62
+ name.start_with?(FILTER_PREFIX)
63
+ end
64
+
65
+ def surrender_filter_scopes_pending?
66
+ !surrender_filter_scopes_defined? && surrender_filterable?
67
+ end
68
+
69
+ def surrender_filter_scopes_defined?
70
+ @surrender_filter_scopes_defined == true
71
+ end
72
+
73
+ # A lookup made while this thread is already defining the scopes returns without them, rather than
74
+ # recursing. The flag is set only once every scope exists, so a failed attempt is retried next lookup.
75
+ def define_surrender_filter_scopes
76
+ SCOPES_LOCK.synchronize do
77
+ return if surrender_filter_scopes_defined? || @surrender_filter_scopes_defining
78
+
79
+ begin
80
+ @surrender_filter_scopes_defining = true
81
+ define_surrender_generated_scopes
82
+ @surrender_filter_scopes_defined = true
83
+ ensure
84
+ @surrender_filter_scopes_defining = false
85
+ end
86
+ end
87
+ end
88
+
89
+ # ActiveRecord::Base itself is asked whether it responds to scope names while scopes are being defined.
90
+ def surrender_filterable?
91
+ self != ActiveRecord::Base && !abstract_class? && table_exists?
92
+ end
93
+
94
+ # An equality filter for each column, and before and after filters for each date or time column, named
95
+ # without its _at suffix.
96
+ def surrender_generated_filters
97
+ columns.each_with_object({}) do |column, filters|
98
+ filters[column.name] = column
99
+ next unless column.type.in?(%i[date datetime])
100
+
101
+ stem = column.name.delete_suffix('_at')
102
+ filters["#{stem}_before"] = column
103
+ filters["#{stem}_after"] = column
104
+ end
105
+ end
106
+
107
+ def define_surrender_generated_scopes
108
+ surrender_generated_filters.each do |name, column|
109
+ define_surrender_filter_scope(name, surrender_filter_body(name, column.name))
110
+ end
111
+ end
112
+
113
+ # Scope bodies run against a relation, which can't call the model's private methods, so they close over it.
114
+ def surrender_filter_body(name, column)
115
+ return ->(value) { where(column => value) } if name == column
116
+
117
+ to_time = method(:surrender_filter_time)
118
+ attribute = arel_table[column]
119
+ comparison = name.end_with?('_before') ? :lt : :gt
120
+ ->(time) { where(attribute.public_send(comparison, to_time.call(name, time))) }
121
+ end
122
+
123
+ # Strict, so a value that is not a time is rejected rather than compared as NULL, which matches nothing.
124
+ # A time without an offset is in the app's time zone.
125
+ def surrender_filter_time(filter, value)
126
+ return value if value.is_a?(Date) || value.acts_like?(:time)
127
+
128
+ Time.zone.iso8601(value.to_s)
129
+ rescue ArgumentError
130
+ raise Error, I18n.t('surrender.error.query_string.filter.invalid_time', param: filter, value: value)
131
+ end
132
+
133
+ # Filters the application defines, on this model or a superclass, take precedence over generated ones.
134
+ def define_surrender_filter_scope(name, body)
135
+ scope_name = :"#{FILTER_PREFIX}#{name}"
136
+ return if singleton_class.method_defined?(scope_name) || singleton_class.private_method_defined?(scope_name)
137
+
138
+ scope scope_name, body
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ class Selection
6
+ # How many records one page holds: the size a request asks for, or the default when it asks for none, capped at
7
+ # the limit. A nil limit leaves pages uncapped.
8
+ PageSize = Data.define(:default, :max) do
9
+ def initialize(default: 50, max: 100)
10
+ check_positive_whole_number('default_per_page', default)
11
+ check_positive_whole_number('max_per_page', max) unless max.nil?
12
+ raise ArgumentError, "default_per_page (#{default}) is larger than max_per_page (#{max})" if max&.<(default)
13
+
14
+ super
15
+ end
16
+
17
+ def for(requested)
18
+ return default if requested.nil?
19
+
20
+ max ? [requested, max].min : requested
21
+ end
22
+
23
+ private
24
+
25
+ def check_positive_whole_number(setting, value)
26
+ return if value.is_a?(Integer) && value.positive?
27
+
28
+ raise ArgumentError, "#{setting} needs a positive whole number, not #{value.inspect}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Surrender
5
+ class Selection
6
+ # The records a selection picked, and the headers that describe how.
7
+ class Selected
8
+ attr_reader :records
9
+
10
+ # `unpaged` holds the records before paging, which a count response counts. `sort` is the sort as requested,
11
+ # and `per` the page size used, each nil when that step didn't apply.
12
+ def initialize(records:, unpaged: records, sort: nil, per: nil)
13
+ @records = records
14
+ @unpaged = unpaged
15
+ @sort = sort
16
+ @per = per
17
+ end
18
+
19
+ # A single record, or anything else that can't count itself, counts as one.
20
+ def count
21
+ @unpaged.respond_to?(:count) ? @unpaged.count : 1
22
+ end
23
+
24
+ def headers
25
+ headers = {}
26
+ headers['X-Sort'] = @sort if @sort
27
+ headers['X-Pagination'] = pagination.to_json if @per
28
+ headers
29
+ end
30
+
31
+ private
32
+
33
+ # The size of the page follows from the total, so it doesn't need a query of its own.
34
+ def pagination
35
+ total = records.total_count
36
+ {
37
+ total: total,
38
+ page_total: (total - records.offset_value).clamp(0, records.limit_value),
39
+ page: records.current_page,
40
+ previous_page: records.prev_page,
41
+ next_page: records.next_page,
42
+ last_page: records.total_pages,
43
+ per_page: @per,
44
+ offset: records.offset_value
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end