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