Skip to content

Instantly share code, notes, and snippets.

@inopinatus
Last active June 25, 2018 10:46
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save inopinatus/f454241886866562f7b39b9b41267a4c to your computer and use it in GitHub Desktop.
Save inopinatus/f454241886866562f7b39b9b41267a4c to your computer and use it in GitHub Desktop.
json models embedded in your activerecord models
class Invoice < ApplicationRecord
include JsonModel::Attribute
json_attribute_model :payment, Payment
validates_associated :payment
end
module JsonModel
module Inspectable
def attribute_for_inspect(attr_name)
value = send(attr_name)
if value.is_a?(String) && value.length > 50
"#{value[0, 50]}...".inspect
elsif value.is_a?(Date) || value.is_a?(Time)
%("#{value.to_s(:iso8601)}")
else
value.inspect
end
end
def inspect
inspection = if defined?(@attributes) && @attributes
@attributes.keys.map do |name|
"#{name}: #{attribute_for_inspect(name)}" if attribute_method?(name)
end.compact.join(", ")
else
"not initialized"
end
"#<#{self.class} #{inspection}>"
end
end
module Predicates
extend ActiveSupport::Concern
class_methods do
def json_predicate(optype, attr, key, comparator, value)
opclass = "JsonModel::Predicates::JsonOperation::#{optype.to_s.camelize}".constantize
operation = opclass.new(arel_table[attr], Arel::Nodes.build_quoted(key))
operation.send(comparator, operation.cast_value(value))
end
private
module JsonOperation
class Object < Arel::Nodes::InfixOperation
def initialize(left, right)
super(:"->", left, right)
end
def cast_value(value)
Arel::Nodes::NamedFunction.new("CAST", [
Arel::Nodes::As.new(
Arel::Nodes::Quoted.new(ActiveSupport::JSON.encode(value)),
Arel::Nodes::SqlLiteral.new("jsonb")
)
])
end
end
class Text < Arel::Nodes::InfixOperation
def initialize(left, right)
super(:"->>", left, right)
end
def cast_value(value)
Arel::Nodes::Quoted.new(value)
end
end
end
end
end
class Type < ActiveModel::Type::Value
attr_reader :klass
def initialize(klass)
@klass = klass
end
def cast(attributes)
klass === attributes ? attributes : new_with_type_injection(attributes)
end
def deserialize(json)
new_with_type_injection(json_to_hash(json))
end
def serialize(object)
ActiveSupport::JSON.encode(object_to_hash(object))
end
def json_to_hash(json)
ActiveSupport::JSON.decode(json || '{}')
end
def object_to_hash(object)
object.respond_to?(:serializable_hash) ? object.serializable_hash : Hash(object)
end
def changed_in_place?(raw_old_json, new_object)
json_to_hash(raw_old_json) != json_to_hash(serialize(new_object))
end
private
def new_with_type_injection(attributes)
klass.new_with_json_model_type(attributes: attributes, json_model_attribute_type: self)
end
end
module Model
extend ActiveSupport::Concern
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Serializers::JSON
include Inspectable
included do
attr_accessor :_json_model_attribute_type
end
class_methods do
def new_with_json_model_type(attributes:, json_model_attribute_type:)
new(attributes).tap { |o| o._json_model_attribute_type = json_model_attribute_type }
end
end
end
module Attribute
extend ActiveSupport::Concern
include Predicates
included do
scope :wherein, ->(attr, key, value) { where json_predicate(:object, attr, key, :eq, value) }
scope :wherein_not, ->(attr, key, value) { where json_predicate(:object, attr, key, :not_eq, value) }
scope :wherein_null, ->(attr, key) { where json_predicate(:text, attr, key, :eq, nil) }
scope :wherein_not_null, ->(attr, key) { where json_predicate(:text, attr, key, :not_eq, nil) }
end
class_methods do
def json_attribute_model(attr, klass)
if (column_type = columns_hash[attr.to_s].sql_type) != "jsonb"
raise StandardError,
"Unsupported json_attribute_model type '#{column_type}' for column '#{attr}'. Only jsonb is supported."
end
attribute attr, Type.new(klass)
define_method :"#{attr}_attributes=" do |attrs|
send(attr).assign_attributes(attrs)
end
end
end
end
end
class Payment
include JsonModel::Model
attribute :amount, :integer
attribute :fees, :integer
attribute :amount_refunded, :integer, default: 0
attribute :comment, :string
attribute :when, :datetime, default: -> { Time.current }
validates :amount, :fees, :amount_refunded, numericality: {
only_integer: true,
greater_than_or_equal_to: 0
}
end
@inopinatus
Copy link
Author

inopinatus commented Jun 22, 2018

Migration like

class AddPaymentToInvoice < ActiveRecord::Migration[5.2]
  def change
    add_column :invoices, :payment, :jsonb, null: false, default: {}
    add_index :invoices, :payment, using: :gin
  end
end

usage like

Invoice.create!(payment: { amount: 2000, fees: 159 })
inv = Invoice.wherein(:payment, :amount, 2000).take
inv.payment #=> #<Payment amount: 2000, fees: 159, amount_refunded: 0, comment: nil, when: "2018-06-25 05:33:16">

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment