diff --git a/CHANGELOG.md b/CHANGELOG.md index a777694..c2f36be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Note: This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2025-07-11 + +### Added + +- New LargeTextField::Base module, which delays the initialization of the association(s), and made LargeTextField::Owner a simple wrapper around Base which includes Base and initializes the association immediately. This was required to handle regressions for use cases that included the module, but did not define any large text value fields + ## [1.3.0] - 2025-07-09 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index 10ea20a..b2c16be 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - large_text_field (1.3.0) + large_text_field (1.4.0) invoca-utils (~> 0.3) rails (>= 6.0) diff --git a/lib/large_text_field/base.rb b/lib/large_text_field/base.rb new file mode 100644 index 0000000..189f97d --- /dev/null +++ b/lib/large_text_field/base.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require "invoca/utils" + +module LargeTextField + module Base + extend ActiveSupport::Concern + include ActiveSupport::Callbacks + + included do + validate :validate_large_text_fields + before_save :write_large_text_field_changes + define_callbacks :large_text_field_save + + class_attribute :large_text_field_options + self.large_text_field_options = {} + + class_attribute :initialized + self.initialized = false + + class_attribute :large_text_field_class_name + self.large_text_field_class_name = "LargeTextField::NamedTextValue" + class_attribute :large_text_field_class + self.large_text_field_class = LargeTextField::NamedTextValue + + class_attribute :large_text_field_deprecated_class_name + self.large_text_field_deprecated_class_name = nil + class_attribute :large_text_field_deprecated_class + self.large_text_field_deprecated_class = nil + end + + def dup + result = super + + result._clear_text_field_on_dup + + large_text_field_options.each_key { |key| result.set_text_field(key, get_text_field(key)) } + + result + end + + def reload(options = nil) + super(options) + + @text_field_hash = nil + self + end + + def text_field_hash + unless @text_field_hash + @text_field_hash = large_text_fields.build_hash { |text_field| [text_field.field_name, text_field] } + if large_text_field_deprecated_class_name + deprecated_large_text_fields.each { |text_field| @text_field_hash[text_field.field_name] ||= text_field } + end + end + @text_field_hash + end + + def text_field_hash_loaded? + defined?(@text_field_hash) && @text_field_hash.present? + end + + def get_text_field(field_name) + text_field_hash[field_name.to_s]&.value || '' + end + + def set_text_field(original_field_name, original_value) + !original_value.nil? or raise "LargeTextField value cannot be set value to nil." + + field_name = original_field_name.to_s + value = original_value.is_a?(File) ? original_value.read : original_value.to_s + if (field = text_field_hash[field_name]) + field.value = value + else + text_field_hash[field_name] = large_text_field_class.new(owner: self, field_name:, value:) + end + end + + def text_field_changed(field_name) + text_field_hash_loaded? && @text_field_hash[field_name]&.changes&.any? + end + + # rubocop:disable Metrics/CyclomaticComplexity + def validate_large_text_fields + if text_field_hash_loaded? + large_text_field_options.each do |key, options| + value = text_field_hash[key]&.value + conjugation = options[:singularize_errors] ? "is" : "are" + maximum = options[:maximum] || MAX_LENGTH + + if value.present? && value.size > maximum + errors.add( + key, + "#{conjugation} too long (maximum is #{self.class.formatted_integer_value(maximum)} characters)" + ) + end + end + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + def write_large_text_field_changes + run_callbacks(:large_text_field_save) + + @text_field_hash = text_field_hash + .compact + .select { |_key, value| value.value.presence } + .transform_values { |value| value.is_a?(large_text_field_class) ? value : large_text_field_class.new(owner: self, field_name: value.field_name, value: value.value) } + self.large_text_fields = text_field_hash.values.compact + true + end + + def _clear_text_field_on_dup + if instance_variable_defined?(:@text_field_hash) + remove_instance_variable(:@text_field_hash) + end + end + + module ClassMethods + def large_text_field_class_name_override(value) + self.large_text_field_class_name = value + self.large_text_field_class = Object.const_get(value) + end + + def large_text_field_deprecated_class_name_override(value) + self.large_text_field_deprecated_class_name = value + self.large_text_field_deprecated_class = Object.const_get(value) + end + + def initialize_large_text_field + return if initialized # skip if already initialized + + has_many( + :large_text_fields, + class_name: large_text_field_class_name, + as: :owner, + autosave: true, + dependent: :destroy, + inverse_of: :owner + ) + if large_text_field_deprecated_class_name + has_many( + :deprecated_large_text_fields, + class_name: large_text_field_deprecated_class_name, + as: :owner, + autosave: true, + dependent: :destroy, + inverse_of: :owner + ) + end + self.initialized = true + end + + def large_text_field(field_name, maximum: nil, singularize_errors: false) + initialize_large_text_field # ensure the association is initialized + + field_name = field_name.to_s + + # validate custom maximum + if maximum + if !maximum.is_a? Integer + raise ArgumentError, "maximum must be a number" + elsif maximum > MAX_LENGTH + raise ArgumentError, "maximum can't be greater than #{formatted_integer_value(MAX_LENGTH)}" + elsif maximum.negative? + raise ArgumentError, "maximum can't be less than 0" + end + end + + large_text_field_options[field_name] = { maximum:, singularize_errors: } + define_method(field_name) { get_text_field(field_name) } + define_method("#{field_name}=") { |value| set_text_field(field_name, value) } + define_method("#{field_name}_changed?") { text_field_changed(field_name) } + end + + def formatted_integer_value(value) + value.to_i.to_s.gsub(/(\d)(?=(\d\d\d)+(?!\d))/, "\\1,") + end + end + end +end diff --git a/lib/large_text_field/owner.rb b/lib/large_text_field/owner.rb index 5aa92af..537c0d1 100644 --- a/lib/large_text_field/owner.rb +++ b/lib/large_text_field/owner.rb @@ -1,181 +1,16 @@ # frozen_string_literal: true require "invoca/utils" +require_relative "base" module LargeTextField module Owner extend ActiveSupport::Concern include ActiveSupport::Callbacks + include LargeTextField::Base included do - validate :validate_large_text_fields - before_save :write_large_text_field_changes - define_callbacks :large_text_field_save - - class_attribute :large_text_field_options - self.large_text_field_options = {} - - class_attribute :initialized - self.initialized = false - - class_attribute :large_text_field_class_name - self.large_text_field_class_name = "LargeTextField::NamedTextValue" - class_attribute :large_text_field_class - self.large_text_field_class = LargeTextField::NamedTextValue - - class_attribute :large_text_field_deprecated_class_name - self.large_text_field_deprecated_class_name = nil - class_attribute :large_text_field_deprecated_class - self.large_text_field_deprecated_class = nil - end - - def dup - result = super - - result._clear_text_field_on_dup - - large_text_field_options.each_key { |key| result.set_text_field(key, get_text_field(key)) } - - result - end - - def reload(options = nil) - super(options) - - @text_field_hash = nil - self - end - - def text_field_hash - unless @text_field_hash - @text_field_hash = large_text_fields.build_hash { |text_field| [text_field.field_name, text_field] } - if large_text_field_deprecated_class_name - deprecated_large_text_fields.each { |text_field| @text_field_hash[text_field.field_name] ||= text_field } - end - end - @text_field_hash - end - - def text_field_hash_loaded? - defined?(@text_field_hash) && @text_field_hash.present? - end - - def get_text_field(field_name) - text_field_hash[field_name.to_s]&.value || '' - end - - def set_text_field(original_field_name, original_value) - !original_value.nil? or raise "LargeTextField value cannot be set value to nil." - - field_name = original_field_name.to_s - value = original_value.is_a?(File) ? original_value.read : original_value.to_s - if (field = text_field_hash[field_name]) - field.value = value - else - text_field_hash[field_name] = large_text_field_class.new(owner: self, field_name:, value:) - end - end - - def text_field_changed(field_name) - text_field_hash_loaded? && @text_field_hash[field_name]&.changes&.any? - end - - # rubocop:disable Metrics/CyclomaticComplexity - def validate_large_text_fields - if text_field_hash_loaded? - large_text_field_options.each do |key, options| - value = text_field_hash[key]&.value - conjugation = options[:singularize_errors] ? "is" : "are" - maximum = options[:maximum] || MAX_LENGTH - - if value.present? && value.size > maximum - errors.add( - key, - "#{conjugation} too long (maximum is #{self.class.formatted_integer_value(maximum)} characters)" - ) - end - end - end - end - # rubocop:enable Metrics/CyclomaticComplexity - - def write_large_text_field_changes - run_callbacks(:large_text_field_save) - - @text_field_hash = text_field_hash - .compact - .select { |_key, value| value.value.presence } - .transform_values { |value| value.is_a?(large_text_field_class) ? value : large_text_field_class.new(owner: self, field_name: value.field_name, value: value.value) } - self.large_text_fields = text_field_hash.values.compact - true - end - - def _clear_text_field_on_dup - if instance_variable_defined?(:@text_field_hash) - remove_instance_variable(:@text_field_hash) - end - end - - module ClassMethods - def large_text_field_class_name_override(value) - self.large_text_field_class_name = value - self.large_text_field_class = Object.const_get(value) - end - - def large_text_field_deprecated_class_name_override(value) - self.large_text_field_deprecated_class_name = value - self.large_text_field_deprecated_class = Object.const_get(value) - end - - def initialize_large_text_field - return if initialized # skip if already initialized - - has_many( - :large_text_fields, - class_name: large_text_field_class_name, - as: :owner, - autosave: true, - dependent: :destroy, - inverse_of: :owner - ) - if large_text_field_deprecated_class_name - has_many( - :deprecated_large_text_fields, - class_name: large_text_field_deprecated_class_name, - as: :owner, - autosave: true, - dependent: :destroy, - inverse_of: :owner - ) - end - self.initialized = true - end - - def large_text_field(field_name, maximum: nil, singularize_errors: false) - initialize_large_text_field # ensure the association is initialized - - field_name = field_name.to_s - - # validate custom maximum - if maximum - if !maximum.is_a? Integer - raise ArgumentError, "maximum must be a number" - elsif maximum > MAX_LENGTH - raise ArgumentError, "maximum can't be greater than #{formatted_integer_value(MAX_LENGTH)}" - elsif maximum.negative? - raise ArgumentError, "maximum can't be less than 0" - end - end - - large_text_field_options[field_name] = { maximum:, singularize_errors: } - define_method(field_name) { get_text_field(field_name) } - define_method("#{field_name}=") { |value| set_text_field(field_name, value) } - define_method("#{field_name}_changed?") { text_field_changed(field_name) } - end - - def formatted_integer_value(value) - value.to_i.to_s.gsub(/(\d)(?=(\d\d\d)+(?!\d))/, "\\1,") - end + initialize_large_text_field end end end diff --git a/lib/large_text_field/version.rb b/lib/large_text_field/version.rb index 171cf8e..48bc6f6 100644 --- a/lib/large_text_field/version.rb +++ b/lib/large_text_field/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module LargeTextField - VERSION = "1.3.0" + VERSION = "1.4.0" end diff --git a/test/dummy/app/models/person.rb b/test/dummy/app/models/person.rb index 81bde7a..5018dc9 100644 --- a/test/dummy/app/models/person.rb +++ b/test/dummy/app/models/person.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Person < ApplicationRecord - include ::LargeTextField::Owner + include ::LargeTextField::Base # Schema # name :string, :limit => 255