praxis 2.0.pre.29 → 2.0.pre.31

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +29 -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/docs/open_api_generator.rb +19 -24
  11. data/lib/praxis/endpoint_definition.rb +1 -1
  12. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +11 -11
  13. data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +0 -1
  14. data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +1 -1
  15. data/lib/praxis/extensions/pagination/active_record_pagination_handler.rb +54 -4
  16. data/lib/praxis/extensions/pagination/ordering_params.rb +38 -10
  17. data/lib/praxis/extensions/pagination/pagination_handler.rb +3 -3
  18. data/lib/praxis/extensions/pagination/sequel_pagination_handler.rb +1 -1
  19. data/lib/praxis/mapper/resource.rb +155 -14
  20. data/lib/praxis/mapper/selector_generator.rb +248 -46
  21. data/lib/praxis/media_type_identifier.rb +1 -1
  22. data/lib/praxis/multipart/part.rb +2 -2
  23. data/lib/praxis/plugins/mapper_plugin.rb +4 -3
  24. data/lib/praxis/renderer.rb +1 -1
  25. data/lib/praxis/routing_config.rb +1 -1
  26. data/lib/praxis/tasks/console.rb +21 -26
  27. data/lib/praxis/types/multipart_array.rb +1 -1
  28. data/lib/praxis/version.rb +1 -1
  29. data/lib/praxis.rb +1 -0
  30. data/praxis.gemspec +1 -1
  31. data/spec/functional_library_spec.rb +187 -0
  32. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +11 -1
  33. data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +16 -4
  34. data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +0 -2
  35. data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +0 -2
  36. data/spec/praxis/extensions/pagination/active_record_pagination_handler_spec.rb +111 -25
  37. data/spec/praxis/extensions/pagination/ordering_params_spec.rb +70 -0
  38. data/spec/praxis/mapper/resource_spec.rb +40 -4
  39. data/spec/praxis/mapper/selector_generator_spec.rb +979 -296
  40. data/spec/praxis/request_stages/action_spec.rb +1 -1
  41. data/spec/spec_app/app/controllers/authors.rb +37 -0
  42. data/spec/spec_app/app/controllers/books.rb +31 -0
  43. data/spec/spec_app/app/resources/author.rb +21 -0
  44. data/spec/spec_app/app/resources/base.rb +14 -0
  45. data/spec/spec_app/app/resources/book.rb +43 -0
  46. data/spec/spec_app/app/resources/tag.rb +9 -0
  47. data/spec/spec_app/app/resources/tagging.rb +9 -0
  48. data/spec/spec_app/config/environment.rb +16 -1
  49. data/spec/spec_app/design/media_types/author.rb +13 -0
  50. data/spec/spec_app/design/media_types/book.rb +22 -0
  51. data/spec/spec_app/design/media_types/tag.rb +11 -0
  52. data/spec/spec_app/design/media_types/tagging.rb +10 -0
  53. data/spec/spec_app/design/resources/authors.rb +35 -0
  54. data/spec/spec_app/design/resources/books.rb +39 -0
  55. data/spec/spec_helper.rb +0 -1
  56. data/spec/support/spec_resources.rb +20 -7
  57. data/spec/{praxis/extensions/support → support}/spec_resources_active_model.rb +14 -0
  58. data/tasks/thor/templates/generator/example_app/Gemfile +12 -8
  59. metadata +23 -6
  60. /data/spec/{functional_spec.rb → functional_cloud_spec.rb} +0 -0
  61. /data/spec/{praxis/extensions/support → support}/spec_resources_sequel.rb +0 -0
@@ -13,15 +13,42 @@ module Praxis
13
13
  end
14
14
  end
15
15
 
16
+ # Simple Object that will respond to a set of methods, by simply delegating to the target (will also delegate _resource)
17
+ class ForwardingStruct
18
+ extend Forwardable
19
+ attr_accessor :target
20
+
21
+ def self.for(names)
22
+ Class.new(self) do
23
+ def_delegator :@target, :_resource
24
+ def_delegator :@target, :id, :_pk
25
+ names.each do |(orig, forwarded)|
26
+ def_delegator :@target, forwarded, orig
27
+ end
28
+ end
29
+ end
30
+
31
+ def initialize(target)
32
+ @target = target
33
+ end
34
+ end
35
+
16
36
  class Resource
