Skip to content

Instantly share code, notes, and snippets.

@joeljunstrom
Created July 8, 2026 10:38
Show Gist options
  • Select an option

  • Save joeljunstrom/36e9ff508ecc2b1ee5f0b9edfe18d1ef to your computer and use it in GitHub Desktop.

Select an option

Save joeljunstrom/36e9ff508ecc2b1ee5f0b9edfe18d1ef to your computer and use it in GitHub Desktop.
SelectableAttributes DSL. Delegation methods that prefers values that are fetched from a select. Automatic scope and delegation method generation
# frozen_string_literal: true
# = Selectable Attributes
#
# Declares attributes that normally come from an associated record, but which
# can instead be loaded in a single +SELECT+ (via a join) alongside the host
# record. This keeps bulk reads (CSV exports, large index pages) free of N+1
# association loads, while individual records still resolve the value by walking
# the association when the joined columns were not selected.
#
# Each declared column becomes a reader method on the model. The reader returns
# the joined value when it is present on the loaded row, and otherwise walks the
# association to fetch it, so callers use the same method regardless of how the
# record was loaded.
#
# == Usage
#
# Declare attributes inside an +attributes_from_select+ block. +column+ takes an
# Arel source, +columns+ pulls several from one table, and each declaration can
# generate a scope that loads the values in a single join. The example below
# exercises the full surface; +attributes_from_select+, +column+ and +columns+
# document every option in depth.
#
# class TeamAssignment < ApplicationRecord
# include SelectableAttributes
# belongs_to :company
# belongs_to :team, class_name: "CompanyTeam"
#
# # Reader company_name, plus a derived with_company_name scope.
# attributes_from_select join: :company do
# column :company_name, Company[:name]
# end
#
# # Several columns, inner join, custom prefix: readers squad_id and squad_color.
# attributes_from_select join: :team, scope_name: :with_team, required: true do
# columns :id, :color, from: CompanyTeam, prefix: :squad
# end
#
# # Computed source: no column to infer a type from, so name one, and
# # pass a fallback since the association chain cannot be derived either.
# attributes_from_select join: {team: :company} do
# column(
# :owner_label,
# Arel::Nodes::NamedFunction.new("COALESCE", [Company[:name], Arel::Nodes.build_quoted("Unassigned")]),
# fallback: -> { team&.company&.name || "Unassigned" },
# type: :string
# )
# end
# end
#
# Readers work the same whether or not the columns were selected. The scope
# fetches the value in one query; a plain load falls back to the association:
#
# TeamAssignment.with_company_name.map(&:company_name)
# TeamAssignment.find(id).company_name
#
# +scope_name:+ is derived as +with_<column>+ for a lone joined column and is
# required once several columns are declared. +required: true+ swaps the left
# outer join for an inner join.
#
# == Strict loading
#
# When a record is +strict_loading+ and a reader would fall back to the
# association, it raises +ActiveRecord::StrictLoadingViolationError+ naming the
# scope to apply, so a missing join surfaces in tests rather than silently
# firing per-row queries.
#
# == Caveats
#
# +:join+ must resolve to a to-one association (+belongs_to+/+has_one+). A
# +has_many+ join fans the host row out, one row per child, and the reader then
# returns an arbitrary child's value.
#
# Apply the generated scope before any narrowing +select+. The scope only adds
# the host's own columns when no +select+ is already present, so a caller
# +select+ that runs first suppresses them and reading an unselected column
# raises +ActiveRecord::MissingAttributeError+:
#
# Model.with_provider_information # full host row + aliases
# Model.select(:id).with_provider_information # only id + the aliases
#
# Do not also eager-load the joined association. Combining a +with_*+ scope with
# +includes+/+preload+/+eager_load+ of the same association is redundant (the
# columns are already selected), and +eager_load+ alongside the explicit select
# can hydrate incorrectly.
#
# Under the default left outer join a missing association selects +NULL+, which
# the reader returns as +nil+ without falling back to the association. A +nil+
# from a bulk read therefore cannot be told apart from a genuinely-nil column
# value.
module SelectableAttributes
extend ActiveSupport::Concern
class_methods do
# Declares selectable attributes in the given block using +column+ and
# +columns+. See SelectableAttributes for the full overview.
#
# ==== Options
#
# [+:join+]
# Association (Symbol, Hash, or Array, as +joins+ accepts) the generated
# scope joins and the fallback reader walks. Must be a to-one association;
# a to-many join fans the host rows out. Omit it to declare a plain
# selected attribute, in which case every +column+ needs an explicit
# +:fallback+.
# [+:scope_name+]
# Name of the generated scope. Derived as +with_<column>+ from a lone
# column when +:join+ is present; required once several columns are
# declared.
# [+:required+]
# Uses an inner join (+joins+) instead of a left outer join, so host rows
# without the association are dropped from the scope's result set. Only
# valid together with +:join+.
def attributes_from_select(join: nil, scope_name: nil, required: false, &block)
builder = Builder.new(host: self, join: join, scope_name: scope_name, required: required)
builder.instance_eval(&block)
builder.finalize!
end
end
class Builder
Column = Data.define(:name, :source, :fallback, :type)
def initialize(host:, join: nil, scope_name: nil, required: false)
@host = host
@join = join
@scope_name = scope_name
@required = required
@columns = []
end
def finalize!
if @required && @join.nil?
raise ArgumentError, "`#{@host}` declared `required: true` without a `join:`; `required:` only applies to joined declarations"
end
if @scope_name && @join.nil?
raise ArgumentError, "`#{@host}` declared `scope_name: #{@scope_name.inspect}` without a `join:`; a generated scope needs an association to join"
end
reject_collection_joins!(@host, @join) if @join
@scope_name ||= derived_scope_name if @join
define_methods!
define_scope! if @scope_name
end
def reject_collection_joins!(klass, join)
case join
when Symbol
reflection = klass.reflect_on_association(join)
raise_collection_join(join) if reflection&.collection?
when Hash
join.each do |name, nested|
reflection = klass.reflect_on_association(name)
next unless reflection
raise_collection_join(name) if reflection.collection?
next if reflection.polymorphic?
reject_collection_joins!(reflection.klass, nested)
end
when Array
join.each { |element| reject_collection_joins!(klass, element) }
end
end
def raise_collection_join(name)
raise ArgumentError, "`#{@host}` joined the collection association `#{name}`; `attributes_from_select` only supports to-one associations, since a collection fans the host row out"
end
# Declares a single selectable attribute named +name+, reading from the Arel
# +source+ (e.g. +Company[:name]+) when the column was selected.
#
# ==== Options
#
# [+:fallback+]
# Lambda evaluated on the instance to produce the value when it was not
# selected, so the reader can run arbitrary Ruby off the bulk-selected
# path. Defaults to walking the +:join+ chain; required when there is no
# +:join+.
# [+:type+]
# Type used to cast the selected value, given as a registered type name
# (+:string+, +:integer+, ...) or a type instance, the same as +attribute+
# accepts. Inferred from the source column when it is a plain column
# reference; pass it explicitly for computed sources (+COALESCE+,
# concatenations), which infer no type.
def column(name, source = nil, fallback: nil, type: nil)
resolved_fallback = fallback || derive_fallback(source)
if resolved_fallback.nil?
raise ArgumentError, "`#{@host}` declared `column :#{name}` with no `fallback:` and no `join:` chain to derive one from"
end
@columns << Column.new(
name: name,
source: source,
fallback: resolved_fallback,
type: resolve_type(source, type)
)
end
# Declares several selectable attributes from one table. Each +name+ is
# prefixed and aliased, so +:id, :name+ from +Company+ become +#company_id+
# and +#company_name+.
#
# ==== Options
#
# [+:from+]
# Arel table (e.g. +Company+) the columns are read from.
# [+:prefix+]
# Prefix for the generated reader names. Defaults to the innermost
# association named in +:join+.
def columns(*names, from:, prefix: nil)
effective_prefix = prefix || join_leaf
if effective_prefix.nil?
raise ArgumentError, "`#{@host}` called `columns` without a `join:` to derive a prefix from; pass `prefix:`"
end
names.each do |col_name|
column(:"#{effective_prefix}_#{col_name}", from.arel_table[col_name])
end
end
def define_methods!
@columns.each { |col| define_one(col) }
end
def derived_scope_name
if @columns.size > 1
raise ArgumentError, "`#{@host}` declared multiple columns (#{@columns.map(&:name).join(", ")}); `scope_name:` is required to name the generated scope"
end
:"with_#{@columns.first.name}"
end
def define_scope!
host = @host
join = @join
columns = @columns
join_method = @required ? :joins : :left_outer_joins
@host.scope @scope_name, -> {
public_send(join_method, join).then do |scope|
scope = scope.select(host[Arel.star]) unless scope.select_values.any?
scope.select(*columns.map { |c| c.source.as(c.name.to_s) })
end
}
end
def resolve_type(source, override)
return infer_type(source) unless override
return override unless override.is_a?(Symbol)
ActiveRecord::Type.lookup(override, adapter: ActiveRecord::Type.adapter_name_from(@host))
end
def infer_type(source)
return nil unless source.respond_to?(:relation) && source.relation.respond_to?(:type_for_attribute)
source.relation.type_for_attribute(source.name)
end
def derive_fallback(source)
return nil unless @join && source.respond_to?(:name)
chain = flatten_chain(@join)
return nil unless chain
chain << source.name.to_sym
-> { chain.reduce(self) { |obj, step| obj&.public_send(step) } }
end
def flatten_chain(join)
case join
when Symbol then [join]
when Hash then join.flat_map { |k, v| [k, *flatten_chain(v)] }
when Array then join.flat_map { |j| flatten_chain(j) }
end
end
def join_leaf
flatten_chain(@join)&.last
end
def define_one(col)
attr_name = col.name.to_s
method_name = col.name
fallback = col.fallback
type = col.type
scope_name = @scope_name
@host.define_method(method_name) do
if has_attribute?(attr_name)
value = self[attr_name]
type ? type.cast(value) : value
elsif strict_loading?
message = "`#{self.class}##{method_name}` would fall back to association"
message += "; scope `#{scope_name}` not applied" if scope_name
raise ActiveRecord::StrictLoadingViolationError, message
else
instance_exec(&fallback)
end
end
end
end
end
# frozen_string_literal: true
require "test_helper"
class SelectableAttributesTest < ActiveSupport::TestCase
def build_selectable(class_name, &block)
klass = Class.new(ApplicationRecord)
klass.define_singleton_method(:name) { class_name }
klass.define_singleton_method(:to_s) { class_name }
klass.class_eval(&block)
klass
end
test "generated method returns the selected alias value over the fallback" do
klass = Class.new(ApplicationRecord) do
self.table_name = "companies"
include SelectableAttributes
attributes_from_select do
column :synthetic, fallback: -> { "from-fallback" }
end
end
record = klass.select("companies.*, 'from-select' AS synthetic").first
assert_equal "from-select", record.synthetic
end
test "method-only declaration calls the fallback when the alias is absent" do
klass = Class.new(ApplicationRecord) do
self.table_name = "companies"
include SelectableAttributes
attributes_from_select do
column :department_label, fallback: -> { "derived-#{name}" }
end
end
record = klass.find(Current.company.id)
assert_equal "derived-#{Current.company.name}", record.department_label
end
test "single-column declaration with join generates a with_<name> scope" do
klass = build_selectable("SelectableScopeProfile") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :company_name, Company[:name]
end
end
profile = create(:profile)
loaded = klass.with_company_name.find(profile.id)
assert_equal Current.company.name, loaded.company_name
end
test "multi-column declaration with join but no scope_name raises at class load" do
error = assert_raises(ArgumentError) do
build_selectable("MultiColumnNoScopeName") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :company_id, Company[:id]
column :company_name, Company[:name]
end
end
end
assert_match(/scope_name/, error.message)
end
test "multi-column declaration with scope_name generates one scope adding all aliased selects" do
klass = build_selectable("MultiColumnScope") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company, scope_name: :with_company_info do
column :company_primary_color, Company[:primary_color]
column :company_secondary_color, Company[:secondary_color]
end
end
profile = create(:profile)
Current.company.update!(primary_color: "#abc", secondary_color: "#def")
loaded = klass.with_company_info.find(profile.id)
assert_equal "#abc", loaded.company_primary_color
assert_equal "#def", loaded.company_secondary_color
end
test "generated scope leaves caller-supplied select columns intact" do
klass = build_selectable("ScopeRespectsSelect") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :company_name, Company[:name]
end
end
profile = create(:profile)
loaded = klass.select("'Mr Smith' AS full_name").with_company_name.find_by(id: profile.id)
assert_equal "Mr Smith", loaded.attributes["full_name"]
assert_equal Current.company.name, loaded.company_name
end
test "generated scope uses LEFT OUTER JOIN so records without the join target still load" do
klass = build_selectable("ScopeLeftJoin") do
self.table_name = "passkey_credentials"
include SelectableAttributes
belongs_to :provider, class_name: "PasskeyProvider", foreign_key: :aaguid, optional: true
attributes_from_select join: :provider do
column :provider_name, PasskeyProvider[:name]
end
end
user = create(:user)
credential = klass.create!(
user_id: user.id,
external_id: SecureRandom.hex,
public_key: "pk",
sign_count: 0
)
loaded = klass.with_provider_name.find(credential.id)
assert_equal credential.id, loaded.id
assert_nil loaded.provider_name
end
test "selected nil value is returned as nil and does not trigger the fallback" do
klass = Class.new(ApplicationRecord) do
self.table_name = "companies"
include SelectableAttributes
attributes_from_select do
column :synthetic, fallback: -> { "should-not-fire" }
end
end
record = klass.select("companies.*, NULL AS synthetic").first
assert_nil record.synthetic
end
test "method casts the value using the source column type" do
klass = build_selectable("IntegerTypecast") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :synthetic_company_id, Company[:id]
end
end
create(:profile)
loaded = klass.select("profiles.*, '42' AS synthetic_company_id").first
assert_equal 42, loaded.synthetic_company_id
assert_kind_of Integer, loaded.synthetic_company_id
end
test "method maps enum integer values back to the declared key" do
klass = build_selectable("EnumCast") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :synthetic_tt_stack, Company[:tt_stack]
end
end
Current.company.update!(tt_stack: "north_america")
profile = create(:profile)
loaded = klass.with_synthetic_tt_stack.find(profile.id)
assert_equal "north_america", loaded.synthetic_tt_stack
end
test "fallback derives the join chain when no explicit fallback is given" do
klass = build_selectable("DerivedFallback") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :company_name, Company[:name]
end
end
profile = create(:profile)
loaded = klass.find(profile.id)
assert_equal Current.company.name, loaded.company_name
end
test "explicit fallback: option overrides the derived chain fallback" do
klass = build_selectable("OverrideFallback") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :company_name, Company[:name], fallback: -> { "override" }
end
end
profile = create(:profile)
loaded = klass.find(profile.id)
assert_equal "override", loaded.company_name
end
test "type: accepts a type instance and overrides the inferred source type" do
klass = build_selectable("OverrideType") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :customer_flag, Company[:name], type: ActiveModel::Type::Boolean.new
end
end
create(:profile)
loaded = klass.select("profiles.*, 't' AS customer_flag").first
assert_equal true, loaded.customer_flag
end
test "type: accepts a registered type name and resolves it like attribute" do
klass = build_selectable("SymbolType") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :customer_flag, Company[:name], type: :boolean
end
end
create(:profile)
loaded = klass.select("profiles.*, 't' AS customer_flag").first
assert_equal true, loaded.customer_flag
end
test "strict_loading record raises StrictLoadingViolationError when the alias is absent" do
klass = build_selectable("StrictLoadingRaises") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :company_name, Company[:name]
end
end
profile = create(:profile)
loaded = klass.strict_loading.find(profile.id)
error = assert_raises(ActiveRecord::StrictLoadingViolationError) do
loaded.company_name
end
assert_match(/company_name/, error.message)
assert_match(/with_company_name/, error.message)
end
test "two with_X scopes compose without duplicating joins or columns" do
klass = build_selectable("Composed") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company do
column :company_name, Company[:name]
end
attributes_from_select join: :company, scope_name: :with_primary_color do
column :primary_color, Company[:primary_color]
end
end
Current.company.update!(primary_color: "#abc")
profile = create(:profile)
loaded = klass.with_company_name.with_primary_color.find(profile.id)
assert_equal Current.company.name, loaded.company_name
assert_equal "#abc", loaded.primary_color
end
test "required: true uses an INNER JOIN that drops rows without the join target" do
klass = build_selectable("RequiredInnerJoin") do
self.table_name = "passkey_credentials"
include SelectableAttributes
belongs_to :provider, class_name: "PasskeyProvider", foreign_key: :aaguid, optional: true
attributes_from_select join: :provider, required: true do
column :provider_name, PasskeyProvider[:name]
end
end
provider = PasskeyProvider.create!(name: "Apple", icon_light: "apple.svg")
user = create(:user)
with_provider = klass.create!(
user_id: user.id,
aaguid: provider.id,
external_id: SecureRandom.hex,
public_key: "pk",
sign_count: 0
)
without_provider = klass.create!(
user_id: user.id,
aaguid: nil,
external_id: SecureRandom.hex,
public_key: "pk",
sign_count: 0
)
ids = klass.with_provider_name.pluck(:id)
assert_includes ids, with_provider.id
refute_includes ids, without_provider.id
end
test "required: true without join: raises at class load" do
error = assert_raises(ArgumentError) do
build_selectable("RequiredWithoutJoin") do
self.table_name = "profiles"
include SelectableAttributes
attributes_from_select required: true do
column :synthetic, fallback: -> { "x" }
end
end
end
assert_match(/required: true/, error.message)
end
test "column without fallback and without join raises at class load" do
error = assert_raises(ArgumentError) do
build_selectable("ColumnWithoutFallback") do
self.table_name = "profiles"
include SelectableAttributes
attributes_from_select do
column :synthetic
end
end
end
assert_match(/synthetic/, error.message)
assert_match(/fallback/, error.message)
end
test "scope_name without join raises at class load" do
error = assert_raises(ArgumentError) do
build_selectable("ScopeNameWithoutJoin") do
self.table_name = "companies"
include SelectableAttributes
attributes_from_select scope_name: :with_synthetic do
column :synthetic, fallback: -> { "x" }
end
end
end
assert_match(/scope_name/, error.message)
assert_match(/join/, error.message)
end
test "a join that is not a walkable association chain raises the missing-fallback error" do
error = assert_raises(ArgumentError) do
build_selectable("StringJoinNoFallback") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: "LEFT JOIN companies ON companies.id = profiles.company_id" do
column :company_name, Company[:name]
end
end
end
assert_match(/company_name/, error.message)
assert_match(/fallback/, error.message)
end
test "columns bulk form derives synthetic names from <join_leaf>_<col>" do
klass = build_selectable("BulkColumns") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: :company, scope_name: :with_company_details do
columns :name, :primary_color, from: Company
end
end
Current.company.update!(primary_color: "#xyz")
profile = create(:profile)
loaded = klass.with_company_details.find(profile.id)
assert_equal Current.company.name, loaded.company_name
assert_equal "#xyz", loaded.company_primary_color
end
test "joining a collection association raises at class load" do
error = assert_raises(ArgumentError) do
build_selectable("CollectionJoin") do
self.table_name = "companies"
include SelectableAttributes
has_many :profiles
attributes_from_select join: :profiles do
column :profile_name, Profile[:first_name]
end
end
end
assert_match(/collection association `profiles`/, error.message)
assert_match(/to-one/, error.message)
end
test "a collection association nested inside the join chain raises at class load" do
error = assert_raises(ArgumentError) do
build_selectable("NestedCollectionJoin") do
self.table_name = "profiles"
include SelectableAttributes
belongs_to :company
attributes_from_select join: {company: :profiles} do
column :sibling_name, Profile[:first_name]
end
end
end
assert_match(/collection association `profiles`/, error.message)
end
test "a collection association listed in a join array raises at class load" do
error = assert_raises(ArgumentError) do
build_selectable("ArrayCollectionJoin") do
self.table_name = "companies"
include SelectableAttributes
has_many :profiles
attributes_from_select join: [:profiles], scope_name: :with_profile do
column :profile_name, Profile[:first_name]
end
end
end
assert_match(/collection association `profiles`/, error.message)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment