module DataMapper module Model include Enumerable WRITER_METHOD_REGEXP = /=\z/ INVALID_WRITER_METHODS = %w[== != === []= taguri= attributes= collection= persistence_state= raise_on_save_failure=].to_set.freeze # Creates a new Model class with its constant already set # # If a block is passed, it will be eval'd in the context of the new Model # # @param [#to_s] name # the name of the new model # @param [Object] namespace # the namespace that will hold the new model # @param [Proc] block # a block that will be eval'd in the context of the new Model class # # @return [Class] # the newly created Model class # # @api private def self.new(name = nil, namespace = Object, &block) model = name ? namespace.const_set(name, Class.new) : Class.new model.class_eval <<-RUBY, __FILE__, __LINE__ + 1 include DataMapper::Resource RUBY model.instance_eval(&block) if block model end # Return all models that extend the Model module # # class Foo # include DataMapper::Resource # end # # DataMapper::Model.descendants.first #=> Foo # # @return [DescendantSet] # Set containing the descendant models # # @api semipublic def self.descendants @descendants ||= DescendantSet.new end # Return all models that inherit from a Model # # class Foo # include DataMapper::Resource # end # # class Bar < Foo # end # # Foo.descendants.first #=> Bar # # @return [DescendantSet] # Set containing the descendant classes # # @api semipublic def descendants @descendants ||= DescendantSet.new end # Return if Resource#save should raise an exception on save failures (globally) # # This is false by default. # # DataMapper::Model.raise_on_save_failure # => false # # @return [Boolean] # true if a failure in Resource#save should raise an exception # # @api public def self.raise_on_save_failure if defined?(@raise_on_save_failure) @raise_on_save_failure else false end end # Specify if Resource#save should raise an exception on save failures (globally) # # @param [Boolean] # a boolean that if true will cause Resource#save to raise an exception # # @return [Boolean] # true if a failure in Resource#save should raise an exception # # @api public def self.raise_on_save_failure=(raise_on_save_failure) @raise_on_save_failure = raise_on_save_failure end # Return if Resource#save should raise an exception on save failures (per-model) # # This delegates to DataMapper::Model.raise_on_save_failure by default. # # User.raise_on_save_failure # => false # # @return [Boolean] # true if a failure in Resource#save should raise an exception # # @api public def raise_on_save_failure if defined?(@raise_on_save_failure) @raise_on_save_failure else DataMapper::Model.raise_on_save_failure end end # Specify if Resource#save should raise an exception on save failures (per-model) # # @param [Boolean] # a boolean that if true will cause Resource#save to raise an exception # # @return [Boolean] # true if a failure in Resource#save should raise an exception # # @api public def raise_on_save_failure=(raise_on_save_failure) @raise_on_save_failure = raise_on_save_failure end # Finish model setup and verify it is valid # # @return [self] # # @api public def finalize finalize_relationships finalize_allowed_writer_methods assert_valid_name assert_valid_properties assert_valid_key self end # Appends a module for inclusion into the model class after Resource. # # This is a useful way to extend Resource while still retaining a # self.included method. # # @param [Module] inclusions # the module that is to be appended to the module after Resource # # @return [Boolean] # true if the inclusions have been successfully appended to the list # # @api semipublic def self.append_inclusions(*inclusions) extra_inclusions.concat inclusions # Add the inclusion to existing descendants descendants.each do |model| inclusions.each { |inclusion| model.send :include, inclusion } end true end # The current registered extra inclusions # # @return [Mixed] # DescendantSet or Array # @api private def self.extra_inclusions @extra_inclusions ||= [] end # Extends the model with this module after Resource has been included. # # This is a useful way to extend Model while still retaining a self.extended method. # # @param [Module] extensions # List of modules that will extend the model after it is extended by Model # # @return [Boolean] # whether or not the inclusions have been successfully appended to the list # # @api semipublic def self.append_extensions(*extensions) extra_extensions.concat extensions # Add the extension to existing descendants descendants.each do |model| extensions.each { |extension| model.extend(extension) } end true end # The current registered extra extensions # # @return [Mixed] # DescendantSet or Array # @api private def self.extra_extensions @extra_extensions ||= [] end # @api private def self.extended(descendant) descendants << descendant descendant.instance_variable_set(:@valid, false) descendant.instance_variable_set(:@base_model, descendant) descendant.instance_variable_set(:@storage_names, {}) descendant.instance_variable_set(:@default_order, {}) descendant.extend(Chainable) extra_extensions.each { |mod| descendant.extend(mod) } extra_inclusions.each { |mod| descendant.send(:include, mod) } super end # @api private def inherited(descendant) descendants << descendant descendant.instance_variable_set(:@valid, false) descendant.instance_variable_set(:@base_model, base_model) descendant.instance_variable_set(:@storage_names, @storage_names.dup) descendant.instance_variable_set(:@default_order, @default_order.dup) super end # Gets the name of the storage receptacle for this resource in the given # Repository (ie., table name, for database stores). # # @return [String] # the storage name (ie., table name, for database stores) associated with # this resource in the given repository # # @api public def storage_name(repository_name = default_repository_name) storage_names[repository_name] ||= repository(repository_name)&.adapter&.resource_naming_convention&.call(default_storage_name).freeze end # the names of the storage receptacles for this resource across all repositories # # @return [Hash(Symbol => String)] # All available names of storage receptacles # # @api public def storage_names @storage_names end # Grab a single record by its key. Supports natural and composite key # lookups as well. # # Zoo.get(1) # get the zoo with primary key of 1. # Zoo.get!(1) # Or get! if you want an ObjectNotFoundError on failure # Zoo.get('DFW') # wow, support for natural primary keys # Zoo.get('Metro', 'DFW') # more wow, composite key look-up # # @param [Object] *key # The primary key or keys to use for lookup # # @return [Resource, nil] # A single model that was found # If no instance was found matching +key+ # # @api public def get(*key) assert_valid_key_size(key) repository = self.repository key = self.key(repository&.name).typecast(key) id_map = repository&.identity_map(self) id_map[key] || first(key_conditions(repository, key).update(order: nil)) end # Grab a single record just like #get, but raise an ObjectNotFoundError # if the record doesn't exist. # # @param [Object] *key # The primary key or keys to use for lookup # @return [Resource] # A single model that was found # @raise [ObjectNotFoundError] # The record was not found # # @api public def get!(*key) get(*key) || raise(ObjectNotFoundError, "Could not find #{name} with key #{key.inspect}") end def [](*args) all[*args] end alias_method :slice, :[] def at(*args) all.at(*args) end def fetch(*args, &block) all.fetch(*args, &block) end def values_at(*args) all.values_at(*args) end def reverse all.reverse end def each(&block) return to_enum unless block_given? all.each(&block) self end # Find a set of records matching an optional set of conditions. Additionally, # specify the order that the records are return. # # Zoo.all # all zoos # Zoo.all(:open => true) # all zoos that are open # Zoo.all(:opened_on => start..end) # all zoos that opened on a date in the date-range # Zoo.all(:order => [ :tiger_count.desc ]) # Ordered by tiger_count # # @param [Mixed] query # [Hash] A hash describing the conditions and order for the query # [DataMapper::Query] A query # @return [Collection] # A set of records found matching the conditions in +query+ # @see Collection # # @api public def all(query = Undefined) if query.equal?(Undefined) || (query.is_a?(Hash) && query.empty?) # TODO: after adding Enumerable methods to Model, try to return self here new_collection(self.query.dup) else new_collection(scoped_query(query)) end end # Return the first Resource or the first N Resources for the Model with an optional query # # When there are no arguments, return the first Resource in the # Model. When the first argument is an Integer, return a # Collection containing the first N Resources. When the last # (optional) argument is a Hash scope the results to the query. # # @param [Mixed] args(optional) # [Integer] limit the returned Collection to a specific number of entries # [Hash] query (optional) # scope the returned Resource or Collection to the supplied query # # @return [Resource, Collection] # The first resource in the entries of this collection, # or a new collection whose query has been merged # # @api public def first(*args) first_arg = args.first last_arg = args.last limit_specified = first_arg.is_a?(Integer) with_query = (last_arg.is_a?(Hash) && !last_arg.empty?) || last_arg.is_a?(Query) limit = limit_specified ? first_arg : 1 query = with_query ? last_arg : {} query = self.query.slice(0, limit).update(query) if limit_specified all(query) else query.repository.read(query).first end end # Return the last Resource or the last N Resources for the Model with an optional query # # When there are no arguments, return the last Resource for the # Model. When the first argument is an Integer, return a # Collection containing the last N Resources. When the last # (optional) argument is a Hash scope the results to the query. # # @param [Mixed] args (optional) # [Integer] limit the returned Collection to a specific number of entries # [Hash] query (optional) # scope the returned Resource or Collection to the supplied query # # @return [Resource, Collection] # The last resource in the entries of this collection, # or a new collection whose query has been merged # # @api public def last(*args) first_arg = args.first last_arg = args.last limit_specified = first_arg.is_a?(Integer) with_query = (last_arg.is_a?(Hash) && !last_arg.empty?) || last_arg.is_a?(Query) limit = limit_specified ? first_arg : 1 query = with_query ? last_arg : {} query = self.query.slice(0, limit).update(query).reverse! if limit_specified all(query) else query.repository.read(query).last end end # Finds the first Resource by conditions, or initializes a new # Resource with the attributes if none found # # @param [Hash] conditions # The conditions to be used to search # @param [Hash] attributes # The attributes to be used to create the record of none is found. # @return [Resource] # The instance found by +query+, or created with +attributes+ if none found # # @api public def first_or_new(conditions = {}, attributes = {}) first(conditions) || new(conditions.merge(attributes)) end # Finds the first Resource by conditions, or creates a new # Resource with the attributes if none found # # @param [Hash] conditions # The conditions to be used to search # @param [Hash] attributes # The attributes to be used to create the record of none is found. # @return [Resource] # The instance found by +query+, or created with +attributes+ if none found # # @api public def first_or_create(conditions = {}, attributes = {}) first(conditions) || create(conditions.merge(attributes)) end # Create a Resource # # @param [Hash(Symbol => Object)] attributes # attributes to set # # @return [Resource] # the newly created Resource instance # # @api public def create(attributes = {}) _create(attributes) end # Create a Resource, bypassing hooks # # @param [Hash(Symbol => Object)] attributes # attributes to set # # @return [Resource] # the newly created Resource instance # # @api public def create!(attributes = {}) _create(attributes, false) end # Update every Resource # # Person.update(:allow_beer => true) # # @param [Hash] attributes # attributes to update with # # @return [Boolean] # true if the resources were successfully updated # # @api public def update(attributes) all.update(attributes) end # Update every Resource, bypassing validations # # Person.update!(:allow_beer => true) # # @param [Hash] attributes # attributes to update with # # @return [Boolean] # true if the resources were successfully updated # # @api public def update!(attributes) all.update!(attributes) end # Remove all Resources from the repository # # @return [Boolean] # true if the resources were successfully destroyed # # @api public def destroy all.destroy end # Remove all Resources from the repository, bypassing validation # # @return [Boolean] # true if the resources were successfully destroyed # # @api public def destroy! all.destroy! end # Copy a set of records from one repository to another. # # @param [String] source_repository_name # The name of the Repository the resources should be copied _from_ # @param [String] target_repository_name # The name of the Repository the resources should be copied _to_ # @param [Hash] query # The conditions with which to find the records to copy. These # conditions are merged with Model.query # # @return [DataMapper::Repository] # A Collection of the Resource instances created in the operation # # @api public def copy(source_repository_name, target_repository_name, query = {}) target_properties = properties(target_repository_name) query[:fields] ||= properties(source_repository_name).select do |property| target_properties.include?(property) end repository(target_repository_name) do |repository| resources = [] all(query.merge(repository: source_repository_name)).each do |resource| new_resource = new query[:fields].each { |property| new_resource.__send__("#{property.name}=", property.get(resource)) } resources << new_resource if new_resource.save end all(Query.target_query(repository, self, resources)) end end # Loads an instance of this Model, taking into account IdentityMap lookup, # inheritance columns(s) and Property typecasting. # # @param [Enumerable(Object)] records # an Array of Resource or Hashes to load a Resource with # # @return [Resource] # the loaded Resource instance # # @api semipublic def load(records, query) repository = query.repository repository_name = repository.name fields = query.fields discriminator = properties(repository_name).discriminator no_reload = !query.reload? field_map = fields.to_h { |property| [property, property.field] } records.map do |record| identity_map = nil key_values = nil resource = nil case record when Hash # remap fields to use the Property object record = record.dup field_map.each { |property, field| record[property] = record.delete(field) if record.key?(field) } model = discriminator&.load(record[discriminator]) || self model_key = model.key(repository_name) resource = if model_key.valid?((key_values = record.values_at(*model_key))) identity_map = repository.identity_map(model) identity_map[key_values] end resource ||= model.allocate fields.each do |property| next if no_reload && property.loaded?(resource) value = record[property] # TODO: typecasting should happen inside the Adapter # and all values should come back as expected objects value = property.load(value) property.set!(resource, value) end when Resource model = record.model model_key = model.key(repository_name) resource = if model_key.valid?((key_values = record.key)) identity_map = repository.identity_map(model) identity_map[key_values] end resource ||= model.allocate fields.each do |property| next if no_reload && property.loaded?(resource) property.set!(resource, property.get!(record)) end end resource.instance_variable_set(:@_repository, repository) if identity_map resource&.persistence_state = Resource::PersistenceState::Clean.new(resource) unless resource&.persistence_state? # defer setting the IdentityMap so second level caches can # record the state of the resource after loaded identity_map[key_values] = resource else resource&.persistence_state = Resource::PersistenceState::Immutable.new(resource) end resource end end # @api semipublic attr_reader :base_model # The list of writer methods that can be mass-assigned to in #attributes= # # @return [Set] # # @api private attr_reader :allowed_writer_methods # @api semipublic def default_repository_name Repository.default_name end # @api semipublic def default_order(repository_name = default_repository_name) @default_order[repository_name] ||= key(repository_name).map { |property| Query::Direction.new(property) }.freeze end # Get the repository with a given name, or the default one for the current # context, or the default one for this class. # # @param [Symbol] name # the name of the repository wanted # @param [Block] block # block to execute with the fetched repository as parameter # # @return [Object, Repository] # whatever the block returns, if given a block, # otherwise the requested repository. # # @api private def repository(name = nil, &block) # # There has been a couple of different strategies here, but me (zond) and dkubb are at least # united in the concept of explicitness over implicitness. That is - the explicit wish of the # caller (+name+) should be given more priority than the implicit wish of the caller (Repository.context.last). # DataMapper.repository(name || repository_name, &block) end # Get the current +repository_name+ for this Model. # # If there are any Repository contexts, the name of the last one will # be returned, else the +default_repository_name+ of this model will be # # @return [String] # the current repository name to use for this Model # # @api private def repository_name context = Repository.context context.any? ? context.last&.name : default_repository_name end # Gets the current Set of repositories for which # this Model has been defined (beyond default) # # @return [Set] # The Set of repositories for which this Model # has been defined (beyond default) # # @api private def repositories [repository].to_set + @properties.keys.map { |repository_name| DataMapper.repository(repository_name) } end # @api private def const_missing(name) if name == :DM raise "#{name} prefix deprecated and no longer necessary (#{caller.first})" elsif name == :Resource Resource else super end end # @api private private def _create(attributes, execute_hooks = true) resource = new(attributes) resource.__send__(execute_hooks ? :save : :save!) resource end # @api private private def default_storage_name base_model.name end # Initializes a new Collection # # @return [Collection] # A new Collection object # # @api private private def new_collection(query, resources = nil, &block) Collection.new(query, resources, &block) end # @api private # TODO: move the logic to create relative query into Query private def scoped_query(query) if query.is_a?(Query) query.dup else repository = if query.key?(:repository) query = query.dup repository = query.delete(:repository) if repository.is_a?(Symbol) DataMapper.repository(repository) else repository end else self.repository end query = self.query.merge(query) if self.query.repository == repository query else repository.new_query(self, query.options) end end end # Initialize all foreign key properties established by relationships # # @return [undefined] # # @api private private def finalize_relationships relationships(repository_name).each(&:finalize) end # Initialize the list of allowed writer methods # # @return [undefined] # # @api private private def finalize_allowed_writer_methods @allowed_writer_methods = public_instance_methods.map(&:to_s).grep(WRITER_METHOD_REGEXP).to_set @allowed_writer_methods -= INVALID_WRITER_METHODS @allowed_writer_methods.freeze end # @api private # TODO: Remove this once appropriate warnings can be added. private def assert_valid(force = false) # :nodoc: return if @valid && !force @valid = true finalize end # Raises an exception if #get receives the wrong number of arguments # # @param [Array] key # the key value # # @return [undefined] # # @raise [UpdateConflictError] # raise if the resource is dirty # # @api private private def assert_valid_key_size(key) expected_key_size = self.key(repository_name).size actual_key_size = key.size return unless actual_key_size != expected_key_size raise ArgumentError, "The number of arguments for the key is invalid, expected #{expected_key_size} but was #{actual_key_size}" end # Test if the model name is valid # # @return [undefined] # # @api private private def assert_valid_name return unless name.to_s.strip.empty? raise IncompleteModelError, "#{inspect} must have a name" end # Test if the model has properties # # A model may also be valid if it has at least one m:1 relationships which # will add inferred foreign key properties. # # @return [undefined] # # @raise [IncompleteModelError] # raised if the model has no properties # # @api private private def assert_valid_properties repository_name = self.repository_name if properties(repository_name).empty? && relationships(repository_name).none? { |relationship| relationship.is_a?(Associations::ManyToOne::Relationship) } raise IncompleteModelError, "#{name} must have at least one property or many to one relationship in #{repository_name} to be valid" end end # Test if the model has a valid key # # @return [undefined] # # @raise [IncompleteModelError] # raised if the model does not have a valid key # # @api private private def assert_valid_key return unless key(repository_name).empty? raise IncompleteModelError, "#{name} must have a key in #{repository_name} to be valid" end end end