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.
Files changed (76) hide show
  1. checksums.yaml +5 -5
  2. data/.rspec +0 -1
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +22 -0
  5. data/Gemfile +1 -1
  6. data/Guardfile +2 -1
  7. data/Rakefile +1 -7
  8. data/TODO.md +28 -0
  9. data/lib/api_browser/package-lock.json +7110 -0
  10. data/lib/praxis.rb +6 -4
  11. data/lib/praxis/action_definition.rb +9 -16
  12. data/lib/praxis/application.rb +1 -2
  13. data/lib/praxis/bootloader_stages/routing.rb +2 -4
  14. data/lib/praxis/extensions/attribute_filtering.rb +2 -0
  15. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +148 -157
  16. data/lib/praxis/extensions/attribute_filtering/active_record_patches.rb +15 -0
  17. data/lib/praxis/extensions/attribute_filtering/active_record_patches/5x.rb +90 -0
  18. data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_0.rb +68 -0
  19. data/lib/praxis/extensions/attribute_filtering/active_record_patches/6_1_plus.rb +58 -0
  20. data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +35 -0
  21. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +9 -12
  22. data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +3 -2
  23. data/lib/praxis/extensions/field_selection/active_record_query_selector.rb +7 -9
  24. data/lib/praxis/extensions/field_selection/sequel_query_selector.rb +6 -9
  25. data/lib/praxis/extensions/pagination.rb +130 -0
  26. data/lib/praxis/extensions/pagination/active_record_pagination_handler.rb +42 -0
  27. data/lib/praxis/extensions/pagination/header_generator.rb +70 -0
  28. data/lib/praxis/extensions/pagination/ordering_params.rb +234 -0
  29. data/lib/praxis/extensions/pagination/pagination_handler.rb +68 -0
  30. data/lib/praxis/extensions/pagination/pagination_params.rb +374 -0
  31. data/lib/praxis/extensions/pagination/sequel_pagination_handler.rb +45 -0
  32. data/lib/praxis/handlers/json.rb +2 -0
  33. data/lib/praxis/handlers/www_form.rb +5 -0
  34. data/lib/praxis/handlers/{xml.rb → xml-sample.rb} +6 -0
  35. data/lib/praxis/mapper/active_model_compat.rb +23 -5
  36. data/lib/praxis/mapper/resource.rb +16 -9
  37. data/lib/praxis/mapper/sequel_compat.rb +1 -0
  38. data/lib/praxis/media_type.rb +1 -56
  39. data/lib/praxis/plugins/mapper_plugin.rb +1 -1
  40. data/lib/praxis/plugins/pagination_plugin.rb +71 -0
  41. data/lib/praxis/resource_definition.rb +4 -12
  42. data/lib/praxis/route.rb +2 -4
  43. data/lib/praxis/routing_config.rb +4 -8
  44. data/lib/praxis/tasks/routes.rb +9 -14
  45. data/lib/praxis/validation_handler.rb +1 -2
  46. data/lib/praxis/version.rb +1 -1
  47. data/praxis.gemspec +2 -3
  48. data/spec/functional_spec.rb +9 -6
  49. data/spec/praxis/action_definition_spec.rb +4 -16
  50. data/spec/praxis/api_general_info_spec.rb +6 -6
  51. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +304 -0
  52. data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +39 -0
  53. data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +34 -0
  54. data/spec/praxis/extensions/field_expansion_spec.rb +6 -24
  55. data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +15 -11
  56. data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +4 -3
  57. data/spec/praxis/extensions/pagination/active_record_pagination_handler_spec.rb +130 -0
  58. data/spec/praxis/extensions/{field_selection/support → support}/spec_resources_active_model.rb +45 -2
  59. data/spec/praxis/extensions/{field_selection/support → support}/spec_resources_sequel.rb +0 -0
  60. data/spec/praxis/media_type_spec.rb +5 -129
  61. data/spec/praxis/request_spec.rb +3 -22
  62. data/spec/praxis/resource_definition_spec.rb +1 -1
  63. data/spec/praxis/response_definition_spec.rb +1 -5
  64. data/spec/praxis/route_spec.rb +2 -9
  65. data/spec/praxis/routing_config_spec.rb +4 -13
  66. data/spec/praxis/types/multipart_array_spec.rb +4 -21
  67. data/spec/spec_app/config/environment.rb +0 -2
  68. data/spec/spec_app/design/api.rb +1 -1
  69. data/spec/spec_app/design/media_types/instance.rb +0 -8
  70. data/spec/spec_app/design/media_types/volume.rb +0 -12
  71. data/spec/spec_app/design/resources/instances.rb +1 -2
  72. data/spec/spec_helper.rb +6 -0
  73. data/spec/support/spec_media_types.rb +0 -73
  74. metadata +35 -45
  75. data/spec/praxis/handlers/xml_spec.rb +0 -177
  76. 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>!=|>=|<=|=|<|>)(?<value>#{VALUE_REGEX}(,#{VALUE_REGEX})*)/
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, specs: { op: match[:operator], value: coerced } )
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?(specs[:op])
219
- errors << "Operator #{specs[:op]} not allowed for filter #{attr_name}"
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 = specs[: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?(specs[:op])
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 '#{specs[:op]}' is not a #{value_type.name.split('::').last})"
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
- spec = item[:specs]
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 build_clause(filters)
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 `build_clause` that restricts
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(debug: false)
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 debug
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
- prev = ActiveRecord::Base.logger
41
- ActiveRecord::Base.logger = Logger.new(STDOUT)
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
- ActiveRecord::Base.logger.debug("Query plan end")
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(debug: false)
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 debug
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
- prev_loggers = Sequel::Model.db.loggers
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
- stdout_logger.debug("Query plan end")
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