17
37
  extend Praxis::Finalizable
18
38
 
19
39
  attr_accessor :record
20
40
 
21
41
  @properties = {}
42
+ @property_groups = {}
43
+ @cached_forwarders = {}
44
+
45
+ # By default every resource will have the main identifier (by default the id method) accessible through '_pk'
46
+ def _pk
47
+ id
48
+ end
22
49
 
23
50
  class << self
24
- attr_reader :model_map, :properties
51
+ attr_reader :model_map, :properties, :property_groups, :cached_forwarders
25
52
  # Names of the memoizable things (without the @__ prefix)
26
53
  attr_accessor :memoized_variables
27
54
  end
@@ -42,8 +69,11 @@ module Praxis
42
69
  end
43
70
 
44
71
  @properties = superclass.properties.clone
72
+ @property_groups = superclass.property_groups.clone
73
+ @cached_forwarders = superclass.cached_forwarders.clone
45
74
  @registered_batch_computations = {} # hash of attribute_name -> {proc: , with_instance_method: }
46
75
  @_filters_map = {}
76
+ @_order_map = {}
47
77
  @memoized_variables = []
48
78
  end
49
79
  end
@@ -60,10 +90,24 @@ module Praxis
60
90
  end
61
91
  end
62
92
 
63
- # The `as:` can be used for properties that correspond to an underlying association of a different name. With this the selector generator, is able to not only add
64
- # any extra dependencies needed for the property, but it also follow and pass any incoming nested fields when necessary (as opposed to only add dependencies and discard nested fields)
65
- def self.property(name, dependencies: nil, through: nil, as: name) # rubocop:disable Naming/MethodParameterName
66
- properties[name] = { dependencies: dependencies, through: through, as: as }
93
+ # The `as:` can be used for properties that correspond to an underlying association of a different name. With this, the selector generator, is able to
94
+ # follow and pass any incoming nested fields when necessary (as opposed to only add dependencies and discard nested fields)
95
+ # No dependencies are allowed to be defined if `as:` is used (as the dependencies should be defined at the final aliased property)
96
+ def self.property(name, dependencies: nil, as: nil) # rubocop:disable Naming/MethodParameterName
97
+ raise "Error defining property '#{name}' in #{self}. Property names must be symbols, not strings." unless name.is_a? Symbol
98
+
99
+ h = { dependencies: dependencies }
100
+ if as
101
+ raise 'Cannot use dependencies for a property when using the "as:" keyword' if dependencies.presence
102
+
103
+ h.merge!({ as: as })
104
+ end
105
+ properties[name] = h
106
+ end
107
+
108
+ # Saves the name of the group, and the associated mediatype where the group attributes are defined at
109
+ def self.property_group(name, media_type)
110
+ property_groups[name] = media_type
67
111
  end
68
112
 
69
113
  def self.batch_computed(attribute, with_instance_method: true, &block)
@@ -81,14 +125,55 @@ module Praxis
81
125
  end
82
126
 
83
127
  def self._finalize!
128
+ validate_properties
84
129
  finalize_resource_delegates
85
130
  define_batch_processors
86
131
  define_model_accessors
132
+ define_property_groups
87
133
 
88
134
  hookup_callbacks
89
135
  super
90
136
  end
91
137
 
