rails-surrender 7.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +103 -0
- data/LICENSE +674 -0
- data/README.md +240 -0
- data/lib/rails/surrender/config/locales/en.yml +3 -0
- data/lib/rails/surrender/controller_additions.rb +13 -73
- data/lib/rails/surrender/fields/asks.rb +50 -0
- data/lib/rails/surrender/fields/attribute.rb +37 -0
- data/lib/rails/surrender/fields/expand.rb +65 -0
- data/lib/rails/surrender/fields/field_tree.rb +71 -0
- data/lib/rails/surrender/fields/id_list.rb +38 -0
- data/lib/rails/surrender/fields/node.rb +86 -0
- data/lib/rails/surrender/menu/field.rb +15 -0
- data/lib/rails/surrender/menu/filter.rb +11 -0
- data/lib/rails/surrender/menu/menu.rb +55 -0
- data/lib/rails/surrender/model_additions.rb +47 -32
- data/lib/rails/surrender/params/list_param.rb +23 -0
- data/lib/rails/surrender/railtie.rb +23 -9
- data/lib/rails/surrender/render/resource.rb +11 -30
- data/lib/rails/surrender/selection/filtering.rb +144 -0
- data/lib/rails/surrender/selection/page_size.rb +33 -0
- data/lib/rails/surrender/selection/selected.rb +50 -0
- data/lib/rails/surrender/selection/selection.rb +70 -0
- data/lib/rails/surrender/selection/sorting.rb +61 -0
- data/lib/rails/surrender/version.rb +1 -1
- data/lib/rails/surrender.rb +13 -12
- metadata +65 -41
- data/lib/rails/surrender/config/initializers/active_record_associations_patch.rb +0 -17
- data/lib/rails/surrender/config/initializers/active_record_preloader_patch.rb +0 -20
- data/lib/rails/surrender/helpers/filter_builder.rb +0 -50
- data/lib/rails/surrender/helpers/pagination_builder.rb +0 -25
- data/lib/rails/surrender/helpers/query_param_parser.rb +0 -98
- data/lib/rails/surrender/helpers/sort_builder.rb +0 -56
- data/lib/rails/surrender/model_filter_scopes.rb +0 -56
- data/lib/rails/surrender/render/configuration/inclusion_mapper_logic.rb +0 -69
- data/lib/rails/surrender/render/configuration/instance_logic.rb +0 -88
- data/lib/rails/surrender/render/configuration.rb +0 -93
- data/lib/rails/surrender/render/count.rb +0 -21
- data/lib/rails/surrender/render/ids.rb +0 -21
- data/lib/rails/surrender/render/resource/collection.rb +0 -26
- data/lib/rails/surrender/render/resource/inclusion_mapper.rb +0 -48
- data/lib/rails/surrender/render/resource/instance.rb +0 -75
- data/test/surrender_test.rb +0 -9
- 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
|
-
#
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
34
|
-
@
|
|
28
|
+
def surrender_available_fields
|
|
29
|
+
@surrender_available_fields || []
|
|
35
30
|
end
|
|
36
31
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
54
|
-
|
|
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.
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
27
|
+
initializer 'rails_surrender.active_record' do
|
|
28
|
+
ActiveSupport.on_load(:active_record) do
|
|
16
29
|
include Rails::Surrender::ModelAdditions
|
|
17
|
-
include Rails::Surrender::
|
|
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
|
-
#
|
|
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, :
|
|
9
|
+
attr_reader :resource, :ability, :asks
|
|
13
10
|
|
|
14
|
-
|
|
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
|
-
@
|
|
15
|
+
@asks = asks
|
|
18
16
|
end
|
|
19
17
|
|
|
20
18
|
def parse
|
|
21
19
|
data = case resource
|
|
22
|
-
when nil
|
|
23
|
-
when Hash
|
|
24
|
-
|
|
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
|
|
33
|
-
|
|
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
|