diff --git a/CHANGELOG.md b/CHANGELOG.md index bcc12af..b6e69b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -400,4 +400,4 @@ end - Removed pry-byebug. ## [0.1.0] - 2024-04-09 -- Initial release \ No newline at end of file +- Initial release diff --git a/README.md b/README.md index bec4a74..a46ca48 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,22 @@ user.errors # => ActiveModel::Errors --- +## Rails ActiveRecord integration (optional) + +If you're storing EasyTalk schemas in JSON/JSONB columns, you can use the +ActiveModel::Type adapter to avoid custom `serialize` coders: + +```ruby +class Space < ApplicationRecord + attribute :prompt_settings, ConversationSettings::SpaceSettings.to_type +end +``` + +This keeps EasyTalk as the single schema source of truth while improving +Rails integration and allowing best-effort type casting. + +--- + ## Property constraints | Constraint | Applies to | Example | diff --git a/lib/easy_talk.rb b/lib/easy_talk.rb index 7021bdb..ec6bbe9 100644 --- a/lib/easy_talk.rb +++ b/lib/easy_talk.rb @@ -11,6 +11,7 @@ module EasyTalk require 'easy_talk/schema_methods' require 'easy_talk/types/composer' require 'easy_talk/types/tuple' + require "easy_talk/active_model_type" # Validation adapters require 'easy_talk/validation_adapters/base' diff --git a/lib/easy_talk/active_model_type.rb b/lib/easy_talk/active_model_type.rb new file mode 100644 index 0000000..f2de91c --- /dev/null +++ b/lib/easy_talk/active_model_type.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true +# typed: true + +require "active_model" +require "active_support/json" + +module EasyTalk + # ActiveModel::Type adapter for EasyTalk schema/model classes. + # + # Usage: + # attribute :settings, ConversationSettings::SpaceSettings.to_type + # + # This replaces `serialize ... coder:` while keeping EasyTalk as the schema + # source of truth. Casting is best-effort for primitive types, arrays, tuples, + # and nested EasyTalk models. + class ActiveModelType < ActiveModel::Type::Value + def initialize(schema_class) + @schema_class = schema_class + super() + end + + def type + :json + end + + def cast(value) + cast_value(value) + end + + def deserialize(value) + cast_value(value) + end + + def serialize(value) + case value + when nil + nil + when @schema_class + value.to_h + when Hash + value + else + value.respond_to?(:to_h) ? value.to_h : value + end + end + + def changed_in_place?(raw_old_value, new_value) + normalize_for_comparison(cast_value(raw_old_value)) != normalize_for_comparison(new_value) + end + + private + + # ActiveRecord calls `changed_in_place?` with the raw DB value and the current + # (type-cast) value to detect in-place mutations of mutable attributes. + # + # EasyTalk schema/model instances only implement `==` against Hashes (and + # otherwise fall back to object identity). If we compare instances directly, + # two distinct instances with identical data will always be treated as + # different, causing "always dirty" attributes. + # + # To avoid that, compare a deep, JSON-ready representation instead. + def normalize_for_comparison(value) + value = serialize(value) if easy_talk_model_class?(value.class) + + case value + when Hash + value.each_with_object({}) do |(k, v), out| + out[k.to_s] = normalize_for_comparison(v) + end + when Array + value.map { |item| normalize_for_comparison(item) } + else + value + end + end + + def cast_value(value) + case value + when nil + nil + when @schema_class + value + when String + build_instance(decode_json(value)) + when Hash + build_instance(value) + else + value.respond_to?(:to_h) ? build_instance(value.to_h) : value + end + end + + def decode_json(value) + ActiveSupport::JSON.decode(value) + rescue JSON::ParserError, TypeError + {} + end + + def build_instance(value) + return value if value.is_a?(@schema_class) + return @schema_class.new(cast_attributes(value)) if value.is_a?(Hash) + + @schema_class.new({}) + end + + def cast_attributes(raw, schema_class: @schema_class) + return raw unless raw.is_a?(Hash) + + schema = schema_definition_for(schema_class) + return raw unless schema.is_a?(Hash) + + properties = schema[:properties] || {} + return raw if properties.empty? + + casted = raw.dup + properties.each do |prop_name, prop_def| + next unless prop_def.is_a?(Hash) + + key, raw_value = fetch_key(casted, prop_name) + next if key.nil? + + casted[key] = cast_property_value(prop_def[:type], raw_value) + end + + casted + end + + def fetch_key(hash, prop_name) + return [prop_name, hash[prop_name]] if hash.key?(prop_name) + + string_key = prop_name.to_s + return [string_key, hash[string_key]] if hash.key?(string_key) + + [nil, nil] + end + + def cast_property_value(type, value) + return nil if value.nil? + + unwrapped_type = unwrap_nilable(type) + + return cast_array_value(unwrapped_type, value) if unwrapped_type.is_a?(T::Types::TypedArray) + + return cast_tuple_value(unwrapped_type, value) if unwrapped_type.is_a?(EasyTalk::Types::Tuple) + + type_class = resolve_type_class(unwrapped_type) + if easy_talk_model_class?(type_class) + return type_class.new(cast_attributes(value, schema_class: type_class)) if value.is_a?(Hash) + return value if value.is_a?(type_class) + end + + cast_primitive(unwrapped_type, value, type_class: type_class) + end + + def cast_array_value(type, value) + return value unless value.is_a?(Array) + + element_type = type.type + value.map { |item| cast_property_value(element_type, item) } + end + + def cast_tuple_value(type, value) + return value unless value.is_a?(Array) + + value.each_with_index.map do |item, index| + element_type = type.types[index] || type.types.last + cast_property_value(element_type, item) + end + end + + def cast_primitive(type, value, type_class: nil) + return ActiveModel::Type::Boolean.new.cast(value) if TypeIntrospection.boolean_type?(type) + + type_class ||= resolve_type_class(type) + return value unless type_class + + case type_class.name + when "Integer" + ActiveModel::Type::Integer.new.cast(value) + when "Float" + ActiveModel::Type::Float.new.cast(value) + when "BigDecimal" + ActiveModel::Type::Decimal.new.cast(value) + when "String" + ActiveModel::Type::String.new.cast(value) + else + value + end + end + + def resolve_type_class(type) + return type if type.is_a?(Class) + return type.raw_type if type.respond_to?(:raw_type) + + nil + end + + def easy_talk_model_class?(type) + type.is_a?(Class) && (type.include?(EasyTalk::Model) || type.include?(EasyTalk::Schema)) + end + + def unwrap_nilable(type) + return type unless type.respond_to?(:nilable?) && type.nilable? + + T::Utils::Nilable.get_underlying_type(type) + end + + def schema_definition_for(schema_class) + return unless schema_class.respond_to?(:schema_definition) + + schema_class.schema_definition&.schema + end + end +end diff --git a/lib/easy_talk/error_formatter/base.rb b/lib/easy_talk/error_formatter/base.rb index 9200c95..a0ece1b 100644 --- a/lib/easy_talk/error_formatter/base.rb +++ b/lib/easy_talk/error_formatter/base.rb @@ -91,7 +91,7 @@ def build_error_entries # This handles the case where an attribute has multiple errors. def find_and_consume_detail(details_by_attr, attribute) detail_list = details_by_attr[attribute] - return {} if detail_list.nil? || detail_list.empty? + return {} if detail_list.blank? detail_list.shift || {} end diff --git a/lib/easy_talk/errors_helper.rb b/lib/easy_talk/errors_helper.rb index 026b5dd..1ae515c 100644 --- a/lib/easy_talk/errors_helper.rb +++ b/lib/easy_talk/errors_helper.rb @@ -6,6 +6,8 @@ module EasyTalk module ErrorHelper extend T::Sig + BOOLEAN_VALUES = [true, false].freeze + def self.raise_constraint_error(property_name:, constraint_name:, expected:, got:) message = "Error in property '#{property_name}': Constraint '#{constraint_name}' expects #{expected}, " \ "but received #{got.inspect} (#{got.class})." @@ -93,7 +95,7 @@ def self.validate_union_element(property_name, constraint_name, inner_type, elem def self.validate_single_type_element(property_name, constraint_name, inner_type, element, index) # Skip if element is a boolean (booleans are valid in many contexts) - return if [true, false].include?(element) + return if BOOLEAN_VALUES.include?(element) if TypeIntrospection.boolean_type?(inner_type) raise_array_constraint_error( @@ -118,9 +120,9 @@ def self.validate_constraint_value(property_name:, constraint_name:, value_type: return if value.nil? if TypeIntrospection.boolean_type?(value_type) - return if value.is_a?(Array) && value.all? { |v| [true, false].include?(v) } + return if value.is_a?(Array) && value.all? { |v| BOOLEAN_VALUES.include?(v) } - unless [true, false].include?(value) + unless BOOLEAN_VALUES.include?(value) raise_constraint_error( property_name: property_name, constraint_name: constraint_name, diff --git a/lib/easy_talk/model.rb b/lib/easy_talk/model.rb index 5a54f2a..ab71df4 100644 --- a/lib/easy_talk/model.rb +++ b/lib/easy_talk/model.rb @@ -220,7 +220,7 @@ def schema # property :name, String # end def define_schema(options = {}, &) - raise ArgumentError, 'The class must have a name' unless name.present? + raise ArgumentError, 'The class must have a name' if name.blank? @schema_definition = SchemaDefinition.new(name) @schema_definition.klass = self # Pass the model class to the schema definition @@ -323,6 +323,13 @@ def schema_definition @schema_definition ||= {} end + # Returns an ActiveModel::Type adapter for this schema class. + # + # @return [EasyTalk::ActiveModelType] + def to_type + EasyTalk::ActiveModelType.new(self) + end + def additional_properties_allowed? ap = @schema_definition&.schema&.fetch(:additional_properties, false) # Allow if true, or if it's a schema object (Class or Hash with type) diff --git a/lib/easy_talk/schema.rb b/lib/easy_talk/schema.rb index 75a5db5..69c79f1 100644 --- a/lib/easy_talk/schema.rb +++ b/lib/easy_talk/schema.rb @@ -149,7 +149,7 @@ def schema # @yield The block to define the schema. # @raise [ArgumentError] If the class does not have a name. def define_schema(&) - raise ArgumentError, 'The class must have a name' unless name.present? + raise ArgumentError, 'The class must have a name' if name.blank? @schema_definition = SchemaDefinition.new(name) @schema_definition.klass = self @@ -171,6 +171,13 @@ def schema_definition @schema_definition ||= {} end + # Returns an ActiveModel::Type adapter for this schema class. + # + # @return [EasyTalk::ActiveModelType] + def to_type + EasyTalk::ActiveModelType.new(self) + end + # Check if additional properties are allowed. # # @return [Boolean] True if additional properties are allowed. diff --git a/lib/easy_talk/validation_adapters/active_model_adapter.rb b/lib/easy_talk/validation_adapters/active_model_adapter.rb index b7a6248..54618f9 100644 --- a/lib/easy_talk/validation_adapters/active_model_adapter.rb +++ b/lib/easy_talk/validation_adapters/active_model_adapter.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'uri' +require 'time' require_relative 'active_model_schema_validation' require 'easy_talk/json_schema_equality' @@ -271,7 +272,8 @@ def apply_time_format_validation value = record.public_send(prop_name) next if value.blank? || !value.is_a?(String) - Time.parse(value) + time_zone = Time.respond_to?(:zone) ? Time.zone : nil + (time_zone || Time).parse(value) record.errors.add(prop_name, 'must be a valid time in HH:MM:SS format') unless value.match?(/\A\d{2}:\d{2}:\d{2}/) rescue ArgumentError record.errors.add(prop_name, 'must be a valid time in HH:MM:SS format') @@ -353,7 +355,7 @@ def apply_tuple_validations(item_types, additional_items) prop_name = @property_name # Pre-resolve type classes for use in validate block resolved_item_types = item_types.map { |t| self.class.resolve_tuple_type_class(t) } - resolved_additional_type = additional_items && ![true, false].include?(additional_items) ? self.class.resolve_tuple_type_class(additional_items) : nil + resolved_additional_type = additional_items && [true, false].exclude?(additional_items) ? self.class.resolve_tuple_type_class(additional_items) : nil @klass.validate do |record| value = record.public_send(prop_name) @@ -470,7 +472,7 @@ def apply_boolean_type_validation prop_name = @property_name @klass.validate do |record| value = record.public_send(prop_name) - record.errors.add(prop_name, 'must be a boolean') if value && ![true, false].include?(value) + record.errors.add(prop_name, 'must be a boolean') if value && [true, false].exclude?(value) end end diff --git a/spec/easy_talk/active_model_type_spec.rb b/spec/easy_talk/active_model_type_spec.rb new file mode 100644 index 0000000..6e32e8e --- /dev/null +++ b/spec/easy_talk/active_model_type_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe EasyTalk::ActiveModelType do + before do + profile_class = Class.new do + include EasyTalk::Schema + end + stub_const('ActiveModelTypeProfile', profile_class) + ActiveModelTypeProfile.define_schema do + property :title, String + end + + settings_class = Class.new do + include EasyTalk::Schema + end + stub_const('ActiveModelTypeSettings', settings_class) + ActiveModelTypeSettings.define_schema do + property :name, String + property :age, Integer + property :active, T::Boolean + property :scores, T::Array[Integer], optional: true + property :profile, ActiveModelTypeProfile, optional: true + property :profiles, T::Array[ActiveModelTypeProfile], optional: true + property :record, T::Tuple[String, Integer], optional: true + property :role, String, default: 'member' + end + end + + let(:type) { described_class.new(ActiveModelTypeSettings) } + + it 'casts primitives from strings' do + result = type.cast('name' => 123, 'age' => '42', 'active' => 'false') + + expect(result).to be_a(ActiveModelTypeSettings) + expect(result.name).to eq('123') + expect(result.age).to eq(42) + expect(result.active).to be(false) + end + + it 'casts typed arrays' do + result = type.cast('scores' => ['1', 2, '3']) + + expect(result.scores).to eq([1, 2, 3]) + end + + it 'casts nested schemas' do + result = type.cast('profile' => { 'title' => 'Captain' }) + + expect(result.profile).to be_a(ActiveModelTypeProfile) + expect(result.profile.title).to eq('Captain') + end + + it 'accepts JSON strings' do + result = type.cast('{"name":"Ada","age":"7","active":"true"}') + + expect(result.name).to eq('Ada') + expect(result.age).to eq(7) + expect(result.active).to be(true) + end + + describe '#changed_in_place?' do + it 'does not mark equivalent values as changed (avoids always-dirty attributes)' do + new_value = type.cast( + 'name' => 'Ada', + 'age' => 7, + 'active' => true, + 'profile' => { 'title' => 'Captain' } + ) + + raw_old_value = { + 'name' => 'Ada', + 'age' => 7, + 'active' => true, + 'scores' => nil, + 'profile' => { 'title' => 'Captain' }, + 'role' => 'member' + } + + expect(type.changed_in_place?(raw_old_value, new_value)).to be(false) + end + + it 'detects changes by comparing the serialized data' do + new_value = type.cast('name' => 'Ada', 'age' => 8, 'active' => true) + raw_old_value = { + 'name' => 'Ada', + 'age' => 7, + 'active' => true, + 'scores' => nil, + 'profile' => nil, + 'role' => 'member' + } + + expect(type.changed_in_place?(raw_old_value, new_value)).to be(true) + end + + it 'accepts JSON strings for raw values' do + new_value = type.cast( + 'name' => 'Ada', + 'age' => 7, + 'active' => true, + 'profile' => { 'title' => 'Captain' } + ) + + raw_old_value = '{"name":"Ada","age":7,"active":true,"profile":{"title":"Captain"},"role":"member"}' + + expect(type.changed_in_place?(raw_old_value, new_value)).to be(false) + end + + it 'treats defaults as unchanged when missing in raw value' do + new_value = type.cast('name' => 'Ada', 'age' => 7, 'active' => true) + raw_old_value = { 'name' => 'Ada', 'age' => 7, 'active' => true } + + expect(type.changed_in_place?(raw_old_value, new_value)).to be(false) + end + + it 'normalizes symbol and string keys (including nested)' do + new_value = type.cast( + 'name' => 'Ada', + 'age' => 7, + 'active' => true, + 'profile' => { 'title' => 'Captain' } + ) + + raw_old_value = { + name: 'Ada', + 'age' => 7, + active: true, + profile: { title: 'Captain' }, + role: 'member' + } + + expect(type.changed_in_place?(raw_old_value, new_value)).to be(false) + end + + it 'does not depend on object identity for schema instances' do + raw_old_value = type.cast( + 'name' => 'Ada', + 'age' => 7, + 'active' => true, + 'profile' => { 'title' => 'Captain' } + ) + new_value = type.cast( + 'name' => 'Ada', + 'age' => 7, + 'active' => true, + 'profile' => { 'title' => 'Captain' } + ) + + expect(type.changed_in_place?(raw_old_value, new_value)).to be(false) + end + + it 'handles nested arrays of schemas' do + new_value = type.cast( + 'name' => 'Ada', + 'age' => 7, + 'active' => true, + 'profiles' => [{ 'title' => 'Captain' }] + ) + + raw_old_value = { + 'name' => 'Ada', + 'age' => 7, + 'active' => true, + 'profiles' => [{ 'title' => 'Captain' }], + 'role' => 'member' + } + + expect(type.changed_in_place?(raw_old_value, new_value)).to be(false) + end + + it 'coerces tuple values for comparison' do + new_value = type.cast('name' => 'Ada', 'age' => 7, 'active' => true, 'record' => ['Ada', 7]) + raw_old_value = { 'name' => 'Ada', 'age' => 7, 'active' => true, 'record' => %w[Ada 7] } + + expect(type.changed_in_place?(raw_old_value, new_value)).to be(false) + end + + it 'ignores unknown keys for schema types' do + new_value = type.cast('name' => 'Ada', 'age' => 7, 'active' => true) + raw_old_value = { 'name' => 'Ada', 'age' => 7, 'active' => true, 'unknown' => 'extra' } + + expect(type.changed_in_place?(raw_old_value, new_value)).to be(false) + end + end +end diff --git a/spec/easy_talk/additional_properties_spec.rb b/spec/easy_talk/additional_properties_spec.rb index 70bd71e..c3e63fb 100644 --- a/spec/easy_talk/additional_properties_spec.rb +++ b/spec/easy_talk/additional_properties_spec.rb @@ -55,7 +55,7 @@ def self.name end it 'allows getting additional properties' do - instance.instance_variable_set('@additional_properties', { 'custom_field' => 'value' }) + instance.instance_variable_set(:@additional_properties, { 'custom_field' => 'value' }) expect(instance.custom_field).to eq('value') end diff --git a/spec/easy_talk/validation_adapters/active_model_adapter_spec.rb b/spec/easy_talk/validation_adapters/active_model_adapter_spec.rb index fbf48f4..6b454c2 100644 --- a/spec/easy_talk/validation_adapters/active_model_adapter_spec.rb +++ b/spec/easy_talk/validation_adapters/active_model_adapter_spec.rb @@ -108,6 +108,42 @@ def self.name = 'UriTest' end end + context 'with time format constraints when Time.zone is nil' do + let(:test_class) do + Class.new do + include EasyTalk::Model + + def self.name = 'TimeFormatTest' + + define_schema do + property :start_time, String, format: 'time' + end + end + end + + around do |example| + next example.run unless Time.respond_to?(:zone=) + + original_zone = Time.zone + Time.zone = nil + example.run + ensure + Time.zone = original_zone + end + + it 'does not raise when Time.zone is nil' do + instance = test_class.new(start_time: '12:34:56') + expect { instance.valid? }.not_to raise_error + expect(instance.errors[:start_time]).to be_empty + end + + it 'adds an error for invalid time strings' do + instance = test_class.new(start_time: 'not-a-time') + expect { instance.valid? }.not_to raise_error + expect(instance.errors[:start_time]).to include('must be a valid time in HH:MM:SS format') + end + end + context 'with integer constraints' do let(:test_class) do Class.new do diff --git a/spec/support/schema_validation_matcher.rb b/spec/support/schema_validation_matcher.rb index 0f9c518..14d5a10 100644 --- a/spec/support/schema_validation_matcher.rb +++ b/spec/support/schema_validation_matcher.rb @@ -140,7 +140,7 @@ def analyze_mismatches end def analyze_error_coverage - schema_error_paths = @schema_result.errors.map { |e| e[:pointer] }.compact + schema_error_paths = @schema_result.errors.filter_map { |e| e[:pointer] } active_model_attrs = @active_model_result.errors.map { |e| e[:attribute] } # Schema errors not covered by ActiveModel @@ -153,7 +153,7 @@ def analyze_error_coverage end def path_to_attribute(pointer) - pointer.gsub(%r{^/}, '').gsub('/', '.') + pointer.gsub(%r{^/}, '').tr('/', '.') end def build_comparison_message(expectation)