praxis 2.0.pre.29 → 2.0.pre.30

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +24 -0
  5. data/SELECTOR_NOTES.txt +0 -0
  6. data/lib/praxis/application.rb +4 -0
  7. data/lib/praxis/blueprint.rb +13 -1
  8. data/lib/praxis/blueprint_attribute_group.rb +29 -0
  9. data/lib/praxis/docs/open_api/schema_object.rb +8 -7
  10. data/lib/praxis/endpoint_definition.rb +1 -1
  11. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +11 -11
  12. data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +0 -1
  13. data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +1 -1
  14. data/lib/praxis/extensions/pagination/active_record_pagination_handler.rb +54 -4
  15. data/lib/praxis/extensions/pagination/ordering_params.rb +38 -10
  16. data/lib/praxis/extensions/pagination/pagination_handler.rb +3 -3
  17. data/lib/praxis/extensions/pagination/sequel_pagination_handler.rb +1 -1
  18. data/lib/praxis/mapper/resource.rb +155 -14
  19. data/lib/praxis/mapper/selector_generator.rb +248 -46
  20. data/lib/praxis/media_type_identifier.rb +1 -1
  21. data/lib/praxis/multipart/part.rb +2 -2
  22. data/lib/praxis/plugins/mapper_plugin.rb +4 -3
  23. data/lib/praxis/renderer.rb +1 -1
  24. data/lib/praxis/routing_config.rb +1 -1
  25. data/lib/praxis/tasks/console.rb +21 -26
  26. data/lib/praxis/types/multipart_array.rb +1 -1
  27. data/lib/praxis/version.rb +1 -1
  28. data/lib/praxis.rb +1 -0
  29. data/praxis.gemspec +1 -1
  30. data/spec/functional_library_spec.rb +187 -0
  31. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +11 -1
  32. data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +16 -4
  33. data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +0 -2
  34. data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +0 -2
  35. data/spec/praxis/extensions/pagination/active_record_pagination_handler_spec.rb +111 -25
  36. data/spec/praxis/extensions/pagination/ordering_params_spec.rb +70 -0
  37. data/spec/praxis/mapper/resource_spec.rb +40 -4
  38. data/spec/praxis/mapper/selector_generator_spec.rb +979 -296
  39. data/spec/praxis/request_stages/action_spec.rb +1 -1
  40. data/spec/spec_app/app/controllers/authors.rb +37 -0
  41. data/spec/spec_app/app/controllers/books.rb +31 -0
  42. data/spec/spec_app/app/resources/author.rb +21 -0
  43. data/spec/spec_app/app/resources/base.rb +14 -0
  44. data/spec/spec_app/app/resources/book.rb +43 -0
  45. data/spec/spec_app/app/resources/tag.rb +9 -0
  46. data/spec/spec_app/app/resources/tagging.rb +9 -0
  47. data/spec/spec_app/config/environment.rb +16 -1
  48. data/spec/spec_app/design/media_types/author.rb +13 -0
  49. data/spec/spec_app/design/media_types/book.rb +22 -0
  50. data/spec/spec_app/design/media_types/tag.rb +11 -0
  51. data/spec/spec_app/design/media_types/tagging.rb +10 -0
  52. data/spec/spec_app/design/resources/authors.rb +35 -0
  53. data/spec/spec_app/design/resources/books.rb +39 -0
  54. data/spec/spec_helper.rb +0 -1
  55. data/spec/support/spec_resources.rb +20 -7
  56. data/spec/{praxis/extensions/support → support}/spec_resources_active_model.rb +14 -0
  57. metadata +24 -7
  58. /data/spec/{functional_spec.rb → functional_cloud_spec.rb} +0 -0
  59. /data/spec/{praxis/extensions/support → support}/spec_resources_sequel.rb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8f0514dd2a155733de0efe5586dd4b0409c70a9b385822b15e39934a7b83ea2
4
- data.tar.gz: ff66561cdaadb84a9c97b83c044c423561b6506e10a31cf72d032dce73dcb50c
3
+ metadata.gz: a2ffb701843f77a4f33c4b2628cdc7b609c5bb8b1d50bf0df454a77eded14ca3
4
+ data.tar.gz: 34852fb86cd240a18701aa013a1d65508c17d9c8831ecf5176efa9c73f7b5339
5
5
  SHA512:
