praxis 2.0.pre.5 → 2.0.pre.6

Sign up to get free protection for your applications and to get access to all the features.
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