Blog
0 pixels scrolled
  • Home
  • Work
  • Services
  • About
  • Blog
  • Contact
Upgrading from Solidus 2 to Solidus 3
Madeline Collier on July 04, 2024
Struggling to upgrade from Solidus 2 to Solidus 3? Here are some tips and tricks to help navigate this upgrade.
Upgrading from Solidus 2 to Solidus 3
Madeline Collier on July 04, 2024
Posted in Solidus
← Back to the blog

I recently had the pleasure of upgrading a long-running e-commerce storefront from Solidus 2.11 to Solidus 3.0. Solidus is a free and open-source e-commerce platform, silently helping thousands of stores turnover hundreds of millions in revenue each year. Like most sensible software projects, Solidus sticks to the convention of only allowing breaking changes in major version bumps, so while I knew that the jump from 2.11 to 3.0 was going to take longer than a day, I figured I’d have it wrapped in a week or so.

I was super wrong.

It quickly became apparent that this long-running store had never paid down an ever-accumulating mountain of tech debt. A tale as old as time. Rather than addressing deprecation warnings as they flared up with each minor version, they had simply done the bare minimum to get each version working, and kicked the rest down the road. The anticipated upgrade time immediately ballooned from days to weeks.

So my first piece of advice is handle deprecation warnings as they arise. Theoretically, if you have no deprecation warnings blaring when you go to do a version bump, you should be safe to go ahead without any additional changes. This allows projects to spread out maintenance work across little sprinklings of tickets that only take an hour to a half day, so you can quickly get back to feature work! What every stakeholder wants!

With that cautionary tale out of the way, let’s talk about the upgrade from Solidus 2.11 to 3.0.

I’m going to focus on 5 major pain points and how I addressed them in my upgrade:

  • From Spree::Variant#price_in to #price_for
  • The Spree::Address#firstname and #lastname migration
  • From paranoia to discard
  • Permitted attributes
  • The new Spree::Order state machine: from Spree::Order::Checkout to Spree::Core::StateMachines::Order

From Spree::Variant#price_in to #price_for (but actually #price_for_options)

In Solidus 3.0, the Spree::Variant#price_in method is removed. Unfortunately, the suggested replacement method, #price_for doesn’t actually return a Spree::Price, and instead returns an instance of Spree::Money. Due to this very confusing behaviour, this was #price_for also immediately deprecated in favour of #price_for_options.

The #price_for_options method does return a Spree::Price and is available in Solidus 3.1. Since this guide assumes you are upgrading to 3.0, and since it’s silly to replace all instances of one deprecated method with another method that is also almost immediately deprecated, my suggestion is to skip #price_for altogether, and go straight to #price_for_options.

As #price_for_options is not available in Solidus 3.0, you’ll need to add some overrides. Here is a link to the Solidus documentation with more details on using overrides. On this store, we refer to them as decorators.

Here is what your Spree::Variant::PriceSelectorDecorator should look like:

module Spree::Variant::PriceSelectorDecorator
  # TODO this whole decorator can be deleted once we hit Solidus 3.1.0.
  def price_for_options(price_options)
    variant.currently_valid_prices.detect do |price|
      (price.country_iso == price_options.desired_attributes[:country_iso] ||
       price.country_iso.nil?
      ) && price.currency == price_options.desired_attributes[:currency]
    end
  end

  ::Spree::Variant::PriceSelector.prepend self
end

And here is what your Spree::VariantDecorator should look like:

module Spree::VariantDecorator
  # TODO remove the price_for_options delegation once we hit Solidus 3.1.0.
  def self.prepended(base)
    base.delegate :price_for_options, to: :price_selector
  end

  ::Spree::Variant.prepend self
end

Since #price_in was callable off a Spree::Product like so:

pricing = product.price_in(current_pricing_options.currency)

You will also want to add the following to your Spree::ProductDecorator

module Spree::ProductDecorator
  # TODO remove the price_for_options delegation once we hit Solidus 3.1.0.
  def price_for_options(pricing_options)
    find_or_build_master.price_for_options(pricing_options)
  end

  ::Spree::Product.prepend self
end

As a side note, you may also want to include a more robust removal reminder than just a FIXME or TODO, so if you want to add a custom version-based deprecation warning, here is an example of how to do that:

module Spree::ProductDecorator
  def price_for_options(pricing_options)
    remove_me_on_solidus_version "3.1.0"
    find_or_build_master.price_for_options(pricing_options)
  end

  private

  def remove_me_on_solidus_version(version_string)
    return unless Spree.solidus_gem_version >= Gem::Version.new(version_string)
    ActiveSupport::Deprecation.warn(
      "You can remove this method and use Solidus's built-in " \
      "`#price_for_options` method now.",
      caller
    )
  end

  ::Spree::Product.prepend self
end

This is a great habit to get into and I strongly encourage you to go the extra mile. At the end of the day it’s way less effort than project-wide searching for rogue FIXME or TODOs to cleanup.

Finally, you’ll need to update everywhere you were calling #price_in so that instead of passing a currency (eg. EUR”, “USD”), you are passing the full pricing options object. Typically this will be an instance of Spree::Variant::PricingOptions, but technically it can be an arbitrary class configured using the Spree::Config.pricing_options_class.

Here is an example diff:

- pricing = product.price_in(current_pricing_options.currency)
+ pricing = product.price_for_options(current_pricing_options)

For more context, here is a link to the PR which introduces this new method #price_for_options into Solidus.

The Spree::Address#firstname and #lastname migration

For the most part, this was a simple find and replace. Spree::Address no longer supports #firstname or #lastname and those calls need to be replaced with the new method name. This is a positive change as many people have names that don’t split evenly into first and last names, and this change allows for more more cultural relativism in the way we store names. Unfortunately this can’t be a close-your-eyes-and-project-wide-find-and-replace, as the firstname and lastname attributes are still valid attributes for the Spree::User model.

