lib/draftsman/model.rb in draftsman-0.5.1 vs lib/draftsman/model.rb in draftsman-0.6.0

- old
+ new

@@ -6,53 +6,65 @@ def self.included(base) base.send :extend, ClassMethods end module ClassMethods - # Declare this in your model to enable the Draftsman API for it. A draft of the model is available in the `draft` - # association (if one exists). + # Declare this in your model to enable the Draftsman API for it. A draft + # of the model is available in the `draft` association (if one exists). # # Options: # # :class_name - # The name of a custom `Draft` class. This class should inherit from `Draftsman::Draft`. A global default can be - # set for this using `Draftsman.draft_class_name=` if the default of `Draftsman::Draft` needs to be overridden. + # The name of a custom `Draft` class. This class should inherit from + # `Draftsman::Draft`. A global default can be set for this using + # `Draftsman.draft_class_name=` if the default of `Draftsman::Draft` needs + # to be overridden. # # :ignore - # An array of attributes for which an update to a `Draft` will not be stored if they are the only ones changed. + # An array of attributes for which an update to a `Draft` will not be + # stored if they are the only ones changed. # # :only - # Inverse of `ignore` - a new `Draft` will be created only for these attributes if supplied. It's recommended that - # you only specify optional attributes for this (that can be empty). + # Inverse of `ignore` - a new `Draft` will be created only for these + # attributes if supplied. It's recommended that you only specify optional + # attributes for this (that can be empty). # # :skip - # Fields to ignore completely. As with `ignore`, updates to these fields will not create a new `Draft`. In - # addition, these fields will not be included in the serialized versions of the object whenever a new `Draft` is - # created. + # Fields to ignore completely. As with `ignore`, updates to these fields + # will not create a new `Draft`. In addition, these fields will not be + # included in the serialized versions of the object whenever a new `Draft` + # is created. # # :meta - # A hash of extra data to store. You must add a column to the `drafts` table for each key. Values are objects or - # `procs` (which are called with `self`, i.e. the model with the `has_drafts`). See - # `Draftsman::Controller.info_for_draftsman` for an example of how to store data from the controller. + # A hash of extra data to store. You must add a column to the `drafts` + # table for each key. Values are objects or `procs` (which are called with + # `self`, i.e. the model with the `has_drafts`). See + # `Draftsman::Controller.info_for_draftsman` for an example of how to + # store data from the controller. # # :draft - # The name to use for the `draft` association shortcut method. Default is `:draft`. + # The name to use for the `draft` association shortcut method. Default is + # `:draft`. # # :published_at - # The name to use for the method which returns the published timestamp. Default is `published_at`. + # The name to use for the method which returns the published timestamp. + # Default is `published_at`. # # :trashed_at - # The name to use for the method which returns the soft delete timestamp. Default is `trashed_at`. + # The name to use for the method which returns the soft delete timestamp. + # Default is `trashed_at`. def has_drafts(options = {}) # Lazily include the instance methods so we don't clutter up # any more ActiveRecord models than we need to. send :include, InstanceMethods send :extend, AttributesSerialization # Define before/around/after callbacks on each drafted model send :extend, ActiveModel::Callbacks - define_model_callbacks :draft_creation, :draft_update, :draft_destruction, :draft_destroy + # TODO: Remove `draft_creation`, `draft_update`, and `draft_destroy` in + # v1.0. + define_model_callbacks :save_draft, :draft_creation, :draft_update, :draft_destruction, :draft_destroy class_attribute :draftsman_options self.draftsman_options = options.dup class_attribute :draft_association_name @@ -76,47 +88,32 @@ class_attribute :trashed_at_attribute_name self.trashed_at_attribute_name = options[:trashed_at] || :trashed_at # `belongs_to :draft` association - belongs_to self.draft_association_name, :class_name => self.draft_class_name, :dependent => :destroy + belongs_to(self.draft_association_name, class_name: self.draft_class_name, dependent: :destroy) # Scopes - scope :drafted, (lambda do |referenced_table_name = nil| + scope :drafted, -> (referenced_table_name = nil) { referenced_table_name = referenced_table_name.present? ? referenced_table_name : table_name + where.not(referenced_table_name => { "#{self.draft_association_name}_id" => nil }) + } - if where_not? - where.not(referenced_table_name => { "#{self.draft_association_name}_id" => nil }) - else - where("#{referenced_table_name}.#{self.draft_association_name}_id IS NOT NULL") - end - end) - - scope :published, (lambda do |referenced_table_name = nil| + scope :published, -> (referenced_table_name = nil) { referenced_table_name = referenced_table_name.present? ? referenced_table_name : table_name + where.not(referenced_table_name => { self.published_at_attribute_name => nil }) + } - if where_not? - where.not(referenced_table_name => { self.published_at_attribute_name => nil }) - else - where("#{self.published_at_attribute_name} IS NOT NULL") - end - end) - - scope :trashed, (lambda do |referenced_table_name = nil| + scope :trashed, -> (referenced_table_name = nil) { referenced_table_name = referenced_table_name.present? ? referenced_table_name : table_name + where.not(referenced_table_name => { self.trashed_at_attribute_name => nil }) + } - if where_not? - where.not(referenced_table_name => { self.trashed_at_attribute_name => nil }) - else - where("#{self.trashed_at_attribute_name} IS NOT NULL") - end - end) - - scope :live, (lambda do |referenced_table_name = nil| + scope :live, -> (referenced_table_name = nil) { referenced_table_name = referenced_table_name.present? ? referenced_table_name : table_name where(referenced_table_name => { self.trashed_at_attribute_name => nil }) - end) + } end # Returns draft class. def draft_class @draft_class ||= draft_class_name.constantize @@ -129,56 +126,27 @@ # Returns whether or not a `trashed_at` timestamp is set up on this model. def trashable? draftable? && method_defined?(self.trashed_at_attribute_name) end - - # Returns whether or not the included ActiveRecord can do `where.not(...)` style queries. - def where_not? - ActiveRecord::VERSION::STRING.to_f >= 4.0 - end end module InstanceMethods # Returns whether or not this item has a draft. def draft? send(self.class.draft_association_name).present? end - # Creates object and records a draft for the object's creation. Returns `true` or `false` depending on whether or not - # the objects passed validation and the save was successful. + # DEPRECATED: Use `#draft_save` instead. def draft_creation - run_callbacks :draft_creation do - transaction do - # We want to save the draft after create - return false unless self.save - - data = { - :item => self, - :event => 'create', - :whodunnit => Draftsman.whodunnit, - :object => object_attrs_for_draft_record - } - data[:object_changes] = changes_for_draftsman(previous_changes: true) if track_object_changes_for_draft? - data = merge_metadata_for_draft(data) - - send "build_#{self.class.draft_association_name}", data - - if send(self.class.draft_association_name).save - write_attribute "#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id - self.update_column "#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id - else - raise ActiveRecord::Rollback and return false - end - end - end - return true + ActiveSupport::Deprecation.warn('`#draft_creation` is deprecated and will be removed from Draftsman 1.0. Use `#save_draft` instead.') + _draft_creation end - # DEPRECATED: Use `draft_destruction` instead. + # DEPRECATED: Use `#draft_destruction` instead. def draft_destroy - ActiveSupport::Deprecation.warn('`draft_destroy` is deprecated and will be removed from Draftsman 1.0. Use `draft_destruction` instead.') + ActiveSupport::Deprecation.warn('`#draft_destroy` is deprecated and will be removed from Draftsman 1.0. Use `draft_destruction` instead.') run_callbacks :draft_destroy do _draft_destruction end end @@ -188,208 +156,263 @@ run_callbacks :draft_destruction do _draft_destruction end end - # Updates object and records a draft for an `update` event. If the draft is being updated to the object's original - # state, the draft is destroyed. Returns `true` or `false` depending on if the object passed validation and the save - # was successful. + # DEPRECATED: Use `#draft_save` instead. def draft_update - run_callbacks :draft_update do - transaction do - save_only_columns_for_draft - - # We want to save the draft before update - return false unless self.valid? - - # If updating a creation draft, also update this item - if self.draft? && send(self.class.draft_association_name).create? - data = { - :item => self, - :whodunnit => Draftsman.whodunnit, - :object => object_attrs_for_draft_record - } - - if track_object_changes_for_draft? - data[:object_changes] = changes_for_draftsman(changed_from: self.send(self.class.draft_association_name).changeset) - end - data = merge_metadata_for_draft(data) - send(self.class.draft_association_name).update_attributes data - self.save - # Destroy the draft if this record has changed back to the original record - elsif changed_to_original_for_draft? - nilified_draft = send(self.class.draft_association_name) - send "#{self.class.draft_association_name}_id=", nil - self.save - nilified_draft.destroy - # Save a draft if record is changed notably - elsif changed_notably_for_draft? - data = { - :item => self, - :whodunnit => Draftsman.whodunnit, - :object => object_attrs_for_draft_record - } - data = merge_metadata_for_draft(data) - - # If there's already a draft, update it. - if send(self.class.draft_association_name).present? - data[:object_changes] = changes_for_draftsman if track_object_changes_for_draft? - send(self.class.draft_association_name).update_attributes data - update_skipped_attributes - # If there's not draft, create an update draft. - else - data[:event] = 'update' - data[:object_changes] = changes_for_draftsman if track_object_changes_for_draft? - send "build_#{self.class.draft_association_name}", data - - if send(self.class.draft_association_name).save - update_column "#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id - update_skipped_attributes - else - raise ActiveRecord::Rollback and return false - end - end - # If record is a draft and not changed notably, then update the draft. - elsif self.draft? - data = { - :item => self, - :whodunnit => Draftsman.whodunnit, - :object => object_attrs_for_draft_record - } - data[:object_changes] = changes_for_draftsman(changed_from: @object.draft.changeset) if track_object_changes_for_draft? - data = merge_metadata_for_draft(data) - send(self.class.draft_association_name).update_attributes data - update_skipped_attributes - # Otherwise, just save the record - else - self.save - end - end - end - rescue Exception => e - false + ActiveSupport::Deprecation.warn('`#draft_update` is deprecated and will be removed from Draftsman 1.0. Use `#save_draft` instead.') + _draft_update end # Returns serialized object representing this drafted item. def object_attrs_for_draft_record(object = nil) object ||= self - _attrs = object.attributes.except(*self.class.draftsman_options[:skip]).tap do |attributes| + attrs = object.attributes.except(*self.class.draftsman_options[:skip]).tap do |attributes| self.class.serialize_attributes_for_draftsman attributes end - self.class.draft_class.object_col_is_json? ? _attrs : Draftsman.serializer.dump(_attrs) + if self.class.draft_class.object_col_is_json? + attrs + else + Draftsman.serializer.dump(attrs) + end end # Returns whether or not this item has been published at any point in its lifecycle. def published? self.published_at.present? end + # Creates or updates draft depending on state of this item and if it has + # any drafts. + # + # - If a completely new record, persists this item to the database and + # records a `create` draft. + # - If an existing record with an existing `create` draft, updates the + # record and the existing `create` draft. + # - If an existing record with no existing draft, records changes in an + # `update` draft. + # - If an existing record with an existing draft (`create` or `update`), + # updated back to its original undrafted state, removes associated + # `draft record`. + # + # Returns `true` or `false` depending on if the object passed validation + # and the save was successful. + def save_draft + run_callbacks :save_draft do + if self.new_record? + _draft_creation + else + _draft_update + end + end + end + # Returns whether or not this item has been trashed def trashed? send(self.class.trashed_at_attribute_name).present? end private + # Creates object and records a draft for the object's creation. Returns + # `true` or `false` depending on whether or not the objects passed + # validation and the save was successful. + def _draft_creation + transaction do + # TODO: Remove callback wrapper in v1.0. + run_callbacks :draft_creation do + # We want to save the draft after create + return false unless self.save + + # Build data to store in draft record. + data = { + item: self, + event: :create, + } + data[:object] = object_attrs_for_draft_record if Draftsman.stash_drafted_changes? + data[Draftsman.whodunnit_field] = Draftsman.whodunnit + data[:object_changes] = serialized_draft_changeset(changes_for_draftsman(:create)) if track_object_changes_for_draft? + data = merge_metadata_for_draft(data) + send("build_#{self.class.draft_association_name}", data) + + if send(self.class.draft_association_name).save + fk = "#{self.class.draft_association_name}_id" + id = send(self.class.draft_association_name).id + self.update_column(fk, id) + else + raise ActiveRecord::Rollback and return false + end + end + end + + return true + end + # This is only abstracted away at this moment because of the # `draft_destroy` deprecation. Move all of this logic back into # `draft_destruction` after `draft_destroy is removed.` def _draft_destruction transaction do data = { - :item => self, - :event => 'destroy', - :whodunnit => Draftsman.whodunnit, - :object => object_attrs_for_draft_record + item: self, + event: :destroy } + data[:object] = object_attrs_for_draft_record if Draftsman.stash_drafted_changes? + data[Draftsman.whodunnit_field] = Draftsman.whodunnit # Stash previous draft in case it needs to be reverted later if self.draft? attrs = send(self.class.draft_association_name).attributes - data[:previous_draft] = if self.class.draft_class.previous_draft_col_is_json? - attrs - else - Draftsman.serializer.dump(attrs) - end + data[:previous_draft] = + if self.class.draft_class.previous_draft_col_is_json? + attrs + else + Draftsman.serializer.dump(attrs) + end end data = merge_metadata_for_draft(data) if send(self.class.draft_association_name).present? - send(self.class.draft_association_name).update_attributes! data + send(self.class.draft_association_name).update!(data) else send("build_#{self.class.draft_association_name}", data) send(self.class.draft_association_name).save! - send "#{self.class.draft_association_name}_id=", send(self.class.draft_association_name).id - self.update_column "#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id + send("#{self.class.draft_association_name}_id=", send(self.class.draft_association_name).id) + self.update_column("#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id) end trash! # Mock `dependent: :destroy` behavior for all trashable associations dependent_associations = self.class.reflect_on_all_associations(:has_one) + self.class.reflect_on_all_associations(:has_many) dependent_associations.each do |association| - if association.klass.draftable? && association.options.has_key?(:dependent) && association.options[:dependent] == :destroy dependents = self.send(association.name) dependents = [dependents] if (dependents && association.macro == :has_one) - dependents.each do |dependent| - dependent.draft_destruction unless dependent.draft? && dependent.send(dependent.class.draft_association_name).destroy? - end if dependents + if dependents + dependents.each do |dependent| + dependent.draft_destruction unless dependent.draft? && dependent.send(dependent.class.draft_association_name).destroy? + end + end end end end end - # Returns changes on this object, excluding attributes defined in the options for `:ignore` and `:skip`. - def changed_and_not_ignored_for_draft(options = {}) - options[:previous_changes] ||= false + # Updates object and records a draft for an `update` event. If the draft + # is being updated to the object's original state, the draft is destroyed. + # Returns `true` or `false` depending on if the object passed validation + # and the save was successful. + def _draft_update + # TODO: Remove callback wrapper in v1.0. + transaction do + run_callbacks :draft_update do + # Run validations. + return false unless self.valid? - my_changed = options[:previous_changes] ? previous_changes.keys : self.changed + # If updating a create draft, also update this item. + if self.draft? && send(self.class.draft_association_name).create? + the_changes = changes_for_draftsman(:create) + data = { item: self } + data[Draftsman.whodunnit_field] = Draftsman.whodunnit + data[:object] = object_attrs_for_draft_record if Draftsman.stash_drafted_changes? + data[:object_changes] = serialized_draft_changeset(the_changes) if track_object_changes_for_draft? - ignore = self.class.draftsman_options[:ignore] - skip = self.class.draftsman_options[:skip] - my_changed - ignore - skip - end + data = merge_metadata_for_draft(data) + send(self.class.draft_association_name).update(data) + self.save + else + the_changes = changes_for_draftsman(:update) + save_only_columns_for_draft if Draftsman.stash_drafted_changes? - # Returns whether or not this instance has changes that should trigger a new draft. - def changed_notably_for_draft? - notably_changed_attributes_for_draft.any? - end + # Destroy the draft if this record has changed back to the original + # record. + if self.draft? && the_changes.empty? + nilified_draft = send(self.class.draft_association_name) + send("#{self.class.draft_association_name}_id=", nil) + self.save + nilified_draft.destroy + # Save an update draft if record is changed notably. + elsif !the_changes.empty? + data = { item: self, event: :update } + data[Draftsman.whodunnit_field] = Draftsman.whodunnit + data[:object] = object_attrs_for_draft_record if Draftsman.stash_drafted_changes? + data[:object_changes] = serialized_draft_changeset(the_changes) if track_object_changes_for_draft? + data = merge_metadata_for_draft(data) - # Returns whether or not the updates change this draft back to the original state - def changed_to_original_for_draft? - send(self.draft_association_name).present? && send(self.class.draft_association_name).update? && !changed_notably_for_draft? - end + # If there's already a draft, update it. + if self.draft? + send(self.class.draft_association_name).update(data) - # Returns array of attributes that have changed for the object. - def changes_for_draftsman(options = {}) - options[:changed_from] ||= {} - options[:previous_changes] ||= false + if Draftsman.stash_drafted_changes? + update_skipped_attributes + else + self.save + end + # If there's not an existing draft, create an update draft. + else + send("build_#{self.class.draft_association_name}", data) - my_changes = options[:previous_changes] ? self.previous_changes : self.changes + if send(self.class.draft_association_name).save + update_column("#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id) - new_changes = my_changes.delete_if do |key, value| - !notably_changed_attributes_for_draft(previous_changes: options[:previous_changes]).include?(key) - end.tap do |changes| - self.class.serialize_draft_attribute_changes(changes) # Use serialized value for attributes when necessary + if Draftsman.stash_drafted_changes? + update_skipped_attributes + else + self.save + end + else + raise ActiveRecord::Rollback and return false + end + end + # Otherwise, just save the record. + else + self.save + end + end + end end + rescue Exception => e + false + end - new_changes.each do |attribute, value| - new_changes[attribute][0] = options[:changed_from][attribute][0] if options[:changed_from].has_key?(attribute) + # Returns hash of attributes that have changed for the object, similar to + # how ActiveRecord's `changes` works. + def changes_for_draftsman(event) + the_changes = {} + ignore = self.class.draftsman_options[:ignore] + skip = self.class.draftsman_options[:skip] + only = self.class.draftsman_options[:only] + draftable_attrs = self.attributes.keys - ignore - skip + draftable_attrs = draftable_attrs & only if only.present? + + # If there's already an update draft, get its changes and reconcile them + # manually. + if event == :update + # Collect all attributes' previous and new values. + draftable_attrs.each do |attr| + if self.draft? && self.draft.changeset.key?(attr) + the_changes[attr] = [self.draft.changeset[attr].first, send(attr)] + else + the_changes[attr] = [self.send("#{attr}_was"), send(attr)] + end + end + # If there is no draft or it's for a create, then all draftable + # attributes are the changes. + else + draftable_attrs.each { |attr| the_changes[attr] = [nil, send(attr)] } end - # We need to merge any previous changes so they are not lost on further updates before committing or - # reverting - my_changes = options[:changed_from].merge new_changes - - self.class.draft_class.object_changes_col_is_json? ? my_changes : Draftsman.serializer.dump(my_changes) + # Purge attributes that haven't changed. + the_changes.delete_if { |key, value| value.first == value.last } end # Merges model-level metadata from `meta` and `controller_info` into draft object. def merge_metadata_for_draft(data) # First, we merge the model-level metadata in `meta`. @@ -411,55 +434,50 @@ # Second, we merge any extra data from the controller (if available). data.merge(Draftsman.controller_info || {}) end - # Returns array of attributes that were changed to trigger a draft. - def notably_changed_attributes_for_draft(options = {}) - options[:previous_changes] ||= false - - only = self.class.draftsman_options[:only] - only.empty? ? changed_and_not_ignored_for_draft(previous_changes: options[:previous_changes]) : (changed_and_not_ignored_for_draft(previous_changes: options[:previous_changes]) & only) - end - # Save columns outside of the `only` option directly to master table def save_only_columns_for_draft if self.class.draftsman_options[:only].any? only_changes = {} - only_changed_attributes = self.changed - self.class.draftsman_options[:only] + only_changed_attributes = self.attributes.keys - self.class.draftsman_options[:only] - only_changed_attributes.each do |attribute| - only_changes[attribute] = self.changes[attribute].last + only_changed_attributes.each do |key| + only_changes[key] = send(key) end - self.update_columns only_changes if only_changes.any? + self.update_columns(only_changes) if only_changes.any? end end + # Returns changeset data in format appropriate for `object_changes` + # column. + def serialized_draft_changeset(my_changes) + self.class.draft_class.object_changes_col_is_json? ? my_changes : Draftsman.serializer.dump(my_changes) + end + # Returns whether or not the draft class includes an `object_changes` attribute. def track_object_changes_for_draft? - self.class.draft_class.column_names.include? 'object_changes' + self.class.draft_class.column_names.include?('object_changes') end # Sets `trashed_at` attribute to now and saves to the database immediately. def trash! - write_attribute self.class.trashed_at_attribute_name, Time.now - self.update_column self.class.trashed_at_attribute_name, send(self.class.trashed_at_attribute_name) + self.update_column(self.class.trashed_at_attribute_name, Time.now) end # Updates skipped attributes' values on this model. def update_skipped_attributes - if draftsman_options[:skip].present? - changed_and_skipped_keys = self.changed.select { |key| draftsman_options[:skip].include?(key) } - changed_and_skipped_attrs = {} - changed_and_skipped_keys.each { |key| changed_and_skipped_attrs[key] = self.changes[key].last } + # Skip over this if nothing's being skipped. + return true unless draftsman_options[:skip].present? - self.reload - self.attributes = changed_and_skipped_attrs - self.save - else - true - end + keys = self.attributes.keys.select { |key| draftsman_options[:skip].include?(key) } + attrs = {} + keys.each { |key| attrs[key] = self.send(key) } + + self.reload + self.update(attrs) end end end end