6
- metadata.gz: 9bcb89e0426fb2e9e8ea51e7794a42f0a1ec6969fce436196da8c9d32697b232a02bddb0008400d51c6dc8b2d475e1122283da8644ed78bb448d759fbf4f2363
7
- data.tar.gz: 5c0a71be2bb1043ccc4269ccfa258d01e93b66d4dfa2f69938f5a495d8a296a305d2b8051c84a6683343672a69cd01e20f717dc87f4bf5ca4049b587438a3194
6
+ metadata.gz: 6578cd3f42a3f5dde6f32f9a860169e90f68d20c5a58dddefee2511e51e18368ec81a8ee5c75e60581ee53134e44940cbdad079a9e4c460e1b65121bea613b5b
7
+ data.tar.gz: 59073268280c18c2a9e77e626c5c75503c45d5b64351bd67898861d68b1f952b1f9c8c124af813049fc4b6aaed87cc74860d6561744c654343f00a60a1c59e4f
data/.rubocop.yml CHANGED
@@ -10,7 +10,7 @@ AllCops:
10
10
  - "pkg/**/*"
11
11
  - "vendor/cache/**/*"
12
12
  - "tasks/thor/templates/**/*"
13
- TargetRubyVersion: 2.5
13
+ TargetRubyVersion: 2.7
14
14
  NewCops: disable
15
15
 
16
16
  Metrics/BlockLength:
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 2.7.1
1
+ 3.1.1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## next
4
4
 
5
+ ## 2.0.pre.30
6
+ * A few cleanup and robustness additions:
7
+ * OpenAPI: Disable overriding a description when the schema is a ref (there are known issues with UI browsers)
8
+ * Internal: use `_pk` in batch processor invocation instead of `id` (resources will now have a `_pk` method which defaults to `id`)
9
+ * Bumped gemspec Ruby dependency to >=2.7 (but note, that this is just a little relaxed for older codebase, we're fully building for 3.x)
10
+ * Backwards incompatible changes:
11
+ * Enforces property names are symbols (before strings were allowed)
12
+ * Resource properties, using the `as:` option, are now enforced to be real association names (will not accept other resource names and unroll their dependencies)
13
+ * Deprecated the `:through` option for a property. You can just use `as:` with a long, dot-separated association path directly.
14
+ * Enhanced ordering semantics for pagination to allow for sorting of deep associated fields:
15
+ * Right now, you can sort by fields such as `books.author.name` as one of the sorting components (with `+` or `-` still available)
16
+ * Introduced better attribute grouping concepts, that help in defining subgroups of attributes of the same object, and allow lazy loading of only partial subsets so that one can have expensive computations on some of them, but they will never be invoked unless necessary. See MediaType.`group` and Resoruce.`property_group` explanations below.
17
+ * Introduced a 'group' stanza in MediaTypes, to specify a structure of attributes that exist in the main object, but that we want to neatly expose as a subset (instead of having them unrolled at the top):
18
+ * You can now use things like `group subinfo do ... end` blocks, defining which attributes to group
19
+ * Internal: Underneath, the system will create a BlueprintAttributeGroup (instead of a Struct) as a way to ensure that only the individual attributes that need to be rendered, are accessed (and not load the whole struct at once). While the behavior, to the outside, is gonna be identical to a Struct (i.e., exposes attributes as methods), this distinct object implementation is very important as it allows you to have attributes in the subgroup that are expensive to compute, and can be rest assured that they will not be accessed/computed unless they are required for rendering.
20
+ * Introduced the `property_group` stanza in resources, to indicate that a property contains a substructure of attributes, each of which must be able to be loaded only when necessary. This commonly goes hand in hand with a `group` stanza in the resource's MediaType:
21
+ * Usage of property group requires the name of the substructure (a symbol), and the associated mediatype that contains the definition of the `group` struct, under the same name of the property.
22
+ * Internally, this stanza, will define a normal property, and include as dependencies all of the sub attributes read from the MediaType's property, but appending the name (and `_`) to them to avoid collisions.
23
+ * Also, it will define a method with the property name which will return a Forwarding object, which will delegate each of the attribute methods back to the original self objects. This allows the object to avoid being 'loaded' as a whole as it happens with Struct, therefore only materializing/calling the attribute that we actually need to use, selectively.
24
+ * For example, if we have the `Book` MediaType which has a group atrribute called `subinfo` with a few attributes (like `name` and `pages`), we can use `property_group :subinfo, Book` on its domain object, so that the system will:
25
+ * define a `subinfo` property which will depend on `subinfo_name` and `subinfo_pages`
26
+ * define a `subinfo` method that will return a Forwarding object, that will forward `name` and `pages` methods to `subinfo_name` and `subinfo_pages` methods of the self resource.
27
+ * with that, we just need to define our `subinfo_name` and `subinfo_page` methods in the resource (and also define property dependencies for them if we need to)
28
+
5
29
  ## 2.0.pre.29
6
30
  * Assorted set of fixes to generate cleaner and more compliant OpenApi documents.
7
31
  * Mostly in the area of multipart generation, and requirements and nullability for OpenApi 3.0
File without changes
@@ -39,6 +39,10 @@ module Praxis
39
39
  @logger = Logger.new($stdout)
40
40
  end
41
41
 
42
+ def inspect
43
+ "<#{self.class}##{object_id} root: #{@root}>"
44
+ end
45
+
42
46
  def setup(root: '.')
43
47
  return self unless @app.nil?
44
48
 
@@ -2,6 +2,18 @@
2
2
 
3
3
  module Praxis
4
4
  class Blueprint
5
+ class DSLCompiler < Attributor::HashDSLCompiler
6
+ # group DSL is meant to group a subset of attributes in the media type (instead of presenting all things flat)
7
+ # It will create a normal attribute, but as a BlueprintAttributeGroup, rather than a Struct, so that we can more easily
8
+ # pass in objects that respond to the right methods, and avoid a Struct loading them all in hash keys.
9
+ # For example, if there are computationally intensive attributes in the subset, we want to make sure those functions
10
+ # aren't invoked by just merely loading, and only really invoked when we've asked to render them
11
+ # It takes the name of the group, and passes the attributes block that needs to be a subset of the MediaType where the group resides
12
+ def group(name, **options, &block)
13
+ attribute(name, Praxis::BlueprintAttributeGroup.for(target.options[:reference]), **options, &block)
14
+ end
15
+ end
16
+
5
17
  # Simple helper class that can parse the `attribute :foobar` dsl into
6
18
  # an equivalent structure hash. Example:
7
19
  # do
@@ -91,7 +103,7 @@ module Praxis
91
103
 
92
104
  opts[:reference] = (reference || self)
93
105
 
94
- @options.merge!(opts)
106
+ @options.merge!(opts.merge(dsl_compiler: DSLCompiler))
95
107
  @block = block
96
108
 
97
109
  return @attribute
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Praxis
4
+ class BlueprintAttributeGroup < Blueprint
5
+ def self.constructable?
6
+ true
7
+ end
8
+
9
+ # Construct a new subclass, using attribute_definition to define attributes.
10
+ def self.construct(attribute_definition, options = {})
11
+ return self if attribute_definition.nil?
12
+
13
+ reference_type = @media_type
14
+ # Construct a group-derived class with the given mediatype as the reference
15
+ ::Class.new(self) do
16
+ @reference = reference_type
17
+ attributes(**options, &attribute_definition)
18
+ end
19
+ end
20
+
21
+ def self.for(media_type)
22
+ return media_type::AttributeGrouping if defined?(media_type::AttributeGrouping) # Cache the grouping class
23
+
24
+ ::Class.new(self) do
25
+ @media_type = media_type
26
+ end
27
+ end
28
+ end
29
+ end
@@ -31,11 +31,13 @@ module Praxis
31
31
 
32
32
  def dump_schema(shallow: false, allow_ref: false)
33
33
  # We will dump schemas for mediatypes by simply creating a reference to the components' section
34
- if type < Attributor::Container && ! (type < Praxis::Types::MultipartArray)
34
+ if type < Attributor::Container && !(type < Praxis::Types::MultipartArray)
35
35
  if (type < Praxis::Blueprint || type < Attributor::Model) && allow_ref && !type.anonymous?
36
- # TODO: Do we even need a description?
37
- h = @attribute_options[:description] ? { 'description' => @attribute_options[:description] } : {}
38
-
36
+ # TODO: Technically OpenAPI/JSON schema support passing a description when pointing to a $ref (to override it)
37
+ # However, it seems that UI browsers like redoc or elements have bugs where if that's done, they get into a loop and crash
38
+ # so for now, we're gonna avoid overriding the description until that is solved
39
+ # h = @attribute_options[:description] ? { 'description' => @attribute_options[:description] } : {}
40
+ h = {}
39
41
  Praxis::Docs::OpenApiGenerator.instance.register_seen_component(type)
40
42
  h.merge!('$ref' => "#/components/schemas/#{type.id}")
41
43
  elsif @collection
@@ -44,12 +46,11 @@ module Praxis
44
46
  h.merge!(type: 'array', items: items)
45
47
  else # Attributor::Struct, etc
46
48
  required_attributes = (type.describe[:requirements] || []).filter { |r| r[:type] == :all }.map { |r| r[:attributes] }.flatten.compact.uniq
47
- props = type.attributes.transform_values.with_index do |definition, index|
49
+ props = type.attributes.transform_values do |definition|
48
50
  # if type has an attribute in its requirements all, then it should be marked as required here
49
- field_name = type.attributes.keys[index]
50
51
  OpenApi::SchemaObject.new(info: definition).dump_schema(allow_ref: true, shallow: shallow)
51
52
  end
52
- h = { type: :object}
53
+ h = { type: :object }
53
54
  h[:properties] = props if props.presence
54
55
  h[:required] = required_attributes unless required_attributes.empty?
55
56
  end
@@ -232,7 +232,7 @@ module Praxis
232
232
  action.sister_post_action # Avoid appending prefix
233
233
  else
234
234
  # Make sure to cleanup the leading '/' if any, as we're always adding it below
235
- cleaned_path = action.sister_post_action.start_with?('/') ? action.sister_post_action[1..-1] : action.sister_post_action
235
+ cleaned_path = action.sister_post_action.start_with?('/') ? action.sister_post_action[1..] : action.sister_post_action
236
236
  "#{action.route.prefixed_path}/#{cleaned_path}"
237
237
  end
238
238
 
@@ -74,7 +74,7 @@ module Praxis
74
74
  # Mark where clause referencing the appropriate alias IF it's not the root table, as there is no association to reference
75
75
  # If we added root table as a reference, we better make sure it is not quoted, as it actually makes AR to see it as an
76
76
  # unmatched reference and eager loads the whole association (it means eager load ALL the things). Not good.
77
- associated_query = associated_query.references(build_reference_value(column_prefix, query: associated_query)) unless for_model.table_name == column_prefix
77
+ associated_query = associated_query.references(self.class.build_reference_value(column_prefix, query: associated_query)) unless for_model.table_name == column_prefix
78
78
  self.class.add_clause(
79
79
  query: associated_query,
80
80
  column_prefix: column_prefix,
@@ -158,7 +158,7 @@ module Praxis
158
158
  if association_op
159
159
  neg = association_op == :not_null
160
160
  qr = quote_right_part(query: query, value: nil, column_object: association_key_column, negative: neg)
161
- return query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: association_key_column)} #{qr}")
161
+ return query.where("#{quote_column_path(query: query, prefix: column_prefix, column_name: association_key_column.name)} #{qr}")
162
162
  end
163
163
 
164
164
  # Add an AND along with the condition, which ensures the left outter join 'exists' for it
@@ -168,7 +168,7 @@ module Praxis
168
168
  # NOTE: we don't need to do it for conditions applying to the root of the tree (there isn't a join to it)
169
169
  if association_key_column
170
170
  qr = quote_right_part(query: query, value: nil, column_object: association_key_column, negative: true)
171
- query = query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: association_key_column)} #{qr}")
171
+ query = query.where("#{quote_column_path(query: query, prefix: column_prefix, column_name: association_key_column.name)} #{qr}")
172
172
  end
173
173
 
174
174
  case op
@@ -177,14 +177,14 @@ module Praxis
177
177
  add_safe_where(query: query, tab: column_prefix, col: column_object, op: 'LIKE', value: likeval)
178
178
  else
179
179
  quoted_right = quote_right_part(query: query, value: value, column_object: column_object, negative: false)
180
- query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: column_object)} #{quoted_right}")
180
+ query.where("#{quote_column_path(query: query, prefix: column_prefix, column_name: column_object.name)} #{quoted_right}")
181
181
  end
182
182
  when '!='
183
183
  if likeval
184
184
  add_safe_where(query: query, tab: column_prefix, col: column_object, op: 'NOT LIKE', value: likeval)
185
185
  else
186
186
  quoted_right = quote_right_part(query: query, value: value, column_object: column_object, negative: true)
187
- query.where("#{quote_column_path(query: query, prefix: column_prefix, column_object: column_object)} #{quoted_right}")
187
+ query.where("#{quote_column_path(query: query, prefix: column_prefix, column_name: column_object.name)} #{quoted_right}")
188
188
  end
189
189
  when '>'
190
190
  add_safe_where(query: query, tab: column_prefix, col: column_object, op: '>', value: value)
@@ -203,13 +203,13 @@ module Praxis
203
203
  # rubocop:disable Naming/MethodParameterName
204
204
  def self.add_safe_where(query:, tab:, col:, op:, value:)
205
205
  quoted_value = query.connection.quote_default_expression(value, col)
206
- query.where("#{quote_column_path(query: query, prefix: tab, column_object: col)} #{op} #{quoted_value}")
206
+ query.where("#{quote_column_path(query: query, prefix: tab, column_name: col.name)} #{op} #{quoted_value}")
207
207
  end
208
208
  # rubocop:enable Naming/MethodParameterName
209
209
 
210
- def self.quote_column_path(query:, prefix:, column_object:)
210
+ def self.quote_column_path(query:, prefix:, column_name:)
211
211
  c = query.connection
212
- quoted_column = c.quote_column_name(column_object.name)
212
+ quoted_column = c.quote_column_name(column_name)
213
213
  if prefix
214
214
  quoted_table = c.quote_table_name(prefix)
215
215
  "#{quoted_table}.#{quoted_column}"
@@ -337,7 +337,7 @@ module Praxis
337
337
  if ref && ['!', '!!'].include?(condition[:op])
338
338
  cp = (nodetree.path + [condition[:name].to_s]).join(REFERENCES_STRING_SEPARATOR)
339
339
  conditions += [condition.merge(column_prefix: cp, model: model, parent_reflection: ref)]
340
- h[condition[:name]] = {}
340
+ h[condition[:name].to_s] = {}
341
341
  else
342
342
  # Save the parent reflection where the condition applies as well (used later to get assoc keys)
343
343
  conditions += [condition.merge(column_prefix: column_prefix, model: model, parent_reflection: parent_reflection)]
@@ -350,14 +350,14 @@ module Praxis
350
350
  maj, min, = ActiveRecord.gem_version.segments
351
351
  if maj == 5 || (maj == 6 && min.zero?)
352
352
  # In AR 6 (and 6.0) the references are simple strings
353
- def build_reference_value(column_prefix, **_args)
353
+ def self.build_reference_value(column_prefix, **_args)
354
354
  column_prefix
355
355
  end
356
356
  else
357
357
  # The latest AR versions discard passing references to joins when they're not SqlLiterals ... so let's wrap it
358
358
  # with our class, so that it is a literal (already quoted), but that can still provide the expected "symbol" without quotes
359
359
  # so that our aliasing code can match it.
360
- def build_reference_value(column_prefix, query:)
360
+ def self.build_reference_value(column_prefix, query:)
361
361
  QuasiSqlLiteral.new(quoted: query.connection.quote_table_name(column_prefix), symbolized: column_prefix.to_sym)
362
362
  end
363
363
  end
@@ -7,7 +7,6 @@ module Praxis
7
7
  attr_reader :path, :conditions, :children
8
8
 
9
9
  # Parsed_filters is an Array of {name: X, op: Y, value: Z} ... exactly the format of the FilteringParams.load method
10
- # It can also contain a :node_object
11
10
  def initialize(parsed_filters, path: [])
12
11
  @path = path # Array that marks the tree 'path' to this node (with respect to the absolute root)
13
12
  @conditions = [] # Conditions to apply directly to this node
@@ -63,7 +63,7 @@ module Praxis
63
63
  newval, fuzzy = if starting && ending
64
64
  [raw_val[1..-2], :start_end]
65
65
  elsif starting
66
- [raw_val[1..-1], :start]
66
+ [raw_val[1..], :start]
67
67
  elsif ending
68
68
  [raw_val[0..-2], :end]
69
69
  else
@@ -15,14 +15,36 @@ module Praxis
15
15
  query.where(query.table[attr].gt(value))
16
16
  end
17
17
 
18
- def self.order(query, order)
18
+ def self.order(query, order, root_resource:)
19
19
  return query unless order
20
20
 
21
21
  query = query.reorder('')
22
-
23
22
  order.each do |spec_hash|
24
- direction, name = spec_hash.first
25
- query = query.order(name => direction)
23
+ direction, string = spec_hash.first
24
+ info = association_info_for(root_resource, string.to_s.split('.'))
25
+
26
+ # Convert the includes path (it is not a tree), to a column prefix
27
+ pointer = info[:includes]
28
+
29
+ dotted = []
30
+ loop do
31
+ break if pointer.empty?
32
+
33
+ key, pointer = pointer.first
34
+ dotted.push(key)
35
+ end
36
+ column_prefix = dotted.empty? ? root_resource.model.table_name : ([''] + dotted).join(AttributeFiltering::ActiveRecordFilterQueryBuilder::REFERENCES_STRING_SEPARATOR)
37
+
38
+ # If the sorting refers to a deeper association, make sure to add the join and the special reference
39
+ if column_prefix
40
+ refval = AttributeFiltering::ActiveRecordFilterQueryBuilder.build_reference_value(column_prefix, query: query)
41
+ # Outter join hash needs to be a string based hash format!! (if it's in symbols, it won't match it and we'll cause extra joins)
42
+ query = query.left_outer_joins(info[:includes]).references(refval)
43
+ end
44
+
45
+ quoted_prefix = AttributeFiltering::ActiveRecordFilterQueryBuilder.quote_column_path(query: query, prefix: column_prefix, column_name: info[:attribute])
46
+ order_clause = Arel.sql(ActiveRecord::Base.sanitize_sql_array("#{quoted_prefix} #{direction}"))
47
+ query = query.order(order_clause)
26
48
  end
27
49
  query
28
50
  end
@@ -38,6 +60,34 @@ module Praxis
38
60
  def self.limit(query, limit)
39
61
  query.limit(limit)
40
62
  end
63
+
64
+ # Based off of a root resource and an incoming path of dot-separated attributes...
65
+ # find the leaf attribute and its associated resource (including mapping names of associations/attributes along the way)
66
+ # as defined by the `order_mapping` stanzas of resources:
67
+ # resource: final resource the attribute is associated with
68
+ # includes: a hash in the shape of AR includes, where keys are strings (that is very important)
69
+ # column_name: final attribute name where this path leads to. Nil if the path ends at an association
70
+ def self.association_info_for(resource, path)
71
+ main, *rest = path
72
+ mapped_name = resource.order_mapping[main.to_sym] || main
73
+
74
+ if (association = resource.model.reflections[mapped_name])
75
+ related_resource = resource.model_map[association.klass]
76
+ if rest.presence
77
+ result = association_info_for(related_resource, rest)
78
+ { resource: result[:resource], includes: { mapped_name => result[:includes] }, attribute: result[:attribute] }
79
+ else # Ends with an association (i.e., for ! or !! attributes)
80
+ { resource: related_resource, includes: { mapped_name => {} }, attribute: nil }
81
+ end
82
+ elsif !rest.presence
83
+ # Since it is not an association, must be a column name
84
+ { resource: resource, includes: {}, attribute: mapped_name }
85
+ else
86
+ # Could not find an association and there are more components to cover...something's not right
87
+ raise 'Error trying to map ordering components to the order_mapping of the resources. ' \
88
+ "Could not find a mapping for property: #{mapped_name} in resource #{resource.name}. Did you forget to add a mapping for it in the `order_mapping` stanza ?"
89
+ end
90
+ end
41
91
  end
42
92
  end
43
93
  end
@@ -25,12 +25,15 @@ module Praxis
25
25
  class DSLCompiler < Attributor::DSLCompiler
26
26
  def by_fields(*fields)
27
27
  requested = fields.map(&:to_sym)
28
- non_matching = requested - target.media_type.attributes.keys
29
- unless non_matching.empty?
30
- raise "Error, you've requested to order by fields that do not exist in the mediatype!\n" \
31
- "The following #{non_matching.size} field/s do not exist in media type #{target.media_type.name} :\n" +
32
- non_matching.join(',').to_s
28
+
29
+ errors = []
30
+ requested.each do |field|
31
+ if (failed_field = self.class.validate_field(target.media_type, field.to_s.split('.').map(&:to_sym)))
32
+ errors += ["Cannot order by field: '#{field}'. It seems that the '#{failed_field}' attribute is not defined in the current #{target.media_type} structure (or its subtree)."]
33
+ end
33
34
  end
35
+ raise errors.join('\n') unless errors.empty?
36
+
34
37
  target.fields_allowed = requested
35
38
  end
36
39
 
@@ -44,6 +47,17 @@ module Praxis
44
47
  raise "Error: unknown parameter for the 'enforce_for' : #{which}. Only :all or :first are allowed"
