Created
July 25, 2024 21:04
-
-
Save seanpdoyle/e3a6f60225aa8eb9d77867c55ec991de to your computer and use it in GitHub Desktop.
Demonstrate how to use built-in Active Model and Active Record concepts to store JSON representations of Active Model instances in SQL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
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`?
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
One more thing! I think you're looking for
stopdoc
andstartdoc
in casenodoc
doesn't work. You can also define the two attributes in one go:You could also rename
format_keys!
tocall
and then setdefault: :itself.to_proc
above, if you want to spare theIdentity
class. I suppose you could also leave it blank and doformatter&.call(hash) || hash
though having the simplified call site is nice.