Shiftable

Do your Spaceships belong to Captains, but sometimes a Captain will retire, and you need to reassign the spaceship?

We've all been there. This gem provides structure around the process of "shifting" your records from one associated record to a new record.

Project Shiftable
name, license, docs RubyGems.org License: MIT RubyDoc.info
version & downloads Version Total Downloads Downloads Today Homepage
dependencies & linting Depfu lint status
unit tests supported rubies unsupported status
coverage & maintainability Test Coverage codecov Maintainability Security Policy
resources Discussion Get help on Codementor Join the chat at https://gitter.im/pboling/shiftable Blog
Spread ~♡ⓛⓞⓥⓔ♡~ Open Source Helpers Liberapay Patrons Sponsor Me 🌏 👼 💻 🌹 Tweet @ Peter

Compatibility

Targeted ruby compatibility is non-EOL versions of Ruby, currently 2.6, 2.7, and 3.0, but may work on older Rubies back to 2.0, though it is limited to 2.5 in the gemspec. Feel free to fork if you need something older! Targeted ActiveRecord (Rails not required) compatibility follows the same scheme as Rails Security Issue maintenance policy, currently 6.1, 6.0, 5.2, but it is highly likely that this code will work in any version of ActiveRecord/Rails that runs on Ruby 2+.

Installation

Add this line to your application's Gemfile:

gem "shiftable"

And then execute:

$ bundle install

Or install it yourself as:

$ gem install shiftable

Usage

You are a spaceship captain (who isn't?!) so you have a spaceship (duh!)


class Captain < ActiveRecord::Base
  has_one :spaceship
end

Spaceships belong to the Captain.


class Spaceship < ActiveRecord::Base
  belongs_to :captain
end

But because you can't afford fuel, and this dystopian future continues to burn carbon, in episode 11, you need to sell the spaceship to your arch-nemesis Captain Sturgle.


class Captain < ActiveRecord::Base
  has_one :spaceship

  def sell_spaceship_to(nemesis_captain)
    Spaceship.shift_single(shift_to: nemesis_captain, shift_from: self)
  end
end

But how can you accomplish this? If you used the shiftable gem, won't take but a line(s) of code...


class Spaceship < ActiveRecord::Base
  belongs_to :captain
  extend Shiftable::Single.new belongs_to: :captain, has_one: :spaceship, precheck: true,
                               before_shift: ->(shifting_rel) { shifting_rel.result.ownership_changes += 1 }
end

NOTE: It doesn't matter if the extend occurs before or after the association macro belongs_to. In fact, it doesn't matter so much that you can even do this...


class Spaceship < ActiveRecord::Base
  belongs_to :captain

  class << self
    include Shiftable::Single.new belongs_to: :captain, has_one: :spaceship, precheck: true,
                                  before_shift: lambda { |shifting:, shift_to:, shift_from:|
                                    shifting.ownership_changes += 1
                                  }
  end
end

Single Table Inheritance

This works as you would expect with STI (single table inheritance) classes, i.e. when defined on a subclass, only the records of that class get shifted.

Multiple associations on a single class

What if the captain and the spaceship have a boss... the space federation! And in a run-in with their arch-Nemesis the Plinth-inth, all federation spaceships are commandeered! You are ruined!

class Spaceship < ActiveRecord::Base
  belongs_to :space_federation
  extend Shiftable::Collection.new belongs_to: :space_federation, has_many: :spaceships,
                                   before_shift: lambda { |shifting_rel|
                                     shifting_rel.each { |spaceship| spaceship.federation_changes += 1 }
                                   }
end

class SpaceFederation < ActiveRecord::Base
  has_many :spaceships

  def all_spaceships_commandeered_by(nemesis_federation)
    Spaceship.shift_cx(shift_to: nemesis_federation, shift_from: self)
  end
end

Polymorphism and has_many through

class SpaceTreaty < ActiveRecord::Base
  has_many :space_treaty_signature
end

class SpaceTreatySignature < ActiveRecord::Base
  belongs_to :space_treaty
  belongs_to :signatory, polymorphic: true
  # When two space federations assimilate (i.e. merge) to form a single larger federation,
  #   they become party to (i.e. signatories of) all the treaties that had been signed by either.
  # In practical terms, this means:
  #
  #   surviving_federation = SpaceFederation.find(1)
  #   assimilated_federation = SpaceFederation.find(2)
  #   SpaceTreatySignature.where(
  #     signatory_id: assimilated_federation_id,
  #     signatory_type: "SpaceFederation"
  #   ).update_all(
  #     signatory_id: surviving_federation.id
  #   )
  extend Shiftable::Collection.new(
    belongs_to: :signatory, has_many: :space_treaty_signature,
    polymorphic: { type: "SpaceFederation", as: :signatory },
    method_prefix: "space_federation_",
    before_shift: lambda { |shifting_rel|
      # Each item in shifting_rel is an instance of the class where Shiftable::Collection is defined,
      #   in this case: SpaceTreatySignature
      # And each of them has a signatory which is of type "SpaceFederation",
      #   because a polymorphic collection only targets one type.
      # shifting_rel.each { |signature| signature.signatory == "SpaceFederation" }
    }
  )
end

class SpaceFederation < ActiveRecord::Base
  has_many :space_treaty_signature, as: :signatory
  has_many :space_treaties, through: :space_treaty_signatures, as: :signatory
  has_many :treaty_planets, class_name: "Planet", through: :space_treaty_signatures, as: :signatory
  has_many :treaty_stations, class_name: "SpaceStation", through: :space_treaty_signatures, as: :signatory
  def assimilate_from(other_federation)
    SpaceTreatySignature.space_federation_shift_pcx(shift_to: self, shift_from: other_federation)
  end
end

# Including Planet and SpaceStation, for completeness of the example as the other "types" of polymorphic signatories
class Planet < ActiveRecord::Base
  has_many :space_treaty_signature, as: :signatory
  has_many :space_treaties, through: :space_treaty_signatures
  has_many :treaty_federations, class_name: "SpaceFederation", through: :space_treaty_signatures, as: :signatory
  has_many :treaty_stations, class_name: "SpaceStation", through: :space_treaty_signatures, as: :signatory
end

class SpaceStation < ActiveRecord::Base
  has_many :space_treaty_signature, as: :signatory
  has_many :space_treaties, through: :space_treaty_signatures
  has_many :treaty_federations, class_name: "SpaceFederation", through: :space_treaty_signatures, as: :signatory
  has_many :treaty_planets, class_name: "Planet", through: :space_treaty_signatures, as: :signatory
end

Wrapping a shift

For example, in a transaction. Let's update the nemesis foundation example from above with a transaction shift_each_wrapper, which we'll pull from the activerecord-transactionable gem, which provides best practice framing around transactions.

class Spaceship < ActiveRecord::Base
  belongs_to :space_federation
  extend Shiftable::Collection.new(
    belongs_to: :space_federation,
    has_many: :spaceships,
    before_shift: lambda { |shifting_rel|
                    shifting_rel.each { |spaceship| spaceship.federation_changes += 1 }
                  },
    wrapper: {
      each: lambda { |rel, record, &block|
              tresult = record.transaction_wrapper(outside_rescued_errors: ActiveRecord::RecordNotUnique) do
                puts "melon #{record.name} honey #{rel.count}"
                block.call # does the actual saving!
              end
              # NOTE: The value returned by the wrapper will also be returned by the call to `shift_cx`.
              #       You could return the whole tresult object here, instead of just true/false!
              tresult.success?
            },
      all: lambda { |rel, &block|
             tresult = Spaceship.transaction_wrapper do
               puts "can you eat #{rel.count} shoes"
               block.call
             end
             tresult.success?
           }
    }
  )
end

class SpaceFederation < ActiveRecord::Base
  has_many :spaceships

  def all_spaceships_commandeered_by(nemesis_federation)
    Spaceship.shift_cx(shift_to: nemesis_federation, shift_from: self)
  end
end

Complete example

Putting it all together...

class Captain < ActiveRecord::Base
  has_one :spaceship

  def sell_spaceship_to(nemesis_captain)
    Spaceship.shift_single(shift_to: nemesis_captain, shift_from: self)
  end
end

class Spaceship < ActiveRecord::Base
  belongs_to :captain
  extend Shiftable::Single.new belongs_to: :captain, has_one: :spaceship, precheck: true,
                               before_shift: ->(shifting_rel) { shifting_rel.result.ownership_changes += 1 }

  belongs_to :space_federation
  extend Shiftable::Collection.new(
    belongs_to: :space_federation,
    has_many: :spaceships,
    before_shift: lambda { |shifting_rel|
      shifting_rel.each { |spaceship| spaceship.federation_changes += 1 }
    },
    wrapper: {
      each: lambda { |rel, record, &block|
              tresult = record.transaction_wrapper(outside_rescued_errors: ActiveRecord::RecordNotUnique) do
                puts "melon #{record.name} honey #{rel.count}"
                block.call # does the actual saving!
              end
              tresult.success?
            },
      all: lambda { |rel, &block|
             tresult = Spaceship.transaction_wrapper do
               puts "can you eat #{rel.count} shoes"
               block.call
             end
             tresult.success?
           }
    }
  )
end

class SpaceFederation < ActiveRecord::Base
  has_many :captains
  has_many :spaceships
  has_many :space_treaty_signature, as: :signatory
  has_many :space_treaties, through: :space_treaty_signatures, as: :signatory
  has_many :treaty_planets, class_name: "Planet", through: :space_treaty_signatures, as: :signatory
  has_many :treaty_stations, class_name: "SpaceStation", through: :space_treaty_signatures, as: :signatory

  def assimilate_from(other_federation)
    SpaceTreatySignature.space_federation_shift_cx(shift_to: self, shift_from: other_federation)
  end

  def all_spaceships_commandeered_by(nemesis_federation)
    Spaceship.shift_cx(shift_to: nemesis_federation, shift_from: self)
  end
end
class SpaceTreaty < ActiveRecord::Base
  has_many :space_treaty_signatures
end

class SpaceTreatySignature < ActiveRecord::Base
  belongs_to :space_treaty
  belongs_to :signatory, polymorphic: true
  extend Shiftable::Collection.new(
    belongs_to: :signatory, has_many: :space_treaty_signatures,
    polymorphic: { type: "SpaceFederation", as: :signatory },
    method_prefix: "space_federation_"
  )
end

# Including Planet and SpaceStation, for completeness of the example as the other "types" of polymorphic signatories
class Planet < ActiveRecord::Base
  has_many :space_treaty_signatures, as: :signatory
  has_many :space_treaties, through: :space_treaty_signatures
  has_many :treaty_federations, class_name: "SpaceFederation", through: :space_treaty_signatures, as: :signatory
  has_many :treaty_stations, class_name: "SpaceStation", through: :space_treaty_signatures, as: :signatory
end

class SpaceStation < ActiveRecord::Base
  has_many :space_treaty_signature, as: :signatory
  has_many :space_treaties, through: :space_treaty_signatures
  has_many :treaty_federations, class_name: "SpaceFederation", through: :space_treaty_signatures, as: :signatory
  has_many :treaty_planets, class_name: "Planet", through: :space_treaty_signatures, as: :signatory
end

... stay tuned!

More Information

  • RubyDoc Documentation: RubyDoc.info
  • GitHub Discussions: Discussion
  • Live Chat on Gitter: Join the chat at https://gitter.im/pboling/activerecord-transactionable
  • Maintainer's Blog: Blog

Code of Conduct

Everyone interacting in the Shiftable project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

See [CONTRIBUTING.md][contributing]

Contributors

Contributors

Made with contributors-img.

Versioning

This library aims to adhere to Semantic Versioning 2.0.0. Violations of this scheme should be reported as bugs. Specifically, if a minor or patch version is released that breaks backward compatibility, a new version should be immediately released that restores compatibility. Breaking changes to the public API will only be introduced with new major versions.

As a result of this policy, you can (and should) specify a dependency on this gem using the Pessimistic Version Constraint with two digits of precision.

For example:

spec.add_dependency "shiftable", "~> 0.7"

Contact

Author and maintainer is Peter Boling (@pboling).

Comments are welcome in the GitHub Discussions board.

For security-related issues see SECURITY.

License

The gem is available as open source under the terms of the MIT License License: MIT. See LICENSE for the official Copyright Notice.