File: attribute_assigner.rb

package info (click to toggle)
ruby-factory-bot 6.5.6-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 1,492 kB
  • sloc: ruby: 9,242; makefile: 6; sh: 4
file content (168 lines) | stat: -rw-r--r-- 6,017 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
module FactoryBot
  # @api private
  class AttributeAssigner
    def initialize(evaluator, build_class, &instance_builder)
      @build_class = build_class
      @instance_builder = instance_builder
      @evaluator = evaluator
      @attribute_list = evaluator.class.attribute_list
      @attribute_names_assigned = []
    end

    # constructs an object-based factory product
    def object
      @evaluator.instance = build_class_instance
      build_class_instance.tap do |instance|
        attributes_to_set_on_instance.each do |attribute|
          instance.public_send(:"#{attribute}=", get(attribute))
          @attribute_names_assigned << attribute
        end
      end
    end

    # constructs a Hash-based factory product
    def hash
      @evaluator.instance = build_hash

      attributes_to_set_on_hash.each_with_object({}) do |attribute, result|
        result[attribute] = get(attribute)
      end
    end

    private

    # Track evaluation of methods on the evaluator to prevent the duplicate
    # assignment of attributes accessed and via `initialize_with` syntax
    def method_tracking_evaluator
      @method_tracking_evaluator ||= Decorator::AttributeHash.new(
        decorated_evaluator,
        attribute_names_to_assign
      )
    end

    def decorated_evaluator
      Decorator::NewConstructor.new(
        Decorator::InvocationTracker.new(@evaluator),
        @build_class
      )
    end

    def methods_invoked_on_evaluator
      method_tracking_evaluator.__invoked_methods__
    end

    def build_class_instance
      @build_class_instance ||= method_tracking_evaluator.instance_exec(&@instance_builder)
    end

    def build_hash
      @build_hash ||= NullObject.new(hash_instance_methods_to_respond_to)
    end

    def get(attribute_name)
      @evaluator.send(attribute_name)
    end

    def attributes_to_set_on_instance
      (attribute_names_to_assign - @attribute_names_assigned - methods_invoked_on_evaluator).uniq
    end

    def attributes_to_set_on_hash
      attribute_names_to_assign - association_names
    end

    # Builds a list of attributes names that should be assigned to the factory product
    def attribute_names_to_assign
      @attribute_names_to_assign ||= begin
        # start a list of candidates containing non-transient attributes and overrides
        assignment_candidates = non_ignored_attribute_names + override_names
        # then remove any transient attributes (potentially reintroduced by the overrides),
        # and remove ignorable aliased attributes from the candidate list
        assignment_candidates - ignored_attribute_names - attribute_names_overriden_by_alias
      end
    end

    def non_ignored_attribute_names
      @attribute_list.non_ignored.names
    end

    def ignored_attribute_names
      @attribute_list.ignored.names
    end

    def association_names
      @attribute_list.associations.names
    end

    def override_names
      @evaluator.__override_names__
    end

    def attribute_names
      @attribute_list.names
    end

    def hash_instance_methods_to_respond_to
      attribute_names + override_names + @build_class.instance_methods
    end

    # Builds a list of attribute names which are slated to be interrupted by an override.
    def attribute_names_overriden_by_alias
      @attribute_list
        .non_ignored
        .flat_map { |attribute|
          override_names.map do |override|
            attribute.name if ignorable_alias?(attribute, override)
          end
        }
        .compact
    end

    # Is the attribute an ignorable alias of the override?
    # An attribute is ignorable when it is an alias of the override AND it is
    # either interrupting an assocciation OR is not the name of another attribute
    #
    # @note An "alias" is currently an overloaded term for two distinct cases:
    #   (1) attributes which are aliases and reference the same value
    #   (2) a logical grouping of a foreign key and an associated object
    def ignorable_alias?(attribute, override)
      return false unless attribute.alias_for?(override)

      # The attribute alias should be ignored when the override interrupts an association
      return true if override_interrupts_association?(attribute, override)

      # Remaining aliases should be ignored when the override does not match a declared attribute.
      # An override which is an alias to a declared attribute should not interrupt the aliased
      # attribute and interrupt only the attribute with a matching name. This workaround allows a
      # factory to declare both <attribute> and <attribute>_id as separate and distinct attributes.
      !override_matches_declared_attribute?(override)
    end

    # Does this override interrupt an association?
    # When true, this indicates the aliased attribute is related to a declared association and the
    # override does not match the attribute name.
    #
    # @note Association overrides should take precedence over a declared foreign key attribute.
    #
    # @note An override may interrupt an association by providing the associated object or
    #   by providing the foreign key.
    #
    # @param [FactoryBot::Attribute] aliased_attribute
    # @param [Symbol] override name of an override which is an alias to the attribute name
    def override_interrupts_association?(aliased_attribute, override)
      (aliased_attribute.association? || association_names.include?(override)) &&
        aliased_attribute.name != override
    end

    # Does this override match the name of any declared attribute?
    #
    # @note Checking against the names of all attributes, resolves any issues with having both
    #   <attribute> and <attribute>_id in the same factory. This also takes into account ignored
    #   attributes that should not be assigned (aka transient attributes)
    #
    # @param [Symbol] override the name of an override
    def override_matches_declared_attribute?(override)
      attribute_names.include?(override)
    end
  end
end