-
Notifications
You must be signed in to change notification settings - Fork 32
/
polymorphic_model.rb
198 lines (171 loc) · 7.39 KB
/
polymorphic_model.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# frozen_string_literal: true
module AttrJson
module Type
# AttrJson::Type::PolymorphicModel can be used to create attr_json attributes
# that can hold any of various specified AttrJson::Model models. It is a
# _somewhat_ experimental feature.
#
# "polymorphic" may not be quite the right word, but we use it out of analogy
# with ActiveRecord [polymorphic assocications](http://guides.rubyonrails.org/association_basics.html#polymorphic-associations),
# which it resembles, as well as ActiveRecord [Single-Table Inheritance](http://guides.rubyonrails.org/association_basics.html#single-table-inheritance).
#
# Similar to these AR features, a PolymorphicModel-typed attribute will serialize the
# _model name_ of a given value in a `type` json hash key, so it can deserialize
# to the same correct model class.
#
# It can be used for single-model attributes, or arrays (which can be hetereogenous),
# in either AttrJson::Record or nested AttrJson::Models. If `CD`, `Book`, `Person`,
# and `Corporation` are all AttrJson::Model classes:
#
# attr_json :favorite, AttrJson::Type::PolymorphicAttribute.new(CD, Book)
# attr_json :authors, AttrJson::Type::PolymorphicAttribute.new(Person, Corporation), array: true
#
# Currently, you need a specific enumerated list of allowed types, and they all
# need to be AttrJson::Model classes. You can't at the moment have an "open" polymorphic
# type that can accept any AttrJson::Model.
#
# You can change the json key that the "type" (class name) for a value is stored to,
# when creating the type:
#
# attr_json, :author, AttrJson::Type::PolymorphicAttribute.new(Person, Corporation, type_key: "__type__")
#
# But if you already have existing data in the db, that's gonna be problematic to change on the fly.
#
# You can set attributes with a hash, but it needs to have an appropriate `type` key
# (or other as set by `type_key` arg). If it does not, or you try to set a non-hash
# value, you will get a AttrJson::Type::PolymorphicModel::TypeError. (maybe a validation
# error would be better? but it's not what it does now.)
#
# **Note** this
# also applies to loading non-compliant data from the database. If you have non-compliant
# data in the db, the only way to look at it will be as a serialized json string in top-level
# {#json_attributes_before_cast} (or other relevant container attribute.)
#
# There is no built-in form support for PolymorphicModels, you'll have to work it out.
#
# ## jsonb_contains support
#
# There is basic jsonb_contains support, but no sophisticated type-casting like normal, beyond
# the polymorphic attribute. But you can do:
#
# MyRecord.jsonb_contains(author: { name: "foo"})
# MyRecord.jsonb_contains(author: { name: "foo", type: "Corporation"})
# MyRecord.jsonb_contains(author: Corporation.new(name: "foo"))
#
# Additionally, there is not_jsonb_contains, which creates the same query terms like jsonb_contains, but negated.
#
class PolymorphicModel < ActiveModel::Type::Value
class TypeError < ::TypeError ; end
attr_reader :type_key, :unrecognized_type, :model_type_lookup
def initialize(*args)
options = { type_key: "type", unrecognized_type: :raise}.merge(
args.extract_options!.assert_valid_keys(:type_key, :unrecognized_type)
)
@type_key = options[:type_key]
@unrecognized_type = options[:unrecognized_type]
model_types = args
model_types.collect! do |m|
if m.respond_to?(:ancestors) && m.ancestors.include?(AttrJson::Model)
m.to_type
else
m
end
end
if bad_arg = model_types.find { |m| !m.is_a? AttrJson::Type::Model }
raise ArgumentError, "#{self.class.name} only works with AttrJson::Model / AttrJson::Type::Model, not '#{bad_arg.inspect}'"
end
if type_key_conflict = model_types.find { |m| m.model.attr_json_registry.has_attribute?(@type_key) }
raise ArgumentError, "conflict between type_key '#{@type_key}' and an existing attr_json in #{type_key_conflict.model}"
end
@model_type_lookup = model_types.collect do |type|
[type.model.name, type]
end.to_h
end
def model_names
model_type_lookup.keys
end
def model_types
model_type_lookup.values
end
# ActiveModel method, symbol type label
def type
@type ||= "any_of_#{model_types.collect(&:type).collect(&:to_s).join('_')}".to_sym
end
def cast(v)
cast_or_deserialize(v, :cast)
end
def deserialize(v)
cast_or_deserialize(v, :deserialize)
end
def serialize(v)
return nil if v.nil?
# if it's not already a model cast it to a model if possible (eg it's a hash)
v = cast(v)
model_name = v.class.name
type = type_for_model_name(model_name)
raise_bad_model_name(model_name, v) if type.nil?
type.serialize(v).merge(type_key => model_name)
end
def type_for_model_name(model_name)
model_type_lookup[model_name]
end
# This is used only by our own keypath-chaining query stuff.
# For PolymorphicModel type, it does no type casting, just
# sticks whatever you gave it in, which needs to be json-compat
# values.
def value_for_contains_query(key_path_arr, value)
hash_arg = {}
key_path_arr.each.with_index.inject(hash_arg) do |hash, (n, i)|
if i == key_path_arr.length - 1
hash[n] = value
else
hash[n] = {}
end
end
hash_arg
end
protected
# We need to make sure to call the correct operation on
# the model type, so that we get the same result as if
# we had called the type directly
#
# @param v [Object, nil] the value to cast or deserialize
# @param operation [Symbol] :cast or :deserialize
def cast_or_deserialize(v, operation)
if v.nil?
v
elsif model_names.include?(v.class.name)
v
elsif v.respond_to?(:to_hash)
model_from_hash(v.to_hash, operation)
elsif v.respond_to?(:to_h)
model_from_hash(v.to_h, operation)
else
raise_bad_model_name(v.class, v)
end
end
# @param hash [Hash] the value to cast or deserialize
# @param operation [Symbol] :cast or :deserialize
def model_from_hash(hash, operation)
new_hash = hash.stringify_keys
model_name = new_hash.delete(type_key.to_s)
raise_missing_type_key(hash) if model_name.nil?
type = type_for_model_name(model_name)
raise_bad_model_name(model_name, hash) if type.nil?
if operation == :deserialize
type.deserialize(new_hash)
elsif operation == :cast
type.cast(new_hash)
else
raise ArgumentError, "Unknown operation #{operation}"
end
end
def raise_missing_type_key(value)
raise TypeError, "AttrJson::Type::Polymorphic can't cast without '#{type_key}' key: #{value}"
end
def raise_bad_model_name(name, value)
raise TypeError, "This AttrJson::Type::PolymorphicType can only include {#{model_names.join(', ')}}, not '#{name}': #{value.inspect}"
end
end
end
end