Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,4 +400,4 @@ end
- Removed pry-byebug.

## [0.1.0] - 2024-04-09
- Initial release
- Initial release
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions lib/easy_talk.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
213 changes: 213 additions & 0 deletions lib/easy_talk/active_model_type.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +47 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid always-dirty attributes in ActiveRecord

When ActiveRecord checks whether a JSON attribute changed, it calls changed_in_place? with the raw DB value and the current (type-cast) value. Here cast_value(raw_old_value) always builds a new schema instance, and EasyTalk schema instances only implement == for Hash (otherwise they use object identity). That means two instances with identical data will still compare unequal, so the attribute is marked dirty on every save even if nothing changed. This breaks dirty tracking/partial updates for records that haven’t been modified.

Useful? React with 👍 / 👎.


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
2 changes: 1 addition & 1 deletion lib/easy_talk/error_formatter/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions lib/easy_talk/errors_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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})."
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion lib/easy_talk/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion lib/easy_talk/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions lib/easy_talk/validation_adapters/active_model_adapter.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require 'uri'
require 'time'
require_relative 'active_model_schema_validation'
require 'easy_talk/json_schema_equality'

Expand Down Expand Up @@ -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}/)
Comment on lines 272 to 277
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle nil Time.zone in time format validation

Switching from Time.parse to Time.zone.parse introduces a crash when Time.zone is nil (common outside Rails or before time zone configuration). In that case Time.zone.parse raises NoMethodError, which isn’t rescued here, so validations blow up instead of adding an error. This regression affects ActiveModel-only usage or early initialization paths.

Useful? React with 👍 / 👎.

rescue ArgumentError
record.errors.add(prop_name, 'must be a valid time in HH:MM:SS format')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
Loading