class Praxis::Mapper::Resource
Attributes
Names of the memoizable things (without the @__ prefix)
Public Class Methods
# 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
# File lib/praxis/mapper/resource.rb, line 334 def self.all(condition = {}) records = model.all(condition) wrap(records) end
# 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
# File lib/praxis/mapper/resource.rb, line 123 def self.batched_attributes @registered_batch_computations.keys end
# 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
# 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
# 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
# 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
# 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
# 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
# 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
Defines wrappers for model associations that return Resources
# 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
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’)
# 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
# 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
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
# 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
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?
# 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
# 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
# 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
# File lib/praxis/mapper/resource.rb, line 328 def self.get(condition) record = model.get(condition) wrap(record) end
# 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
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.
# 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
TODO: Take symbol/string and resolve the klass (but lazily, so we don’t care about load order)
# 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
# File lib/praxis/mapper/resource.rb, line 497 def initialize(record) @record = record end
# 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
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)
# 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
Saves the name of the group, and the associated mediatype where the group attributes are defined at
# File lib/praxis/mapper/resource.rb, line 109 def self.property_group(name, media_type) property_groups[name] = media_type end
# 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
# File lib/praxis/mapper/resource.rb, line 340 def self.resource_delegates @resource_delegates ||= {} end
# 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
# 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
# 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
Private Class Methods
Defines a ‘proxy’ method in the given module (mod), so it can then be prepended There are mostly 3 flavors, which dictate how to define the procs (to make sure we play nicely with ruby’s arguments and all). Method with only args, with only kwords, and with both Note: if procs could be defined with the (…) syntax, this could be more DRY and simple…
# File lib/praxis/mapper/resource.rb, line 535 def self.create_override_module(mod:, method:, calls:) has_args = method.parameters.any? { |(type, _)| %i[req opt rest].include?(type) } has_kwargs = method.parameters.any? { |(type, _)| %i[keyreq keyrest].include?(type) } mod.class_eval do if has_args && has_kwargs # Setup the method to take both args and kwargs define_method(method.name.to_sym) do |*args, **kwargs| calls[:before]&.each do |target| target.is_a?(Symbol) ? send(target, *args, **kwargs) : instance_exec(*args, **kwargs, &target) end orig_call = proc { |*a, **kw| super(*a, **kw) } around_chain = calls[:around].inject(orig_call) do |inner, target| proc { |*a, **kw| send(target, *a, **kw, &inner) } end result = if calls[:around].presence around_chain.call(*args, **kwargs) else super(*args, **kwargs) end calls[:after]&.each do |target| target.is_a?(Symbol) ? send(target, *args, **kwargs) : instance_exec(*args, **kwargs, &target) end result end elsif has_kwargs && !has_args # Setup the method to only take kwargs define_method(method.name.to_sym) do |**kwargs| calls[:before]&.each do |target| target.is_a?(Symbol) ? send(target, **kwargs) : instance_exec(**kwargs, &target) end orig_call = proc { |**kw| super(**kw) } around_chain = calls[:around].inject(orig_call) do |inner, target| proc { |**kw| send(target, **kw, &inner) } end result = if calls[:around].presence around_chain.call(**kwargs) else super(**kwargs) end calls[:after]&.each do |target| target.is_a?(Symbol) ? send(target, **kwargs) : instance_exec(**kwargs, &target) end result end else # Setup the method to only take args define_method(method.name.to_sym) do |*args| calls[:before]&.each do |target| target.is_a?(Symbol) ? send(target, *args) : instance_exec(*args, &target) end orig_call = proc { |*a| super(*a) } around_chain = calls[:around].inject(orig_call) do |inner, target| proc { |*a| send(target, *a, &inner) } end result = if calls[:around].presence around_chain.call(*args) else super(*args) end calls[:after]&.each do |target| target.is_a?(Symbol) ? send(target, *args) : instance_exec(*args, &target) end result end end end end
Public Instance Methods
By default every resource will have the main identifier (by default the id method) accessible through ‘_pk’
# File lib/praxis/mapper/resource.rb, line 46 def _pk id end
# 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
# 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
# File lib/praxis/mapper/resource.rb, line 501 def reload clear_memoization reload_record self end
# File lib/praxis/mapper/resource.rb, line 514 def reload_record record.reload end
# File lib/praxis/mapper/resource.rb, line 518 def respond_to_missing?(name, *) @record.respond_to?(name) || super end