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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +24 -0
- data/SELECTOR_NOTES.txt +0 -0
- data/lib/praxis/application.rb +4 -0
- data/lib/praxis/blueprint.rb +13 -1
- data/lib/praxis/blueprint_attribute_group.rb +29 -0
- data/lib/praxis/docs/open_api/schema_object.rb +8 -7
- data/lib/praxis/endpoint_definition.rb +1 -1
- data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +11 -11
- data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +0 -1
- data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +1 -1
- data/lib/praxis/extensions/pagination/active_record_pagination_handler.rb +54 -4
- data/lib/praxis/extensions/pagination/ordering_params.rb +38 -10
- data/lib/praxis/extensions/pagination/pagination_handler.rb +3 -3
- data/lib/praxis/extensions/pagination/sequel_pagination_handler.rb +1 -1
- data/lib/praxis/mapper/resource.rb +155 -14
- data/lib/praxis/mapper/selector_generator.rb +248 -46
- data/lib/praxis/media_type_identifier.rb +1 -1
- data/lib/praxis/multipart/part.rb +2 -2
- data/lib/praxis/plugins/mapper_plugin.rb +4 -3
- data/lib/praxis/renderer.rb +1 -1
- data/lib/praxis/routing_config.rb +1 -1
- data/lib/praxis/tasks/console.rb +21 -26
- data/lib/praxis/types/multipart_array.rb +1 -1
- data/lib/praxis/version.rb +1 -1
- data/lib/praxis.rb +1 -0
- data/praxis.gemspec +1 -1
- data/spec/functional_library_spec.rb +187 -0
- data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +11 -1
- data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +16 -4
- data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +0 -2
- data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +0 -2
- data/spec/praxis/extensions/pagination/active_record_pagination_handler_spec.rb +111 -25
- data/spec/praxis/extensions/pagination/ordering_params_spec.rb +70 -0
- data/spec/praxis/mapper/resource_spec.rb +40 -4
- data/spec/praxis/mapper/selector_generator_spec.rb +979 -296
- data/spec/praxis/request_stages/action_spec.rb +1 -1
- data/spec/spec_app/app/controllers/authors.rb +37 -0
- data/spec/spec_app/app/controllers/books.rb +31 -0
- data/spec/spec_app/app/resources/author.rb +21 -0
- data/spec/spec_app/app/resources/base.rb +14 -0
- data/spec/spec_app/app/resources/book.rb +43 -0
- data/spec/spec_app/app/resources/tag.rb +9 -0
- data/spec/spec_app/app/resources/tagging.rb +9 -0
- data/spec/spec_app/config/environment.rb +16 -1
- data/spec/spec_app/design/media_types/author.rb +13 -0
- data/spec/spec_app/design/media_types/book.rb +22 -0
- data/spec/spec_app/design/media_types/tag.rb +11 -0
- data/spec/spec_app/design/media_types/tagging.rb +10 -0
- data/spec/spec_app/design/resources/authors.rb +35 -0
- data/spec/spec_app/design/resources/books.rb +39 -0
- data/spec/spec_helper.rb +0 -1
- data/spec/support/spec_resources.rb +20 -7
- data/spec/{praxis/extensions/support → support}/spec_resources_active_model.rb +14 -0
- metadata +24 -7
- /data/spec/{functional_spec.rb → functional_cloud_spec.rb} +0 -0
- /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
|
64
|
-
#
|
65
|
-
|
66
|
-
|
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 })[
|
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} ||= #{
|
364
|
+
"@__#{name} ||= #{association_resource_class_name}.wrap(records)"
|
239
365
|
else
|
240
|
-
"@__#{name} ||= #{
|
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
|
-
|
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
|
-
|
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
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
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
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
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
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
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(
|
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
|
-
|
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
|
-
|
142
|
-
|
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(
|
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
|
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)
|