Skip to content

Instantly share code, notes, and snippets.

@seanpdoyle
Created July 25, 2024 21:04
Show Gist options
  • Save seanpdoyle/e3a6f60225aa8eb9d77867c55ec991de to your computer and use it in GitHub Desktop.
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
# 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
@kaspth
Copy link

kaspth commented Aug 2, 2024

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.

@kaspth
Copy link

kaspth commented Aug 2, 2024

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

@kaspth
Copy link

kaspth commented Aug 3, 2024

Also just found a recent PR comment that talks more broadly about the expectations of as_json rails/rails#52448 (comment)

@kaspth
Copy link

kaspth commented Aug 3, 2024

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