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 Jul 30, 2024

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:

module ActiveModel::Model::JSON
  extend ActiveSupport::Concern

  include ActiveModel::Serializers::JSON
  include ActiveModel::Model, ActiveModel::Attributes

  class_methods do
    def load(json)
      new.from_json(json) if json.present?
    end
    def dump(model) = model.to_json
  end

  def assign_attributes(attributes)
    super(attributes.deep_transform_keys { deserialize_json_key _1 })
  end
  alias_method :attributes=, :assign_attributes # TODO Upstream as https://github.com/rails/rails/pull/51781

  def serializable_hash(...)
    super.deep_transform_keys { serialize_json_key _1 }
  end

  Camelized = dup
  Camelized.module_eval do
    private
      def deserialize_json_key(key) = key.to_s.underscore
      def serialize_json_key(key)   = key.camelize(:lower)
  end

  private
    def deserialize_json_key(key) = key
    def serialize_json_key(key)   = key
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::JSON
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 ActiveModel::Model::JSON::Camelized

    attribute :data, :collection, default: [], element_type: :model, class_name: "AnApiService::SpecializedData"
  end

  class WebhookRequest < ::WebhookRequest
    serialize :payload, coder: AnApiService::Payload
  end
end

@kaspth
Copy link

kaspth commented Jul 30, 2024

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.

@seanpdoyle
Copy link
Author

seanpdoyle commented Jul 30, 2024

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

@kaspth
Copy link

kaspth commented Jul 30, 2024

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.

@seanpdoyle
Copy link
Author

@kaspth I've opened rails/rails#52494 to explore key formatting further.

@kaspth
Copy link

kaspth commented Aug 2, 2024

@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

@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