138
+ def self.validate_properties
139
+ # Disabled for now
140
+ # errors = detect_invalid_properties
141
+ # unless errors.nil?
142
+ # raise StandardError, errors
143
+ # end
144
+ end
145
+
146
+ # Verifies if the system has badly defined properties
147
+ # For example, properties that correspond to an underlying association method (for which there is no
148
+ # overriden method in the resource) must not have dependencies defined, as it is clear the association is the only one
149
+ def self.detect_invalid_properties
150
+ return nil unless !model.nil? && model.respond_to?(:_praxis_associations)
151
+
152
+ invalid = {}
153
+ existing_associations = model._praxis_associations.keys
154
+ properties.slice(*existing_associations).each do |prop_name, data|
155
+ # If we have overriden the assoc with our own method, we allow you to define deps (or as: aliases)
156
+ next if instance_methods.include? prop_name
157
+
158
+ example_def = "property #{prop_name} "
159
+ example_def.concat("dependencies: #{data[:dependencies]}") if data[:dependencies].presence
160
+ example_def.concat("as: #{data[:as]}") if data[:as].presence
161
+ # If we haven't overriden the method, we'll create an accessor, so defining deps does not make sense
162
+ error = "Bad definition of property '#{prop_name}'. Method #{prop_name} is already an association " \
163
+ "which will be properly wrapped with an accessor, so you do not need to define it as a property.\n" \
164
+ "Current definition looks like: #{example_def}\n"
165
+ invalid[prop_name] = error
166
+ end
167
+ unless invalid.empty?
168
+ msg = "Error defining one or more propeties in resource #{name}.\n".dup
169
+ invalid.each_value { |err| msg.concat err }
170
+ msg.concat 'Only define properties for methods that you override in the resource, as a way to specify which dependencies ' \
171
+ "that requires to use inside it\n"
172
+ return msg
173
+ end
174
+ nil
175
+ end
176
+
92
177
  def self.define_batch_processors
93
178
  return unless @registered_batch_computations.presence
94
179
 
@@ -99,11 +184,11 @@ module Praxis
99
184
  end
100
185
  next unless opts[:with_instance_method]
101
186
 
102
- # Define the instance method for it to call the batch processor...passing its 'id' and value
187
+ # Define the instance method for it to call the batch processor...passing its _pk (i.e., 'id' by default) and value
103
188
  # This can be turned off by setting :with_instance_method, in case the 'id' of a resource
104
- # it is not called 'id' (simply define an instance method similar to this one below)
189
+ # it is not called 'id' (simply define an instance method similar to this one below or redefine '_pk')
105
190
  define_method(name) do
106
- self.class::BatchProcessors.send(name, rows_by_id: { id => self })[id]
191
+ self.class::BatchProcessors.send(name, rows_by_id: { id => self })[_pk]
107
192
  end
108
193
  end
109
194
  end
@@ -128,11 +213,28 @@ module Praxis
128
213
  end
129
214
  end
130
215
 
216
+ def self.validate_associations_path(model, path)
217
+ first, *rest = path
218
+
219
+ assoc = model._praxis_associations[first]
220
+ return first unless assoc
221
+
222
+ rest.presence ? validate_associations_path(assoc[:model], rest) : nil
223
+ end
224
+
131
225
  def self.define_aliased_methods
132
- with_different_alias_name = properties.reject { |name, opts| name == opts[:as] }
226
+ with_different_alias_name = properties.reject { |name, opts| name == opts[:as] || opts[:as].nil? }
227
+
133
228
  with_different_alias_name.each do |prop_name, opts|
134
229
  next if instance_methods.include? prop_name
135
230
 
231
+ # Check that the as: symbol, or each of the dotten notation names are pure association names in the corresponding resources, aliases aren't supported"
232
+ unless opts[:as] == :self
233
+ raise "Cannot define property #{prop_name} with an `as:` option (#{opts[:as]}) for resource (#{name}) because it does not have associations!" unless model.respond_to?(:_praxis_associations)
234
+
235
+ raise "Invalid property definition named #{prop_name} for `as:` value '#{opts[:as]}': this association name/path does not exist" if validate_associations_path(model, opts[:as].to_s.split('.').map(&:to_sym))
236
+ end
237
+
136
238
  # Straight call to another association method (that we will generate automatically in our association accessors)
137
239
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
138
240
  def #{prop_name}
@@ -142,6 +244,29 @@ module Praxis
142
244
  end
143
245
  end
144
246
 
