Back to Spree

Store-scoped Custom Field Definitions + persisted `filter_key`

docs/plans/6.0-store-scoped-custom-field-definitions.md

5.6.16.9 KB
Original Source

Store-scoped Custom Field Definitions + persisted filter_key

Status: Draft Target: Spree 6.0 Depends on: 5.4-6.0-custom-fields-rename.md, 5.6-6.0-single-store-promotions-payment-methods.md Author: Damian Legawiec Last updated: 2026-07-27

Summary

Spree::MetafieldDefinition (public name: Spree::CustomFieldDefinition) is currently global — it has no store_id, and nothing scopes it by store. Every store in an installation shares one custom-field schema, and the Admin API lets any store's admin read and edit every other store's definitions.

Two related changes are deferred here from the 5.6 custom-field search/sort/filter work (that PR shipped as a patch release and these are schema changes):

  1. store_id on definitions — make custom-field schema store-owned, matching the single-owner direction already taken for Product, Promotion, and PaymentMethod.
  2. Persist filter_key — the cf_<namespace>_<key> identifier used to sort and filter product listings is currently computed in Ruby on every call. A column lets it be uniquely indexed and looked up directly.

Together they make uniqueness (store_id, resource_type, filter_key), enforced by both a model validation and a DB unique index.

Key Decisions (do not deviate without discussion)

  • filter_key is the name, not search_key — the value drives sorting and filtering, not just text search. full_key was already taken: it returns the dotted custom.material form used by CSV headers (metafield.custom.material) and the Store API's key field, and must not be repurposed.
  • Format stays cf_<namespace>_<key>. Not cf_<namespace.length>_... (the original length-delimited form, dropped as unreadable) and not the definition's prefixed ID (opaque, and not portable across environments).
  • Uniqueness needs enforcing either way. key is already unique per (resource_type, namespace), but both segments normalize to underscored slugs, so ("a_b", "c") and ("a", "b_c") both flatten to cf_a_b_c — one of them would be unaddressable. Until the column exists this is a CONCAT-based validation (MetafieldDefinition::SearchCapabilities#filter_key_must_be_unique); after, it becomes a plain uniqueness validation plus a unique index.
  • Definitions get a has_many on Store, not a for_store scope — the association is the scoping mechanism (store.metafield_definitions).

Design Details

Schema

ruby
add_column :spree_metafield_definitions, :filter_key, :string
add_reference :spree_metafield_definitions, :store, index: true   # nullable at 5.x, null: false at 6.0

add_index :spree_metafield_definitions, [:store_id, :resource_type, :filter_key],
          unique: true, name: 'index_metafield_definitions_on_store_and_filter_key'

Model

  • Spree::Store gains has_many :metafield_definitions, dependent: :destroy_async plus a custom_field_definitions alias.
  • Spree::MetafieldDefinition gains belongs_to :store, a before_validation :set_filter_key callback (so the value follows a renamed namespace/key and back-fills on next write), and store-scoped uniqueness on both key and filter_key.
  • Backend callers that create definitions implicitly need a store. set_default_store falls back to Spree::Current.store || Spree::Store.default.

Call sites that must become store-scoped

All of these currently fetch definitions with for_resource_type and no store filter. The CSV pairs must stay in lockstep or header and value columns misalign:

LocationNote
Spree::Export#metafields_headersheader row
Spree::Product#to_csvvalue row — must match the header scope
Spree::Order#to_csvvalue row — takes a store arg that is currently ignored (_store)
Spree::Import#metafield_definitions_for_model
Spree::Product.search scope
Spree::SearchProvider::MetafieldSchema#product_definitionsneeds a store constructor arg
Spree::Metafields.ensure_metafield_definition_exists!auto-creates — needs a store
Spree::Metafields#resolve_metafield_definition_id_from_stringauto-creates via the "namespace.key" form
Api::V3::Admin::CustomFieldDefinitionsController#scopecurrently unscoped — cross-store read/write

Backfill

A rake task registered in the upgrade manifest, run post-db:migrate:

  • Assign store_id = Spree::Store.default.id to every definition missing one.
  • Populate filter_key on rows still missing it, via valid? (which fires the callback) then save!(validate: false) so unrelated legacy data doesn't block the column backfill.
  • Report and skip rows whose namespace/key pair collides with an already-backfilled row in the same store; only one can own the key. Operator renames one of each pair and re-runs. Idempotent.

Migration Path

  1. 5.x (additive): add both columns nullable, add has_many/belongs_to, ship the backfill task in the upgrade manifest, switch every call site above to the association. NULL store_id rows keep working until backfilled.
  2. 6.0 (cleanup): enforce null: false on store_id and filter_key, drop the CONCAT validation in favour of the plain uniqueness validation, and drop any global-row fallbacks.

Constraints on Current Work

Until this lands:

  • filter_key is a computed method, not a column — don't add a Ransack predicate, an order(:filter_key), or a where(filter_key: …) against it. Filter it in Ruby or match on namespace/key.
  • Uniqueness is enforced by filter_key_must_be_unique, a CONCAT-based validation. It's not backed by a DB index, so it can't hold under concurrent creates. Don't rely on it as a hard guarantee.
  • Definitions are global. Any new code reading them is reading every store's schema. Don't add features that assume per-store custom-field schemas.
  • Don't add a for_store scope to MetafieldDefinition — the has_many on Store is the intended mechanism.

Open Questions

  • Should existing installations get one definition set per store (copy the default store's schema to each store) or keep a single set on the default store? The backfill above assumes the latter; copying may surprise multi-store operators either way.
  • Does spree_multi_store want to keep sharing definitions across stores, the way it does for promotions and payment methods? If so the sharing bridge belongs in that extension, not core.
  • Spree::Metafield rows point at a definition, so a definition's store is implied by its resource. Worth validating that a metafield's resource and its definition agree on the store, or is that over-constraining?

References

  • docs/plans/5.4-6.0-custom-fields-rename.md — the Metafield → CustomField rename
  • docs/plans/5.6-6.0-single-store-promotions-payment-methods.md — the same single-owner migration for Promotion and PaymentMethod
  • docs/developer/core-concepts/metafields.mdx — search/sort/filter docs