Skip to content

WIP: Association route refactor - #66

Open
matthewd wants to merge 10 commits into
Shopify:mainfrom
matthewd:association-route-refactor-4
Open

matthewd wants to merge 10 commits into
Shopify:mainfrom
matthewd:association-route-refactor-4

Conversation

@matthewd

Copy link
Copy Markdown
Member

No description provided.

Derived memos can tolerate redundant construction; lease cleanup must
not create entries owned by another context.
Array-valued key components aren't extra columns.
An empty tuple condition is false, not invalid SQL.
Array-valued scalars aren't tuples. Also avoid per-column casting
allocations.
SQL can reorder, repeat, or discard binds without changing which input
supplies each value.
A query can need columns that assignment shouldn't copy. Resolve the
match and reference separately, using concrete endpoint classes for
keys and aliases.

Query-only mappings are infrastructure for now; declarations still
assign all their key columns.
Loaded records must match the same complete key as SQL, even when
endpoint types differ. Inverses must compare in their own direction;
the opposite pairing can attach an unrelated record.
Assignment and autosave exclude query-only columns from the writable
reference; counter, touch, and destruction lookups include them in the
full target match. Async destruction uses the loaded target's persisted
identity, whose aliases may differ from the job's declared class.

Remove the old reflection join-key methods now that consumers resolve
routes; callers of those undocumented methods must migrate.

@adrianna-chang-shopify adrianna-chang-shopify left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the last four commits incrementally (earlier ones appear to overlap with the stack you extracted?) with my agent, tried to spot check a couple things and look for refactors / simplifications. If there are any changes you'd like a closer look at in particular please advise 😅 Overall I think the architecture makes sense, and we're solving quite a few association-related bugs throughout this PR, so despite the large changeset I think it's going in the right direction. From a correctness standpoint seems good to go.


# Returns an equivalent mapping with single-column composite keys
# converted to scalar keys.
def normalize

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalize_single_column_keys might be a bit more descriptive here, and then we could drop the comment?

end

def convert_to_id(value)
def convert_to_id(value, route)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, but I think it might simplify thing slightly to always return a list here, because in #route_groups we can avoid branching again depending on if ids is composite / scalar. Relatedly, since the hash key is already storing fixed_values and origin_key, we could have the current value contain only the list of ids. e.g.:

--- a/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb
+++ b/activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb
@@ -12,7 +12,7 @@
       def queries
         return [ reference_key_name => values ] if values.empty?

-        route_groups.filter_map do |_key, (fixed_values, origin_key, ids)|
+        route_groups.filter_map do |(fixed_values, origin_key), ids|
           fixed_values.merge(origin_key => ids) unless ids.empty?
         end
       end
@@ -32,14 +32,8 @@
               origin_key = reference_key_name
             end

-            key = [fixed_values, origin_key]
-            group = groups[key] ||= [fixed_values, origin_key, []]
-            ids = convert_to_id(value, route)
-            if value.is_a?(Relation) && route.destination_key.composite?
-              group.last.concat(ids)
-            else
-              group.last << ids
-            end
+            ids = groups[[fixed_values, origin_key]] ||= []
+            ids.concat(ids_for(value, route))
           end
         end

@@ -54,17 +48,20 @@
           end
         end

-        def convert_to_id(value, route)
-          if value.is_a?(Base)
-            route.destination_key.value_of(value)
-          elsif value.is_a?(Relation)
-            if route.destination_key.composite?
-              value.pluck(route.destination_key.name)
+        def ids_for(value, route)
+          case value
+          when Base
+            [route.destination_key.value_of(value)]
+          when Relation
+            key = route.destination_key
+
+            if key.composite?
+              value.pluck(key.name)
             else
-              value.select(route.destination_key.name)
+              [value.select(key.name)]
             end
           else
-            value
+            [value]
           end
         end

Comment on lines +210 to 215
route.each_match do |origin_column, destination_column|
destination_attribute = predicate_builder.predicate_attribute(table[destination_column])
origin_attribute = predicate_builder.predicate_attribute(foreign_table[origin_column])