247
+ # Defines the dependencies and the method of a property group
248
+ # The dependencies are going to be defined as the methods that wrap the group's attributes i.e., 'group_attribute1'
249
+ # The method defined will return a ForwardingStruct object instance, that will simply define a method name for each existing property
250
+ # which simply calls the underlying 'group name' prefixed methods on the original object
251
+ # For example: if we have a group named 'grouping', which has 'name' and 'phone' attributes defined.
252
+ # - the property dependencies will be defined as: property :grouping, dependencies: [:name, :phone]
253
+ # - the 'grouping' method will return an instance object, that will respond to 'name' (and forward to 'grouping_name') and to 'phone'
254
+ # (and forward to 'grouping_phone')
255
+ def self.define_property_groups
256
+ property_groups.each do |(name, media_type)|
257
+ # Set a property for their dependencies using the "group"_"attribute"
258
+ prefixed_property_deps = media_type.attribute.attributes[name].type.attributes.keys.each_with_object({}) do |key, hash|
259
+ hash[key] = "#{name}_#{key}".to_sym
260
+ end
261
+ property name, dependencies: prefixed_property_deps.values
262
+ @cached_forwarders[name] = ForwardingStruct.for(prefixed_property_deps)
263
+
264
+ define_method(name) do
265
+ self.class.cached_forwarders[name].new(self)
266
+ end
267
+ end
268
+ end
269
+
145
270
  def self.hookup_callbacks
146
271
  return unless ancestors.include?(Praxis::Mapper::Resources::Callbacks)
147
272
 
@@ -229,15 +354,16 @@ module Praxis
229
354
 
230
355
  return unless association_resource_class
231
356
 
357
+ association_resource_class_name = "::#{association_resource_class}" # Ensure we point at classes globally
232
358
  memoized_variables << name
233
359
 
234
360
  # Add the call to wrap (for true collections) or simply for_record if it's a n:1 association
235
361
  wrapping = \
236
362
  case association_spec.fetch(:type)
237
363
  when :one_to_many, :many_to_many
238
- "@__#{name} ||= #{association_resource_class}.wrap(records)"
364
+ "@__#{name} ||= #{association_resource_class_name}.wrap(records)"
239
365
  else
240
- "@__#{name} ||= #{association_resource_class}.for_record(records)"
366
+ "@__#{name} ||= #{association_resource_class_name}.for_record(records)"
241
367
  end
242
368
 
243
369
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
@@ -322,6 +448,21 @@ module Praxis
322
448
  end
323
449
  end
324
450
 
451
+ def self.order_mapping(definition = nil)
452
+ if definition.nil?
453
+ @_order_map ||= {} # initialize to empty hash by default
454
+ return @_order_map
455
+ end
456
+
457
+ @_order_map = \
458
+ case definition
459
+ when Hash
460
+ definition.transform_values(&:to_s)
461
+ else
462
+ raise 'Resource.orders_mapping only allows a hash'
463
+ end
464
+ end
465
+
325
466
  def self.craft_filter_query(base_query, filters:)
326
467
  if filters
327
468
  raise "To use API filtering, you must define the mapping of api-names to resource properties (using the `filters_mapping` method in #{self})" unless @_filters_map
@@ -342,15 +483,15 @@ module Praxis
342
483
  base_query
343
484
  end
344
485
 
345
- def self.craft_pagination_query(base_query, pagination:)
486
+ def self.craft_pagination_query(base_query, pagination:, selectors:)
346
487
  handler_klass = model._pagination_query_builder_class
347
488
  return base_query unless handler_klass && (pagination.paginator || pagination.order)
348
489
 
349
490
  # Gather and save the count if required
350
491
  pagination.total_count = handler_klass.count(base_query.dup) if pagination.paginator&.total_count
351
492
 
352
- base_query = handler_klass.order(base_query, pagination.order)
353
- handler_klass.paginate(base_query, pagination)
493
+ base_query = handler_klass.order(base_query, pagination.order, root_resource: selectors.resource)
494
+ handler_klass.paginate(base_query, pagination, root_resource: selectors.resource)
354
495
  end
355
496
 
356
497
  def initialize(record)
@@ -3,35 +3,148 @@
3
3
  module Praxis
4
4
  module Mapper
5
5
  class SelectorGeneratorNode
