-
-
Save seanpdoyle/e3a6f60225aa8eb9d77867c55ec991de to your computer and use it in GitHub Desktop.
| # frozen_string_literal: true | |
| require "bundler/inline" | |
| gemfile(true) do | |
| source "https://rubygems.org" | |
| gem "rails" | |
| gem "sqlite3", "~> 1.4" | |
| end | |
| require "active_record" | |
| require "minitest/autorun" | |
| ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") | |
| ActiveRecord::Base.logger = Logger.new(STDOUT) | |
| # | |
| # SECTION: ==================== NOT YET ON rails/rails MAIN ==================== | |
| # | |
| # DO NOT EDIT UNLESS COPY-PASTING FROM: | |
| # https://github.com/rails/rails/pull/51420 | |
| # | |
| module ActiveModel | |
| Attributes.include(Module.new do | |
| # Returns a hash of attributes for assignment to the database. | |
| def attributes_for_database | |
| @attributes.values_for_database | |
| end | |
| end) | |
| end | |
| # DO NOT EDIT UNLESS COPY-PASTING FROM: | |
| # https://github.com/rails/rails/pull/51421 | |
| # | |
| module ActiveModel | |
| module Type | |
| # Attribute type for a collection of values. It is registered under | |
| # the +:collection+ key. +:element_type+ option is used to specify elements type | |
| # | |
| # class User | |
| # include ActiveModel::Attributes | |
| # | |
| # attribute :lucky_numbers, :collection, element_type: :integer | |
| # end | |
| # | |
| # user = User.new(lucky_numbers: [1, 2, 3]) | |
| # user.lucky_numbers # => [1, 2, 3] | |
| # | |
| # Value is wrapped into an Array if not an Array already | |
| # | |
| # User.new(lucky_numbers: 1).lucky_numbers # => [1] | |
| # | |
| # Collection elements are coerced by their +:element_type+ type | |
| # | |
| # User.new(lucky_numbers: ["1"]).lucky_numbers # => [1] | |
| # | |
| class Collection < Value | |
| def initialize(**args) | |
| @element_type = args.delete(:element_type) | |
| @type_object = Type.lookup(element_type, **args) | |
| @serializer = args.delete(:serializer) || ActiveSupport::JSON | |
| super() | |
| end | |
| def type | |
| :collection | |
| end | |
| def cast(value) | |
| return [] if value.nil? | |
| Array(value).map { |el| @type_object.cast(el) } | |
| end | |
| def serializable?(value) | |
| value.all? { |el| @type_object.serializable?(el) } | |
| end | |
| def serialize(value) | |
| serializer.encode(value.map { |el| @type_object.serialize(el) }) | |
| end | |
| def deserialize(value) | |
| serializer.decode(value).map { |el| @type_object.deserialize(el) } | |
| end | |
| def assert_valid_value(value) | |
| return if valid_value?(value) | |
| raise ArgumentError, "'#{value}' is not a valid #{type} of #{element_type}" | |
| end | |
| def changed_in_place?(raw_old_value, new_value) | |
| old_value = deserialize(raw_old_value) | |
| return true if old_value.size != new_value.size | |
| old_value.each_with_index.any? do |raw_old, i| | |
| @type_object.changed_in_place?(raw_old, new_value[i]) | |
| end | |
| end | |
| def valid_value?(value) | |
| value.is_a?(Array) && value.all? { |el| @type_object.valid_value?(el) } | |
| end | |
| private | |
| attr_reader :element_type, :serializer | |
| end | |
| register :collection, Collection | |
| end | |
| end | |
| # DO NOT EDIT UNLESS COPY-PASTING FROM: | |
| # https://github.com/rails/rails/pull/51420 | |
| # | |
| module ActiveModel | |
| module Type | |
| class Model < Value # :nodoc: | |
| def initialize(**args) | |
| @class_name = args.delete(:class_name) | |
| @serializer = args.delete(:serializer) || ActiveSupport::JSON | |
| super | |
| end | |
| def changed_in_place?(raw_old_value, value) | |
| old_value = deserialize(raw_old_value) | |
| old_value.attributes != value.attributes | |
| end | |
| def valid_value?(value) | |
| return valid_hash?(value) if value.is_a?(Hash) | |
| value.is_a?(klass) | |
| end | |
| def type | |
| :model | |
| end | |
| def serializable?(value) | |
| value.is_a?(klass) | |
| end | |
| def serialize(value) | |
| serializer.encode(value.attributes_for_database) | |
| end | |
| def deserialize(value) | |
| attributes = serializer.decode(value) | |
| klass.new(attributes) | |
| end | |
| private | |
| attr_reader :serializer | |
| def valid_hash?(value) | |
| # TODO upstream as follow-up to | |
| # https://github.com/rails/rails/pull/51420#discussion_r1597220609 | |
| attribute_keys = value.keys.map do |key| | |
| key = key.to_s | |
| if klass.attribute_alias?(key) | |
| klass.attribute_alias(key) | |
| else | |
| key | |
| end | |
| end | |
| attribute_keys.difference(klass.attribute_names).none? | |
| end | |
| def klass | |
| @_model_type_class ||= @class_name.constantize | |
| end | |
| def cast_value(value) | |
| case value | |
| when Hash | |
| klass.new(value) | |
| else | |
| klass.new(value.attributes) | |
| end | |
| end | |
| end | |
| register :model, Model | |
| end | |
| end | |
| # | |
| # SECTION: =============== Active Model Serialization Extensions =============== | |
| # | |
| module JsonSerializable | |
| extend ActiveSupport::Concern | |
| included do | |
| include ActiveModel::Serializers::JSON | |
| def assign_attributes(attributes) | |
| transformed_attributes = attributes.deep_transform_keys do |key| | |
| deserialize_json_key(key) | |
| end | |
| super(transformed_attributes) | |
| end | |
| # TODO Upstream as https://github.com/rails/rails/pull/51781 | |
| alias_method :attributes=, :assign_attributes | |
| def serializable_hash(...) | |
| super.deep_transform_keys do |key| | |
| serialize_json_key(key) | |
| end | |
| end | |
| private | |
| def deserialize_json_key(key) = key | |
| def serialize_json_key(key) = key | |
| end | |
| class_methods do | |
| def load(json) | |
| if json.present? | |
| new.from_json(json) | |
| end | |
| end | |
| def dump(model) | |
| model.to_json | |
| end | |
| end | |
| end | |
| module JsonSerializable::CamelCased | |
| extend ActiveSupport::Concern | |
| included do | |
| include JsonSerializable | |
| private | |
| def deserialize_json_key(key) = key.to_s.underscore | |
| def serialize_json_key(key) = key.camelize(:lower) | |
| end | |
| end | |
| # | |
| # SECTION: ========================= Application Code ========================== | |
| # | |
| ActiveRecord::Schema.define do | |
| create_table :webhook_requests, force: true do |t| | |
| t.text :payload | |
| end | |
| end | |
| class ApplicationModel | |
| include ActiveModel::Model | |
| include ActiveModel::Attributes | |
| include JsonSerializable | |
| end | |
| class Payload < ApplicationModel | |
| attribute :request_id, :string | |
| end | |
| class WebhookRequest < ActiveRecord::Base | |
| serialize :payload, coder: Payload | |
| end | |
| module AnApiService | |
| class SpecializedData < ApplicationModel | |
| attribute :value, :integer | |
| end | |
| class Payload < ::Payload | |
| include JsonSerializable::CamelCased | |
| attribute :data, :collection, default: [], element_type: :model, class_name: "AnApiService::SpecializedData" | |
| end | |
| class WebhookRequest < ::WebhookRequest | |
| serialize :payload, coder: AnApiService::Payload | |
| end | |
| end | |
| class WebhookRequestTest < ActiveSupport::TestCase | |
| test "writes payload to JSON, reads to Payload instance" do | |
| payload = Payload.new(request_id: "abc123") | |
| webhook_request = WebhookRequest.create!(payload:) | |
| assert_equal payload.to_json, webhook_request.payload_before_type_cast | |
| assert_equal payload.request_id, webhook_request.payload.request_id | |
| end | |
| end | |
| class AnApiService::WebhookRequestTest < ActiveSupport::TestCase | |
| test "writes to camelCase, reads from snake_case" do | |
| payload = AnApiService::Payload.new(request_id: "abc123") | |
| webhook_request = AnApiService::WebhookRequest.create!(payload:) | |
| assert_equal({requestId: "abc123", data: []}.to_json, webhook_request.payload_before_type_cast) | |
| assert_equal payload.request_id, webhook_request.payload.request_id | |
| end | |
| test "supports nesting collections of objects" do | |
| payload = AnApiService::Payload.new(requestId: nil, data: [{value: "1"}, {value: "2"}]) | |
| webhook_request = AnApiService::WebhookRequest.create!(payload:) | |
| assert_equal({requestId: nil, data: [{value: 1}, {value: 2}]}.to_json, webhook_request.payload_before_type_cast) | |
| assert_equal 1, webhook_request.payload.data.first.value | |
| assert_equal 2, webhook_request.payload.data.second.value | |
| end | |
| end |
I also wonder if the key formatting should borrow from Jbuilder?
https://github.com/rails/jbuilder?tab=readme-ov-file#formatting-keys
Though I guess it's a little clunky if every model had to do key_format camelize: :lower.
I wasn't aware of jbuilder's key_format!
Though I guess it's a little clunky if every model had to do
key_format camelize: :lower.
I'm not sure if it'd be a requirement for every model.
When declaring models for each services, I tend to declare an ApplicationModel as the Kernel-level root, and then a scoped ApplicationModel per service.
For example, we might do something like:
class ApplicationModel
include ActiveModel::Model::JSON
key_format :underscore
end
class AnApiService::ApplicationModel < ::ApplicationModel
key_format camelize: :lower
end
class AnApiService::Resource < AnApiService::ApplicationModel
attribute :value, ...
end
class AnotherService::ApplicationModel < ::ApplicationModel
key_format camelize: :upper
endOh yeah, that's cool! I think that works and I do like the parallel with Jbuilder — that may make it easier to showcase precedent in terms of pitching this to Rails.
@kaspth I've opened rails/rails#52494 to explore key formatting further.
@seanpdoyle oh really cool!
Reading over it the key_format to_json: { camelize: :lower } doesn't seem that much more readable to something like key_format to_json: -> { _1.camelize :lower }, and I think if we only allow Symbols or Procs an interesting optimization could occur:
# key_format from_json: :underscore, to_json: -> { _1.camelize :lower }
def initialize(*args)
@format = args.map(&:to_proc).inject(&:>>) # We can use Proc#>> to prebuild the processing chain upfront.
@cache = {}
end
def format_keys!(hash)
hash.deep_transform_keys! { format _1 }
end
private
def format(key)
@cache[key] ||= @format.call(key.to_s)
endI guess another thing to consider, since underscoring and camelization are common, could be to support key_format :underscore with something like this?
def key_format(at_rest_format = nil, from_json: nil, to_json: nil)
_from_json, _to_json = :underscore, -> { _1.camelize :lower }
_from_json, _to_json = _to_json, _from_json if at_rest_format == :camelize
from_json ||= _from_json
to_json ||= _to_json
endOne more thing! I think you're looking for stopdoc and startdoc in case nodoc doesn't work. You can also define the two attributes in one go:
# :stopdoc:
class_attribute :_from_json_formatter, :_to_json_formatter, instance_writer: false, default: KeyFormatter::Identity.new
# :startdoc:You could also rename format_keys! to call and then set default: :itself.to_proc above, if you want to spare the Identity class. I suppose you could also leave it blank and do formatter&.call(hash) || hash though having the simplified call site is nice.
Played around with the as_json definition a little more, don't think I've ever realized how pattern matching could provide a fallback, sorta like:
def as_json(options = nil)
hash = serializable_hash(options).then { _to_json_formatter.call _1 }.as_json
options in { root: } or root = include_root_in_json
if root
root = model_name.element if root == true
{ root => hash }
else
hash
end
endAlso just found a recent PR comment that talks more broadly about the expectations of as_json rails/rails#52448 (comment)
Ok, one more thought. Since there's also ActiveRecord::Base.include_root_in_json and as_json(root: true) and from_json(json, true) (no keyword argument there!), I wonder if we should allow assigning that too?
json_formatting include_root: true, to: :underscore, from: -> { _1.camelize :lower }
json_schema include_root: true, deserialize_keys: :underscore, serialize_keys: -> { _1.camelize :lower }, include: :posts # Could maybe even support default arguments to `as_json`?
Looks cool! I played around a bit with styling and naming just to get a better feel for it.
I think this could be really interesting if it was
include ActiveModel::Model::JSON, and be immediately understandable.I ended up breaking the code somehow though or I'm not on the right Rails checkout or something, but here's what I played around with: