|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +# Copyright 2025 Roy Liu |
| 4 | +# |
| 5 | +# Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| 6 | +# use this file except in compliance with the License. You may obtain a copy of |
| 7 | +# the License at |
| 8 | +# |
| 9 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# Unless required by applicable law or agreed to in writing, software |
| 12 | +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| 13 | +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 14 | +# License for the specific language governing permissions and limitations under |
| 15 | +# the License. |
| 16 | + |
| 17 | +module Scalient |
| 18 | + module ActiveRecord |
| 19 | + module PreloaderBranchMonkeyPatch |
| 20 | + SPECIAL_LITERAL_SYNTAX_PATTERN = Regexp.new("\\A(?<prefix>[\\.:])(?<name>.*)\\z") |
| 21 | + |
| 22 | + def self.apply(clazz) |
| 23 | + outer_self = self |
| 24 | + |
| 25 | + clazz.class_eval do |
| 26 | + prepend outer_self |
| 27 | + end |
| 28 | + end |
| 29 | + |
| 30 | + def selects |
| 31 | + @selects ||= [] |
| 32 | + end |
| 33 | + |
| 34 | + def polymorphic_specializations_to_subtrees |
| 35 | + @polymorphic_specializations_to_subtrees ||= {} |
| 36 | + end |
| 37 | + |
| 38 | + def polymorphic_specializations_to_subtrees=(polymorphic_specializations_to_subtrees) |
| 39 | + @polymorphic_specializations_to_subtrees = polymorphic_specializations_to_subtrees |
| 40 | + end |
| 41 | + |
| 42 | + # Overridden to preprocess special preload syntax and store those directives in auxiliary data structures, while |
| 43 | + # passing the normal syntax to the superclass method. Appends polymorphic specialization subtrees to the result so |
| 44 | + # that loading can proceed conditionally based on detected foreign types. |
| 45 | + def build_children(preloads) |
| 46 | + preloads = preloads.deep_dup |
| 47 | + |
| 48 | + if preloads |
| 49 | + preprocess_preloads!(preloads) |
| 50 | + end |
| 51 | + |
| 52 | + # Add the polymorphic specialization subtrees for consideration when later code interrogates subtrees for their |
| 53 | + # loaders. |
| 54 | + super + polymorphic_specializations_to_subtrees.values |
| 55 | + end |
| 56 | + |
| 57 | + # Preprocess the preload specification by populating auxiliary data structures and stripping out syntax that would |
| 58 | + # otherwise cause the regular preloader to choke. |
| 59 | + def preprocess_preloads!(preloads) |
| 60 | + case preloads |
| 61 | + when Hash |
| 62 | + preloads.select! do |reflection_name, sub_preloads| |
| 63 | + preprocess_literal(reflection_name, sub_preloads) |
| 64 | + end |
| 65 | + |
| 66 | + preloads.size > 0 |
| 67 | + when Array |
| 68 | + preloads.select! do |sub_preloads| |
| 69 | + preprocess_preloads!(sub_preloads) |
| 70 | + end |
| 71 | + |
| 72 | + preloads.size > 0 |
| 73 | + when String, Symbol |
| 74 | + preprocess_literal(preloads) |
| 75 | + else |
| 76 | + raise ArgumentError, "Invalid preload specification" |
| 77 | + end |
| 78 | + end |
| 79 | + |
| 80 | + # Preprocess a literal that is usually a reflection name, but sometimes is `.#{attribute_name}` or |
| 81 | + # `:#{class_name}`. |
| 82 | + def preprocess_literal(literal, preloads = nil) |
| 83 | + literal = literal.to_s |
| 84 | + |
| 85 | + if (m = SPECIAL_LITERAL_SYNTAX_PATTERN.match(literal)) |
| 86 | + case m[:prefix] |
| 87 | + when "." |
| 88 | + # A `.#{attribute_name}` literal has been detected: This means that we want to selectively load the column |
| 89 | + # `attribute_name` in the parent relation's projection. |
| 90 | + selects.push(m[:name]) |
| 91 | + when ":" |
| 92 | + name = m[:name] |
| 93 | + specialization_class = name.safe_constantize |
| 94 | + |
| 95 | + if !specialization_class |
| 96 | + raise ArgumentError, "No constant for class name #{name.dump} found" |
| 97 | + end |
| 98 | + |
| 99 | + # A `:#{class_name}` literal has been detected: This means that said preloads are for the specialized type |
| 100 | + # `class_name` under polymorphism. In effect, we disambiguate the nested reflections and attributes of the |
| 101 | + # single, overloaded polymorphic reflection in Rails' implementation with potentially different nested |
| 102 | + # reflections and attributes for each specialized type. |
| 103 | + polymorphic_specializations_to_subtrees[name] = self.class.new( |
| 104 | + parent:, |
| 105 | + association:, |
| 106 | + children: preloads, |
| 107 | + associate_by_default:, |
| 108 | + scope:, |
| 109 | + ).tap do |child| |
| 110 | + # Share the original map with specialized children. |
| 111 | + child.polymorphic_specializations_to_subtrees = polymorphic_specializations_to_subtrees |
| 112 | + end |
| 113 | + else |
| 114 | + raise "Control should never reach here" |
| 115 | + end |
| 116 | + |
| 117 | + # Signal to the caller to reject the item: We already gave it special treatment. |
| 118 | + false |
| 119 | + else |
| 120 | + # Signal to the caller to keep the item. |
| 121 | + true |
| 122 | + end |
| 123 | + end |
| 124 | + |
| 125 | + # Overridden to inject the columns referenced in `#selects` into the query scope's projection. |
| 126 | + # |
| 127 | + # TODO: The library currently doesn't automatically include primary keys and association foreign keys when using |
| 128 | + # dot syntax for selective preloading; those are the user's responsibility for now. |
| 129 | + def preloaders_for_reflection(reflection, reflection_records) |
| 130 | + reflection_records.group_by do |record| |
| 131 | + klass = record.association(association).klass |
| 132 | + |
| 133 | + if reflection.scope && reflection.scope.arity != 0 |
| 134 | + # For instance dependent scopes, the scope is potentially |
| 135 | + # different for each record. To allow this we'll group each |
| 136 | + # object separately into its own preloader |
| 137 | + reflection_scope = reflection.join_scopes( |
| 138 | + klass.arel_table, klass.predicate_builder, klass, record, |
| 139 | + ).inject(&:merge!) |
| 140 | + end |
| 141 | + |
| 142 | + [klass, reflection_scope] |
| 143 | + end.map do |(rhs_klass, reflection_scope), rs| |
| 144 | + # Start off with the preloader's scope. |
| 145 | + preloader_scope = rhs_klass.select(selects) |
| 146 | + |
| 147 | + # Merge in the explicitly provided scope. |
| 148 | + if scope |
| 149 | + preloader_scope.merge!(scope) |
| 150 | + end |
| 151 | + |
| 152 | + preloader_for(reflection).new( |
| 153 | + rhs_klass, rs, reflection, preloader_scope, reflection_scope, associate_by_default, |
| 154 | + ) |
| 155 | + end |
| 156 | + end |
| 157 | + |
| 158 | + # Overridden to consult polymorphic specialization subtrees if they are present in composite hash keys. |
| 159 | + def loaders |
| 160 | + # Try to destructure subtrees corresponding to polymorphic specializations. |
| 161 | + @loaders ||= |
| 162 | + grouped_records.flat_map do |(reflection, subtree), reflection_records| |
| 163 | + (!subtree ? self : subtree).preloaders_for_reflection(reflection, reflection_records) |
| 164 | + end |
| 165 | + end |
| 166 | + |
| 167 | + # Overridden to match records with potential polymorphic specializations. |
| 168 | + def grouped_records |
| 169 | + h = {} |
| 170 | + polymorphic_parent = !root? && parent.polymorphic? |
| 171 | + source_records.each do |record| |
| 172 | + reflection = record.class._reflect_on_association(association) |
| 173 | + next if (polymorphic_parent && !reflection) || !record.association(association).klass |
| 174 | + |
| 175 | + # If the reflection is a registered polymorphic specialization, include subtree information by setting a |
| 176 | + # composite hash key. |
| 177 | + key = if reflection.polymorphic? && polymorphic_specializations_to_subtrees.size > 0 |
| 178 | + subtree = polymorphic_specializations_to_subtrees[record.read_attribute(reflection.foreign_type)] |
| 179 | + |
| 180 | + if self == subtree |
| 181 | + [reflection, subtree] |
| 182 | + else |
| 183 | + next |
| 184 | + end |
| 185 | + else |
| 186 | + reflection |
| 187 | + end |
| 188 | + |
| 189 | + (h[key] ||= []) << record |
| 190 | + end |
| 191 | + h |
| 192 | + end |
| 193 | + end |
| 194 | + end |
| 195 | +end |
0 commit comments