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

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 (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
@@ -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)