rails-fields 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f6148f3e28ada22e83ccdd3d5c9ebe0054a61e61d28af95fcac0052b42e03ef5
4
+ data.tar.gz: 9095670043d7f5df6fc540739c2b81951d16b2abc4da9b901ce3f58c7aa71515
5
+ SHA512:
6
+ metadata.gz: 16125dfe4d17cc6b2c12465bf7182a0e73d0be3e2e79efb33b6feaa8272bc79bed026bc4ff5f35d3cf1bf27ed23c6b17151083827b55f9cf2f09be80b7410782
7
+ data.tar.gz: 956dc90b7ee6e99f1a0fb75987d86b1d0f88db2e294d9ff334c77f06da1476cc29fb538d4c2e7748b55f1f7e76b24a8bd574328d5e2a33be22524cb81536d21e
data/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # Rails Fields
2
+
3
+ ## Summary
4
+ Enforce field types and attributes for ActiveRecord models in Ruby on Rails applications.
5
+
6
+ ## Description
7
+ The `rails-fields` gem provides robust field type enforcement for ActiveRecord models in Ruby on Rails applications. It includes utility methods for type validation, logging, and field mappings between GraphQL and ActiveRecord types. Custom error classes provide clear diagnostics for field-related issues, making it easier to maintain consistent data models.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'rails-fields'
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ ```bash
20
+ $ bundle install
21
+ ```
22
+
23
+ Or install it yourself as:
24
+
25
+ ```
26
+ $ gem install rails-fields
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ (TBD: Add usage examples here)
32
+
33
+ ## License
34
+
35
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
36
+
37
+ ## Author
38
+
39
+ Gaston Morixe - gaston@gastonmorixe.com
@@ -0,0 +1,125 @@
1
+ module RailsFields
2
+ module ClassMethods
3
+ # TODO: Check all models at rails init app? like migrations?
4
+
5
+ def declared_fields
6
+ @declared_fields ||= []
7
+ end
8
+
9
+ def declared_fields=(value)
10
+ @declared_fields = value
11
+ end
12
+
13
+ def write_migration(index: nil)
14
+ changes = RailsFields::Utils.detect_changes(self)
15
+ RailsFields::Utils.generate_migration(self, changes, index:, write: true)
16
+ end
17
+
18
+ # Declares a field with enforcement.
19
+ #
20
+ # @!method
21
+ # @param name [Symbol] the name of the field
22
+ # @param type [Symbol] the type of the field
23
+ # @param null [Boolean] whether the field can be null (default: true)
24
+ # @param index [Boolean] whether to index the field (default: false)
25
+ # @return [void]
26
+ #
27
+ # @!macro [attach] field
28
+ # @!attribute $1
29
+ # @return [$2] the $1 property
30
+ def field(name, type, null: true, index: false)
31
+ # Check if type is a valid GraphQL type
32
+ # GraphQL::Types.const_get(type) if type.is_a?(Symbol) || type.is_a?(String)
33
+ unless Utils.valid_type?(type)
34
+ raise Errors::RailsFieldsUnknownTypeError.new("
35
+ Declared field '#{name}' in class '#{self.name}' of unknown type '#{type}'. Allowed types are: #{Utils.allowed_types.join(', ')}.
36
+ ")
37
+ end
38
+
39
+ declared_fields << OpenStruct.new(name: name.to_s, type:, null:, index:)
40
+ end
41
+
42
+ def gql_type
43
+ return RailsFields.processed_classes[self] if RailsFields.processed_classes[self].present?
44
+
45
+ fields = declared_fields
46
+ owner_self = self
47
+
48
+ type = Class.new(::Types::BaseObject) do
49
+ # graphql_name "#{owner_self.name}Type"
50
+ graphql_name "#{owner_self.name}"
51
+ description "A type representing a #{owner_self.name}"
52
+
53
+ fields.each do |f|
54
+ next if f.type.nil? # TODO: ! remove references fields
55
+
56
+ # Assuming a proper mapping from your custom types to GraphQL types
57
+ # TODO: use a better method or block
58
+ field_gql_type = f.name == :id ? GraphQL::Types::ID : Utils::RAILS_TO_GQL_TYPE_MAP[f.type]
59
+ field f.name, field_gql_type
60
+ end
61
+ end
62
+
63
+ # Cache the processed class here to prevent infinite recursion
64
+ RailsFields.processed_classes[self] = type
65
+
66
+ type.instance_eval do
67
+ owner_self.reflections.each do |association_name, reflection|
68
+ if reflection.macro == :has_many
69
+ reflection_klass = if reflection.options[:through]
70
+ through_reflection_klass = reflection.through_reflection.klass
71
+ source_reflection_name = reflection.source_reflection_name.to_s
72
+ source_reflection = through_reflection_klass.reflections[source_reflection_name]
73
+ source_reflection ? source_reflection.klass : through_reflection_klass
74
+ else
75
+ reflection.klass
76
+ end
77
+ field association_name, [reflection_klass.gql_type], null: true
78
+ elsif reflection.macro == :belongs_to
79
+ field association_name, reflection.klass.gql_type, null: true
80
+ end
81
+ end
82
+
83
+ type
84
+ end
85
+ end
86
+
87
+ def enforce_declared_fields
88
+ database_columns = column_names.map(&:to_sym)
89
+ declared_fields_names = declared_fields.map(&:name).map(&:to_sym) || []
90
+ changes = RailsFields::Utils.detect_changes(self)
91
+ migration = RailsFields::Utils.generate_migration(self, changes)
92
+ instance_methods = self.instance_methods(false).select do |method|
93
+ instance_method(method).source_location.first.start_with?(Rails.root.to_s)
94
+ end
95
+ extra_methods = instance_methods - declared_fields_names.map(&:to_sym)
96
+ has_changes = !changes.nil?
97
+
98
+ unless extra_methods.empty?
99
+ # TODO: Custom error subclass
100
+ raise "You have extra methods declared in #{name}: #{extra_methods.join(', ')}. Please remove them or declare them as fields."
101
+ end
102
+
103
+ if has_changes
104
+ error_message = <<~STRING
105
+
106
+ ----------------
107
+
108
+ Declared Fields:
109
+ #{declared_fields_names.join(', ')}
110
+
111
+ Database columns:
112
+ #{database_columns.join(', ')}
113
+
114
+ Changes:
115
+ #{changes.to_yaml.lines[1..-1].join}
116
+ Migration:
117
+ #{migration}
118
+
119
+ ----------------
120
+ STRING
121
+ raise Errors::RailsFieldsMismatchError.new(error_message)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,5 @@
1
+ module RailsFields
2
+ module Errors
3
+ class RailsFieldsError < StandardError; end
4
+ end
5
+ end
@@ -0,0 +1,18 @@
1
+ module RailsFields
2
+ module Errors
3
+ class RailsFieldsMismatchError < RailsFieldsError
4
+ include ActiveSupport::ActionableError
5
+
6
+ action "Save migrations" do
7
+ models = RailsFields::Utils.active_record_models
8
+ models.each_with_index do |m, index|
9
+ m.write_migration(index:)
10
+ end
11
+ end
12
+
13
+ # action "Run db:migrations" do
14
+ # ActiveRecord::Tasks::DatabaseTasks.migrate
15
+ # end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ module RailsFields
2
+ module Errors
3
+ class RailsFieldsUnknownTypeError < RailsFieldsError; end
4
+ end
5
+ end
@@ -0,0 +1,4 @@
1
+ module RailsFields
2
+ module InstanceMethods
3
+ end
4
+ end
@@ -0,0 +1,202 @@
1
+ module RailsFields
2
+ module Utils
3
+ class << self
4
+ def allowed_types
5
+ # TODO: this may depend on the current database adapter or mapper
6
+ ActiveRecord::Base.connection.native_database_types.keys
7
+ end
8
+
9
+ def valid_type?(type)
10
+ # TODO: this may depend on the current database adapter or mapper
11
+ allowed_types.include?(type)
12
+ end
13
+
14
+ def active_record_models
15
+ Rails.application.eager_load! # Ensure all models are loaded
16
+
17
+ ActiveRecord::Base.descendants.reject do |model|
18
+ !(model.is_a?(Class) && model < ApplicationRecord) ||
19
+ model.abstract_class? ||
20
+ model.name.nil? ||
21
+ model.name == "ActiveRecord::SchemaMigration"
22
+ end
23
+ end
24
+
25
+ # Detect changes between the ActiveRecord model declared fields and the database structure.
26
+ # @example
27
+ # model_changes = FieldEnforcement::Utils.detect_changes(User)
28
+ # # => {
29
+ # added: [],
30
+ # removed: [],
31
+ # renamed: [],
32
+ # type_changed: [],
33
+ # potential_renames: []
34
+ # }
35
+ # @param model [ActiveRecord::Base] the model to check
36
+ # @return [Hash, Nil] the changes detected
37
+ def detect_changes(model)
38
+ previous_fields = model.attribute_types.to_h { |k, v| [k.to_sym, v.type] }
39
+ declared_fields = model.declared_fields.to_h do |f|
40
+ [f.name.to_sym, {
41
+ name: f.type.to_sym,
42
+ options: f.options
43
+ }]
44
+ end
45
+
46
+ LOGGER.debug "Log: previous_fields: #{previous_fields}"
47
+ LOGGER.debug "Log: declared_fields #{declared_fields}}"
48
+
49
+ model_changes = {
50
+ added: [],
51
+ removed: [],
52
+ renamed: [],
53
+ type_changed: [],
54
+ potential_renames: [],
55
+ associations_added: [],
56
+ associations_removed: []
57
+ }
58
+
59
+ # Detect added and type-changed fields
60
+ declared_fields.each do |name, type|
61
+ type_name = type[:name]
62
+ if previous_fields[name]
63
+ if previous_fields[name] != type_name
64
+ model_changes[:type_changed] << { name:, from: previous_fields[name],
65
+ to: type }
66
+ end
67
+ else
68
+ model_changes[:added] << { name:, type: }
69
+ end
70
+ end
71
+
72
+ LOGGER.debug "Log: model_changes[:added] before filter #{model_changes[:added]}"
73
+ # Remove added fields that have a defined method in the the model
74
+ model_changes[:added] = model_changes[:added].filter { |f| !model.instance_methods.include?(f[:name]) }
75
+ LOGGER.debug "Log: model_changes[:added] after filter #{model_changes[:added]}"
76
+
77
+ # Detect removed fields
78
+ removed_fields = previous_fields.keys - declared_fields.keys
79
+ model_changes[:removed] = removed_fields.map { |name| { name:, type: previous_fields[name] } }
80
+
81
+ LOGGER.debug "Log: model_changes[:removed] 1 #{model_changes[:removed]}"
82
+
83
+ # Remove foreign keys from removed fields
84
+ associations = model.reflections.values.map(&:foreign_key).map(&:to_sym)
85
+ model_changes[:removed].reject! { |f| associations.include?(f[:name]) }
86
+
87
+ LOGGER.debug "Log: model_changes[:removed] 2 #{model_changes[:removed]} | associations #{associations}"
88
+
89
+ # Detect potential renames
90
+ potential_renames = []
91
+ model_changes[:removed].each do |removed_field|
92
+ # puts "Log: removed_field: #{removed_field}"
93
+ added_field = model_changes[:added].find { |f| f[:type] == removed_field[:type] }
94
+ if added_field
95
+ potential_renames << { from: removed_field[:name],
96
+ to: added_field[:name] }
97
+ end
98
+ end
99
+
100
+ LOGGER.debug "Log: potential_renames: #{potential_renames}"
101
+
102
+ model_changes[:potential_renames] = potential_renames
103
+
104
+ # Filter out incorrect renames (one-to-one mapping)
105
+ potential_renames.each do |rename|
106
+ next unless model_changes[:added].count { |f| f[:type] == rename[:to].to_sym } == 1 &&
107
+ model_changes[:removed].count { |f| f[:type] == rename[:from].to_sym } == 1
108
+
109
+ model_changes[:renamed] << rename
110
+ model_changes[:added].reject! { |f| f[:name] == rename[:to].to_sym }
111
+ model_changes[:removed].reject! { |f| f[:name] == rename[:from].to_sym }
112
+ end
113
+
114
+ # Handle associations changes
115
+ declared_associations = model.reflections.values.select do |reflection|
116
+ [:belongs_to].include?(reflection.macro)
117
+ end
118
+
119
+ declared_foreign_keys = declared_associations.map(&:foreign_key).map(&:to_sym)
120
+ existing_foreign_keys = ActiveRecord::Base.connection.foreign_keys(model.table_name).map(&:options).map { |opt| opt[:column].to_sym }
121
+
122
+ associations_added = declared_associations.select do |reflection|
123
+ !existing_foreign_keys.include?(reflection.foreign_key.to_sym)
124
+ end
125
+
126
+ associations_removed = existing_foreign_keys.select do |foreign_key|
127
+ !declared_foreign_keys.include?(foreign_key)
128
+ end.map { |foreign_key| model.reflections.values.find { |reflection| reflection.foreign_key == foreign_key.to_s } }
129
+
130
+ model_changes[:associations_added] = associations_added
131
+ model_changes[:associations_removed] = associations_removed
132
+
133
+ return model_changes unless model_changes.values.all?(&:empty?)
134
+
135
+ nil
136
+ end
137
+
138
+ def generate_migration(model, model_changes, index: 0, write: false)
139
+ return if model_changes.blank?
140
+
141
+ model_name = model.name
142
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i + index
143
+ migration_class_name = "#{model_name}Migration#{timestamp}"
144
+
145
+ migration_code = []
146
+ migration_code << "class #{migration_class_name} < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]"
147
+
148
+ migration_code << " def change"
149
+
150
+ model_changes.dig(:added)&.each do |change|
151
+ field_type = change[:type]
152
+ field_type_for_db = field_type[:name]
153
+ # TODO: custom mapper
154
+ migration_code << " add_column :#{model_name.tableize}, :#{change[:name]}, :#{field_type_for_db}"
155
+ end
156
+
157
+ # Handle added associations
158
+ model_changes.dig(:associations_added)&.each do |assoc|
159
+ migration_code << " add_reference :#{model_name.tableize}, :#{assoc.name}, foreign_key: true"
160
+ end
161
+
162
+ # Handle removed associations
163
+ model_changes.dig(:associations_removed)&.each do |assoc|
164
+ migration_code << " remove_reference :#{model_name.tableize}, :#{assoc.name}, foreign_key: true"
165
+ end
166
+
167
+ # Handle removed fields
168
+ model_changes.dig(:removed)&.each do |change|
169
+ migration_code << " remove_column :#{model_name.tableize}, :#{change[:name]}"
170
+ end
171
+
172
+ # Handle renamed fields
173
+ model_changes.dig(:renamed)&.each do |change|
174
+ change_to = change[:to]
175
+ migration_code << " rename_column :#{model_name.tableize}, :#{change[:from]}, :#{change_to}"
176
+ end
177
+
178
+ # Handle fields' type changes
179
+ model_changes.dig(:type_changed)&.each do |change|
180
+ change_to = change[:to]
181
+ migration_code << " change_column :#{model_name.tableize}, :#{change[:name]}, :#{change_to}"
182
+ end
183
+
184
+ migration_code << " end"
185
+ migration_code << "end"
186
+ migration_code << ""
187
+
188
+ write_migration(migration_code, migration_class_name, timestamp) if write
189
+
190
+ migration_code.join("\n")
191
+ end
192
+
193
+ def write_migration(migration_code, migration_class_name, timestamp)
194
+ migration_filename = "#{timestamp}_#{migration_class_name.underscore}.rb"
195
+ migration_path = Rails.root.join("db", "migrate", migration_filename)
196
+ File.write(migration_path, migration_code.join("\n"))
197
+ LOGGER.info "Migration saved at #{migration_path}"
198
+ { migration_filename:, migration_path: }
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,11 @@
1
+ require "logger"
2
+
3
+ module RailsFields
4
+ module Utils
5
+ LOGGER = begin
6
+ logger = Logger.new($stdout)
7
+ logger.level = Rails.env.production? ? Logger::INFO : Logger::DEBUG
8
+ logger
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ module RailsFields
2
+ module Utils
3
+ # TODO: mapper can be different or custom
4
+ GQL_TO_RAILS_TYPE_MAP = {
5
+ ::GraphQL::Types::String => :string,
6
+ ::GraphQL::Types::Int => :integer,
7
+ ::GraphQL::Types::Float => :float,
8
+ ::GraphQL::Types::Boolean => :boolean,
9
+ ::GraphQL::Types::ID => :integer, # or :string depending on how you handle IDs
10
+ ::GraphQL::Types::ISO8601DateTime => :datetime,
11
+ ::GraphQL::Types::ISO8601Date => :date,
12
+ ::GraphQL::Types::JSON => :json,
13
+ ::GraphQL::Types::BigInt => :bigint
14
+ }.freeze
15
+
16
+ RAILS_TO_GQL_TYPE_MAP = {
17
+ # id: ::GraphQL::Types::String,
18
+ string: ::GraphQL::Types::String,
19
+ integer: ::GraphQL::Types::Int,
20
+ float: ::GraphQL::Types::Float,
21
+ boolean: ::GraphQL::Types::Boolean,
22
+ datetime: ::GraphQL::Types::ISO8601DateTime,
23
+ date: ::GraphQL::Types::ISO8601Date,
24
+ json: ::GraphQL::Types::JSON,
25
+ bigint: ::GraphQL::Types::BigInt,
26
+ text: ::GraphQL::Types::String
27
+ }.freeze
28
+ end
29
+ end
@@ -0,0 +1,3 @@
1
+ module RailsFields
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,26 @@
1
+ require "rails_fields/errors/rails_fields_error"
2
+ require "rails_fields/errors/rails_fields_mismatch_error"
3
+ require "rails_fields/errors/rails_fields_unknown_type_error"
4
+ require "rails_fields/utils/logging"
5
+ require "rails_fields/utils/mappings"
6
+ require "rails_fields/utils/helpers"
7
+ require "rails_fields/class_methods"
8
+ require "rails_fields/instance_methods"
9
+
10
+ # Provides enforcement of declared field for ActiveRecord models.
11
+ module RailsFields
12
+ @processed_classes = {}
13
+
14
+ def self.processed_classes
15
+ @processed_classes
16
+ end
17
+
18
+ # @param base [ActiveRecord::Base] the model to include the module in
19
+ def self.included(base)
20
+ # base.extend(ClassMethods)
21
+ # todo: raise if class methods not found
22
+ base.after_initialize do
23
+ self.class.enforce_declared_fields
24
+ end
25
+ end
26
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-fields
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Gaston Morixe
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-08-27 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: The rails-fields gem provides robust field type enforcement for ActiveRecord
14
+ models in Ruby on Rails applications. It includes utility methods for type validation,
15
+ logging, and field mappings between GraphQL and ActiveRecord types. Custom error
16
+ classes provide clear diagnostics for field-related issues, making it easier to
17
+ maintain consistent data models.
18
+ email:
19
+ - gaston@gastonmorixe.com
20
+ executables: []
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - README.md
25
+ - lib/rails_fields.rb
26
+ - lib/rails_fields/class_methods.rb
27
+ - lib/rails_fields/errors/rails_fields_error.rb
28
+ - lib/rails_fields/errors/rails_fields_mismatch_error.rb
29
+ - lib/rails_fields/errors/rails_fields_unknown_type_error.rb
30
+ - lib/rails_fields/instance_methods.rb
31
+ - lib/rails_fields/utils/helpers.rb
32
+ - lib/rails_fields/utils/logging.rb
33
+ - lib/rails_fields/utils/mappings.rb
34
+ - lib/rails_fields/version.rb
35
+ homepage: https://github.com/gastonmorixe/rails-fields
36
+ licenses:
37
+ - MIT
38
+ metadata: {}
39
+ post_install_message:
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 3.2.2
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 3.4.19
55
+ signing_key:
56
+ specification_version: 4
57
+ summary: Enforce field types and attributes for ActiveRecord models in Ruby on Rails
58
+ applications.
59
+ test_files: []