klass_scope.where!(primary_key_attribute.eq(foreign_key_attribute))
klass_scope.where!(destination_attribute.eq(origin_attribute))
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have similar logic in AssociationScope#next_chain_scope (iterating through route.each_match, resolving both attributes through predicate_attribute, and constructing an equality), is it worth extracting a helper on PredicateBuilder?

def association_match_predicates(route, origin_table:, destination_table:)
  route.each_match.map do |origin_column, destination_column|
    destination_attribute = predicate_attribute(destination_table[destination_column])
    origin_attribute = predicate_attribute(origin_table[origin_column])

    destination_attribute.eq(origin_attribute)
  end
end

(join_scope would add the where! and next_chain_scope would combine the predicates for the join still)

loader = preloader_for(reflection)
# An inherited reflection can resolve differently for each owner class,
# so include the route when grouping those owners into loaders.
group_by_route = reflection_records.any? { |record| record.class != reflection.active_record }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loader groups could technically contain different key types, is that a use case we should consider right now? We currently use the first owner's key conversions for all owners:

  # activerecord/lib/active_record/associations/preloader/association.rb
  # L115
  @model = owners.first && owners.first.class

  # L274
  def match_normalizers
    @match_normalizers ||= association_route.match_normalizers(origin_class: model, destination_class: klass)
  end

  def owners_by_key
    @owners_by_key ||= begin
      route = association_route
      owners.each_with_object({}) do |owner, result|
        next unless route.origin_reference_complete?(owner)

        (result[origin_key_value(owner)] ||= []) << owner # for every owner, call #origin_key_value, which uses match_nromalizers.first
       end
    end
  end

We have similar coverage in AssociationRouteTest#test_matching_origins_compare_each_class_by_its_own_types_and_preserve_order

Comment on lines +161 to +182
def build_match_normalizers(origin_class, destination_class)
origin_normalizers = destination_normalizers = nil
each_match.with_index do |(origin_column, destination_column), index|
origin_type = origin_class.type_for_attribute(origin_column)
destination_type = destination_class.type_for_attribute(destination_column)
origin_normalizer, destination_normalizer = normalizers_for_types(origin_type, destination_type)

if origin_normalizer
(origin_normalizers ||= Array.new(@key_pairs.length))[index] = origin_normalizer
end
if destination_normalizer
(destination_normalizers ||= Array.new(@key_pairs.length))[index] = destination_normalizer
end
end
[origin_normalizers&.freeze, destination_normalizers&.freeze].freeze
end

def normalizers_for_types(origin_type, destination_type)
unless origin_type.type == destination_type.type
[STRING_NORMALIZER, STRING_NORMALIZER]
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we build one frozen normalizer list and return it for both sides? Both sides always use the same conversion, so the two lists are identical.

Suggested change
def build_match_normalizers(origin_class, destination_class)
origin_normalizers = destination_normalizers = nil
each_match.with_index do |(origin_column, destination_column), index|
origin_type = origin_class.type_for_attribute(origin_column)
destination_type = destination_class.type_for_attribute(destination_column)
origin_normalizer, destination_normalizer = normalizers_for_types(origin_type, destination_type)
if origin_normalizer
(origin_normalizers ||= Array.new(@key_pairs.length))[index] = origin_normalizer
end
if destination_normalizer
(destination_normalizers ||= Array.new(@key_pairs.length))[index] = destination_normalizer
end
end
[origin_normalizers&.freeze, destination_normalizers&.freeze].freeze
end
def normalizers_for_types(origin_type, destination_type)
unless origin_type.type == destination_type.type
[STRING_NORMALIZER, STRING_NORMALIZER]
end
end
def build_match_normalizers(origin_class, destination_class)
normalizers = nil
each_match.with_index do |(origin_column, destination_column), index|
origin_type = origin_class.type_for_attribute(origin_column)
destination_type = destination_class.type_for_attribute(destination_column)
next if origin_type.type == destination_type.type
(normalizers ||= Array.new(@key_pairs.length))[index] = STRING_NORMALIZER
end
normalizers&.freeze
[normalizers, normalizers].freeze
end