45
48
  end
46
49
  end
50
+
51
+ def self.validate_field(type, path)
52
+ main, rest = path
53
+ next_attribute = type.respond_to?(:member_attribute) ? type.member_type.attributes[main] : type.attributes[main]
54
+
55
+ return main unless next_attribute
56
+
57
+ return nil if rest.nil?
58
+
59
+ validate_field(next_attribute.type, rest)
60
+ end
47
61
  end
48
62
 
49
63
  # Configurable DEFAULTS
@@ -154,9 +168,9 @@ module Praxis
154
168
  parsed_order = order.split(',').each_with_object([]) do |order_string, arr|
155
169
  item = case order_string[0]
156
170
  when '-'
157
- { desc: order_string[1..-1].to_s }
171
+ { desc: order_string[1..].to_s }
158
172
  when '+'
159
- { asc: order_string[1..-1].to_s }
173
+ { asc: order_string[1..].to_s }
160
174
  else
161
175
  { asc: order_string.to_s }
162
176
  end
@@ -199,11 +213,12 @@ module Praxis
199
213
  field = field.to_sym
200
214
  next if self.class.fields_allowed.include?(field)
201
215
 
202
- errors << if self.class.media_type.attributes.key?(field)
203
- "Ordering by field \'#{field}\' is disallowed. Ordering is only allowed using the following fields: " +
216
+ field_path = field.to_s.split('.').map(&:to_sym)
217
+ errors << if valid_attribute_path?(self.class.media_type, field_path)
218
+ "Ordering by field \'#{field}\' in media type #{self.class.media_type.name} is disallowed. Ordering is only allowed using the following fields: " +
204
219
  self.class.fields_allowed.map { |f| "\'#{f}\'" }.join(', ').to_s
205
220
  else
206
- "Ordering by field \'#{field}\' is not possible as this field does not exist in " \
221
+ "Ordering by field \'#{field}\' is not possible as this field is not reachable from " \
207
222
  "media type #{self.class.media_type.name}"
208
223
  end
209
224
  end
@@ -226,6 +241,19 @@ module Praxis
226
241
  def each(&block)
227
242
  items.each(&block)
228
243
  end
244
+
245
+ # Looks up if the given path (with symbol attribute names at each component) is actually
246
+ # a valid path from the given mediatype
247
+ def valid_attribute_path?(media_type, path)
248
+ first, *rest = path
249
+ # Get the member type if this is a collection
250
+ media_type = media_type.member_type if media_type.respond_to?(:member_attribute)
251
+ if (attribute = media_type.attributes[first])
252
+ rest.empty? ? true : valid_attribute_path?(attribute.type, rest)
253
+ else
254
+ false
255
+ end
256
+ end
229
257
  end
230
258
  end
231
259
  end
@@ -6,7 +6,7 @@ module Praxis
6
6
  class PaginationHandler
7
7
  class PaginationException < RuntimeError; end
8
8
 
9
- def self.paginate(query, pagination)
9
+ def self.paginate(query, pagination, root_resource:)
10
10
  return query unless pagination.paginator
11
11
 
12
12
  paginator = pagination.paginator
@@ -18,7 +18,7 @@ module Praxis
18
18
  # i.e., We can be smart about allowing the main sort field matching the pagination one (in case you want to sub-order in a custom way)
19
19
  oclause = if pagination.order.nil? || pagination.order.empty? # No ordering specified => use ascending based on the "by" field
20
20
  direction = :asc
21
- order(query, [{ asc: paginator.by }])
21
+ order(query, [{ asc: paginator.by }], root_resource: root_resource)
22
22
  else
23
23
  first_ordering = pagination.order.items.first
24
24
  direction = first_ordering.keys.first
@@ -32,7 +32,7 @@ module Praxis
32
32
  "When paginating by a field value, one cannot specify the 'order' clause " \
33
33
  "unless the clause's primary field matches the pagination field."
34
34
  end
35
- order(query, pagination.order)
35
+ order(query, pagination.order, root_resource: root_resource)
36
36
  end
37
37
 
38
38
  if paginator.from
@@ -14,7 +14,7 @@ module Praxis
14
14
  query.where("#{attr} > ?", value)
15
15
  end
16
16
 
17
- def self.order(query, order)
17
+ def self.order(query, order, _root_resource: nil)
18
18
  return query unless order
19
19
 
20
20
  order_clause = order.map do |spec_hash|