praxis 2.0.pre.3 → 2.0.pre.8
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.rspec +0 -1
- data/.ruby-version +1 -0
- data/CHANGELOG.md +26 -0
- data/Gemfile +1 -1
- data/Guardfile +2 -1
- data/Rakefile +1 -7
- data/TODO.md +28 -0
- data/lib/api_browser/package-lock.json +7110 -0
- data/lib/praxis.rb +7 -4
- data/lib/praxis/action_definition.rb +9 -16
- data/lib/praxis/api_general_info.rb +21 -0
- data/lib/praxis/application.rb +1 -2
- data/lib/praxis/bootloader_stages/routing.rb +2 -4
- data/lib/praxis/docs/generator.rb +11 -6
- data/lib/praxis/docs/open_api_generator.rb +255 -0
- data/lib/praxis/docs/openapi/info_object.rb +31 -0
- data/lib/praxis/docs/openapi/media_type_object.rb +59 -0
- data/lib/praxis/docs/openapi/operation_object.rb +40 -0
- data/lib/praxis/docs/openapi/parameter_object.rb +69 -0
- data/lib/praxis/docs/openapi/paths_object.rb +55 -0
- data/lib/praxis/docs/openapi/request_body_object.rb +51 -0
- data/lib/praxis/docs/openapi/response_object.rb +63 -0
- data/lib/praxis/docs/openapi/responses_object.rb +44 -0
- data/lib/praxis/docs/openapi/schema_object.rb +87 -0
- data/lib/praxis/docs/openapi/server_object.rb +24 -0
- data/lib/praxis/docs/openapi/tag_object.rb +21 -0
- data/lib/praxis/extensions/attribute_filtering.rb +2 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +148 -157
- data/lib/praxis/extensions/attribute_filtering/active_record_patches.rb +15 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_patches/5x.rb +90 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_0.rb +68 -0
- data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_1_plus.rb +58 -0
- data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +35 -0
- data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +13 -12
- data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +3 -2
- data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +7 -9
- data/lib/praxis/extensions/field_selection/field_selector.rb +4 -0
- data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +6 -9
- data/lib/praxis/extensions/pagination.rb +130 -0
- data/lib/praxis/extensions/pagination/active_record_pagination_handler.rb +42 -0
- data/lib/praxis/extensions/pagination/header_generator.rb +70 -0
- data/lib/praxis/extensions/pagination/ordering_params.rb +238 -0
- data/lib/praxis/extensions/pagination/pagination_handler.rb +68 -0
- data/lib/praxis/extensions/pagination/pagination_params.rb +378 -0
- data/lib/praxis/extensions/pagination/sequel_pagination_handler.rb +45 -0
- data/lib/praxis/handlers/json.rb +2 -0
- data/lib/praxis/handlers/www_form.rb +5 -0
- data/lib/praxis/handlers/{xml.rb → xml-sample.rb} +6 -0
- data/lib/praxis/links.rb +4 -0
- data/lib/praxis/mapper/active_model_compat.rb +23 -5
- data/lib/praxis/mapper/resource.rb +16 -9
- data/lib/praxis/mapper/selector_generator.rb +11 -10
- data/lib/praxis/mapper/sequel_compat.rb +1 -0
- data/lib/praxis/media_type.rb +1 -56
- data/lib/praxis/multipart/part.rb +5 -2
- data/lib/praxis/plugins/mapper_plugin.rb +1 -1
- data/lib/praxis/plugins/pagination_plugin.rb +71 -0
- data/lib/praxis/resource_definition.rb +4 -12
- data/lib/praxis/response_definition.rb +1 -1
- data/lib/praxis/route.rb +2 -4
- data/lib/praxis/routing_config.rb +4 -8
- data/lib/praxis/tasks/api_docs.rb +23 -0
- data/lib/praxis/tasks/routes.rb +10 -15
- data/lib/praxis/types/media_type_common.rb +10 -0
- data/lib/praxis/types/multipart_array.rb +62 -0
- data/lib/praxis/validation_handler.rb +1 -2
- data/lib/praxis/version.rb +1 -1
- data/praxis.gemspec +4 -5
- data/spec/functional_spec.rb +9 -6
- data/spec/praxis/action_definition_spec.rb +4 -16
- data/spec/praxis/api_general_info_spec.rb +6 -6
- data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +304 -0
- data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +39 -0
- data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +34 -0
- data/spec/praxis/extensions/field_expansion_spec.rb +6 -24
- data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +15 -11
- data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +4 -3
- data/spec/praxis/extensions/pagination/active_record_pagination_handler_spec.rb +130 -0
- data/spec/praxis/extensions/{field_selection/support → support}/spec_resources_active_model.rb +45 -2
- data/spec/praxis/extensions/{field_selection/support → support}/spec_resources_sequel.rb +0 -0
- data/spec/praxis/mapper/selector_generator_spec.rb +32 -0
- data/spec/praxis/media_type_spec.rb +5 -129
- data/spec/praxis/request_spec.rb +3 -22
- data/spec/praxis/resource_definition_spec.rb +1 -1
- data/spec/praxis/response_definition_spec.rb +8 -9
- data/spec/praxis/route_spec.rb +2 -9
- data/spec/praxis/routing_config_spec.rb +4 -13
- data/spec/praxis/types/multipart_array_spec.rb +4 -21
- data/spec/spec_app/config/environment.rb +0 -2
- data/spec/spec_app/design/api.rb +7 -1
- data/spec/spec_app/design/media_types/instance.rb +0 -8
- data/spec/spec_app/design/media_types/volume.rb +0 -12
- data/spec/spec_app/design/resources/instances.rb +1 -2
- data/spec/spec_helper.rb +6 -0
- data/spec/support/spec_media_types.rb +0 -73
- metadata +51 -49
- data/spec/praxis/handlers/xml_spec.rb +0 -177
- data/spec/praxis/links_spec.rb +0 -68
@@ -0,0 +1,68 @@
|
|
1
|
+
# FOR AR < 6.1
|
2
|
+
module ActiveRecord
|
3
|
+
PRAXIS_JOIN_ALIAS_PREFIX = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
|
4
|
+
class Relation
|
5
|
+
def construct_join_dependency(associations, join_type) # :nodoc:
|
6
|
+
# Praxis: inject references into the join dependency
|
7
|
+
ActiveRecord::Associations::JoinDependency.new(
|
8
|
+
klass, table, associations, join_type, references: references_values
|
9
|
+
)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module Associations
|
14
|
+
class JoinDependency
|
15
|
+
attr_accessor :references
|
16
|
+
|
17
|
+
private
|
18
|
+
def initialize(base, table, associations, join_type, references: nil)
|
19
|
+
tree = self.class.make_tree associations
|
20
|
+
@references = references # Save the references values into the instance (to use during build)
|
21
|
+
built = build(tree, base)
|
22
|
+
|
23
|
+
@join_root = JoinBase.new(base, table, built)
|
24
|
+
@join_type = join_type
|
25
|
+
end
|
26
|
+
|
27
|
+
# Praxis: table aliases for is shared for 5x and 6.0
|
28
|
+
def table_aliases_for(parent, node)
|
29
|
+
node.reflection.chain.map do |reflection|
|
30
|
+
is_root_reflection = reflection == node.reflection
|
31
|
+
table = alias_tracker.aliased_table_for(
|
32
|
+
reflection.table_name,
|
33
|
+
table_alias_for(reflection, parent, !is_root_reflection),
|
34
|
+
reflection.klass.type_caster
|
35
|
+
)
|
36
|
+
# through tables do not need a special alias_path alias (as they shouldn't really referenced by the client)
|
37
|
+
if is_root_reflection && node.alias_path
|
38
|
+
table = table.left if table.is_a?(Arel::Nodes::TableAlias) #un-alias it if necessary
|
39
|
+
table = table.alias(node.alias_path.join('/'))
|
40
|
+
end
|
41
|
+
table
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Praxis: build for is shared for 5x and 6.0
|
46
|
+
def build(associations, base_klass, path: [PRAXIS_JOIN_ALIAS_PREFIX])
|
47
|
+
associations.map do |name, right|
|
48
|
+
reflection = find_reflection base_klass, name
|
49
|
+
reflection.check_validity!
|
50
|
+
reflection.check_eager_loadable!
|
51
|
+
|
52
|
+
if reflection.polymorphic?
|
53
|
+
raise EagerLoadPolymorphicError.new(reflection)
|
54
|
+
end
|
55
|
+
# Praxis: set an alias_path in the JoinAssociation if its path matches a requested reference
|
56
|
+
child_path = (path && !path.empty?) ? path + [name] : nil
|
57
|
+
association = JoinAssociation.new(reflection, build(right, reflection.klass, path: child_path))
|
58
|
+
association.alias_path = child_path if references.include?(child_path.join('/'))
|
59
|
+
association
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
class ActiveRecord::Associations::JoinDependency::JoinAssociation
|
65
|
+
attr_accessor :alias_path
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# FOR AR >= 6.1
|
2
|
+
module ActiveRecord
|
3
|
+
PRAXIS_JOIN_ALIAS_PREFIX = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
|
4
|
+
module Associations
|
5
|
+
class JoinDependency
|
6
|
+
|
7
|
+
private
|
8
|
+
def make_constraints(parent, child, join_type)
|
9
|
+
foreign_table = parent.table
|
10
|
+
foreign_klass = parent.base_klass
|
11
|
+
child.join_constraints(foreign_table, foreign_klass, join_type, alias_tracker) do |reflection|
|
12
|
+
table, terminated = @joined_tables[reflection]
|
13
|
+
root = reflection == child.reflection
|
14
|
+
|
15
|
+
if table && (!root || !terminated)
|
16
|
+
@joined_tables[reflection] = [table, root] if root
|
17
|
+
next table, true
|
18
|
+
end
|
19
|
+
|
20
|
+
table_name = @references[reflection.name.to_sym]
|
21
|
+
# Praxis: set an alias_path in the JoinAssociation if its path matches a requested reference
|
22
|
+
table_name = @references[child&.alias_path.join('/').to_sym] unless table_name
|
23
|
+
|
24
|
+
table = alias_tracker.aliased_table_for(reflection.klass.arel_table, table_name) do
|
25
|
+
name = reflection.alias_candidate(parent.table_name)
|
26
|
+
root ? name : "#{name}_join"
|
27
|
+
end
|
28
|
+
|
29
|
+
@joined_tables[reflection] ||= [table, root] if join_type == Arel::Nodes::OuterJoin
|
30
|
+
table
|
31
|
+
end.concat child.children.flat_map { |c| make_constraints(child, c, join_type) }
|
32
|
+
end
|
33
|
+
|
34
|
+
# Praxis: build for is shared for 5x and 6.0
|
35
|
+
def build(associations, base_klass, path: [PRAXIS_JOIN_ALIAS_PREFIX])
|
36
|
+
associations.map do |name, right|
|
37
|
+
reflection = find_reflection base_klass, name
|
38
|
+
reflection.check_validity!
|
39
|
+
reflection.check_eager_loadable!
|
40
|
+
|
41
|
+
if reflection.polymorphic?
|
42
|
+
raise EagerLoadPolymorphicError.new(reflection)
|
43
|
+
end
|
44
|
+
# Praxis: set an alias_path in the JoinAssociation if its path matches a requested reference
|
45
|
+
child_path = (path && !path.empty?) ? path + [name] : nil
|
46
|
+
association = JoinAssociation.new(reflection, build(right, reflection.klass, path: child_path))
|
47
|
+
#association.alias_path = child_path if references.include?(child_path.join('/'))
|
48
|
+
association.alias_path = child_path # ??? should be the line above no?
|
49
|
+
association
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class ActiveRecord::Associations::JoinDependency::JoinAssociation
|
55
|
+
attr_accessor :alias_path
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Praxis
|
2
|
+
module Extensions
|
3
|
+
module AttributeFiltering
|
4
|
+
class FilterTreeNode
|
5
|
+
attr_reader :path, :conditions, :children
|
6
|
+
# # parsed_filters is an Array of {name: X, op: , value: } ... exactly the format of the FilteringParams.load method
|
7
|
+
def initialize(parsed_filters, path: [])
|
8
|
+
@path = path # Array that marks the tree 'path' to this node (with respect to the absolute root)
|
9
|
+
@conditions = [] # Conditions to apply directly to this node
|
10
|
+
@children = {} # Hash with a new NodeTree object value, keyed by name
|
11
|
+
children_data = {} # Hash with keys as names of the first level component of the children nodes (and values as array of matching filters)
|
12
|
+
parsed_filters.map do |hash|
|
13
|
+
*components = hash[:name].to_s.split('.')
|
14
|
+
if components.empty?
|
15
|
+
return
|
16
|
+
elsif components.size == 1
|
17
|
+
@conditions << hash.slice(:name, :op, :value)
|
18
|
+
else
|
19
|
+
children_data[components.first] ||= []
|
20
|
+
children_data[components.first] << hash
|
21
|
+
end
|
22
|
+
end
|
23
|
+
# An array of FilterTreeNodes corresponding to each children
|
24
|
+
@children = children_data.each_with_object({}) do |(name, arr), hash|
|
25
|
+
sub_filters = arr.map do |item|
|
26
|
+
_parent, *rest = item[:name].to_s.split('.')
|
27
|
+
item.merge(name: rest.join('.'))
|
28
|
+
end
|
29
|
+
hash[name] = self.class.new(sub_filters, path: path + [name] )
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -26,7 +26,6 @@ module Praxis
|
|
26
26
|
include Attributor::Type
|
27
27
|
include Attributor::Dumpable
|
28
28
|
|
29
|
-
# This DSL allows to define which attributes are allowed in the filters, and with which operators
|
30
29
|
class DSLCompiler < Attributor::DSLCompiler
|
31
30
|
# "account.id": { operators: ["=", "!="] },
|
32
31
|
# name: { operators: ["=", "!="], fuzzy_match: true },
|
@@ -38,8 +37,8 @@ module Praxis
|
|
38
37
|
end
|
39
38
|
|
40
39
|
VALUE_REGEX = /[^,&]*/
|
41
|
-
AVAILABLE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>']).freeze
|
42
|
-
FILTER_REGEX = /(?<attribute>([^=!><])+)(?<operator
|
40
|
+
AVAILABLE_OPERATORS = Set.new(['!=', '>=', '<=', '=', '<', '>','!','!!']).freeze
|
41
|
+
FILTER_REGEX = /(?<attribute>([^=!><])+)(?<operator>!=|>=|<=|!!|=|<|>|!)(?<value>#{VALUE_REGEX}(,#{VALUE_REGEX})*)/
|
43
42
|
|
44
43
|
# Abstract class, which needs to be used by subclassing it through the .for method, to set the allowed filters
|
45
44
|
# definition should be a hash, keyed by field name, which contains a hash that can have two pieces of metadata
|
@@ -62,6 +61,10 @@ module Praxis
|
|
62
61
|
end
|
63
62
|
end
|
64
63
|
|
64
|
+
def json_schema_type
|
65
|
+
:string
|
66
|
+
end
|
67
|
+
|
65
68
|
def add_filter(name, operators:, fuzzy:)
|
66
69
|
components = name.to_s.split('.').map(&:to_sym)
|
67
70
|
attribute, enclosing_type = find_filter_attribute(components, media_type)
|
@@ -176,7 +179,7 @@ module Praxis
|
|
176
179
|
else
|
177
180
|
value
|
178
181
|
end
|
179
|
-
arr.push(name: attr_name,
|
182
|
+
arr.push(name: attr_name, op: match[:operator], value: coerced )
|
180
183
|
end
|
181
184
|
new(parsed)
|
182
185
|
end
|
@@ -204,28 +207,27 @@ module Praxis
|
|
204
207
|
def validate(_context = Attributor::DEFAULT_ROOT_CONTEXT)
|
205
208
|
parsed_array.each_with_object([]) do |item, errors|
|
206
209
|
attr_name = item[:name]
|
207
|
-
specs = item[:specs]
|
208
210
|
attr_filters = allowed_filters[attr_name]
|
209
211
|
unless attr_filters
|
210
212
|
errors << "Filtering by #{attr_name} is not allowed. You can filter by #{allowed_filters.keys.map(&:to_s).join(', ')}"
|
211
213
|
next
|
212
214
|
end
|
213
215
|
allowed_operators = attr_filters[:operators]
|
214
|
-
unless allowed_operators.include?(
|
215
|
-
errors << "Operator #{
|
216
|
+
unless allowed_operators.include?(item[:op])
|
217
|
+
errors << "Operator #{item[:op]} not allowed for filter #{attr_name}"
|
216
218
|
end
|
217
219
|
value_type = attr_filters[:value_type]
|
218
|
-
value =
|
220
|
+
value = item[:value]
|
219
221
|
if value_type && !value_type.valid_type?(value)
|
220
222
|
# Allow a collection of values of the right type for multimatch (if operators are = or !=)
|
221
|
-
if ['=','!='].include?(
|
223
|
+
if ['=','!='].include?(item[:op])
|
222
224
|
coll_type = Attributor::Collection.of(value_type)
|
223
225
|
if !coll_type.valid_type?(value)
|
224
226
|
errors << "Invalid type in filter/s value for #{attr_name} " +\
|
225
227
|
"(one or more of the multiple matches in #{value} are not a #{value_type.name.split('::').last})"
|
226
228
|
end
|
227
229
|
else
|
228
|
-
errors << "Invalid type in filter value for #{attr_name} (#{value} using '#{
|
230
|
+
errors << "Invalid type in filter value for #{attr_name} (#{value} using '#{item[:op]}' is not a #{value_type.name.split('::').last})"
|
229
231
|
end
|
230
232
|
end
|
231
233
|
|
@@ -243,8 +245,7 @@ module Praxis
|
|
243
245
|
def dump
|
244
246
|
parsed_array.each_with_object([]) do |item, arr|
|
245
247
|
field = item[:name]
|
246
|
-
|
247
|
-
arr << "#{field}#{spec[:op]}#{spec[:value]}"
|
248
|
+
arr << "#{field}#{item[:op]}#{item[:value]}"
|
248
249
|
end.join('&')
|
249
250
|
end
|
250
251
|
|
@@ -34,7 +34,8 @@ module Praxis
|
|
34
34
|
|
35
35
|
# By default we'll simply use the incoming op and value, and will map
|
36
36
|
# the attribute based on what's on the `attr_to_column` hash
|
37
|
-
def
|
37
|
+
def generate(filters)
|
38
|
+
raise "Not refactored yet!"
|
38
39
|
seen_associations = Set.new
|
39
40
|
filters.each do |(attr, spec)|
|
40
41
|
column_name = attr_to_column[attr]
|
@@ -68,7 +69,7 @@ module Praxis
|
|
68
69
|
self.class.attr_to_column
|
69
70
|
end
|
70
71
|
|
71
|
-
# Private to try to funnel all column names through `
|
72
|
+
# Private to try to funnel all column names through `generate` that restricts
|
72
73
|
# the attribute names better (to allow more difficult SQL injections )
|
73
74
|
private def add_clause(attr:, op:, value:)
|
74
75
|
# TODO: partial matching
|
@@ -5,19 +5,20 @@ module Praxis
|
|
5
5
|
class ActiveRecordQuerySelector
|
6
6
|
attr_reader :selector, :query
|
7
7
|
# Gets a dataset, a selector...and should return a dataset with the selector definition applied.
|
8
|
-
def initialize(query:, selectors:)
|
8
|
+
def initialize(query:, selectors:, debug: false)
|
9
9
|
@selector = selectors
|
10
10
|
@query = query
|
11
|
+
@logger = debug ? Logger.new(STDOUT) : nil
|
11
12
|
end
|
12
13
|
|
13
|
-
def generate
|
14
|
+
def generate
|
14
15
|
# TODO: unfortunately, I think we can only control the select clauses for the top model
|
15
16
|
# (as I'm not sure ActiveRecord supports expressing it in the join...)
|
16
17
|
@query = add_select(query: query, selector_node: selector)
|
17
18
|
eager_hash = _eager(selector)
|
18
19
|
|
19
20
|
@query = @query.includes(eager_hash)
|
20
|
-
explain_query(query, eager_hash) if
|
21
|
+
explain_query(query, eager_hash) if @logger
|
21
22
|
|
22
23
|
@query
|
23
24
|
end
|
@@ -37,13 +38,10 @@ module Praxis
|
|
37
38
|
end
|
38
39
|
|
39
40
|
def explain_query(query, eager_hash)
|
40
|
-
|
41
|
-
|
42
|
-
ActiveRecord::Base.logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
|
43
|
-
ActiveRecord::Base.logger.debug(" ActiveRecord query: #{selector.resource.model}.includes(#{eager_hash})")
|
41
|
+
@logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
|
42
|
+
@logger.debug(" ActiveRecord query: #{selector.resource.model}.includes(#{eager_hash})")
|
44
43
|
query.explain
|
45
|
-
|
46
|
-
ActiveRecord::Base.logger = prev
|
44
|
+
@logger.debug("Query plan end")
|
47
45
|
end
|
48
46
|
end
|
49
47
|
end
|
@@ -8,19 +8,20 @@ module Praxis
|
|
8
8
|
class SequelQuerySelector
|
9
9
|
attr_reader :selector, :query
|
10
10
|
# Gets a dataset, a selector...and should return a dataset with the selector definition applied.
|
11
|
-
def initialize(query:, selectors:)
|
11
|
+
def initialize(query:, selectors:, debug: false)
|
12
12
|
@selector = selectors
|
13
13
|
@query = query
|
14
|
+
@logger = debug ? Logger.new(STDOUT) : nil
|
14
15
|
end
|
15
16
|
|
16
|
-
def generate
|
17
|
+
def generate
|
17
18
|
@query = add_select(query: query, selector_node: @selector)
|
18
19
|
|
19
20
|
@query = @selector.tracks.inject(@query) do |ds, (track_name, track_node)|
|
20
21
|
ds.eager(track_name => _eager(track_node) )
|
21
22
|
end
|
22
23
|
|
23
|
-
explain_query(query) if
|
24
|
+
explain_query(query) if @logger
|
24
25
|
@query
|
25
26
|
end
|
26
27
|
|
@@ -47,13 +48,9 @@ module Praxis
|
|
47
48
|
end
|
48
49
|
|
49
50
|
def explain_query(ds)
|
50
|
-
|
51
|
-
stdout_logger = Logger.new($stdout)
|
52
|
-
Sequel::Model.db.loggers = [stdout_logger]
|
53
|
-
stdout_logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
|
51
|
+
@logger.debug("Query plan for ...#{selector.resource.model} with selectors: #{JSON.generate(selector.dump)}")
|
54
52
|
ds.all
|
55
|
-
|
56
|
-
Sequel::Model.db.loggers = prev_loggers
|
53
|
+
@logger.debug("Query plan end")
|
57
54
|
end
|
58
55
|
end
|
59
56
|
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
begin
|
2
|
+
require 'link_header'
|
3
|
+
rescue LoadError
|
4
|
+
warn "Praxis::Pagination requires the 'link_header' gem, which can not be found. " \
|
5
|
+
"Please make sure it's in your Gemfile or installed in your system."
|
6
|
+
end
|
7
|
+
require 'praxis/extensions/pagination/pagination_params'
|
8
|
+
require 'praxis/extensions/pagination/ordering_params'
|
9
|
+
require 'praxis/extensions/pagination/pagination_handler'
|
10
|
+
require 'praxis/extensions/pagination/header_generator'
|
11
|
+
|
12
|
+
module Praxis
|
13
|
+
module Extensions
|
14
|
+
module Pagination
|
15
|
+
extend ActiveSupport::Concern
|
16
|
+
# This PaginatedController concern should be added to controllers that have actions that define the
|
17
|
+
# pagination and order parameters so that calling `paginate( query: <base_query>, table: <main_table_name> )`
|
18
|
+
# would handle all the required logic for paginating, ordering and generating the Link and TotalCount headers.
|
19
|
+
# This assumes that the query object are chainable and based on ActiveRecord at the moment (although that logic)
|
20
|
+
# can be easily applied to other chainable query proxies.
|
21
|
+
#
|
22
|
+
# Here's a simple example on how to use it for a fake Items controller
|
23
|
+
# class Items < V1::Controllers::BaseController
|
24
|
+
# include Praxis::Controller
|
25
|
+
# include Praxis::Extensions::Rendering
|
26
|
+
# implements V1::Endpoints::Items
|
27
|
+
#
|
28
|
+
# include Praxis::Extensions::Pagination
|
29
|
+
#
|
30
|
+
# def index(filters: nil, pagination: nil, order: nil, **_args)
|
31
|
+
# items = current_user.items.all
|
32
|
+
# items = handle_pagination( query: items)
|
33
|
+
#
|
34
|
+
# display(items)
|
35
|
+
# end
|
36
|
+
# end
|
37
|
+
#
|
38
|
+
# This code will properly add the right clauses to the final query based on the pagination strategy and ordering
|
39
|
+
# and it will also generate the Link header with the appropriate relationships depending on the paging strategy.
|
40
|
+
# When total_count is requested in the pagination a header with TotalCount will also be included.
|
41
|
+
|
42
|
+
PaginationStruct = Struct.new(:paginator, :order, :total_count)
|
43
|
+
|
44
|
+
included do
|
45
|
+
after :action do |controller, _callee|
|
46
|
+
if controller.response.status < 300
|
47
|
+
# If this action has the pagination parameter defined,
|
48
|
+
# calculate and set the pagination headers (Link header and possibly Total-Count)
|
49
|
+
if controller._pagination.paginator
|
50
|
+
headers = controller.build_pagination_headers(
|
51
|
+
pagination: controller._pagination,
|
52
|
+
current_url: controller.request.path,
|
53
|
+
current_query_params: controller.request.query
|
54
|
+
)
|
55
|
+
controller.response.headers.merge! headers
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Will set the typed paginator and order object into a controller ivar
|
62
|
+
# This is lazily evaluated and memoized, so there's no need to only calculate things for actions that paginate/sort
|
63
|
+
def _pagination
|
64
|
+
return @_pagination if @_pagination
|
65
|
+
|
66
|
+
pagination = {}
|
67
|
+
attrs = request.action&.params&.type&.attributes
|
68
|
+
pagination[:paginator] = request.params.pagination if attrs&.key? :pagination
|
69
|
+
pagination[:order] = request.params.order if attrs&.key? :order
|
70
|
+
|
71
|
+
@_pagination = PaginationStruct.new(pagination[:paginator], pagination[:order])
|
72
|
+
end
|
73
|
+
|
74
|
+
# Main entrypoint: Handles all pagination pieces
|
75
|
+
# takes:
|
76
|
+
# * the query to build from and the table
|
77
|
+
# * the request (for link header generation)
|
78
|
+
# * requires the _pagination variable to be there (set by this module) to return the pagination struct
|
79
|
+
def handle_pagination(query:, type: :active_record)
|
80
|
+
handler_klass = \
|
81
|
+
case type
|
82
|
+
when :active_record
|
83
|
+
ActiveRecordPaginationHandler
|
84
|
+
when :sequel
|
85
|
+
SequelPaginationHandler
|
86
|
+
else
|
87
|
+
raise "Attempting to use pagination but Active Record or Sequel gems found"
|
88
|
+
end
|
89
|
+
|
90
|
+
# Gather and save the count if required
|
91
|
+
if _pagination.paginator&.total_count
|
92
|
+
_pagination.total_count = handler_klass.count(query.dup)
|
93
|
+
end
|
94
|
+
|
95
|
+
query = handler_klass.order(query, _pagination.order)
|
96
|
+
# Maybe this is a class instance instead of a class method?...(of the appropriate AR/Sequel type)...
|
97
|
+
# self.class.paginate(query, table, _pagination)
|
98
|
+
handler_klass.paginate(query, _pagination)
|
99
|
+
end
|
100
|
+
|
101
|
+
def build_pagination_headers(pagination:, current_url:, current_query_params:)
|
102
|
+
links = if pagination.paginator.by
|
103
|
+
# We're assuming that the last element has a "symbol/string" field with the same name of the "by" pagination.
|
104
|
+
last_element = response.body.last
|
105
|
+
if last_element
|
106
|
+
last_value = last_element[pagination.paginator.by.to_sym] || last_element[pagination.paginator.by]
|
107
|
+
end
|
108
|
+
HeaderGenerator.build_cursor_headers(
|
109
|
+
paginator: pagination.paginator,
|
110
|
+
last_value: last_value,
|
111
|
+
total_count: pagination.total_count
|
112
|
+
)
|
113
|
+
else
|
114
|
+
HeaderGenerator.build_paging_headers(
|
115
|
+
paginator: pagination.paginator,
|
116
|
+
total_count: pagination.total_count
|
117
|
+
)
|
118
|
+
end
|
119
|
+
|
120
|
+
HeaderGenerator.generate_headers(
|
121
|
+
links: links,
|
122
|
+
current_url: current_url,
|
123
|
+
current_query_params: current_query_params,
|
124
|
+
total_count: pagination.total_count
|
125
|
+
)
|
126
|
+
end
|
127
|
+
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|