-
-
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
end
Oh 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)
end
I 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
end
One 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
end
Also 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: