praxis 2.0.pre.29 → 2.0.pre.30
Sign up to get free protection for your applications and to get access to all the features.
- 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)
|