6
- attr_reader :select, :model, :resource, :tracks
6
+ # prepend SelectorGeneratorNodeDebugger # Uncomment this to see the traces of how methods are called
7
+ attr_reader :select, :model, :resource, :tracks, :fields_node
8
+
9
+ # FieldDependenciesNode, attached to a SelectorGeneratorNode, which will contain, for every field passed in (not properties, but fields), the
10
+ # list of property dependencies associated with them.
11
+ # If these property dependenceis are for the 'local' resource of the SelectorGeneratorNode, they'd be just an array of property names
12
+ # If a field is a property that is an association to another resource, the reference field will point to which other node it depends on
13
+ # (this node fields does not need to be one of the immediate tracks, but it could be further down the tracks SelectorGeneratorNode's tree)
14
+ # In the case of references, any further resolution of dependencies from fields need to be continued in that track's SelectorGenerator's FieldDependenciesNode (recursively)
15
+ class FieldDependenciesNode
16
+ attr_reader :deps, :fields, :selector_node
17
+ attr_accessor :references
18
+
19
+ def initialize(name:, selector_node:)
20
+ @name = name
21
+ @fields = Hash.new do |hash, key|
22
+ hash[key] = FieldDependenciesNode.new(name: key, selector_node: selector_node)
23
+ end
24
+ @deps = Set.new
25
+ @references = nil
26
+ # Field path, currently being processed
27
+ @current_field = []
28
+ @selector_node = selector_node
29
+ end
30
+
31
+ def start_field(field_name)
32
+ @current_field.push field_name
33
+ end
34
+
35
+ def end_field
36
+ @current_field.pop
37
+ end
38
+
39
+ def add_local_dep(name)
40
+ pointer = @current_field.empty? ? @fields[true] : @fields.dig(*@current_field)
41
+ pointer.deps.add name
42
+ end
43
+
44
+ def save_reference(selector_node)
45
+ pointer = @current_field.empty? ? @fields[true] : @fields.dig(*@current_field)
46
+ pointer.references = selector_node
47
+ end
48
+
49
+ def dig(...)
50
+ @fields.dig(...) # rubocop:disable Style/SingleArgumentDig
51
+ end
52
+
53
+ def [](*path)
54
+ @fields.dig(*path)
55
+ end
56
+
57
+ # For spec/debugging purposes only
58
+ def dump
59
+ hash = {}
60
+ hash[:deps] = @deps.to_a unless @deps.empty?
61
+ unless @references.nil?
62
+ # Point, using a simple string, that it references another node (just print the resource name)
63
+ # We don't know how deep in the tree this will be, or if there are other nodes of the same resource
64
+ # type, but it seems good enough for checking things in specs
65
+ hash[:references] = "Linked to resource: #{@references.resource}"
66
+ end
67
+ field_deps = @fields.each_with_object({}) do |(name, node), h|
68
+ dumped = node.dump
69
+ h[name] = dumped unless dumped.empty?
70
+ end
71
+ hash[:fields] = field_deps unless field_deps.empty?
72
+ hash
73
+ end
74
+ end
7
75
 
8
76
  def initialize(resource)
9
77
  @resource = resource
10
-
11
78
  @select = Set.new
12
79
  @select_star = false
80
+ @fields_node = FieldDependenciesNode.new(name: '/', selector_node: self)
13
81
  @tracks = {}
14
82
  end
15
83
 
84
+ def inspect
85
+ "<#{self.class}# @resource=#{@resource.name} @select=#{@select} @select_star=#{@select_star} tracking: #{@tracks.keys} (recursion omited)>"
86
+ end
87
+
16
88
  def add(fields)
17
89
  fields.each do |name, field|
90
+ fields_node.start_field(name)
18
91
  map_property(name, field)
92
+ fields_node.end_field
19
93
  end
20
- self
21
94
  end
22
95
 
23
- def map_property(name, fields)
96
+ def map_property(name, fields, as_dependency: false)
24
97
  praxis_compat_model = resource.model&.respond_to?(:_praxis_associations)
25
98
  if resource.properties.key?(name)
26
- add_property(name, fields)
99
+ if (target = resource.properties[name][:as])
100
+ leaf_node = add_fwding_property(name, fields)
101
+ fields_node.save_reference(leaf_node) unless target == :self
102
+ else
103
+ add_property(name, fields)
104
+ end
105
+ fields_node.add_local_dep(name)
27
106
  elsif praxis_compat_model && resource.model._praxis_associations.key?(name)
28
107
  add_association(name, fields)
108
+ # Single association properties are also pointing to the corresponding tracked SelectorGeneratorNode
109
+ # but only if they are implicit properties, without dependencies
110
+ if as_dependency
111
+ fields_node.add_local_dep(name)
112
+ else
113
+ fields_node.save_reference(tracks[name])
114
+ end
29
115
  else
30
116
  add_select(name)
31
117
  end
32
118
  end
33
119
 
120
+ def add_string_association(first, *rest)
121
+ association = resource.model._praxis_associations.fetch(first) do
122
+ raise "missing association for #{resource} with name #{first}"
123
+ end
124
+ associated_resource = resource.model_map[association[:model]]
125
+ raise "Whoops! could not find a resource associated with model #{association[:model]} (root resource #{resource})" unless associated_resource
126
+
127
+ # Add the required columns in this model to make sure the association can be loaded
128
+ association[:local_key_columns].each { |col| add_select(col, add_field: false) }
129
+
130
+ node = SelectorGeneratorNode.new(associated_resource)
131
+ unless association[:remote_key_columns].empty?
132
+ # Make sure we add the required columns for this association to the remote model query
133
+ fields = {}
134
+ new_fields_as_hash = association[:remote_key_columns].each_with_object({}) do |key, hash|
135
+ hash[key] = true
136
+ end
137
+ fields = fields.merge(new_fields_as_hash)
138
+ end
139
+
140
+ node.add(fields) unless fields == true
141
+ leaf_node = rest.empty? ? nil : node.add_string_association(*rest)
142
+ merge_track(first, node)
143
+ leaf_node || node # Return the leaf (i.e., us, if we're the last component or the result of the string_association if there was one)
144
+ end
145
+
34
146
  def add_association(name, fields)
147
+ # fields_node.retrieve_last_of_chain = true
35
148
  association = resource.model._praxis_associations.fetch(name) do
36
149
  raise "missing association for #{resource} with name #{name}"
37
150
  end
@@ -39,7 +152,7 @@ module Praxis
39
152
  raise "Whoops! could not find a resource associated with model #{association[:model]} (root resource #{resource})" unless associated_resource
40
153
 
41
154
  # Add the required columns in this model to make sure the association can be loaded
42
- association[:local_key_columns].each { |col| add_select(col) }
155
+ association[:local_key_columns].each { |col| add_select(col, add_field: false) }
43
156
 
44
157
  node = SelectorGeneratorNode.new(associated_resource)
45
158
  unless association[:remote_key_columns].empty?
@@ -54,68 +167,100 @@ module Praxis
54
167
  node.add(fields) unless fields == true
55
168
 
56
169
  merge_track(name, node)
170
+ node
57
171
  end
58
172
 
59
- def add_select(name)
173
+ def add_select(name, add_field: true)
60
174
  return @select_star = true if name == :*
61
175
  return if @select_star
62
176
 
177
+ # Do not add a field dependency, if we know we're just adding a Local/FK constraint
178
+ @fields_node.add_local_dep(name) if add_field
63
179
  @select.add name
64
180
  end
65
181
 
182
+ def add_fwding_property(name, fields)
183
+ aliased_as = resource.properties[name][:as]
184
+ if aliased_as == :self
185
+ # Special keyword to add itself as the association, but still continue procesing the fields
186
+ # This is useful when we expose resource fields tucked inside another sub-struct, this way
187
+ # we can make sure that if the fields necessary to compute things inside the struct, they are preloaded
188
+ add(fields) unless fields == true
189
+ else
190
+ # Assumes (as: option of the property DSL should check check) that all forwarded properties need to be pure associations
191
+ # We know we've now added the chain of association dependencies under our node...so we'll start getting the 'first' of them
192
+ # and recurse down the node until the leaf.
193
+ # Then, we need to apply the incoming fields to that.
194
+ leaf_node = add_string_association(*aliased_as.to_s.split('.').map(&:to_sym))
195
+ leaf_node.add(fields) unless fields == true # If true, no fields to apply
196
+ leaf_node
197
+ end
198
+ end
199
+
66
200
  def add_property(name, fields)
