json_sti 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3624d823ce141fe74222d5ce9d2fe9dd6a07692046db37aeb18fce3a137a5b10
4
+ data.tar.gz: e6ecba2cb054e9dbab7d69ccba1f0b820125ad8cb8fff80480cd9c0bc12730c1
5
+ SHA512:
6
+ metadata.gz: 4682108fa7c68af0dfdf92e7ce35eece47c281cf57421507ff2d5509f945d80f4fa55a299e40386544d6f862afff080eca017cbe7bed9ef8781001ea52efef52
7
+ data.tar.gz: 861e476792d0fd1da19f14db46aef05c8d646870a8b61eb099cfa34a330fec2e1b288043d732261765cf984e75df3d829a26ea09ed8f2b61563316139fc07449
data/lib/json_sti.rb ADDED
@@ -0,0 +1,150 @@
1
+ require "zeitwerk"
2
+
3
+ loader = Zeitwerk::Loader.for_gem
4
+ loader.setup
5
+
6
+ require_relative "json_sti/class_master_list"
7
+ require_relative "json_sti/inheritable_seeder"
8
+
9
+ module JsonSti
10
+ extend ActiveSupport::Concern
11
+
12
+ def self.initialize_single_table_arel_helpers
13
+ JsonSti::ClassMasterList.base_class_list.each do |receiving_class_name|
14
+ receiving_class = receiving_class_name.to_s.camelize.constantize
15
+
16
+ next unless ClassMasterList.relations_lookup[receiving_class_name]
17
+
18
+ ClassMasterList.relations_lookup[receiving_class_name][:relationships].each do |relationship_to_create|
19
+ ClassMasterList.relations_lookup[relationship_to_create][:members].each do |relationship_to_create_member|
20
+ creation_class = "#{relationship_to_create.to_s.camelize}::#{relationship_to_create_member.to_s.singularize.camelize}".constantize
21
+
22
+ ar_association = receiving_class.reflect_on_all_associations.detect do |association|
23
+ association.class_name == relationship_to_create.to_s.camelize
24
+ end
25
+
26
+ receiving_class.class_eval do
27
+ if ar_association.to_s.downcase =~ /many/
28
+ # create has_many helper methods for sti subtypes
29
+ define_method "#{relationship_to_create_member.to_s.pluralize}" do
30
+ self.send(relationship_to_create.to_s.pluralize).where(type: creation_class.to_s)
31
+ end
32
+
33
+ else
34
+ # create belongs_to helper methods for sti subtypes
35
+ define_method "#{relationship_to_create_member.to_s.singularize}" do
36
+ object = self.send(relationship_to_create.to_s.singularize)
37
+ return object.class.to_s == creation_class.to_s ? object : nil
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ included do
47
+ def self.inherited(subclass)
48
+ begin
49
+ #check to make sure if it exists, throws error and rescues with constant creation otherwise
50
+ subclass.to_s.split("::").last.constantize
51
+ rescue
52
+ # define subclass name as constant on global object
53
+ # probably smarter way to do this? Something zeitwerk?
54
+ Object.const_set(subclass.to_s.split("::").last, subclass)
55
+
56
+ subclass.class_eval do
57
+ # patch subclasses to validate based on their schema.
58
+ # Schemas are defined in subclasses with `define_schema`
59
+ validates :module_data,
60
+ presence: false,
61
+ json: {
62
+ message: ->(errors) { errors },
63
+ schema: lambda { cleaned_schema }
64
+ }
65
+
66
+ # a helper similar to ARs `where` only for json fields
67
+ scope :jwhere, lambda { |hash| where("module_data @> ?", hash.to_json) }
68
+
69
+ def initialize(params)
70
+ super
71
+
72
+ self.class::SCHEMA["properties"].keys.each do |attr|
73
+ unless self.module_data[attr]
74
+ if self.class::SCHEMA["required"].include? attr
75
+ self.module_data[attr] = "REQUIRED: #{self.class::SCHEMA["properties"][attr]["type"]}"
76
+ else
77
+ self.module_data[attr] = nil
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def cleaned_schema
86
+ schema = self.class::SCHEMA.to_json
87
+
88
+ hash_schema = JSON.parse(schema)
89
+ properties = hash_schema["properties"]
90
+ required = schema['required']
91
+
92
+ properties.each do |property, value|
93
+ unless self.module_data[property]
94
+ unless required.include?(property)
95
+ properties.delete property
96
+ end
97
+ end
98
+ end
99
+
100
+ hash_schema["properties"] = properties
101
+ hash_schema.to_json
102
+ end
103
+ end
104
+ end
105
+
106
+ super
107
+ end
108
+
109
+ def self.descendants
110
+ ObjectSpace.each_object(Class).select { |klass| klass < self }
111
+ end
112
+
113
+ def self.define_schema(hash)
114
+ class_eval do
115
+ const_set(:SCHEMA, hash.with_indifferent_access)
116
+
117
+ json_attrs = self::SCHEMA["properties"]
118
+
119
+ class_variable_set(:@@json_attrs, self::SCHEMA["properties"])
120
+ class_variable_set(:@@json_required, self::SCHEMA["required"])
121
+
122
+ initialize_attr_getters(json_attrs.keys)
123
+ initialize_attr_setters(json_attrs.keys)
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def self.initialize_attr_getters(attr_names)
130
+ # patches including classes to have getters for their json attr names
131
+ attr_names.each do |attr_name|
132
+ define_method attr_name do
133
+ self.module_data[attr_name]
134
+ end
135
+ end
136
+ end
137
+
138
+ def self.initialize_attr_setters(attr_names)
139
+ # patches including classes to have setters for their json attr names
140
+ attr_names.each do |attr_name|
141
+ define_method "#{attr_name}=" do |new_value|
142
+ self.module_data[attr_name] = new_value
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ end
149
+
150
+ loader.eager_load
@@ -0,0 +1,77 @@
1
+ module JsonSti
2
+ class ClassMasterList
3
+ def self.sti_base_class_list
4
+ # a list of STI base classes which have their own tables
5
+ @@sti_base_class_list ||= build_sti_base_class_list
6
+ end
7
+
8
+ def self.relations_lookup
9
+ # a lookup table including the subclasses of each baseclasses
10
+ # and what relationships each STI class has to other STI classes
11
+ @@relations_lookup ||= build_relations_lookup
12
+ end
13
+
14
+ def self.base_class_list
15
+ # a list of STI base classes which have their own tables
16
+ @@base_class_list ||= build_base_class_list
17
+ end
18
+
19
+ private
20
+
21
+ def self.build_sti_base_class_list
22
+ ObjectSpace.
23
+ each_object(Class).
24
+ select { |klass| klass.included_modules.include? JsonSti }.
25
+ map(&:to_s).
26
+ reject { |klass| klass.include?("::") }.
27
+ map(&:underscore).
28
+ map(&:to_sym)
29
+ end
30
+
31
+ def self.build_base_class_list
32
+ models = ActiveRecord::Base.
33
+ descendants.
34
+ map(&:name).
35
+ reject { |klass| klass.include?("::") }.
36
+ reject { |klass| klass.include?("HABTM") }.
37
+ reject { |klass| klass.include?("WP") }.
38
+ map(&:underscore).
39
+ map(&:to_sym).
40
+ tap { |models| models.delete :application_record}
41
+ end
42
+
43
+ def self.build_relations_lookup
44
+ @@relations_lookup = {}
45
+
46
+ base_class_list.each do |class_name|
47
+ @@relations_lookup[class_name] = {}
48
+ build_relation_list_for_class(class_name)
49
+ build_members_list_for_class(class_name)
50
+ end
51
+
52
+ @@relations_lookup
53
+ end
54
+
55
+ def self.build_relation_list_for_class(class_name)
56
+ klass = class_name.to_s.camelize.constantize
57
+ associations = klass.reflect_on_all_associations
58
+
59
+ relation_list = associations.map(&:class_name).
60
+ map(&:to_s).
61
+ map(&:underscore).
62
+ map(&:to_sym)
63
+
64
+ @@relations_lookup[class_name][:relationships] = (relation_list & JsonSti::ClassMasterList.sti_base_class_list)
65
+ end
66
+
67
+ def self.build_members_list_for_class(class_name)
68
+ klass = class_name.to_s.camelize.constantize
69
+ members = klass.descendants.map(&:to_s).
70
+ map{ |descendant| descendant.gsub("#{class_name.to_s.camelize}::", "") }.
71
+ map(&:underscore).
72
+ map(&:to_sym)
73
+
74
+ @@relations_lookup[class_name][:members] = members
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,125 @@
1
+ module JsonSti
2
+ class InheritableSeeder
3
+ def self.seed!(num_to_create=3)
4
+ relations_lookup = JsonSti::ClassMasterList.relations_lookup
5
+
6
+ relations_lookup.each do |type, info|
7
+ info[:members].each do |member|
8
+ klass = "#{type.to_s.camelize}::#{member.to_s.camelize}".constantize
9
+ instance = klass.create
10
+
11
+ if !instance.valid?
12
+ skip_sub_object_creation = self.fix_errors_on_instance_and_determine_next_step(instance)
13
+ next if skip_sub_object_creation
14
+ end
15
+
16
+ p "Created a #{klass.to_s}"
17
+
18
+ info[:relationships].each do |relationship|
19
+ relations_lookup[relationship][:members].each do |relation_member|
20
+ self.create_relationship_for_instance(instance, relationship, relation_member, num_to_create)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ def self.populate_attrs_for_instance!(instance, only_required=false)
28
+ klass = instance.class
29
+ json_attrs = klass.class_variable_get(:@@json_attrs)
30
+ return if json_attrs.blank?
31
+
32
+ attrs = only_required ? klass.class_variable_get(:@@json_required) : klass.class_variable_get(:@@json_attrs).keys
33
+
34
+ return if attrs.blank?
35
+
36
+ attrs.each do |attr|
37
+ type = json_attrs[attr].values
38
+ case type.first
39
+ when "string"
40
+ if type[1] && type[1] == "date"
41
+ new_val = Faker::Date.between(from: 10.days.ago, to: Date.today)
42
+ elsif type[1] && type[1] == "time"
43
+ new_val = Faker::Time.between(from: DateTime.now - 1, to: DateTime.now)
44
+ else
45
+ new_val = Faker::Marketing.buzzwords
46
+ end
47
+ when "boolean"
48
+ new_val = [true, false].sample
49
+ when "integer"
50
+ new_val = Integer(Faker::Number.within(range: 0..99))
51
+ when "number"
52
+ new_val = Faker::Number.decimal(l_digits:2, r_digits: 3)
53
+ end
54
+
55
+ instance.send((attr.to_s + "="), new_val)
56
+ end
57
+
58
+ instance.save!
59
+
60
+ instance
61
+ end
62
+
63
+ def self.generate_valid_instance_of_class!(klass, only_required=false)
64
+ populate_attrs_for_instance!(klass.new, only_required)
65
+ end
66
+
67
+ private
68
+
69
+ def self.create_relationship_for_instance(instance, relationship_type, relation_member, num_to_create)
70
+ p "Creating a #{relation_member} for a #{instance.class}"
71
+
72
+ num_to_create.times do |n|
73
+ ar_association = instance.class.reflect_on_all_associations.detect do |association|
74
+ association.class_name == relationship_type.to_s.camelize
75
+ end
76
+
77
+ if ar_association.to_s.downcase =~ /many/
78
+ relation_name = relation_member.to_s.pluralize
79
+ sub_instance = instance.send(relation_name).create
80
+ else
81
+ klass_to_create = "#{relationship_type.to_s.camelize}::#{relation_member.to_s.camelize}".constantize
82
+ sub_instance = generate_valid_instance_of_class!(klass_to_create)
83
+
84
+ sub_instance_rel_id = sub_instance.class.to_s.split("::").first.underscore + "_id="
85
+ instance.send(sub_instance_rel_id, sub_instance.id)
86
+ end
87
+
88
+ JsonSti::InheritableSeeder.populate_attrs_for_instance!(sub_instance)
89
+ end
90
+ end
91
+
92
+ def self.fix_errors_on_instance_and_determine_next_step(instance)
93
+ klass = instance.class
94
+ skip_sub_object_creation = false
95
+
96
+ instance.errors.messages.each do |error_key, error_value|
97
+ if error_key == :module_data
98
+ JsonSti::InheritableSeeder.populate_attrs_for_instance!(instance)
99
+
100
+ elsif error_value.include? "must exist"
101
+ fix_belongs_to_based_errors_for_instance(instance, error_key)
102
+ skip_sub_object_creation = true
103
+
104
+ else
105
+ p "============================="
106
+ p "There was an unhandled error on instance creation: #{error_key} : #{error_value.first}"
107
+ p "============================="
108
+ skip_sub_object_creation = true
109
+ end
110
+ end
111
+
112
+ skip_sub_object_creation
113
+ end
114
+
115
+ def self.fix_belongs_to_based_errors_for_instance(instance, error_key)
116
+ valid_sub_type = ClassMasterList.relations_lookup[error_key][:members].sample.to_s
117
+ sub_type_klass = "#{error_key.to_s.camelize}::#{valid_sub_type.camelize}".constantize
118
+
119
+ p "creating #{sub_type_klass.to_s} belongs to for #{instance.class.to_s}"
120
+ new_sub_instance = instance.send("#{error_key}=", sub_type_klass.create)
121
+
122
+ JsonSti::InheritableSeeder.populate_attrs_for_instance!(new_sub_instance)
123
+ end
124
+ end
125
+ end
metadata ADDED
@@ -0,0 +1,215 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: json_sti
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Max
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-12-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.12'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.12'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.8.7
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.8.7
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.5'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.5'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.77'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.77'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pry-byebug
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.7'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.7'
83
+ - !ruby/object:Gem::Dependency
84
+ name: faker
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.7'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.7'
97
+ - !ruby/object:Gem::Dependency
98
+ name: activerecord
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: 4.2.0
104
+ - - "<"
105
+ - !ruby/object:Gem::Version
106
+ version: '7'
107
+ type: :runtime
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: 4.2.0
114
+ - - "<"
115
+ - !ruby/object:Gem::Version
116
+ version: '7'
117
+ - !ruby/object:Gem::Dependency
118
+ name: activesupport
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: 4.2.0
124
+ - - "<"
125
+ - !ruby/object:Gem::Version
126
+ version: '7'
127
+ type: :runtime
128
+ prerelease: false
129
+ version_requirements: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: 4.2.0
134
+ - - "<"
135
+ - !ruby/object:Gem::Version
136
+ version: '7'
137
+ - !ruby/object:Gem::Dependency
138
+ name: pg
139
+ requirement: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: '1.1'
144
+ type: :runtime
145
+ prerelease: false
146
+ version_requirements: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - "~>"
149
+ - !ruby/object:Gem::Version
150
+ version: '1.1'
151
+ - !ruby/object:Gem::Dependency
152
+ name: zeitwerk
153
+ requirement: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - "~>"
156
+ - !ruby/object:Gem::Version
157
+ version: '2.2'
158
+ type: :runtime
159
+ prerelease: false
160
+ version_requirements: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - "~>"
163
+ - !ruby/object:Gem::Version
164
+ version: '2.2'
165
+ - !ruby/object:Gem::Dependency
166
+ name: activerecord_json_validator
167
+ requirement: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - "~>"
170
+ - !ruby/object:Gem::Version
171
+ version: 1.3.0
172
+ type: :runtime
173
+ prerelease: false
174
+ version_requirements: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - "~>"
177
+ - !ruby/object:Gem::Version
178
+ version: 1.3.0
179
+ description: A common argument against STI in rails is that the tables eventually
180
+ get cluttered with class specific columns. By keeping all subtype specific attrs
181
+ in json, you completely avoid table bloat while keeping all the advantages of AR
182
+ and a relational database
183
+ email: andrew.max89@gmail.com
184
+ executables: []
185
+ extensions: []
186
+ extra_rdoc_files: []
187
+ files:
188
+ - lib/json_sti.rb
189
+ - lib/json_sti/class_master_list.rb
190
+ - lib/json_sti/inheritable_seeder.rb
191
+ homepage:
192
+ licenses:
193
+ - MIT
194
+ metadata: {}
195
+ post_install_message:
196
+ rdoc_options: []
197
+ require_paths:
198
+ - lib
199
+ required_ruby_version: !ruby/object:Gem::Requirement
200
+ requirements:
201
+ - - ">="
202
+ - !ruby/object:Gem::Version
203
+ version: '0'
204
+ required_rubygems_version: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ">="
207
+ - !ruby/object:Gem::Version
208
+ version: '0'
209
+ requirements: []
210
+ rubygems_version: 3.0.3
211
+ signing_key:
212
+ specification_version: 4
213
+ summary: A scheme for single table inheritance with ActiveRecord which puts all class
214
+ specific attributes in a json blob, and allows validations
215
+ test_files: []