Once again here is a simple example of the change:

  def serialize_order(order)
    {
      email: order.email,
-     lastname: order.shipping_address.lastname,
-     firstname: order.shipping_address.firstname,
+     name: order.shipping_address.name,
    }
  end

Unfortunately, sometimes when communicating with external APIs, they may still require a first and last name. For these cases, we chose to ad-hoc split the names into first and last names, but this is not ideal and should be avoided if possible.

names = order.shipping_address.name.split(" ")

{
  firstname: names.shift,
  lastname: names.join(" "),
}

Alternatively, you can rely on this Solidus module, Spree::Address::Name, defined this in v2.11 which is commonly used to split address names for third-party services that require first and last names.

Finally, ensure that anywhere you are creating a Spree::Address, you are using the name attribute instead of firstname and lastname.

- Spree::Address.create(first_name: "John", last_name: "Doe")
+ Spree::Address.create(name: "John Doe")

Do a quick project-wide search for firstname and lastname (as well as first_name, last_name, and full_name for good measure) to ensure you’ve caught all the instances of this change in relation to the Spree::Address model. You might also need to run a rake task to migrate data from the firstname/lastname fields to the name field.

Here is a PR which defines a rake task for just such an occasion.

For more context see the following PR in Solidus where the deprecations on the Address model are initially introduced.

Switching from paranoia to discard

Paranoia is a gem that allows you to “soft delete” records in your database. It does this by adding a deleted_at column to your table and then overriding the default #destroy method to set the deleted_at column to the current time instead of actually deleting the record.

Discard is a gem that does the same thing, but it takes a better approach. Instead of overriding the ActiveRecord #destroy method (something which is Not Good™ ), it adds a #discard method to your model that you can call to discard the record.

In the simplest cases, my diffs looked like this:

-  acts_as_paranoid
+  include Spree::SoftDeletable

The Spree::SoftDeletable module is pretty tiny, but worth taking a look at so that we can understand the implications of the changes we need to make:

require 'discard'

module Spree
  module SoftDeletable
    extend ActiveSupport::Concern

    included do
      include Discard::Model
      self.discard_column = :deleted_at

      default_scope { kept }
    end
  end
end

Essentially, this module works to make the transition to discard as painless as possible. Discard, by default, wants to look for a discarded_at column, but this module configures it to look for deleted_at (Paranoia’s default column)instead. This lets us avoid migrating all of our paranoid records from one column to the next, which would have been a minor to medium inconvenience depending on the number of records we have in production.

Next, it includes the Discard::Model module, which is the core of the gem.

Finally, it sets up a default scope kept that only returns records that have not been discarded. This is again not standard Discard behaviour, but it is standard Paranoia behaviour, so it’s a nice bridge to help us get used to the new gem as it may help your app continue to receive the same set of records it expects from the database.

For (only slightly) more complicated cases I had to do a little bit of manual work to make sure that references to Paranoia methods were replaced with the equivalent methods from Discard, while ensuring the returned records were consistent.

-  default_variant = Spree::Variant.with_deleted.find(default_variant_id)
+  default_variant = Spree::Variant.with_discarded.find(default_variant_id)

You’ll want to check the Paranoia repo for a full list of methods that you’ll need to migrate to Discard. Here are a few of the most common ones:

paranoia_destroy
paranoia_delete
with_deleted
only_deleted
really_destroy!
after_real_destroy

For more context, see the following PR which swaps Solidus from Paranoia to Discard.

Spree::PermittedAttributes

Permitted attributes are part of StrongParameters or what Solidus and Rails use to ensure incoming params have been previously greenlit for sensitive controllers and actions.

In brief, the checkout attributes have now been split up by checkout step so that each step can have individual control over expected and allowed parameters.

Here is what the diff looks like inside of Solidus (not your application code):

-  permitted_checkout_attributes
+  permitted_checkout_address_attributes
+  permitted_checkout_delivery_attributes
+  permitted_checkout_payment_attributes
+  permitted_checkout_confirm_attributes

And here is an example diff for the required changes inside of /config/initializers/spree.rb

-  Spree::PermittedAttributes.checkout_attributes << :gift
+  Spree::PermittedAttributes.checkout_address_attributes << :gift
+  Spree::PermittedAttributes.checkout_delivery_attributes << :gift
+  Spree::PermittedAttributes.checkout_payment_attributes << :gift
+  Spree::PermittedAttributes.checkout_confirm_attributes << :gift

This will vary wildly per app, and the use of :gift here is a totally arbitrary stand-in for whatever your app’s current permitted_checkout_attributes are. It’s also worth doing a bit of extra digging to see if all the checkout steps really need access to the :gift param, or if it’s only relevant to say checkout_delivery_attributes for example.

Here is a commit with more information regarding the change.

Finally, a quick note about Spree::Core::StateMachines::Order

The Solidus CHANGELOG.md states that Spree::Order::Checkout is not used anymore. Spree::Core::StateMachines::Order is identical. This is true, however some stores may already be using a custom state machine based on the legacy Spree::Order::Checkout state machine, which will break. This can be a tough error to surface so it’s worth double checking there are no custom overrides to your app’s order state machine.

For more information, see the relevant PR against Solidus.

That’s all folks

This guide covers some of the key changes, but it is not exhaustive. For a less in-depth, but more complete list of changes please refer to the official Solidus changelog as an invaluable roadmap for the upgrade. Hopefully this saves someone somewhere some amount of time in the future.

Work ServicesAboutBlogCareersContact


Privacy policyTerms and conditions© 2024 Super Good Software Inc. All rights reserved.