# Copyright 2011 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"). You # may not use this file except in compliance with the License. A copy of # the License is located at # # http://aws.amazon.com/apache2.0/ # # or in the "license" file accompanying this file. This file is # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. # todo move these to included modules (like validations and naming) require 'set' require 'uuidtools' require 'aws/indifferent_hash' require 'aws/record/naming' require 'aws/record/attribute_macros' require 'aws/record/scopes' require 'aws/record/finder_methods' require 'aws/record/optimistic_locking' require 'aws/record/validations' require 'aws/record/dirty_tracking' require 'aws/record/conversion' require 'aws/record/errors' require 'aws/record/exceptions' module AWS module Record # An ActiveRecord-like interface built ontop of AWS. # # class Book < AWS::Record::Base # # string_attr :title # string_attr :author # integer :number_of_pages # # timestamps # adds a :created_at and :updated_at pair of timestamps # # end # # b = Book.new(:title => 'My Book', :author => 'Me', :pages => 1) # b.save # # = Attribute Macros # # When extending AWS::Record::Base you should first consider what # attributes your class should have. Unlike ActiveRecord, AWS::Record # models are not backed by a database table/schema. You must choose what # attributes (and what types) you need. # # * +string_attr+ # * +boolean_attr+ # * +integer_attr+ # * +float_attr+ # * +datetime_attr+ # # For more information about the various attribute macros available, # and what options they accept, see {AttributeMacros}. # # === Usage # # Normally you just call these methods inside your model class definition: # # class Book < AWS::Record::Base # string_attr :title # boolean_attr :has_been_read # integer_attr :number_of_pages # float_attr :weight_in_pounds # datetime_attr :published_at # end # # For each attribute macro a pair of setter/getter methods are added # # to your class (and a few other useful methods). # # b = Book.new # b.title = "My Book" # b.has_been_read = true # b.number_of_pages = 1000 # b.weight_in_pounds = 1.1 # b.published_at = Time.now # b.save # # b.id #=> "0aa894ca-8223-4d34-831e-e5134b2bb71c" # b.attributes # #=> { 'title' => 'My Book', 'has_been_read' => true, ... } # # === Default Values # # All attribute macros accept the +:default_value+ option. This sets # a value that is populated onto all new instnaces of the class. # # class Book < AWS::Record::Base # string_attr :author, :deafult_value => 'Me' # end # # Book.new.author #=> 'Me' # # === Multi-Valued (Set) Attributes # # AWS::Record permits storing multiple values with a single attribute. # # class Book < AWS::Record::Base # string_attr :tags, :set => true # end # # b = Book.new # b.tags #=> #<Set: {}> # # b.tags = ['fiction', 'fantasy'] # b.tags #=> #<Set: {'fiction', 'fantasy'}> # # These multi-valued attributes are treated as sets, not arrays. This # means: # # * values are unordered # * duplicate values are automatically omitted # # Please consider these limitations when you choose to use the +:set+ # option with the attribute macros. # # = Validations # # It's important to validate models before there are persisted to keep # your data clean. AWS::Record supports most of the ActiveRecord style # validators. # # class Book < AWS::Record::Base # string_attr :title # validates_presence_of :title # end # # b = Book.new # b.valid? #=> false # b.errors.full_messages #=> ['Title may not be blank'] # # Validations are checked before saving a record. If any of the validators # adds an error, the the save will fail. # # For more information about the available validation methods see # {Validations}. # # = Finder Methods # # You can find records by their ID. Each record gets a UUID when it # is saved for the first time. You can use this ID to fetch the record # at a latter time: # # b = Book["0aa894ca-8223-4d34-831e-e5134b2bb71c"] # # b = Book.find("0aa894ca-8223-4d34-831e-e5134b2bb71c") # # If you try to find a record by ID that has no data an error will # be raised. # # === All # # You can enumerate all of your records using +all+. # # Book.all.each do |book| # puts book.id # end # # Book.find(:all) do |book| # puts book.id # end # # Be careful when enumerating all. Depending on the number of records # and number of attributes each record has, this can take a while, # causing quite a few requests. # # === First # # If you only want a single record, you should use +first+. # # b = Book.first # # === Modifiers # # Frequently you do not want ALL records or the very first record. You # can pass options to +find+, +all+ and +first+. # # my_books = Book.find(:all, :where => 'owner = "Me"') # # book = Book.first(:where => { :has_been_read => false }) # # You can pass as find options: # # * +:where+ - Conditions that must be met to be returned # * +:order+ - The order to sort matched records by # * +:limit+ - The maximum number of records to return # # = Scopes # # More useful than writing query fragments all over the place is to # name your most common conditions for reuse. # # class Book < AWS::Record::Base # # scope :mine, where(:owner => 'Me') # # scope :unread, where(:has_been_read => false) # # scope :by_popularity, order(:score, :desc) # # scope :top_10, by_popularity.limit(10) # # end # # # The following expression returns 10 books that belong # # to me, that are unread sorted by popularity. # next_good_reads = Book.mine.unread.top_10 # # There are 3 standard scope methods: # # * +where+ # * +order+ # * +limit+ # # === Conditions (where) # # Where accepts aruments in a number of forms: # # 1. As an sql-like fragment. If you need to escape values this form is # not suggested. # # Book.where('title = "My Book"') # # 2. An sql-like fragment, with placeholders. This escapes quoted # arguments properly to avoid injection. # # Book.where('title = ?', 'My Book') # # 3. A hash of key-value pairs. This is the simplest form, but also the # least flexible. You can not use this form if you need more complex # expressions that use or. # # Book.where(:title => 'My Book') # # === Order # # This orders the records as returned by AWS. Default ordering is ascending. # Pass the value :desc as a second argument to sort in reverse ordering. # # Book.order(:title) # alphabetical ordering # Book.order(:title, :desc) # reverse alphabetical ordering # # You may only order by a single attribute. If you call order twice in the # chain, the last call gets presedence: # # Book.order(:title).order(:price) # # In this example the books will be ordered by :price and the order(:title) # is lost. # # === Limit # # Just call +limit+ with an integer argument. This sets the maximum # number of records to retrieve: # # Book.limit(2) # # === Delayed Execution # # It should be noted that all finds are lazy (except +first+). This # means the value returned is not an array of records, rather a handle # to a {Scope} object that will return records when you enumerate over them. # # This allows you to build an expression without making unecessary requests. # In the following example no request is made until the call to # each_with_index. # # all_books = Books.all # ten_books = all_books.limit(10) # # ten_books.each_with_index do |book,n| # puts "#{n + 1} : #{book.title}" # end # class Base # for rails 3+ active model compatability extend Naming include Naming extend Validations extend AttributeMacros extend FinderMethods extend OptimisticLocking extend Scopes include Conversion include DirtyTracking # Constructs a new record for this class/domain. # # @param [Hash] attributes A set of attribute values to seed this record # with. The attributes are bulk assigned. # @return [Base] Returns a new record that has not been persisted yet. def initialize attributes = {} @_data = {} assign_default_values bulk_assign(attributes) end # The id for each record is auto-generated. The default strategy # generates uuid strings. # @return [String] Returns the id string (uuid) for this record. Retuns # nil if this is a new record that has not been persisted yet. def id @_id end # @return [Hash] A hash with attribute names as hash keys (strings) and # attribute values (of mixed types) as hash values. def attributes attributes = IndifferentHash.new attributes['id'] = id if persisted? self.class.attributes.keys.inject(attributes) do |hash,attr_name| hash[attr_name] = __send__(attr_name) hash end end # Persistence indicates if the record has been saved previously or not. # # @example # @recipe = Recipe.new(:name => 'Buttermilk Pancackes') # @recipe.persisted? #=> false # @recipe.save! # @recipe.persisted? #=> true # # @return [Boolean] Returns true if this record has been persisted. def persisted? !!@_persisted end # @return [Boolean] Returns true if this record has not been persisted # to SimpleDB. def new_record? !persisted? end # @return [Boolean] Returns true if this record has no validation errors. def valid? validate errors.empty? end # Creates new records, updates existing records. # @return [Boolean] Returns true if the record saved without errors, # false otherwise. def save if valid? persisted? ? update : create clear_changes! true else false end end # Creates new records, updates exsting records. If there is a validation # error then an exception is raised. # @raise [InvalidRecordError] Raised when the record has validation # errors and can not be saved. # @return [true] Returns true after a successful save. def save! raise InvalidRecordError.new(self) unless save true end # Bulk assigns the attributes and then saves the record. # @param [Hash] attribute_hash A hash of attribute names (keys) and # attribute values to assign to this record. # @return (see #save) def update_attributes attribute_hash bulk_assign(attribute_hash) save end # Bulk assigns the attributes and then saves the record. Raises # an exception (AWS::Record::InvalidRecordError) if the record is not # valid. # @param (see #update_attributes) # @return [true] def update_attributes! attribute_hash if update_attributes(attribute_hash) true else raise InvalidRecordError.new(self) end end # Deletes the record. # @return (see #delete_item) def delete if persisted? if deleted? raise 'unable to delete, this object has already been deleted' else delete_item end else raise 'unable to delete, this object has not been saved yet' end end # @return [Boolean] Returns true if this instance object has been deleted. def deleted? persisted? ? !!@_deleted : false end class << self # @return [Hash<String,Attribute>] Returns a hash of all of the # configured attributes for this class. def attributes @attributes ||= {} end # Allows you to override the default domain name for this record. # The defualt domain name is the class name. # @param [String] The domain name that should be used for this class. def set_domain_name name @_domain_name = name end # @return [String] Returns the full prefixed domain name for this class. def domain_name @_domain_name ||= self.to_s "#{Record.domain_prefix}#{@_domain_name}" end # Creates the SimpleDB domain that is configured for this class. def create_domain AWS::SimpleDB.new.domains.create(domain_name) end # @return [AWS::SimpleDB::Domain] Returns a reference to the domain # this class will save data to. # @private def sdb_domain AWS::SimpleDB.new.domains[domain_name] end end # If you define a custom setter, you use #[]= to set the value # on the record. # # class Book < AWS::Record::Base # # string_attr :name # # # replace the default #author= method # def author= name # self['author'] = name.blank? ? 'Anonymous' : name # end # # end # # @param [String,Symbol] The attribute name to set a value for # @param attribute_value The value to assign. protected def []= attribute_name, new_value self.class.attribute_for(attribute_name) do |attribute| if_tracking_changes do original_value = type_cast(attribute, attribute_was(attribute.name)) incoming_value = type_cast(attribute, new_value) if original_value == incoming_value clear_change!(attribute.name) else attribute_will_change!(attribute.name) end end @_data[attribute.name] = new_value end end # Returns the typecasted value for the named attribute. # # book = Book.new(:title => 'My Book') # book['title'] #=> 'My Book' # book.title #=> 'My Book' # # === Intended Use # # This method's primary use is for getting/setting the value for # an attribute inside a custom method: # # class Book < AWS::Record::Base # # string_attr :title # # def title # self['title'] ? self['title'].upcase : nil # end # # end # # book = Book.new(:title => 'My Book') # book.title #=> 'MY BOOK' # # @param [String,Symbol] attribute_name The name of the attribute to fetch # a value for. # @return The current type-casted value for the named attribute. protected def [] attribute_name self.class.attribute_for(attribute_name) do |attribute| type_cast(attribute, @_data[attribute.name]) end end # @return [SimpleDB::Item] Returns a reference to the item as stored in # simple db. # @private private def sdb_item self.class.sdb_domain.items[id] end # @private private def assign_default_values # populate default attribute values ignore_changes do self.class.attributes.values.each do |attribute| begin # copy default values down so methods like #gsub! don't # modify the default values for other objects @_data[attribute.name] = attribute.default_value.clone rescue TypeError @_data[attribute.name] = attribute.default_value end end end end # @return [true] # @private private def delete_item options = {} add_optimistic_lock_expectation(options) sdb_item.delete(options) @_deleted = true end # @private private def bulk_assign hash hash.each_pair do |attribute_name, attribute_value| __send__("#{attribute_name}=", attribute_value) end end # @private # @todo need to do something about partial hyrdation of attributes private def hydrate id, data @_id = id # New objects are populated with default values, but we don't # want these values to hang around when hydrating persisted values # (those values may have been blanked out before save). self.class.attributes.values.each do |attribute| @_data[attribute.name] = nil end ignore_changes do bulk_assign(deserialize_item_data(data)) end @_persisted = true end # This function accepts a hash of item data (as returned from # AttributeCollection#to_h or ItemData#attributes) and returns only # the key/value pairs that are configured attribues for this class. # @private private def deserialize_item_data item_data marked_for_deletion = item_data['_delete_'] || [] data = {} item_data.each_pair do |attr_name,values| attribute = self.class.attributes[attr_name] next unless attribute next if marked_for_deletion.include?(attr_name) if attribute.set? data[attr_name] = values.map{|v| attribute.deserialize(v) } else data[attr_name] = attribute.deserialize(values.first) end end data end # @private private def create populate_id touch_timestamps('created_at', 'updated_at') increment_optimistic_lock_value to_add = serialize_attributes add_optimistic_lock_expectation(to_add) sdb_item.attributes.add(to_add) @_persisted = true end # @private private def update return unless changed? touch_timestamps('updated_at') increment_optimistic_lock_value to_update = {} to_delete = [] # serialized_attributes will raise error if the entire record is blank attribute_values = serialize_attributes changed.each do |attr_name| if values = attribute_values[attr_name] to_update[attr_name] = values else to_delete << attr_name end end add_optimistic_lock_expectation(to_update) if to_delete.empty? sdb_item.attributes.replace(to_update) else sdb_item.attributes.replace(to_update.merge('_delete_' => to_delete)) sdb_item.attributes.delete(to_delete + ['_delete_']) end end # @private private def serialize_attributes hash = {} self.class.attributes.each_pair do |attribute_name,attribute| values = serialize(attribute, @_data[attribute_name]) unless values.empty? hash[attribute_name] = values end end # simple db does not support persisting items without attribute values raise EmptyRecordError.new(self) if hash.empty? hash end # @private private def increment_optimistic_lock_value if_locks_optimistically do |lock_attr_name| if value = self[lock_attr_name] self[lock_attr_name] += 1 else self[lock_attr_name] = 1 end end end # @private private def add_optimistic_lock_expectation options if_locks_optimistically do |lock_attr_name| was = attribute_was(lock_attr_name) if was options[:if] = { lock_attr_name => was.to_s } else options[:unless] = lock_attr_name end end end private def if_locks_optimistically &block if opt_lock_attr = self.class.optimistic_locking_attr yield(opt_lock_attr.name) end end # @private private def populate_id @_id = UUIDTools::UUID.random_create.to_s end # @private private def touch_timestamps *attributes time = Time.now attributes.each do |attr_name| if self.class.attributes[attr_name] and !attribute_changed?(attr_name) __send__("#{attr_name}=", time) end end end # @private private def type_cast attribute, raw if attribute.set? values = Record.as_array(raw).inject([]) do |values,value| values << attribute.type_cast(value) values end Set.new(values.compact) else attribute.type_cast(raw) end end # @private private def serialize attribute, raw type_casted = type_cast(attribute, raw) Record.as_array(type_casted).inject([]) do |values, value| values << attribute.serialize(value) values end end # @private private def self.attribute_for attribute_name, &block unless attributes[attribute_name.to_s] raise UndefinedAttributeError.new(attribute_name.to_s) end yield(attributes[attribute_name.to_s]) end end end end