Class: Praxis::Mapper::Resource

Inherits:
Object
  • Object
show all
Extended by:
Finalizable
Defined in:
lib/praxis/mapper/resource.rb

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Finalizable

_finalize!, extended, finalizable, finalize!, finalized?, inherited

Constructor Details

#initialize(record) ⇒ Resource

Returns a new instance of Resource.



497
498
499
# File 'lib/praxis/mapper/resource.rb', line 497

def initialize(record)
  @record = record
end

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(name, *args) ⇒ Object



522
523
524
525
526
527
528
529
# File 'lib/praxis/mapper/resource.rb', line 522

def method_missing(name, *args)
  if @record.respond_to?(name)
    self.class.define_accessor(name)
    send(name)
  else
    super
  end
end

Class Attribute Details

.cached_forwardersObject (readonly)

Returns the value of attribute cached_forwarders.



51
52
53
# File 'lib/praxis/mapper/resource.rb', line 51

def cached_forwarders
  @cached_forwarders
end

.memoized_variablesObject

Names of the memoizable things (without the @__ prefix)



53
54
55
# File 'lib/praxis/mapper/resource.rb', line 53

def memoized_variables
  @memoized_variables
end

.model_mapObject (readonly)

Returns the value of attribute model_map.



51
52
53
# File 'lib/praxis/mapper/resource.rb', line 51

def model_map
  @model_map
end

.propertiesObject (readonly)

Returns the value of attribute properties.



51
52
53
# File 'lib/praxis/mapper/resource.rb', line 51

def properties
  @properties
end

.property_groupsObject (readonly)

Returns the value of attribute property_groups.



51
52
53
# File 'lib/praxis/mapper/resource.rb', line 51

def property_groups
  @property_groups
end

Instance Attribute Details

#recordObject

Returns the value of attribute record.



39
40
41
# File 'lib/praxis/mapper/resource.rb', line 39

def record
  @record
end

Class Method Details

._finalize!Object



127
128
129
130
131
132
133
134
135
136
# File 'lib/praxis/mapper/resource.rb', line 127

def self._finalize!
  validate_properties
  finalize_resource_delegates
  define_batch_processors
  define_model_accessors
  define_property_groups

  hookup_callbacks
  super
end

.all(condition = {}) ⇒ Object



334
335
336
337
338
# File 'lib/praxis/mapper/resource.rb', line 334

def self.all(condition = {})
  records = model.all(condition)

  wrap(records)
end

.batch_computed(attribute, with_instance_method: true, &block) ⇒ Object



113
114
115
116
117
118
119
120
121
# File 'lib/praxis/mapper/resource.rb', line 113

def self.batch_computed(attribute, with_instance_method: true, &block)
  raise "This resource (#{name})is already finalized. Defining batch_computed attributes needs to be done before finalization" if @finalized
  raise 'It is necessary to pass a block when using the batch_computed method' unless block_given?

  required_params = block.parameters.select { |t, _n| t == :keyreq }.map { |_a, b| b }.uniq
  raise 'The block for batch_computed can only accept one required kw param named :rows_by_id' unless required_params == [:rows_by_id]

  @registered_batch_computations[attribute.to_sym] = { proc: block.to_proc, with_instance_method: with_instance_method }
end

.batched_attributesObject



123
124
125
# File 'lib/praxis/mapper/resource.rb', line 123

def self.batched_attributes
  @registered_batch_computations.keys
end

.craft_field_selection_query(base_query, selectors:) ⇒ Object



477
478
479
480
481
482
483
484
# File 'lib/praxis/mapper/resource.rb', line 477

def self.craft_field_selection_query(base_query, selectors:)
  if selectors && model._field_selector_query_builder_class
    debug = Praxis::Application.instance.config.mapper.debug_queries
    base_query = model._field_selector_query_builder_class.new(query: base_query, selectors: selectors, debug: debug).generate
  end

  base_query
end

.craft_filter_query(base_query, filters:) ⇒ Object



466
467
468
469
470
471
472
473
474
475
# File 'lib/praxis/mapper/resource.rb', line 466

def self.craft_filter_query(base_query, filters:)
  if filters
    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

    debug = Praxis::Application.instance.config.mapper.debug_queries
    base_query = model._filter_query_builder_class.new(query: base_query, model: model, filters_map: @_filters_map, debug: debug).generate(filters)
  end

  base_query
end

.craft_pagination_query(base_query, pagination:, selectors:) ⇒ Object



486
487
488
489
490
491
492
493
494
495
# File 'lib/praxis/mapper/resource.rb', line 486

def self.craft_pagination_query(base_query, pagination:, selectors:)
  handler_klass = model._pagination_query_builder_class
  return base_query unless handler_klass && (pagination.paginator || pagination.order)

  # Gather and save the count if required
  pagination.total_count = handler_klass.count(base_query.dup) if pagination.paginator&.total_count

  base_query = handler_klass.order(base_query, pagination.order, root_resource: selectors.resource)
  handler_klass.paginate(base_query, pagination, root_resource: selectors.resource)
end

.define_accessor(name) ⇒ Object



419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/praxis/mapper/resource.rb', line 419

def self.define_accessor(name)
  ivar_name = case name.to_s
              when /\?/
                "is_#{name.to_s[0..-2]}"
              when /!/
                "#{name.to_s[0..-2]}_bang"
              else
                name.to_s
              end
  memoized_variables << ivar_name
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
def #{name}
  return @__#{ivar_name} if instance_variable_defined?("@__#{ivar_name}")
  @__#{ivar_name} = record.#{name}
end
  RUBY
end

.define_aliased_methodsObject



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/praxis/mapper/resource.rb', line 225

def self.define_aliased_methods
  with_different_alias_name = properties.reject { |name, opts| name == opts[:as] || opts[:as].nil? }

  with_different_alias_name.each do |prop_name, opts|
    next if instance_methods.include? prop_name

    # Check that the as: symbol, or each of the dotten notation names are pure association names in the corresponding resources, aliases aren't supported"
    unless opts[:as] == :self
      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)

      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))
    end

    # Straight call to another association method (that we will generate automatically in our association accessors)
    module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def #{prop_name}
        #{opts[:as]}
      end
    RUBY
  end
end

.define_batch_processorsObject



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/praxis/mapper/resource.rb', line 177

def self.define_batch_processors
  return unless @registered_batch_computations.presence

  const_set(:BatchProcessors, Module.new)
  @registered_batch_computations.each do |name, opts|
    self::BatchProcessors.module_eval do
      define_singleton_method(name, opts[:proc])
    end
    next unless opts[:with_instance_method]

    # Define the instance method for it to call the batch processor...passing its _pk (i.e., 'id' by default) and value
    # This can be turned off by setting :with_instance_method, in case the 'id' of a resource
    # it is not called 'id' (simply define an instance method similar to this one below or redefine '_pk')
    define_method(name) do
      self.class::BatchProcessors.send(name, rows_by_id: { id => self })[_pk]
    end
  end
end


403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/praxis/mapper/resource.rb', line 403