@@ -416,28 +416,8 @@ def inversable?(record)
end

def matches_foreign_key?(record)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to rename this to matches_association_key? since we now compare the QC and reference columns?

Comment on lines +121 to 133
elsif group_by_route
reflection_records.group_by do |record|
klass = record.association(association).klass
[klass, loader.association_route_for(reflection, record.class, klass)]
end.map do |(rhs_klass, _), records|
loader.new(rhs_klass, records, reflection, scope, nil, associate_by_default)
end
else
reflection_records.group_by do |record|
record.association(association).klass
end.map do |rhs_klass, records|
preloader_for(reflection).new(rhs_klass, records, reflection, scope, nil, associate_by_default)
loader.new(rhs_klass, records, reflection, scope, nil, associate_by_default)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, we could combine these branches since only the grouping key is different:

Suggested change
elsif group_by_route
reflection_records.group_by do |record|
klass = record.association(association).klass
[klass, loader.association_route_for(reflection, record.class, klass)]
end.map do |(rhs_klass, _), records|
loader.new(rhs_klass, records, reflection, scope, nil, associate_by_default)
end
else
reflection_records.group_by do |record|
record.association(association).klass
end.map do |rhs_klass, records|
preloader_for(reflection).new(rhs_klass, records, reflection, scope, nil, associate_by_default)
loader.new(rhs_klass, records, reflection, scope, nil, associate_by_default)
end
else
groups = reflection_records.group_by do |record|
klass = record.association(association).klass
if group_by_route
[klass, loader.association_route_for(reflection, record.class, klass)]
else
klass
end
end
groups.map do |key, records|
rhs_klass = group_by_route ? key.first : key
loader.new(rhs_klass, records, reflection, scope, nil, associate_by_default)
end

end

def saved_change_to_target?
foreign_key.any? { |fk| owner.saved_change_to_attribute?(fk) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're only checking the FK here, so if an owner's QC value changes, we might miss updates (e.g. for counter caches). There are a couple of other places where we encounter a similar issue; BelongsToAssociation#stale_state also only checks the FK, same with .touch_record in activerecord/lib/active_record/associations/builder/belongs_to.rb:51. Does it make sense to have these check QCs?

end

assert_operator old_ship.reload.updated_at, :>, original_time
assert_equal original_time, first_decoy.reload.updated_at

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert_equal original_time, first_decoy.reload.updated_at
assert_operator new_ship.reload.updated_at, :>, original_time
assert_equal original_time, first_decoy.reload.updated_at

assert AliasedKeyPost.exists?(decoy.id), "#{target_source} target"
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested additional test based on

# activerecord/lib/active_record/associations/belongs_to_association.rb L128-130

# An unselected primary key can be present as nil. Use the owner's
# reference for that case, but preserve nil query-constraint values.
if !destination_value.nil? || !route.reference_destination_key.include?(destination_column)
Suggested change
test "belongs to preserves a nil query constraint from the persisted target" do
book = BookDestroyAsync.create!(name: nil)
other = BookDestroyAsync.create!(name: "Owner constraint")
essay = EssayDestroyAsync.create!(name: other.name, book: book)
reflection = EssayDestroyAsync.reflect_on_association(:book)
constraints = ActiveRecord::Key::Mapping.new(
reference_key: ActiveRecord::Key.for(:name),
target_key: ActiveRecord::Key.for(:name)
)
reflection.stub(:association_route_constraints, constraints) do
reflection.clear_association_scope_cache
essay.destroy!
end
perform_enqueued_jobs only: ActiveRecord::DestroyAssociationAsyncJob
assert_not BookDestroyAsync.exists?(book.id)
assert BookDestroyAsync.exists?(other.id)
ensure
reflection&.clear_association_scope_cache
EssayDestroyAsync.delete_all
BookDestroyAsync.delete_all
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants