Created
July 8, 2026 10:38
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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