From 9a0c02b97585ae66caccee122074101be81fc97e Mon Sep 17 00:00:00 2001 From: Gaston Morixe Date: Sun, 27 Aug 2023 02:16:12 -0300 Subject: [PATCH] init --- .gitignore | 14 ++ README.md | 39 ++++ TODO.txt | 2 + lib/rails_fields.rb | 26 +++ lib/rails_fields/class_methods.rb | 125 +++++++++++ lib/rails_fields/errors/rails_fields_error.rb | 5 + .../errors/rails_fields_mismatch_error.rb | 18 ++ .../errors/rails_fields_unknown_type_error.rb | 5 + lib/rails_fields/instance_methods.rb | 4 + lib/rails_fields/utils/helpers.rb | 202 ++++++++++++++++++ lib/rails_fields/utils/logging.rb | 11 + lib/rails_fields/utils/mappings.rb | 29 +++ lib/rails_fields/version.rb | 3 + rails_fields.gemspec | 18 ++ 14 files changed, 501 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 TODO.txt create mode 100644 lib/rails_fields.rb create mode 100644 lib/rails_fields/class_methods.rb create mode 100644 lib/rails_fields/errors/rails_fields_error.rb create mode 100644 lib/rails_fields/errors/rails_fields_mismatch_error.rb create mode 100644 lib/rails_fields/errors/rails_fields_unknown_type_error.rb create mode 100644 lib/rails_fields/instance_methods.rb create mode 100644 lib/rails_fields/utils/helpers.rb create mode 100644 lib/rails_fields/utils/logging.rb create mode 100644 lib/rails_fields/utils/mappings.rb create mode 100644 lib/rails_fields/version.rb create mode 100644 rails_fields.gemspec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..589fdf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.gem +coverage/* +Gemfile.lock +*~ +.bundle +.rvmrc +log/* +measurement/* +pkg/* +.DS_Store +.env +spec/dummy/tmp/* +spec/dummy/log/*.log +.idea diff --git a/README.md b/README.md new file mode 100644 index 0000000..00f5ab2 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Rails Fields + +## Summary +Enforce field types and attributes for ActiveRecord models in Ruby on Rails applications. + +## Description +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. + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'rails-fields' +``` + +And then execute: + +```bash +$ bundle install +``` + +Or install it yourself as: + +``` +$ gem install rails-fields +``` + +## Usage + +(TBD: Add usage examples here) + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + +## Author + +Gaston Morixe - gaston@gastonmorixe.com diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..8919231 --- /dev/null +++ b/TODO.txt @@ -0,0 +1,2 @@ +- [ ] Generators +- [ ] Install to ApplicationRecord by default \ No newline at end of file diff --git a/lib/rails_fields.rb b/lib/rails_fields.rb new file mode 100644 index 0000000..16bf118 --- /dev/null +++ b/lib/rails_fields.rb @@ -0,0 +1,26 @@ +require "rails_fields/errors/rails_fields_error" +require "rails_fields/errors/rails_fields_mismatch_error" +require "rails_fields/errors/rails_fields_unknown_type_error" +require "rails_fields/utils/logging" +require "rails_fields/utils/mappings" +require "rails_fields/utils/helpers" +require "rails_fields/class_methods" +require "rails_fields/instance_methods" + +# Provides enforcement of declared field for ActiveRecord models. +module RailsFields + @processed_classes = {} + + def self.processed_classes + @processed_classes + end + + # @param base [ActiveRecord::Base] the model to include the module in + def self.included(base) + # base.extend(ClassMethods) + # todo: raise if class methods not found + base.after_initialize do + self.class.enforce_declared_fields + end + end +end diff --git a/lib/rails_fields/class_methods.rb b/lib/rails_fields/class_methods.rb new file mode 100644 index 0000000..a6dde5f --- /dev/null +++ b/lib/rails_fields/class_methods.rb @@ -0,0 +1,125 @@ +module RailsFields + module ClassMethods + # TODO: Check all models at rails init app? like migrations? + + def declared_fields + @declared_fields ||= [] + end + + def declared_fields=(value) + @declared_fields = value + end + + def write_migration(index: nil) + changes = RailsFields::Utils.detect_changes(self) + RailsFields::Utils.generate_migration(self, changes, index:, write: true) + end + + # Declares a field with enforcement. + # + # @!method + # @param name [Symbol] the name of the field + # @param type [Symbol] the type of the field + # @param null [Boolean] whether the field can be null (default: true) + # @param index [Boolean] whether to index the field (default: false) + # @return [void] + # + # @!macro [attach] field + # @!attribute $1 + # @return [$2] the $1 property + def field(name, type, null: true, index: false) + # Check if type is a valid GraphQL type + # GraphQL::Types.const_get(type) if type.is_a?(Symbol) || type.is_a?(String) + unless Utils.valid_type?(type) + raise Errors::RailsFieldsUnknownTypeError.new(" + Declared field '#{name}' in class '#{self.name}' of unknown type '#{type}'. Allowed types are: #{Utils.allowed_types.join(', ')}. + ") + end + + declared_fields << OpenStruct.new(name: name.to_s, type:, null:, index:) + end + + def gql_type + return RailsFields.processed_classes[self] if RailsFields.processed_classes[self].present? + + fields = declared_fields + owner_self = self + + type = Class.new(::Types::BaseObject) do + # graphql_name "#{owner_self.name}Type" + graphql_name "#{owner_self.name}" + description "A type representing a #{owner_self.name}" + + fields.each do |f| + next if f.type.nil? # TODO: ! remove references fields + + # Assuming a proper mapping from your custom types to GraphQL types + # TODO: use a better method or block + field_gql_type = f.name == :id ? GraphQL::Types::ID : Utils::RAILS_TO_GQL_TYPE_MAP[f.type] + field f.name, field_gql_type + end + end + + # Cache the processed class here to prevent infinite recursion + RailsFields.processed_classes[self] = type + + type.instance_eval do + owner_self.reflections.each do |association_name, reflection| + if reflection.macro == :has_many + reflection_klass = if reflection.options[:through] + through_reflection_klass = reflection.through_reflection.klass + source_reflection_name = reflection.source_reflection_name.to_s + source_reflection = through_reflection_klass.reflections[source_reflection_name] + source_reflection ? source_reflection.klass : through_reflection_klass + else + reflection.klass + end + field association_name, [reflection_klass.gql_type], null: true + elsif reflection.macro == :belongs_to + field association_name, reflection.klass.gql_type, null: true + end + end + + type + end + end + + def enforce_declared_fields + database_columns = column_names.map(&:to_sym) + declared_fields_names = declared_fields.map(&:name).map(&:to_sym) || [] + changes = RailsFields::Utils.detect_changes(self) + migration = RailsFields::Utils.generate_migration(self, changes) + instance_methods = self.instance_methods(false).select do |method| + instance_method(method).source_location.first.start_with?(Rails.root.to_s) + end + extra_methods = instance_methods - declared_fields_names.map(&:to_sym) + has_changes = !changes.nil? + + unless extra_methods.empty? + # TODO: Custom error subclass + raise "You have extra methods declared in #{name}: #{extra_methods.join(', ')}. Please remove them or declare them as fields." + end + + if has_changes + error_message = <<~STRING + + ---------------- + + Declared Fields: + #{declared_fields_names.join(', ')} + + Database columns: + #{database_columns.join(', ')} + + Changes: + #{changes.to_yaml.lines[1..-1].join} + Migration: + #{migration} + + ---------------- + STRING + raise Errors::RailsFieldsMismatchError.new(error_message) + end + end + end +end diff --git a/lib/rails_fields/errors/rails_fields_error.rb b/lib/rails_fields/errors/rails_fields_error.rb new file mode 100644 index 0000000..55ce59e --- /dev/null +++ b/lib/rails_fields/errors/rails_fields_error.rb @@ -0,0 +1,5 @@ +module RailsFields + module Errors + class RailsFieldsError < StandardError; end + end +end diff --git a/lib/rails_fields/errors/rails_fields_mismatch_error.rb b/lib/rails_fields/errors/rails_fields_mismatch_error.rb new file mode 100644 index 0000000..f623301 --- /dev/null +++ b/lib/rails_fields/errors/rails_fields_mismatch_error.rb @@ -0,0 +1,18 @@ +module RailsFields + module Errors + class RailsFieldsMismatchError < RailsFieldsError + include ActiveSupport::ActionableError + + action "Save migrations" do + models = RailsFields::Utils.active_record_models + models.each_with_index do |m, index| + m.write_migration(index:) + end + end + + # action "Run db:migrations" do + # ActiveRecord::Tasks::DatabaseTasks.migrate + # end + end + end +end diff --git a/lib/rails_fields/errors/rails_fields_unknown_type_error.rb b/lib/rails_fields/errors/rails_fields_unknown_type_error.rb new file mode 100644 index 0000000..92f62b6 --- /dev/null +++ b/lib/rails_fields/errors/rails_fields_unknown_type_error.rb @@ -0,0 +1,5 @@ +module RailsFields + module Errors + class RailsFieldsUnknownTypeError < RailsFieldsError; end + end +end diff --git a/lib/rails_fields/instance_methods.rb b/lib/rails_fields/instance_methods.rb new file mode 100644 index 0000000..58897ae --- /dev/null +++ b/lib/rails_fields/instance_methods.rb @@ -0,0 +1,4 @@ +module RailsFields + module InstanceMethods + end +end diff --git a/lib/rails_fields/utils/helpers.rb b/lib/rails_fields/utils/helpers.rb new file mode 100644 index 0000000..29af0ec --- /dev/null +++ b/lib/rails_fields/utils/helpers.rb @@ -0,0 +1,202 @@ +module RailsFields + module Utils + class << self + def allowed_types + # TODO: this may depend on the current database adapter or mapper + ActiveRecord::Base.connection.native_database_types.keys + end + + def valid_type?(type) + # TODO: this may depend on the current database adapter or mapper + allowed_types.include?(type) + end + + def active_record_models + Rails.application.eager_load! # Ensure all models are loaded + + ActiveRecord::Base.descendants.reject do |model| + !(model.is_a?(Class) && model < ApplicationRecord) || + model.abstract_class? || + model.name.nil? || + model.name == "ActiveRecord::SchemaMigration" + end + end + + # Detect changes between the ActiveRecord model declared fields and the database structure. + # @example + # model_changes = FieldEnforcement::Utils.detect_changes(User) + # # => { + # added: [], + # removed: [], + # renamed: [], + # type_changed: [], + # potential_renames: [] + # } + # @param model [ActiveRecord::Base] the model to check + # @return [Hash, Nil] the changes detected + def detect_changes(model) + previous_fields = model.attribute_types.to_h { |k, v| [k.to_sym, v.type] } + declared_fields = model.declared_fields.to_h do |f| + [f.name.to_sym, { + name: f.type.to_sym, + options: f.options + }] + end + + LOGGER.debug "Log: previous_fields: #{previous_fields}" + LOGGER.debug "Log: declared_fields #{declared_fields}}" + + model_changes = { + added: [], + removed: [], + renamed: [], + type_changed: [], + potential_renames: [], + associations_added: [], + associations_removed: [] + } + + # Detect added and type-changed fields + declared_fields.each do |name, type| + type_name = type[:name] + if previous_fields[name] + if previous_fields[name] != type_name + model_changes[:type_changed] << { name:, from: previous_fields[name], + to: type } + end + else + model_changes[:added] << { name:, type: } + end + end + + LOGGER.debug "Log: model_changes[:added] before filter #{model_changes[:added]}" + # Remove added fields that have a defined method in the the model + model_changes[:added] = model_changes[:added].filter { |f| !model.instance_methods.include?(f[:name]) } + LOGGER.debug "Log: model_changes[:added] after filter #{model_changes[:added]}" + + # Detect removed fields + removed_fields = previous_fields.keys - declared_fields.keys + model_changes[:removed] = removed_fields.map { |name| { name:, type: previous_fields[name] } } + + LOGGER.debug "Log: model_changes[:removed] 1 #{model_changes[:removed]}" + + # Remove foreign keys from removed fields + associations = model.reflections.values.map(&:foreign_key).map(&:to_sym) + model_changes[:removed].reject! { |f| associations.include?(f[:name]) } + + LOGGER.debug "Log: model_changes[:removed] 2 #{model_changes[:removed]} | associations #{associations}" + + # Detect potential renames + potential_renames = [] + model_changes[:removed].each do |removed_field| + # puts "Log: removed_field: #{removed_field}" + added_field = model_changes[:added].find { |f| f[:type] == removed_field[:type] } + if added_field + potential_renames << { from: removed_field[:name], + to: added_field[:name] } + end + end + + LOGGER.debug "Log: potential_renames: #{potential_renames}" + + model_changes[:potential_renames] = potential_renames + + # Filter out incorrect renames (one-to-one mapping) + potential_renames.each do |rename| + next unless model_changes[:added].count { |f| f[:type] == rename[:to].to_sym } == 1 && + model_changes[:removed].count { |f| f[:type] == rename[:from].to_sym } == 1 + + model_changes[:renamed] << rename + model_changes[:added].reject! { |f| f[:name] == rename[:to].to_sym } + model_changes[:removed].reject! { |f| f[:name] == rename[:from].to_sym } + end + + # Handle associations changes + declared_associations = model.reflections.values.select do |reflection| + [:belongs_to].include?(reflection.macro) + end + + declared_foreign_keys = declared_associations.map(&:foreign_key).map(&:to_sym) + existing_foreign_keys = ActiveRecord::Base.connection.foreign_keys(model.table_name).map(&:options).map { |opt| opt[:column].to_sym } + + associations_added = declared_associations.select do |reflection| + !existing_foreign_keys.include?(reflection.foreign_key.to_sym) + end + + associations_removed = existing_foreign_keys.select do |foreign_key| + !declared_foreign_keys.include?(foreign_key) + end.map { |foreign_key| model.reflections.values.find { |reflection| reflection.foreign_key == foreign_key.to_s } } + + model_changes[:associations_added] = associations_added + model_changes[:associations_removed] = associations_removed + + return model_changes unless model_changes.values.all?(&:empty?) + + nil + end + + def generate_migration(model, model_changes, index: 0, write: false) + return if model_changes.blank? + + model_name = model.name + timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i + index + migration_class_name = "#{model_name}Migration#{timestamp}" + + migration_code = [] + migration_code << "class #{migration_class_name} < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]" + + migration_code << " def change" + + model_changes.dig(:added)&.each do |change| + field_type = change[:type] + field_type_for_db = field_type[:name] + # TODO: custom mapper + migration_code << " add_column :#{model_name.tableize}, :#{change[:name]}, :#{field_type_for_db}" + end + + # Handle added associations + model_changes.dig(:associations_added)&.each do |assoc| + migration_code << " add_reference :#{model_name.tableize}, :#{assoc.name}, foreign_key: true" + end + + # Handle removed associations + model_changes.dig(:associations_removed)&.each do |assoc| + migration_code << " remove_reference :#{model_name.tableize}, :#{assoc.name}, foreign_key: true" + end + + # Handle removed fields + model_changes.dig(:removed)&.each do |change| + migration_code << " remove_column :#{model_name.tableize}, :#{change[:name]}" + end + + # Handle renamed fields + model_changes.dig(:renamed)&.each do |change| + change_to = change[:to] + migration_code << " rename_column :#{model_name.tableize}, :#{change[:from]}, :#{change_to}" + end + + # Handle fields' type changes + model_changes.dig(:type_changed)&.each do |change| + change_to = change[:to] + migration_code << " change_column :#{model_name.tableize}, :#{change[:name]}, :#{change_to}" + end + + migration_code << " end" + migration_code << "end" + migration_code << "" + + write_migration(migration_code, migration_class_name, timestamp) if write + + migration_code.join("\n") + end + + def write_migration(migration_code, migration_class_name, timestamp) + migration_filename = "#{timestamp}_#{migration_class_name.underscore}.rb" + migration_path = Rails.root.join("db", "migrate", migration_filename) + File.write(migration_path, migration_code.join("\n")) + LOGGER.info "Migration saved at #{migration_path}" + { migration_filename:, migration_path: } + end + end + end +end diff --git a/lib/rails_fields/utils/logging.rb b/lib/rails_fields/utils/logging.rb new file mode 100644 index 0000000..69c4dce --- /dev/null +++ b/lib/rails_fields/utils/logging.rb @@ -0,0 +1,11 @@ +require "logger" + +module RailsFields + module Utils + LOGGER = begin + logger = Logger.new($stdout) + logger.level = Rails.env.production? ? Logger::INFO : Logger::DEBUG + logger + end + end +end diff --git a/lib/rails_fields/utils/mappings.rb b/lib/rails_fields/utils/mappings.rb new file mode 100644 index 0000000..28a532f --- /dev/null +++ b/lib/rails_fields/utils/mappings.rb @@ -0,0 +1,29 @@ +module RailsFields + module Utils + # TODO: mapper can be different or custom + GQL_TO_RAILS_TYPE_MAP = { + ::GraphQL::Types::String => :string, + ::GraphQL::Types::Int => :integer, + ::GraphQL::Types::Float => :float, + ::GraphQL::Types::Boolean => :boolean, + ::GraphQL::Types::ID => :integer, # or :string depending on how you handle IDs + ::GraphQL::Types::ISO8601DateTime => :datetime, + ::GraphQL::Types::ISO8601Date => :date, + ::GraphQL::Types::JSON => :json, + ::GraphQL::Types::BigInt => :bigint + }.freeze + + RAILS_TO_GQL_TYPE_MAP = { + # id: ::GraphQL::Types::String, + string: ::GraphQL::Types::String, + integer: ::GraphQL::Types::Int, + float: ::GraphQL::Types::Float, + boolean: ::GraphQL::Types::Boolean, + datetime: ::GraphQL::Types::ISO8601DateTime, + date: ::GraphQL::Types::ISO8601Date, + json: ::GraphQL::Types::JSON, + bigint: ::GraphQL::Types::BigInt, + text: ::GraphQL::Types::String + }.freeze + end +end diff --git a/lib/rails_fields/version.rb b/lib/rails_fields/version.rb new file mode 100644 index 0000000..5f1306f --- /dev/null +++ b/lib/rails_fields/version.rb @@ -0,0 +1,3 @@ +module RailsFields + VERSION = "0.1.0" +end diff --git a/rails_fields.gemspec b/rails_fields.gemspec new file mode 100644 index 0000000..336d2b3 --- /dev/null +++ b/rails_fields.gemspec @@ -0,0 +1,18 @@ +$LOAD_PATH.push File.expand_path("../lib", __FILE__) +require "rails_fields/version" +require "date" + +Gem::Specification.new do |spec| + spec.name = "rails-fields" + spec.version = RailsFields::VERSION + spec.date = Date.today.to_s + spec.authors = ["Gaston Morixe"] + spec.email = ["gaston@gastonmorixe.com"] + spec.summary = "Enforce field types and attributes for ActiveRecord models in Ruby on Rails applications." + spec.description = "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." + spec.homepage = "https://github.com/gastonmorixe/rails-fields" + spec.license = "MIT" + spec.files = Dir["lib/**/*", "README.md"] + spec.require_paths = ["lib"] + spec.required_ruby_version = ">= 3.2.2" +end