formed 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +146 -0
  3. data/Rakefile +12 -0
  4. data/lib/active_form.rb +12 -0
  5. data/lib/formed/acts_like_model.rb +27 -0
  6. data/lib/formed/association_relation.rb +22 -0
  7. data/lib/formed/associations/association.rb +193 -0
  8. data/lib/formed/associations/builder/association.rb +116 -0
  9. data/lib/formed/associations/builder/collection_association.rb +71 -0
  10. data/lib/formed/associations/builder/has_many.rb +24 -0
  11. data/lib/formed/associations/builder/has_one.rb +44 -0
  12. data/lib/formed/associations/builder/singular_association.rb +46 -0
  13. data/lib/formed/associations/builder.rb +13 -0
  14. data/lib/formed/associations/collection_association.rb +296 -0
  15. data/lib/formed/associations/collection_proxy.rb +519 -0
  16. data/lib/formed/associations/foreign_association.rb +37 -0
  17. data/lib/formed/associations/has_many_association.rb +63 -0
  18. data/lib/formed/associations/has_one_association.rb +27 -0
  19. data/lib/formed/associations/singular_association.rb +66 -0
  20. data/lib/formed/associations.rb +62 -0
  21. data/lib/formed/attributes.rb +42 -0
  22. data/lib/formed/base.rb +183 -0
  23. data/lib/formed/core.rb +73 -0
  24. data/lib/formed/from_model.rb +41 -0
  25. data/lib/formed/from_params.rb +33 -0
  26. data/lib/formed/inheritance.rb +179 -0
  27. data/lib/formed/nested_attributes.rb +287 -0
  28. data/lib/formed/reflection.rb +781 -0
  29. data/lib/formed/relation/delegation.rb +147 -0
  30. data/lib/formed/relation.rb +113 -0
  31. data/lib/formed/version.rb +3 -0
  32. data/lib/generators/active_form/form_generator.rb +72 -0
  33. data/lib/generators/active_form/templates/form.rb.tt +8 -0
  34. data/lib/generators/active_form/templates/form_spec.rb.tt +5 -0
  35. data/lib/generators/active_form/templates/module.rb.tt +4 -0
  36. metadata +203 -0
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Associations
5
+ extend ActiveSupport::Concern
6
+
7
+ def association(name) # :nodoc:
8
+ association = association_instance_get(name)
9
+
10
+ if association.nil?
11
+ unless (reflection = self.class._reflect_on_association(name))
12
+ raise AssociationNotFoundError.new(self, name)
13
+ end
14
+
15
+ association = reflection.association_class.new(self, reflection)
16
+ association_instance_set(name, association)
17
+ end
18
+
19
+ association
20
+ end
21
+
22
+ def association_cached?(name) # :nodoc:
23
+ @association_cache.key?(name)
24
+ end
25
+
26
+ def initialize_dup(*) # :nodoc:
27
+ @association_cache = {}
28
+ super
29
+ end
30
+
31
+ private
32
+
33
+ def init_internals
34
+ @association_cache = {}
35
+ super
36
+ end
37
+
38
+ # Returns the specified association instance if it exists, +nil+ otherwise.
39
+ def association_instance_get(name)
40
+ @association_cache[name]
41
+ end
42
+
43
+ # Set the specified association instance.
44
+ def association_instance_set(name, association)
45
+ @association_cache[name] = association
46
+ end
47
+
48
+ class_methods do
49
+ def has_many(name, scope = nil, **options, &extension)
50
+ reflection = Builder::HasMany.build(self, name, scope, options, &extension)
51
+ Reflection.add_reflection self, name, reflection
52
+ accepts_nested_attributes_for name
53
+ end
54
+
55
+ def has_one(name, scope = nil, **options)
56
+ reflection = Builder::HasOne.build(self, name, scope, options)
57
+ Reflection.add_reflection self, name, reflection
58
+ accepts_nested_attributes_for name
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Attributes
5
+ extend ActiveSupport::Concern
6
+
7
+ def _has_attribute?(name)
8
+ attributes.key?(name)
9
+ end
10
+
11
+ def attribute_present?(attr_name)
12
+ attr_name = attr_name.to_s
13
+ attr_name = self.class.attribute_aliases[attr_name] || attr_name
14
+ value = _read_attribute(attr_name)
15
+ !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
16
+ end
17
+
18
+ def type_for_attribute(attr)
19
+ self.class.attribute_types[attr].type
20
+ end
21
+
22
+ def column_for_attribute(attr)
23
+ model.column_for_attribute(attr)
24
+ end
25
+
26
+ def has_attribute?(attr_name)
27
+ attr_name = attr_name.to_s
28
+ attr_name = self.class.attribute_aliases[attr_name] || attr_name
29
+ @attributes.key?(attr_name)
30
+ end
31
+
32
+ def attributes_with_values
33
+ attributes.select { |_, v| v.present? }
34
+ end
35
+
36
+ module ClassMethods
37
+ def _has_attribute?(name)
38
+ attribute_types.key?(name.to_s)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "formed/relation/delegation"
4
+ require "formed/associations"
5
+ require "formed/core"
6
+ require "formed/inheritance"
7
+ require "formed/reflection"
8
+ require "formed/relation"
9
+ require "formed/attributes"
10
+ require "formed/nested_attributes"
11
+ require "formed/association_relation"
12
+ require "formed/associations/association"
13
+ require "formed/associations/singular_association"
14
+ require "formed/associations/collection_association"
15
+ require "formed/associations/foreign_association"
16
+ require "formed/associations/collection_proxy"
17
+ require "formed/associations/builder"
18
+ require "formed/associations/builder/association"
19
+ require "formed/associations/builder/singular_association"
20
+ require "formed/associations/builder/collection_association"
21
+ require "formed/associations/builder/has_one"
22
+ require "formed/associations/builder/has_many"
23
+ require "formed/associations/has_many_association"
24
+ require "formed/associations/has_one_association"
25
+
26
+ require "formed/acts_like_model"
27
+ require "formed/from_model"
28
+ require "formed/from_params"
29
+
30
+ module Formed
31
+ RESTRICTED_CLASS_METHODS = %w(private public protected allocate new name parent superclass)
32
+
33
+ class FormedError < StandardError
34
+ end
35
+
36
+ class AssociationTypeMismatch < FormedError
37
+ end
38
+
39
+ class Base
40
+ extend Relation::Delegation::DelegateCache
41
+
42
+ include Formed::Associations
43
+ include ActiveModel::Model
44
+ include ActiveModel::Validations
45
+ include ActiveModel::Attributes
46
+ include ActiveModel::AttributeAssignment
47
+ include ActiveModel::AttributeMethods
48
+ include ActiveModel::Callbacks
49
+ include ActiveModel::Dirty
50
+ include Formed::Attributes
51
+
52
+ include Formed::Core
53
+ include Formed::Inheritance
54
+ include Formed::Reflection
55
+ include Formed::NestedAttributes
56
+
57
+ include Formed::ActsLikeModel
58
+ include Formed::FromParams
59
+ include FromModel
60
+
61
+ def init_internals
62
+ @marked_for_destruction = false
63
+ @association_cache = {}
64
+ klass = self.class
65
+
66
+ @strict_loading = false
67
+ @strict_loading_mode = :all
68
+
69
+ klass.define_attribute_methods
70
+ end
71
+
72
+ def initialize_internals_callback; end
73
+
74
+ def initialize(attributes = nil)
75
+ @new_record = true
76
+ @attributes = self.class._default_attributes.deep_dup
77
+
78
+ init_internals
79
+ initialize_internals_callback
80
+
81
+ assign_attributes(attributes) if attributes
82
+
83
+ yield self if block_given?
84
+ _run_initialize_callbacks
85
+ end
86
+
87
+ class_attribute :inheritance_column, default: :type
88
+
89
+ define_model_callbacks :initialize, only: [:after]
90
+ define_model_callbacks :validation, only: %i[before after]
91
+
92
+ attribute :id, :integer
93
+ attribute :_destroy, :boolean
94
+
95
+ class_attribute :model
96
+ class_attribute :primary_key, default: "id"
97
+ class_attribute :model_name
98
+ class_attribute :default_ignored_attributes, default: %w[id created_at updated_at]
99
+ class_attribute :ignored_attributes, default: []
100
+
101
+ def with_context(contexts = {})
102
+ @context = OpenStruct.new(contexts)
103
+ _reflections.each do |_, reflection|
104
+ if (instance = public_send(reflection.name))
105
+ instance.with_context(@context)
106
+ end
107
+ end
108
+
109
+ self
110
+ end
111
+
112
+ attr_reader :context
113
+
114
+ def inspect
115
+ # We check defined?(@attributes) not to issue warnings if the object is
116
+ # allocated but not initialized.
117
+ inspection = if defined?(@attributes) && @attributes
118
+ self.class.attribute_names.filter_map do |name|
119
+ "#{name}: #{_read_attribute(name).inspect}" if self.class.attribute_types.key?(name)
120
+ end.join(", ")
121
+ else
122
+ "not initialized"
123
+ end
124
+
125
+ "#<#{self.class} #{inspection}>"
126
+ end
127
+
128
+ def persisted?
129
+ id.present? && id.to_i.positive?
130
+ end
131
+
132
+ def self.from_json(json)
133
+ params = JSON.parse(json)
134
+ from_params(params)
135
+ end
136
+
137
+ def valid?(options = {})
138
+ run_callbacks(:validation) do
139
+ options = {} if options.blank?
140
+ context = options[:context]
141
+ validations = [super(context)]
142
+
143
+ validations.all?
144
+ end
145
+ end
146
+
147
+ def invalid?(options = {})
148
+ !valid?(options)
149
+ end
150
+
151
+ def to_key
152
+ [id]
153
+ end
154
+
155
+ def self.model_name
156
+ if model.is_a?(Symbol)
157
+ ActiveModel::Name.new(self, nil, model.to_s)
158
+ elsif model.present?
159
+ ActiveModel::Name.new(self, nil, model.model_name.name.split("::").last)
160
+ else
161
+ name = self.name.demodulize.delete_suffix("Form")
162
+ name = self.name if name.blank?
163
+ ActiveModel::Name.new(self, nil, name)
164
+ end
165
+ end
166
+
167
+ def model_name
168
+ self.class.model_name
169
+ end
170
+
171
+ def new_record?
172
+ !persisted?
173
+ end
174
+
175
+ def marked_for_destruction?
176
+ attributes["_destroy"]
177
+ end
178
+
179
+ def destroy?
180
+ _destroy
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Core
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ def inherited(child_class) # :nodoc:
9
+ # initialize cache at class definition for thread safety
10
+ unless child_class.base_class?
11
+ klass = self
12
+ klass = klass.superclass until klass.base_class?
13
+ end
14
+ super
15
+ end
16
+
17
+ def initialize_generated_modules # :nodoc:
18
+ generated_association_methods
19
+ end
20
+
21
+ def generated_association_methods # :nodoc:
22
+ @generated_association_methods ||= begin
23
+ mod = const_set(:GeneratedAssociationMethods, Module.new)
24
+ private_constant :GeneratedAssociationMethods
25
+ include mod
26
+
27
+ mod
28
+ end
29
+ end
30
+
31
+ # Returns columns which shouldn't be exposed while calling +#inspect+.
32
+ def filter_attributes
33
+ if defined?(@filter_attributes)
34
+ @filter_attributes
35
+ else
36
+ superclass.filter_attributes
37
+ end
38
+ end
39
+
40
+ # Specifies columns which shouldn't be exposed while calling +#inspect+.
41
+ def filter_attributes=(filter_attributes)
42
+ @inspection_filter = nil
43
+ @filter_attributes = filter_attributes
44
+ end
45
+
46
+ def inspection_filter # :nodoc:
47
+ if defined?(@filter_attributes)
48
+ @inspection_filter ||= begin
49
+ mask = InspectionMask.new(ActiveSupport::ParameterFilter::FILTERED)
50
+ ActiveSupport::ParameterFilter.new(@filter_attributes, mask: mask)
51
+ end
52
+ else
53
+ superclass.inspection_filter
54
+ end
55
+ end
56
+
57
+ # Returns a string like 'Post(id:integer, title:string, body:text)'
58
+ def inspect # :nodoc:
59
+ if self == Formed::Base
60
+ super
61
+ else
62
+ attr_list = attribute_types.map { |name, type| "#{name}: #{type.type}" } * ", "
63
+ "#{super}(#{attr_list})"
64
+ end
65
+ end
66
+
67
+ # Override the default class equality method to provide support for decorated models.
68
+ def ===(object) # :nodoc:
69
+ object.is_a?(self)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module FromModel
5
+ class FromModelAssignment
6
+ def self.call(instance, record)
7
+ record.attributes.each do |k, v|
8
+ instance.public_send("#{k}=", v) if instance.attributes.key?(k)
9
+ end
10
+ record._reflections.each do |attr, _record_reflection|
11
+ next unless (form_reflection = instance._reflections[attr])
12
+
13
+ case form_reflection.macro
14
+ when :has_one
15
+ instance.send("build_#{attr}").from_model(record.public_send(attr))
16
+ when :has_many
17
+ record.public_send(attr).each do |associated_record|
18
+ instance.public_send(attr).build.from_model(associated_record)
19
+ end
20
+ end
21
+ end
22
+
23
+ instance.id = record.id
24
+ instance.map_model(record)
25
+ instance
26
+ end
27
+ end
28
+
29
+ extend ActiveSupport::Concern
30
+
31
+ def from_model(model)
32
+ FromModelAssignment.call(self, model)
33
+ end
34
+
35
+ module ClassMethods
36
+ def from_model(model)
37
+ new.from_model(model)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module FromParams
5
+ extend ActiveSupport::Concern
6
+
7
+ class FromParamsAssignment
8
+ def self.call(instance, attributes_hash)
9
+ attributes_hash.each do |k, v|
10
+ if instance.attributes.key?(k.to_s)
11
+ instance.public_send("#{k}=", v)
12
+ elsif instance.respond_to?("#{k}=")
13
+ instance.public_send("#{k}=", v)
14
+ end
15
+ end
16
+
17
+ instance
18
+ end
19
+ end
20
+
21
+ def from_params(params, additional_params = {})
22
+ attributes_hash = params.merge(additional_params)
23
+
24
+ FromParamsAssignment.call(self, attributes_hash)
25
+ end
26
+
27
+ module ClassMethods
28
+ def from_params(params, additional_params = {})
29
+ new.from_params(params, additional_params)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Formed
4
+ module Inheritance
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :store_full_class_name, instance_writer: false, default: true
9
+
10
+ set_base_class
11
+ end
12
+
13
+ module ClassMethods
14
+ # Determines if one of the attributes passed in is the inheritance column,
15
+ # and if the inheritance column is attr accessible, it initializes an
16
+ # instance of the given subclass instead of the base class.
17
+ def new(attributes = nil, &block)
18
+ if abstract_class? || self == Formed
19
+ raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated."
20
+ end
21
+
22
+ if _has_attribute?(inheritance_column)
23
+ subclass = subclass_from_attributes(attributes)
24
+
25
+ if subclass.nil? && (scope_attributes = current_scope&.scope_for_create)
26
+ subclass = subclass_from_attributes(scope_attributes)
27
+ end
28
+
29
+ subclass = subclass_from_attributes(column_defaults) if subclass.nil? && base_class?
30
+ end
31
+
32
+ if subclass && subclass != self
33
+ subclass.new(attributes, &block)
34
+ else
35
+ super
36
+ end
37
+ end
38
+
39
+ # Returns the class descending directly from ActiveRecord::Base, or
40
+ # an abstract class, if any, in the inheritance hierarchy.
41
+ #
42
+ # If A extends ActiveRecord::Base, A.base_class will return A. If B descends from A
43
+ # through some arbitrarily deep hierarchy, B.base_class will return A.
44
+ #
45
+ # If B < A and C < B and if A is an abstract_class then both B.base_class
46
+ # and C.base_class would return B as the answer since A is an abstract_class.
47
+ attr_reader :base_class
48
+
49
+ # Returns whether the class is a base class.
50
+ # See #base_class for more information.
51
+ def base_class?
52
+ base_class == self
53
+ end
54
+
55
+ attr_accessor :abstract_class
56
+
57
+ def abstract_class?
58
+ defined?(@abstract_class) && @abstract_class == true
59
+ end
60
+
61
+ def primary_abstract_class; end
62
+
63
+ def inherited(subclass)
64
+ subclass.set_base_class
65
+ subclass.instance_variable_set(:@_type_candidates_cache, Concurrent::Map.new)
66
+ super
67
+ end
68
+
69
+ def dup # :nodoc:
70
+ # `initialize_dup` / `initialize_copy` don't work when defined
71
+ # in the `singleton_class`.
72
+ other = super
73
+ other.set_base_class
74
+ other
75
+ end
76
+
77
+ def initialize_clone(other) # :nodoc:
78
+ super
79
+ set_base_class
80
+ end
81
+
82
+ protected
83
+
84
+ # Returns the class type of the record using the current module as a prefix. So descendants of
85
+ # MyApp::Business::Account would appear as MyApp::Business::AccountSubclass.
86
+ def compute_type(type_name)
87
+ if type_name.start_with?("::")
88
+ # If the type is prefixed with a scope operator then we assume that
89
+ # the type_name is an absolute reference.
90
+ type_name.constantize
91
+ else
92
+
93
+ type_candidate = @_type_candidates_cache[type_name]
94
+ if type_candidate && (type_constant = type_candidate.safe_constantize)
95
+ return type_constant
96
+ end
97
+
98
+ # Build a list of candidates to search for
99
+ candidates = []
100
+ name.scan(/::|$/) { candidates.unshift "#{::Regexp.last_match.pre_match}::#{type_name}" }
101
+ candidates << type_name
102
+ form_candidates = []
103
+ candidates.each do |candidate|
104
+ next if candidate.end_with?("Form")
105
+
106
+ form_candidates << "#{candidate}Form"
107
+ end
108
+
109
+ candidates += form_candidates
110
+
111
+ candidates.each do |candidate|
112
+ constant = candidate.safe_constantize
113
+ if candidate == constant.to_s
114
+ @_type_candidates_cache[type_name] = candidate
115
+ return constant
116
+ end
117
+ end
118
+
119
+ raise NameError.new("uninitialized constant #{candidates.first}", candidates.first)
120
+ end
121
+ end
122
+
123
+ def set_base_class # :nodoc:
124
+ @base_class = if self == Formed::Base
125
+ self
126
+ else
127
+ unless self < Formed::Base
128
+ raise FormedError, "#{name} doesn't belong in a hierarchy descending from Formed"
129
+ end
130
+
131
+ if superclass == Formed || superclass.abstract_class?
132
+ self
133
+ else
134
+ superclass.base_class
135
+ end
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ # Detect the subclass from the inheritance column of attrs. If the inheritance column value
142
+ # is not self or a valid subclass, raises ActiveRecord::SubclassNotFound
143
+ def subclass_from_attributes(attrs)
144
+ attrs = attrs.to_h if attrs.respond_to?(:permitted?)
145
+ return unless attrs.is_a?(Hash)
146
+
147
+ subclass_name = attrs[inheritance_column] || attrs[inheritance_column.to_sym]
148
+
149
+ return unless subclass_name.present?
150
+
151
+ find_sti_class(subclass_name)
152
+ end
153
+ end
154
+
155
+ def initialize_dup(other)
156
+ super
157
+ ensure_proper_type
158
+ end
159
+
160
+ private
161
+
162
+ def initialize_internals_callback
163
+ super
164
+ ensure_proper_type
165
+ end
166
+
167
+ # Sets the attribute used for single table inheritance to this class name if this is not the
168
+ # ActiveRecord::Base descendant.
169
+ # Considering the hierarchy Reply < Message < ActiveRecord::Base, this makes it possible to
170
+ # do Reply.new without having to set <tt>Reply[Reply.inheritance_column] = "Reply"</tt> yourself.
171
+ # No such attribute would be set for objects of the Message class in that example.
172
+ def ensure_proper_type
173
+ klass = self.class
174
+ return unless klass.finder_needs_type_condition?
175
+
176
+ _write_attribute(klass.inheritance_column, klass.sti_name)
177
+ end
178
+ end
179
+ end