praxis 2.0.pre.5 → 2.0.pre.6
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 +5 -5
- data/.rspec +0 -1
- data/.ruby-version +1 -0
- data/CHANGELOG.md +22 -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 +6 -4
- data/lib/praxis/action_definition.rb +9 -16
- data/lib/praxis/application.rb +1 -2
- data/lib/praxis/bootloader_stages/routing.rb +2 -4
- 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 +9 -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/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 +234 -0
- data/lib/praxis/extensions/pagination/pagination_handler.rb +68 -0
- data/lib/praxis/extensions/pagination/pagination_params.rb +374 -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/mapper/active_model_compat.rb +23 -5
- data/lib/praxis/mapper/resource.rb +16 -9
- data/lib/praxis/mapper/sequel_compat.rb +1 -0
- data/lib/praxis/media_type.rb +1 -56
- 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/route.rb +2 -4
- data/lib/praxis/routing_config.rb +4 -8
- data/lib/praxis/tasks/routes.rb +9 -14
- data/lib/praxis/validation_handler.rb +1 -2
- data/lib/praxis/version.rb +1 -1
- data/praxis.gemspec +2 -3
- 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/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 +1 -5
- 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 +1 -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 +35 -45
- data/spec/praxis/handlers/xml_spec.rb +0 -177
- data/spec/praxis/links_spec.rb +0 -68
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
maj, min, _ = ActiveRecord.gem_version.segments
|
4
|
+
|
5
|
+
if maj == 5
|
6
|
+
require_relative 'active_record_patches/5x.rb'
|
7
|
+
elsif maj == 6
|
8
|
+
if min == 0
|
9
|
+
require_relative 'active_record_patches/6_0.rb'
|
10
|
+
else
|
11
|
+
require_relative 'active_record_patches/6_1_plus.rb'
|
12
|
+
end
|
13
|
+
else
|
14
|
+
raise "Filtering only supported for ActiveRecord >= 5 && <= 6"
|
15
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
PRAXIS_JOIN_ALIAS_PREFIX = Praxis::Extensions::AttributeFiltering::ALIAS_TABLE_PREFIX
|
5
|
+
class Relation
|
6
|
+
def construct_join_dependency
|
7
|
+
including = eager_load_values + includes_values
|
8
|
+
# Praxis: inject references into the join dependency
|
9
|
+
ActiveRecord::Associations::JoinDependency.new(
|
10
|
+
klass, table, including, references: references_values
|
11
|
+
)
|
12
|
+
end
|
13
|
+
|
14
|
+
def build_join_query(manager, buckets, join_type, aliases)
|
15
|
+
buckets.default = []
|
16
|
+
|
17
|
+
association_joins = buckets[:association_join]
|
18
|
+
stashed_joins = buckets[:stashed_join]
|
19
|
+
join_nodes = buckets[:join_node].uniq
|
20
|
+
string_joins = buckets[:string_join].map(&:strip).uniq
|
21
|
+
|
22
|
+
join_list = join_nodes + convert_join_strings_to_ast(string_joins)
|
23
|
+
alias_tracker = alias_tracker(join_list, aliases)
|
24
|
+
|
25
|
+
# Praxis: inject references into the join dependency
|
26
|
+
join_dependency = ActiveRecord::Associations::JoinDependency.new(
|
27
|
+
klass, table, association_joins, references: references_values
|
28
|
+
)
|
29
|
+
|
30
|
+
joins = join_dependency.join_constraints(stashed_joins, join_type, alias_tracker)
|
31
|
+
joins.each { |join| manager.from(join) }
|
32
|
+
|
33
|
+
manager.join_sources.concat(join_list)
|
34
|
+
|
35
|
+
alias_tracker.aliases
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
module Associations
|
40
|
+
class JoinDependency
|
41
|
+
attr_accessor :references
|
42
|
+
private
|
43
|
+
def initialize(base, table, associations, references: )
|
44
|
+
tree = self.class.make_tree associations
|
45
|
+
@references = references # Save the references values into the instance (to use during build)
|
46
|
+
@join_root = JoinBase.new(base, table, build(tree, base))
|
47
|
+
end
|
48
|
+
|
49
|
+
# Praxis: table aliases for is shared for 5x and 6.0
|
50
|
+
def table_aliases_for(parent, node)
|
51
|
+
node.reflection.chain.map do |reflection|
|
52
|
+
is_root_reflection = reflection == node.reflection
|
53
|
+
table = alias_tracker.aliased_table_for(
|
54
|
+
reflection.table_name,
|
55
|
+
table_alias_for(reflection, parent, !is_root_reflection),
|
56
|
+
reflection.klass.type_caster
|
57
|
+
)
|
58
|
+
# through tables do not need a special alias_path alias (as they shouldn't really referenced by the client)
|
59
|
+
if is_root_reflection && node.alias_path
|
60
|
+
table = table.left if table.is_a?(Arel::Nodes::TableAlias) #un-alias it if necessary
|
61
|
+
table = table.alias(node.alias_path.join('/'))
|
62
|
+
end
|
63
|
+
table
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Praxis: build for is shared for 5x and 6.0
|
68
|
+
def build(associations, base_klass, path: [PRAXIS_JOIN_ALIAS_PREFIX])
|
69
|
+
associations.map do |name, right|
|
70
|
+
reflection = find_reflection base_klass, name
|
71
|
+
reflection.check_validity!
|
72
|
+
reflection.check_eager_loadable!
|
73
|
+
|
74
|
+
if reflection.polymorphic?
|
75
|
+
raise EagerLoadPolymorphicError.new(reflection)
|
76
|
+
end
|
77
|
+
# Praxis: set an alias_path in the JoinAssociation if its path matches a requested reference
|
78
|
+
child_path = (path && !path.empty?) ? path + [name] : nil
|
79
|
+
association = JoinAssociation.new(reflection, build(right, reflection.klass, path: child_path))
|
80
|
+
association.alias_path = child_path if references.include?(child_path.join('/'))
|
81
|
+
association
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
end
|
86
|
+
class ActiveRecord::Associations::JoinDependency::JoinAssociation
|
87
|
+
attr_accessor :alias_path
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -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
|
@@ -180,7 +179,7 @@ module Praxis
|
|
180
179
|
else
|
181
180
|
value
|
182
181
|
end
|
183
|
-
arr.push(name: attr_name,
|
182
|
+
arr.push(name: attr_name, op: match[:operator], value: coerced )
|
184
183
|
end
|
185
184
|
new(parsed)
|
186
185
|
end
|
@@ -208,28 +207,27 @@ module Praxis
|
|
208
207
|
def validate(_context = Attributor::DEFAULT_ROOT_CONTEXT)
|
209
208
|
parsed_array.each_with_object([]) do |item, errors|
|
210
209
|
attr_name = item[:name]
|
211
|
-
specs = item[:specs]
|
212
210
|
attr_filters = allowed_filters[attr_name]
|
213
211
|
unless attr_filters
|
214
212
|
errors << "Filtering by #{attr_name} is not allowed. You can filter by #{allowed_filters.keys.map(&:to_s).join(', ')}"
|
215
213
|
next
|
216
214
|
end
|
217
215
|
allowed_operators = attr_filters[:operators]
|
218
|
-
unless allowed_operators.include?(
|
219
|
-
errors << "Operator #{
|
216
|
+
unless allowed_operators.include?(item[:op])
|
217
|
+
errors << "Operator #{item[:op]} not allowed for filter #{attr_name}"
|
220
218
|
end
|
221
219
|
value_type = attr_filters[:value_type]
|
222
|
-
value =
|
220
|
+
value = item[:value]
|
223
221
|
if value_type && !value_type.valid_type?(value)
|
224
222
|
# Allow a collection of values of the right type for multimatch (if operators are = or !=)
|
225
|
-
if ['=','!='].include?(
|
223
|
+
if ['=','!='].include?(item[:op])
|
226
224
|
coll_type = Attributor::Collection.of(value_type)
|
227
225
|
if !coll_type.valid_type?(value)
|
228
226
|
errors << "Invalid type in filter/s value for #{attr_name} " +\
|
229
227
|
"(one or more of the multiple matches in #{value} are not a #{value_type.name.split('::').last})"
|
230
228
|
end
|
231
229
|
else
|
232
|
-
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})"
|
233
231
|
end
|
234
232
|
end
|
235
233
|
|
@@ -247,8 +245,7 @@ module Praxis
|
|
247
245
|
def dump
|
248
246
|
parsed_array.each_with_object([]) do |item, arr|
|
249
247
|
field = item[:name]
|
250
|
-
|
251
|
-
arr << "#{field}#{spec[:op]}#{spec[:value]}"
|
248
|
+
arr << "#{field}#{item[:op]}#{item[:value]}"
|
252
249
|
end.join('&')
|
253
250
|
end
|
254
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
|