def self.define_delegation_for_related_association(resource_name, resource_attribute, related_association)
  related_resource_class = model_map[related_association[:model]]
  return unless related_resource_class

  memoized_variables << resource_attribute
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
  def #{resource_attribute}
    @__#{resource_attribute} ||= if (rec = self.#{resource_name})
    if (related = rec.#{resource_attribute})
      #{related_resource_class.name}.wrap(related)
    end
  end
end
  RUBY
end


392
393
394
395
396
397
398
399
400
401
# File 'lib/praxis/mapper/resource.rb', line 392

def self.define_delegation_for_related_attribute(resource_name, resource_attribute)
  memoized_variables << resource_attribute
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
  def #{resource_attribute}
    @__#{resource_attribute} ||= if (rec = self.#{resource_name})
    rec.#{resource_attribute}
      end
  end
  RUBY
end

.define_model_accessorsObject



206
207
208
209
210
211
212
213
214
# File 'lib/praxis/mapper/resource.rb', line 206

def self.define_model_accessors
  return if model.nil?

  define_aliased_methods

  model._praxis_associations.each do |k, v|
    define_model_association_accessor(k, v) unless instance_methods.include? k
  end
end

.define_model_association_accessor(name, association_spec) ⇒ Object

Defines wrappers for model associations that return Resources



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
# File 'lib/praxis/mapper/resource.rb', line 351

def self.define_model_association_accessor(name, association_spec)
  association_model = association_spec.fetch(:model)
  association_resource_class = model_map[association_model]

  return unless association_resource_class

  association_resource_class_name = "::#{association_resource_class}" # Ensure we point at classes globally
  memoized_variables << name

  # Add the call to wrap (for true collections) or simply for_record if it's a n:1 association
  wrapping = \
    case association_spec.fetch(:type)
    when :one_to_many, :many_to_many
      "@__#{name} ||= #{association_resource_class_name}.wrap(records)"
    else
      "@__#{name} ||= #{association_resource_class_name}.for_record(records)"
    end

  module_eval <<-RUBY, __FILE__, __LINE__ + 1
  def #{name}
    return @__#{name} if instance_variable_defined?("@__#{name}")

    records = record.#{name}
    return nil if records.nil?

    #{wrapping}
  end
  RUBY
end

.define_property_groupsObject

Defines the dependencies and the method of a property group The dependencies are going to be defined as the methods that wrap the group’s attributes i.e., ‘group_attribute1’ The method defined will return a ForwardingStruct object instance, that will simply define a method name for each existing property which simply calls the underlying ‘group name’ prefixed methods on the original object For example: if we have a group named ‘grouping’, which has ‘name’ and ‘phone’ attributes defined.

  • the property dependencies will be defined as: property :grouping, dependencies: [:name, :phone]

  • the ‘grouping’ method will return an instance object, that will respond to ‘name’ (and forward to ‘grouping_name’) and to ‘phone’ (and forward to ‘grouping_phone’)



255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/praxis/mapper/resource.rb', line 255

def self.define_property_groups
  property_groups.each do |(name, media_type)|
    # Set a property for their dependencies using the "group"_"attribute"
    prefixed_property_deps = media_type.attribute.attributes[name].type.attributes.keys.each_with_object({}) do |key, hash|
      hash[key] = "#{name}_#{key}".to_sym
    end
    property name, dependencies: prefixed_property_deps.values
    @cached_forwarders[name] = ForwardingStruct.for(prefixed_property_deps)

    define_method(name) do
      self.class.cached_forwarders[name].new(self)
    end
  end
end

.define_resource_delegate(resource_name, resource_attribute) ⇒ Object



381
382
383
384
385
386
387
388
389
390
# File 'lib/praxis/mapper/resource.rb', line 381

def self.define_resource_delegate(resource_name, resource_attribute)
  related_model = model._praxis_associations[resource_name][:model]
  related_association = related_model._praxis_associations[resource_attribute]

  if related_association
    define_delegation_for_related_association(resource_name, resource_attribute, related_association)
  else
    define_delegation_for_related_attribute(resource_name, resource_attribute)
  end
end

.detect_invalid_propertiesObject

Verifies if the system has badly defined properties For example, properties that correspond to an underlying association method (for which there is no overriden method in the resource) must not have dependencies defined, as it is clear the association is the only one



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/praxis/mapper/resource.rb', line 149

def self.detect_invalid_properties
  return nil unless !model.nil? && model.respond_to?(:_praxis_associations)

  invalid = {}
  existing_associations = model._praxis_associations.keys
  properties.slice(*existing_associations).each do |prop_name, data|
    # If we have overriden the assoc with our own method, we allow you to define deps (or as: aliases)
    next if instance_methods.include? prop_name

    example_def = "property #{prop_name} "
    example_def.concat("dependencies: #{data[:dependencies]}") if data[:dependencies].presence
    example_def.concat("as: #{data[:as]}") if data[:as].presence
    # If we haven't overriden the method, we'll create an accessor, so defining deps does not make sense
    error = "Bad definition of property '#{prop_name}'. Method #{prop_name} is already an association " \
            "which will be properly wrapped with an accessor, so you do not need to define it as a property.\n" \
            "Current definition looks like: #{example_def}\n"
    invalid[prop_name] = error
  end
  unless invalid.empty?
    msg = "Error defining one or more propeties in resource #{name}.\n".dup
    invalid.each_value { |err| msg.concat err }
    msg.concat 'Only define properties for methods that you override in the resource, as a way to specify which dependencies ' \
            "that requires to use inside it\n"
    return msg
  end
  nil
end

.filters_mapping(definition = {}) ⇒ Object

TODO: this shouldn’t be needed if we incorporate it with the properties of the mapper… …maybe what this means is that we can change it for a better DSL in the resource?



439
440
441
442
443
444
445
446
447
448
449
# File 'lib/praxis/mapper/resource.rb', line 439

def self.filters_mapping(definition = {})
  @_filters_map = \
    case definition
    when Hash
      definition
    when Array
      definition.each_with_object({}) { |item, hash| hash[item.to_sym] = item }
    else
      raise 'Resource.filters_mapping only allows a hash or an array'
    end
end

.finalize_resource_delegatesObject



196
197
198
199
200
201
202
203
204
# File 'lib/praxis/mapper/resource.rb', line 196

def self.finalize_resource_delegates
  return unless @resource_delegates

  @resource_delegates.each do |record_name, record_attributes|
    record_attributes.each do |record_attribute|
      define_resource_delegate(record_name, record_attribute)
    end
  end
end

.for_record(record) ⇒ Object



303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/praxis/mapper/resource.rb', line 303

def self.for_record(record)
  return record._resource if record._resource

  if (resource_class_for_record = model_map[record.class])
    record._resource = resource_class_for_record.new(record)
  else
    version = name.split('::')[0..-2].join('::')
    resource_name = record.class.name.split('::').last

    raise "No resource class corresponding to the model class '#{record.class}' is defined. (Did you forget to define '#{version}::#{resource_name}'?)"
  end
end

.get(condition) ⇒ Object



328
329
330
331
332
# File 'lib/praxis/mapper/resource.rb', line 328

def self.get(condition)
  record = model.get(condition)

  wrap(record)
end

.hookup_callbacksObject



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/praxis/mapper/resource.rb', line 270

def self.hookup_callbacks
  return unless ancestors.include?(Praxis::Mapper::Resources::Callbacks)

  instance_module = nil
  class_module = nil

  affected_methods = (before_callbacks.keys + after_callbacks.keys + around_callbacks.keys).uniq
  affected_methods&.each do |method|
    calls = {}
    calls[:before] = before_callbacks[method] if before_callbacks.key?(method)
    calls[:around] = around_callbacks[method] if around_callbacks.key?(method)
    calls[:after] = after_callbacks[method] if after_callbacks.key?(method)

    if method.start_with?('self.')
      # Look for a Class method
      simple_name = method.to_s.gsub(/^self./, '').to_sym
      raise "Error building callback: Class-level method #{method} is not defined in class #{name}" unless methods.include?(simple_name)

      class_module ||= Module.new
      create_override_module(mod: class_module, method: method(simple_name), calls: calls)
    else
      # Look for an instance method
      raise "Error building callback: Instance method #{method} is not defined in class #{name}" unless method_defined?(method)

      instance_module ||= Module.new
      create_override_module(mod: instance_module, method: instance_method(method), calls: calls)
    end
  end
  # Prepend the created instance and/or class modules if there were any functions in them
  prepend instance_module if instance_module
  singleton_class.send(:prepend, class_module) if class_module
end

.inherited(klass) ⇒ Object

TODO: also support an attribute of sorts on the versioned resource module. ie, V1::Resources.api_version.

replacing the self.superclass == Praxis::Mapper::Resource condition below.


58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/praxis/mapper/resource.rb', line 58

def self.inherited(klass)
  super

  klass.instance_eval do
    # It is expected that each versioned set of resources
    # will have a common Base class, and so should share
    # a model_map
    @model_map = if superclass == Praxis::Mapper::Resource
                   {}
                 else
                   superclass.model_map
                 end

    @properties = superclass.properties.clone
    @property_groups = superclass.property_groups.clone
    @cached_forwarders = superclass.cached_forwarders.clone
    @registered_batch_computations = {} # hash of attribute_name -> {proc: , with_instance_method: }
    @_filters_map = {}
    @_order_map = {}
    @memoized_variables = []
  end
end

.model(klass = nil) ⇒ Object

TODO: Take symbol/string and resolve the klass (but lazily, so we don’t care about load order)



82
83
84
85
86
87
88
89
90
91
# File 'lib/praxis/mapper/resource.rb', line 82

def self.model(klass = nil)
  if klass
    raise "Model #{klass.name} must be compatible with Praxis. Use ActiveModelCompat or similar compatability plugin." unless klass.methods.include?(:_praxis_associations)

    @model = klass
    model_map[klass] = self
  else
    @model
  end
end

.order_mapping(definition = nil) ⇒ Object



451
452
453
454
455
456
457
458
459
460
461
462
463
464
# File 'lib/praxis/mapper/resource.rb', line 451

def self.order_mapping(definition = nil)
  if definition.nil?
    @_order_map ||= {} # initialize to empty hash by default
    return @_order_map
  end

  @_order_map = \
    case definition
    when Hash
      definition.transform_values(&:to_s)
    else
      raise 'Resource.orders_mapping only allows a hash'
    end
end

.property(name, dependencies: nil, as: nil) ⇒ Object

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 follow and pass any incoming nested fields when necessary (as opposed to only add dependencies and discard nested fields) No dependencies are allowed to be defined if `as:` is used (as the dependencies should be defined at the final aliased property)



96
97
98
99
100
101
102
103
104
105
106
# File 'lib/praxis/mapper/resource.rb', line 96

def self.property(name, dependencies: nil, as: nil)
  raise "Error defining property '#{name}' in #{self}. Property names must be symbols, not strings." unless name.is_a? Symbol

  h = { dependencies: dependencies }
  if as
    raise 'Cannot use dependencies for a property when using the "as:" keyword' if dependencies.presence

    h.merge!({ as: as })
  end
  properties[name] = h
end

.property_group(name, media_type) ⇒ Object

Saves the name of the group, and the associated mediatype where the group attributes are defined at



109
110
111
# File 'lib/praxis/mapper/resource.rb', line 109

def self.property_group(name, media_type)
  property_groups[name] = media_type
end

.resource_delegate(spec) ⇒ Object



344
345
346
347
348
# File 'lib/praxis/mapper/resource.rb', line 344

def self.resource_delegate(spec)
  spec.each do |resource_name, attributes|
    resource_delegates[resource_name] = attributes
  end
end

.resource_delegatesObject



340
341
342
# File 'lib/praxis/mapper/resource.rb', line 340

def self.resource_delegates
  @resource_delegates ||= {}
end

.validate_associations_path(model, path) ⇒ Object



216
217
218
219
220
221
222
223
# File 'lib/praxis/mapper/resource.rb', line 216

def self.validate_associations_path(model, path)
  first, *rest = path

  assoc = model._praxis_associations[first]
  return first unless assoc

  rest.presence ? validate_associations_path(assoc[:model], rest) : nil
end

.validate_propertiesObject



138
139
140
141
142
143
144
# File 'lib/praxis/mapper/resource.rb', line 138

def self.validate_properties
  # Disabled for now
  # errors = detect_invalid_properties
  # unless errors.nil?
  #   raise StandardError, errors
  # end
end

.wrap(records) ⇒ Object



316
317
318
319
320
321
322
323
324
325
326
# File 'lib/praxis/mapper/resource.rb', line 316

def self.wrap(records)
  if records.nil?
    []
  elsif records.is_a?(Enumerable)
    records.compact.map { |record| for_record(record) }
  elsif records.respond_to?(:to_a)
    records.to_a.compact.map { |record| for_record(record) }
  else
    for_record(records)
  end
end

Instance Method Details

#_pkObject

By default every resource will have the main identifier (by default the id method) accessible through ‘_pk’



46
47
48
# File 'lib/praxis/mapper/resource.rb', line 46

def _pk
  id
end

#clear_memoizationObject



507
508
509
510
511
512
# File 'lib/praxis/mapper/resource.rb', line 507

def clear_memoization
  self.class.memoized_variables.each do |name|
    ivar = "@__#{name}"
    remove_instance_variable(ivar) if instance_variable_defined?(ivar)
  end
end

#reloadObject



501
502
503
504
505
# File 'lib/praxis/mapper/resource.rb', line 501

def reload
  clear_memoization
  reload_record
  self
end

#reload_recordObject



514
515
516
# File 'lib/praxis/mapper/resource.rb', line 514

def reload_record
  record.reload
end

#respond_to_missing?(name) ⇒ Boolean

Returns:

  • (Boolean)


518
519
520
# File 'lib/praxis/mapper/resource.rb', line 518

def respond_to_missing?(name, *)
  @record.respond_to?(name) || super
end