-
Notifications
You must be signed in to change notification settings - Fork 13
Typed schema for better integrate with json column #162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
babe4f2
4d902d1
63b49e8
737717c
ce78fef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -400,4 +400,4 @@ end | |
| - Removed pry-byebug. | ||
|
|
||
| ## [0.1.0] - 2024-04-09 | ||
| - Initial release | ||
| - Initial release | ||
| 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 | ||
|
|
||
| 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 | ||
| 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' | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Switching from Useful? React with 👍 / 👎. |
||
| 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 | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When ActiveRecord checks whether a JSON attribute changed, it calls
changed_in_place?with the raw DB value and the current (type-cast) value. Herecast_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 👍 / 👎.