67
201
  dependencies = resource.properties[name][:dependencies]
68
202
  # Always add the underlying association if we're overriding the name...
69
203
  if (praxis_compat_model = resource.model&.respond_to?(:_praxis_associations))
70
204
  aliased_as = resource.properties[name][:as]
71
- if aliased_as == :self
72
- # Special keyword to add itself as the association, but still continue procesing the fields
73
- # This is useful when we expose resource fields tucked inside another sub-struct, this way
74
- # we can make sure that if the fields necessary to compute things inside the struct, they are preloaded
75
- add(fields)
76
- else
77
- first, *rest = aliased_as.to_s.split('.').map(&:to_sym)
78
-
79
- extended_fields = \
80
- if rest.empty?
81
- fields
82
- else
83
- rest.reverse.inject(fields) do |accum, prop|
84
- { prop => accum }
205
+ if aliased_as
206
+ if aliased_as == :self
207
+ # Special keyword to add itself as the association, but still continue procesing the fields
208
+ # This is useful when we expose resource fields tucked inside another sub-struct, this way
209
+ # we can make sure that if the fields necessary to compute things inside the struct, they are preloaded
210
+ add(fields)
211
+ else
212
+ first, *rest = aliased_as.to_s.split('.').map(&:to_sym)
213
+
214
+ extended_fields = \
215
+ if rest.empty?
216
+ fields
217
+ else
218
+ rest.reverse.inject(fields) do |accum, prop|
219
+ { prop => accum }
220
+ end
85
221
  end
86
- end
87
- add_association(first, extended_fields) if resource.model._praxis_associations[first]
88
- end
89
- end
90
- dependencies&.each do |dependency|
91
- # To detect recursion, let's allow mapping depending fields to the same name of the property
92
- # but properly detecting if it's a real association...in which case we've already added it above
93
- if dependency == name
94
- add_select(name) unless praxis_compat_model && resource.model._praxis_associations.key?(name)
95
- else
96
- apply_dependency(dependency)
222
+
223
+ add_association(first, extended_fields) if resource.model._praxis_associations[first]
224
+ end
225
+ elsif resource.model._praxis_associations[name]
226
+ # Not aliased ... but if there is an existing association for the propety name, we add it (and ignore any deps in place)
227
+ add_association(name, fields)
97
228
  end
98
229
  end
230
+ # If we have a property group, and the subfields want to selectively restrict what to depend on
231
+ if fields != true && resource.property_groups[name]
232
+ # Prepend the group name to fields if it's an inner hash
233
+ prefixed_fields = fields == true ? {} : fields.keys.each_with_object({}) { |k, h| h["#{name}_#{k}".to_sym] = k }
234
+ # Try to match all inner fields
235
+ prefixed_fields.each do |prefixedname, origfieldname|
236
+ next unless dependencies.include?(prefixedname)
99
237
 
100
- head, *tail = resource.properties[name][:through]
101
- return if head.nil?
102
-
103
- new_fields = tail.reverse.inject(fields) do |thing, step|
104
- { step => thing }
238
+ fields_node.start_field(origfieldname) # Mark it as orig name
239
+ apply_dependency(prefixedname, fields[origfieldname])
240
+ fields_node.end_field
241
+ end
242
+ else
243
+ dependencies&.each do |dependency|
244
+ # To detect recursion, let's allow mapping depending fields to the same name of the property
245
+ # but properly detecting if it's a real association...in which case we've already added it above
246
+ if dependency == name
247
+ add_select(name) unless praxis_compat_model && resource.model._praxis_associations.key?(name)
248
+ else
249
+ apply_dependency(dependency)
250
+ end
251
+ end
105
252
  end
106
-
107
- add_association(head, new_fields)
108
253
  end
109
254
 
110
- def apply_dependency(dependency)
255
+ def apply_dependency(dependency, fields = true)
111
256
  case dependency
112
257
  when Symbol
113
- map_property(dependency, true)
258
+ map_property(dependency, fields, as_dependency: true)
114
259
  when String
115
260
  head, *tail = dependency.split('.').collect(&:to_sym)
116
261
  raise 'String dependencies can not be singular' if tail.nil?
117
262
 
118
- add_association(head, tail.reverse.inject({}) { |hash, dep| { dep => hash } })
263
+ add_association(head, tail.reverse.inject(true) { |hash, dep| { dep => hash } })
119
264
  end
120
265
  end
121
266
 
@@ -135,13 +280,26 @@ module Praxis
135
280
  end
136
281
  end
137
282
 
138
- def dump
283
+ # Debugging method for rspec, to easily match the desired output
284
+ # By default it only outputs the info related to computing columns and track dependencies.
285
+ # Overriding the mode will allow to dump the model and only the field dependencies
286
+ def dump(mode: :columns_and_tracks)
139
287
  hash = {}
140
288
  hash[:model] = resource.model
141
- if !@select.empty? || @select_star
142
- hash[:columns] = @select_star ? [:*] : @select.to_a
289
+ case mode
290
+ when :columns_and_tracks
291
+ if !@select.empty? || @select_star
292
+ hash[:columns] = @select_star ? [:*] : @select.to_a
293
+ end
294
+ when :fields
295
+ dumped_fields_node = @fields_node.dump
296
+ raise "Fields node has more keys than fields!! #{dumped_fields_node}" if dumped_fields_node.keys.size > 1
297
+
298
+ hash[:fields] = dumped_fields_node[:fields] if dumped_fields_node[:fields]
299
+ else
300
+ raise "Unknown mode #{mode} for dumping SelectorGenerator"
143
301
  end
144
- hash[:tracks] = @tracks.transform_values(&:dump) unless @tracks.empty?
302
+ hash[:tracks] = @tracks.transform_values { |v| v.dump(mode: mode) } unless @tracks.empty?
145
303
  hash
146
304
  end
147
305
  end
@@ -149,6 +307,8 @@ module Praxis
149
307
  # Generates a set of selectors given a resource and
150
308
  # list of resource attributes.
151
309
  class SelectorGenerator
310
+ attr_reader :root
311
+
152
312
  # Entry point
153
313
  def add(resource, fields)
154
314
  @root = SelectorGeneratorNode.new(resource)
@@ -159,6 +319,48 @@ module Praxis
159
319
  def selectors
160
320
  @root
161
321
  end
322
+
323
+ def inspect
324
+ "<#{self.class}# @root=#{@root.inspect}>"
325
+ end
326
+ end
327
+
328
+ # Includeable module to trace the execution of the method call tree while building the Nodes
329
+ module SelectorGeneratorNodeDebugger
330
+ def add(fields)
331
+ puts "ADD fields: #{fields}"
332
+ super
333
+ end
334
+
335
+ def map_property(name, fields, **args)
336
+ puts "MAP PROP #{name} fields: #{fields} (args: #{args})"
337
+ super
338
+ end
339
+
340
+ def add_association(name, fields, **args)
341
+ puts "ADD ASSOCIATION #{name} fields: #{fields} (args: #{args})"
342
+ super
343
+ end
344
+
345
+ def add_select(name, add_field: true)
346
+ puts "ADD SELECT #{name} (add field: #{add_field})"
347
+ super
348
+ end
349
+
350
+ def add_fwding_property(name, fields)
351
+ puts "ADD FWD ASSOC #{name} fields: #{fields}"
352
+ super
353
+ end
354
+
355
+ def add_property(name, fields)
356
+ puts "ADD PROP #{name} fields: #{fields}"
357
+ super
358
+ end
359
+
360
+ def apply_dependency(dependency, fields = true)
361
+ puts "APPLY DEP #{dependency} fields: #{fields}"
362
+ super
363
+ end
162
364
  end
163
365
  end
164
366
  end
@@ -190,7 +190,7 @@ module Praxis
190
190
 
191
191
  suffix = parameters.shift unless parameters.first.include?('=')
192
192
  # remove redundant '+'
193
- suffix = suffix[1..-1] if suffix && suffix[0] == '+'
193
+ suffix = suffix[1..] if suffix && suffix[0] == '+'
194
194
 
195
195
  parameters = parameters.each_with_object({}) do |e, h|
196
196
  k, v = e.split('=', 2)