activemodel-embedding 0.1.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +651 -0
- data/Rakefile +13 -0
- data/lib/active_model/embedding/associations.rb +80 -0
- data/lib/active_model/embedding/collecting.rb +122 -0
- data/lib/active_model/embedding/collection.rb +12 -0
- data/lib/active_model/embedding/document.rb +40 -0
- data/lib/active_model/embedding/railtie.rb +6 -0
- data/lib/active_model/embedding/version.rb +5 -0
- data/lib/active_model/embedding.rb +16 -0
- data/lib/active_model/type/document.rb +111 -0
- data/lib/active_record/type/document.rb +9 -0
- data/lib/activemodel-embedding.rb +3 -0
- data/lib/tasks/activemodel/embedding_tasks.rake +4 -0
- metadata +75 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 8f2e73a8dfef66fcbb740be69feb3b700e0ac9dccb51e3e2d5667a46d66ff601
|
4
|
+
data.tar.gz: db5c730161a89a5618bdd556d3927fad81b5aee4b1fa574e76957b6b4e211591
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 23a9641ef3e6d52a1b491b48652b71eeac71ebb872a43a92d21430505e725acbc0085c97b20409a7dd9b0b0f9b6c3d0216ad2584155ddf1849b58edad1b7de3d
|
7
|
+
data.tar.gz: 536223569744645e51403284c0c68257712039c8d6bcfa32ada21f7565dc02156f60ad58302708d0e6892a7d7931707bbbdc2e8a4d500aa96b164435a1395718
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2021 mansakondo
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,651 @@
|
|
1
|
+
# ActiveModel::Embedding [![Gem Version](https://badge.fury.io/rb/activemodel-embedding.svg)](https://badge.fury.io/rb/activemodel-embedding)
|
2
|
+
An ActiveModel extension to model your [semi-structured data](#semi-structured-data) using
|
3
|
+
[embedded associations](#embedded-associations).
|
4
|
+
|
5
|
+
- [Features](#features)
|
6
|
+
- [Introduction](#introduction)
|
7
|
+
- [Usage](#usage)
|
8
|
+
- [:warning: Warning](#warning-warning)
|
9
|
+
- [Use Case: Dealing with bibliographic data](#use-case%3A-dealing-with-bibliographic-data)
|
10
|
+
- [Concepts](#concepts)
|
11
|
+
- [Components](#components)
|
12
|
+
- [Installation](#installation)
|
13
|
+
- [License](#license)
|
14
|
+
|
15
|
+
## Features
|
16
|
+
- ActiveRecord-like associations (powered by the [Attributes API](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-attribute))
|
17
|
+
- Nested attributes support out-of-the-box
|
18
|
+
- [Custom collections](#custom-collections)
|
19
|
+
- [Custom types](#custom-types)
|
20
|
+
- Autosaving
|
21
|
+
- Dirty tracking
|
22
|
+
|
23
|
+
## Introduction
|
24
|
+
Relational databases are very powerful. Their power comes from their ability to...
|
25
|
+
- Preserve data integrity with a predefined schema.
|
26
|
+
- Make complex relationships through joins.
|
27
|
+
|
28
|
+
But sometimes, we can stumble accross data that don't fit in the [relational
|
29
|
+
model](https://www.digitalocean.com/community/tutorials/what-is-the-relational-model). We call
|
30
|
+
this kind of data: [semi-structured data](#semi-structured-data). When this happens, the
|
31
|
+
things that makes relational databases powerful are the things that gets in our way, and
|
32
|
+
complicate our model instead of simplifying it.
|
33
|
+
|
34
|
+
That's why [document databases](https://en.wikipedia.org/wiki/Document-oriented_database)
|
35
|
+
exist, to model and store semi-structured data. However, if we choose to use a document
|
36
|
+
database, we'll loose all the power of using a relational database.
|
37
|
+
|
38
|
+
Luckily for us, relational databases like Postgres and MySQL now has good JSON support. So most
|
39
|
+
of us won't need to use a document database like MongoDB, as it would be overkill. Most of the
|
40
|
+
time, we only need to
|
41
|
+
[denormalize](https://www.geeksforgeeks.org/denormalization-in-databases/) some parts of our
|
42
|
+
model. So it makes more sense to use simple JSON columns for those, instead of going all-in,
|
43
|
+
and dump your beloved relational database for MongoDB.
|
44
|
+
|
45
|
+
Currently in Rails, we have several features that we can use to interact with JSON:
|
46
|
+
- [JSON serialization](https://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html)
|
47
|
+
- [JSON column](https://guides.rubyonrails.org/active_record_postgresql.html#json-and-jsonb)
|
48
|
+
- [Attributes API](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-attribute)
|
49
|
+
|
50
|
+
By combining these features, we have full control over how our JSON data is stored and
|
51
|
+
retrieved from the database.
|
52
|
+
|
53
|
+
And that's what this extension does, in order to provide a convinient way to model
|
54
|
+
semi-structured data in a Rails application.
|
55
|
+
|
56
|
+
## Usage
|
57
|
+
Let's say that we need to store books in our database. We might want to "embed" data such as
|
58
|
+
parts, chapters and sections without creating additional tables. By doing so, we can retrieve
|
59
|
+
all the embedded data of a book in a single read operation, instead of performing expensive
|
60
|
+
multi-table joins.
|
61
|
+
|
62
|
+
We can then model our data this way:
|
63
|
+
```ruby
|
64
|
+
class Book < ApplicationRecord
|
65
|
+
include ActiveModel::Embedding::Associations
|
66
|
+
|
67
|
+
embeds_many :parts
|
68
|
+
end
|
69
|
+
|
70
|
+
class Book::Part
|
71
|
+
include ActiveModel::Embedding::Document
|
72
|
+
|
73
|
+
attribute :title, :string
|
74
|
+
|
75
|
+
embeds_many :chapters
|
76
|
+
end
|
77
|
+
|
78
|
+
class Book::Part::Chapter
|
79
|
+
include ActiveModel::Embedding::Document
|
80
|
+
|
81
|
+
attribute :title, :string
|
82
|
+
|
83
|
+
embeds_many :sections
|
84
|
+
end
|
85
|
+
|
86
|
+
class Book::Part::Chapter::Section
|
87
|
+
include ActiveModel::Embedding::Document
|
88
|
+
|
89
|
+
attribute :title, :string
|
90
|
+
attribute :content, :string
|
91
|
+
end
|
92
|
+
```
|
93
|
+
|
94
|
+
And display it like this (with nested attributes support out-of-the-box):
|
95
|
+
```erb
|
96
|
+
# app/views/books/_form.html.erb
|
97
|
+
<%= form_with model: @book do |book_form| %>
|
98
|
+
<%= book_form.fields_for :parts do |part_fields| %>
|
99
|
+
|
100
|
+
<%= part_fields.label :title %>
|
101
|
+
<%= part_fields.text_field :title %>
|
102
|
+
|
103
|
+
<%= part_fields.fields_for :chapters do |chapter_fields| %>
|
104
|
+
<%= chapter_fields.label :title %>
|
105
|
+
<%= chapter_fields.text_field :title %>
|
106
|
+
|
107
|
+
<%= chapter_fields.fields_for :sections do |section_fields| %>
|
108
|
+
<%= section_fields.label :title %>
|
109
|
+
<%= section_fields.text_field :title %>
|
110
|
+
<%= section_fields.text_area :content %>
|
111
|
+
<% end %>
|
112
|
+
<% end %>
|
113
|
+
<% end %>
|
114
|
+
|
115
|
+
<%= book_form.submit %>
|
116
|
+
<% end %>
|
117
|
+
```
|
118
|
+
### Custom collections
|
119
|
+
```ruby
|
120
|
+
class SomeCollection
|
121
|
+
include ActiveModel::Embedding::Collecting
|
122
|
+
end
|
123
|
+
|
124
|
+
class Thing
|
125
|
+
end
|
126
|
+
|
127
|
+
class SomeModel
|
128
|
+
include ActiveModel::Embedding::Document
|
129
|
+
|
130
|
+
embeds_many :things, collection: "SomeCollection"
|
131
|
+
end
|
132
|
+
|
133
|
+
some_model = SomeModel.new things: Array.new(3) { Thing.new }
|
134
|
+
some_model.things.class
|
135
|
+
# => SomeCollection
|
136
|
+
```
|
137
|
+
### Custom types
|
138
|
+
```ruby
|
139
|
+
# config/initializers/types.rb
|
140
|
+
class SomeType < ActiveModel::Type::Value
|
141
|
+
def cast(value)
|
142
|
+
value.cast_type = self.class
|
143
|
+
super
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
ActiveModel::Type.register(:some_type, SomeType)
|
148
|
+
|
149
|
+
class SomeOtherType < ActiveModel::Type::Value
|
150
|
+
attr_reader :context
|
151
|
+
|
152
|
+
def initialize(context:)
|
153
|
+
@context = context
|
154
|
+
end
|
155
|
+
|
156
|
+
def cast(value)
|
157
|
+
value.cast_type = self.class
|
158
|
+
value.context = context
|
159
|
+
super
|
160
|
+
end
|
161
|
+
end
|
162
|
+
```
|
163
|
+
```ruby
|
164
|
+
class Thing
|
165
|
+
attr_accessor :cast_type
|
166
|
+
attr_accessor :context
|
167
|
+
end
|
168
|
+
|
169
|
+
class SomeModel
|
170
|
+
include ActiveModel::Embedding::Document
|
171
|
+
|
172
|
+
embeds_many :things, cast_type: :some_type
|
173
|
+
embeds_many :other_things, cast_type: SomeOtherType.new(context: self)
|
174
|
+
end
|
175
|
+
|
176
|
+
@some_model.things.first.cast_type
|
177
|
+
# => SomeType
|
178
|
+
@some_model.other_things.first.cast_type
|
179
|
+
# => SomeOtherType
|
180
|
+
@some_model.other_things.first.context
|
181
|
+
# => SomeModel
|
182
|
+
```
|
183
|
+
|
184
|
+
### Associations
|
185
|
+
#### embeds_many
|
186
|
+
Maps a JSON array to a [collection](#collection).
|
187
|
+
|
188
|
+
Options:
|
189
|
+
- `:class_name`: Specify the class of the [documents](#document) in the collection. Inferred by default.
|
190
|
+
- `:collection`: Specify a custom collection class which includes
|
191
|
+
[`ActiveModel::Collecting`](#activemodel%3A%3Acollecting) (`ActiveModel::Collection` by
|
192
|
+
default).
|
193
|
+
- `:cast_type`: Specify a custom type that should be used to cast the documents in the
|
194
|
+
collection. (the `:class_name` is ignored if this option is present.)
|
195
|
+
#### embed_one
|
196
|
+
Maps a JSON object to a [document](#document).
|
197
|
+
|
198
|
+
Options:
|
199
|
+
- `:class_name`: Same as above.
|
200
|
+
- `:cast_type`: Same as above.
|
201
|
+
|
202
|
+
## :warning: Warning
|
203
|
+
[Embedded associations](#embedded-associations) should only be used if you're sure that the data you want to embed is
|
204
|
+
**encapsulated**. Which means, that embedded associations should only be accessed through the
|
205
|
+
parent, and not from the outside. Thus, this should only be used if performing joins isn't a
|
206
|
+
viable option.
|
207
|
+
|
208
|
+
Read the section below (and [this
|
209
|
+
article](http://www.sarahmei.com/blog/2013/11/11/why-you-should-never-use-mongodb/)) for more
|
210
|
+
insights on the use cases of this feature.
|
211
|
+
|
212
|
+
## Use case: Dealing with bibliographic data
|
213
|
+
Let's say that we are building an app to help libraries build and manage an online catalog.
|
214
|
+
When we're browsing through a catalog, we often see item information formatted like this:
|
215
|
+
```
|
216
|
+
Author: Shakespeare, William, 1564-1616.
|
217
|
+
Title: Hamlet / William Shakespeare.
|
218
|
+
Description: xiii, 295 pages : illustrations ; 23 cm.
|
219
|
+
Series: NTC Shakespeare series.
|
220
|
+
Local Call No: 822.33 S52 S7
|
221
|
+
ISBN: 0844257443
|
222
|
+
Series Entry: NTC Shakespeare series.
|
223
|
+
Control No.: ocm30152659
|
224
|
+
```
|
225
|
+
|
226
|
+
But in the library world, data is produced and exchanged is this form:
|
227
|
+
```
|
228
|
+
LDR 00815nam 2200289 a 4500
|
229
|
+
001 ocm30152659
|
230
|
+
003 OCoLC
|
231
|
+
005 19971028235910.0
|
232
|
+
008 940909t19941994ilua 000 0 eng
|
233
|
+
010 $a92060871
|
234
|
+
020 $a0844257443
|
235
|
+
040 $aDLC$cDLC$dBKL$dUtOrBLW
|
236
|
+
049 $aBKLA
|
237
|
+
099 $a822.33$aS52$aS7
|
238
|
+
100 1 $aShakespeare, William,$d1564-1616.
|
239
|
+
245 10$aHamlet /$cWilliam Shakespeare.
|
240
|
+
264 1$aLincolnwood, Ill. :$bNTC Pub. Group,$c[1994]
|
241
|
+
264 4$c©1994.
|
242
|
+
300 $axiii, 295 pages :$billustrations ;$c23 cm.
|
243
|
+
336 $atext$btxt$2rdacontent.
|
244
|
+
337 $aunmediated$bn$2rdamedia.
|
245
|
+
338 $avolume$bnc$2rdacarrier.
|
246
|
+
490 1 $aNTC Shakespeare series.
|
247
|
+
830 0$aNTC Shakespeare series.
|
248
|
+
907 $a.b108930609
|
249
|
+
948 $aLTI 2018-07-09
|
250
|
+
948 $aMARS
|
251
|
+
```
|
252
|
+
This is what we call a *MARC record*. That's how libraries describes the ressources they own.
|
253
|
+
|
254
|
+
As you can see, that's really verbose! That's because in the library world, ressources are
|
255
|
+
described very precisely, in order to be "machine-readable" (MARC stands for "MAchine-Readable
|
256
|
+
Cataloging").
|
257
|
+
|
258
|
+
For convinience, developpers usually represent MARC data in JSON:
|
259
|
+
```json
|
260
|
+
{
|
261
|
+
"leader": "00815nam 2200289 a 4500",
|
262
|
+
"fields": [
|
263
|
+
{ "tag": "001", "value": "ocm30152659" },
|
264
|
+
{ "tag": "003", "value": "OCoLC" },
|
265
|
+
{ "tag": "005", "value": "19971028235910.0" },
|
266
|
+
{ "tag": "008", "value": "940909t19941994ilua 000 0 eng " },
|
267
|
+
{ "tag": "010", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "92060871" }] },
|
268
|
+
{ "tag": "020", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "0844257443" }] },
|
269
|
+
{ "tag": "040", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "DLC" }, { "code": "c", "value": "DLC" }, { "code": "d", "value": "BKL" }, { "code": "d", "value": "UtOrBLW" } ] },
|
270
|
+
{ "tag": "049", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "BKLA" }] },
|
271
|
+
{ "tag": "099", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "822.33" }, { "code": "a", "value": "S52" }, { "code": "a", "value": "S7" } ] },
|
272
|
+
{ "tag": "100", "indicator1": "1", "indicator2": " ", "subfields": [{ "code": "a", "value": "Shakespeare, William," }, { "code": "d", "value": "1564-1616." } ] },
|
273
|
+
{ "tag": "245", "indicator1": "1", "indicator2": "0", "subfields": [{ "code": "a", "value": "Hamlet" }, { "code": "c", "value": "William Shakespeare." } ] },
|
274
|
+
{ "tag": "264", "indicator1": " ", "indicator2": "1", "subfields": [{ "code": "a", "value": "Lincolnwood, Ill. :" }, { "code": "b", "value": "NTC Pub. Group," }, { "code": "c", "value": "[1994]" } ] },
|
275
|
+
{ "tag": "264", "indicator1": " ", "indicator2": "4", "subfields": [{ "code": "c", "value": "©1994." }] },
|
276
|
+
{ "tag": "300", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "xiii, 295 pages :" }, { "code": "b", "value": "illustrations ;" }, { "code": "c", "value": "23 cm." } ] },
|
277
|
+
{ "tag": "336", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "text" }, { "code": "b", "value": "txt" }, { "code": "2", "value": "rdacontent." } ] },
|
278
|
+
{ "tag": "337", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "unmediated" }, { "code": "b", "value": "n" }, { "code": "2", "value": "rdamedia." } ] },
|
279
|
+
{ "tag": "338", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "volume" }, { "code": "b", "value": "nc" }, { "code": "2", "value": "rdacarrier." } ] },
|
280
|
+
{ "tag": "490", "indicator1": "1", "indicator2": " ", "subfields": [{ "code": "a", "value": "NTC Shakespeare series." }] },
|
281
|
+
{ "tag": "830", "indicator1": " ", "indicator2": "0", "subfields": [{ "code": "a", "value": "NTC Shakespeare series." }] },
|
282
|
+
{ "tag": "907", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": ".b108930609" }] },
|
283
|
+
{ "tag": "948", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "LTI 2018-07-09" }] },
|
284
|
+
{ "tag": "948", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "MARS" }] }
|
285
|
+
]
|
286
|
+
}
|
287
|
+
```
|
288
|
+
|
289
|
+
By looking at this JSON representation, we can see that the data is...
|
290
|
+
- **Nested**: A MARC record contains many fields, and most of them contains multiple subfields.
|
291
|
+
- **Dynamic**: Some fields are repeatable ("264" and "948"), and subfields too. The first
|
292
|
+
fields don't have subfields nor indicators (they're called *control fields*).
|
293
|
+
- **Encapsulated**: The meaning of subfields depends on the field they're in (take a look at
|
294
|
+
the "a" subfield for example).
|
295
|
+
|
296
|
+
All those characteristics can be grouped into what we call: [**semi-structured
|
297
|
+
data**](#semi-structured-data).
|
298
|
+
> Semi-structured data is a form of structured data that does not obey the tabular structure of data models associated with relational databases or other forms of data tables, but nonetheless contains tags or other markers to separate semantic elements and enforce hierarchies of records and fields within the data. Therefore, it is also known as self-describing structure. - Wikipedia
|
299
|
+
|
300
|
+
A perfect example of that is HTML documents. An HTML document contains different types of tags,
|
301
|
+
which can nested with one and other. It wouldn't make sense to model HTML documents with tables
|
302
|
+
and columns. Imagine having to access nested tags through joins, considering the fact that we
|
303
|
+
could potentially have hundreds of them on a single HTML document. That's why we usually store
|
304
|
+
this kind of data in a text field.
|
305
|
+
|
306
|
+
In our case, we're using JSON to represent MARC data. Luckily for us, we can store JSON
|
307
|
+
data directly in relational databases like Postgres or MySQL:
|
308
|
+
|
309
|
+
```ruby
|
310
|
+
# config/initializers/inflections.rb
|
311
|
+
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
312
|
+
inflect.acronym "MARC"
|
313
|
+
end
|
314
|
+
```
|
315
|
+
|
316
|
+
```bash
|
317
|
+
> rails g model marc/record leader:string fields:json
|
318
|
+
> rails db:migrate
|
319
|
+
```
|
320
|
+
|
321
|
+
We can then create a MARC record like this:
|
322
|
+
```ruby
|
323
|
+
MARC::Record.create leader: "00815nam 2200289 a 4500", fields: [
|
324
|
+
{ "tag": "001", "value": "ocm30152659" },
|
325
|
+
{ "tag": "003", "value": "OCoLC" },
|
326
|
+
{ "tag": "005", "value": "19971028235910.0" },
|
327
|
+
{ "tag": "008", "value": "940909t19941994ilua 000 0 eng " },
|
328
|
+
{ "tag": "010", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "92060871" }] },
|
329
|
+
{ "tag": "020", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "0844257443" }] },
|
330
|
+
{ "tag": "040", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "DLC" }, { "code": "c", "value": "DLC" }, { "code": "d", "value": "BKL" }, { "code": "d", "value": "UtOrBLW" } ] },
|
331
|
+
{ "tag": "049", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "BKLA" }] },
|
332
|
+
{ "tag": "099", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "822.33" }, { "code": "a", "value": "S52" }, { "code": "a", "value": "S7" } ] },
|
333
|
+
{ "tag": "100", "indicator1": "1", "indicator2": " ", "subfields": [{ "code": "a", "value": "Shakespeare, William," }, { "code": "d", "value": "1564-1616." } ] },
|
334
|
+
{ "tag": "245", "indicator1": "1", "indicator2": "0", "subfields": [{ "code": "a", "value": "Hamlet" }, { "code": "c", "value": "William Shakespeare." } ] },
|
335
|
+
{ "tag": "264", "indicator1": " ", "indicator2": "1", "subfields": [{ "code": "a", "value": "Lincolnwood, Ill. :" }, { "code": "b", "value": "NTC Pub. Group," }, { "code": "c", "value": "[1994]" } ] },
|
336
|
+
{ "tag": "264", "indicator1": " ", "indicator2": "4", "subfields": [{ "code": "c", "value": "©1994." }] },
|
337
|
+
{ "tag": "300", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "xiii, 295 pages :" }, { "code": "b", "value": "illustrations ;" }, { "code": "c", "value": "23 cm." } ] },
|
338
|
+
{ "tag": "336", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "text" }, { "code": "b", "value": "txt" }, { "code": "2", "value": "rdacontent." } ] },
|
339
|
+
{ "tag": "337", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "unmediated" }, { "code": "b", "value": "n" }, { "code": "2", "value": "rdamedia." } ] },
|
340
|
+
{ "tag": "338", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "volume" }, { "code": "b", "value": "nc" }, { "code": "2", "value": "rdacarrier." } ] },
|
341
|
+
{ "tag": "490", "indicator1": "1", "indicator2": " ", "subfields": [{ "code": "a", "value": "NTC Shakespeare series." }] },
|
342
|
+
{ "tag": "830", "indicator1": " ", "indicator2": "0", "subfields": [{ "code": "a", "value": "NTC Shakespeare series." }] },
|
343
|
+
{ "tag": "907", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": ".b108930609" }] },
|
344
|
+
{ "tag": "948", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "LTI 2018-07-09" }] },
|
345
|
+
{ "tag": "948", "indicator1": " ", "indicator2": " ", "subfields": [{ "code": "a", "value": "MARS" }] }
|
346
|
+
]
|
347
|
+
```
|
348
|
+
|
349
|
+
And access it this way:
|
350
|
+
```ruby
|
351
|
+
> record = MARC::Record.first
|
352
|
+
> field = record.fields.find { |field| field["tag"] == "245" }
|
353
|
+
> subfield = field["subfields"].first
|
354
|
+
> subfield["value"]
|
355
|
+
=> "Hamlet"
|
356
|
+
```
|
357
|
+
It works, but...
|
358
|
+
- It's not very convinient to access nested data this way.
|
359
|
+
- We cannot easily attach logic to our JSON data without polluting our model.
|
360
|
+
|
361
|
+
What if we could interact with our JSON data the same way we do with ActiveRecord associations
|
362
|
+
? Enters ActiveModel and the
|
363
|
+
[AttributesAPI](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html#method-i-attribute)
|
364
|
+
!
|
365
|
+
|
366
|
+
First, we have to define a custom type which...
|
367
|
+
- Maps JSON objects to ActiveModel-compliant objects.
|
368
|
+
- Handles collections.
|
369
|
+
|
370
|
+
To do that, we'll add the following options to our type:
|
371
|
+
- `:class_name`: The class name of an ActiveModel-compliant object.
|
372
|
+
- `:collection`: Specify if the attribute is a collection. Default to `false`.
|
373
|
+
|
374
|
+
```ruby
|
375
|
+
class DocumentType < ::ActiveModel::Type::Value
|
376
|
+
attr_reader :document_class, :collection
|
377
|
+
|
378
|
+
def initialize(class_name:, collection: false)
|
379
|
+
@document_class = class_name.constantize
|
380
|
+
@collection = collection
|
381
|
+
end
|
382
|
+
|
383
|
+
def cast(value)
|
384
|
+
if collection
|
385
|
+
value.map { |attributes| process attributes }
|
386
|
+
else
|
387
|
+
process value
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
def process(value)
|
392
|
+
document_class.new(value)
|
393
|
+
end
|
394
|
+
|
395
|
+
def serialize(value)
|
396
|
+
value.to_json
|
397
|
+
end
|
398
|
+
|
399
|
+
def deserialize(json)
|
400
|
+
value = ActiveSupport::JSON.decode(json)
|
401
|
+
|
402
|
+
cast value
|
403
|
+
end
|
404
|
+
|
405
|
+
# Track changes
|
406
|
+
def changed_in_place?(old_value, new_value)
|
407
|
+
deserialize(old_value) != new_value
|
408
|
+
end
|
409
|
+
end
|
410
|
+
```
|
411
|
+
Let's register our type as we gonna use it multiple times:
|
412
|
+
```ruby
|
413
|
+
# config/initializers/type.rb
|
414
|
+
ActiveModel::Type.register(:document, DocumentType)
|
415
|
+
ActiveRecord::Type.register(:document, DocumentType)
|
416
|
+
```
|
417
|
+
|
418
|
+
Now we can use it in our models:
|
419
|
+
```ruby
|
420
|
+
class MARC::Record < ApplicationRecord
|
421
|
+
attribute :fields, :document,
|
422
|
+
class_name: "MARC::Record::Field",
|
423
|
+
collection: true
|
424
|
+
|
425
|
+
# Hash-like reader method
|
426
|
+
def [](tag)
|
427
|
+
occurences = fields.select { |field| field.tag == tag }
|
428
|
+
occurences.first unless occurences.count > 1
|
429
|
+
end
|
430
|
+
end
|
431
|
+
```
|
432
|
+
```ruby
|
433
|
+
class MARC::Record::Field
|
434
|
+
include ActiveModel::Model
|
435
|
+
include ActiveModel::Attributes
|
436
|
+
include ActiveModel::Serializers::JSON
|
437
|
+
|
438
|
+
attribute :tag, :string
|
439
|
+
attribute :indicator1, :string
|
440
|
+
attribute :indicator2, :string
|
441
|
+
attribute :subfields, :document,
|
442
|
+
class_name: "MARC::Record::Field::Subfield",
|
443
|
+
collection: true
|
444
|
+
|
445
|
+
attribute :value, :string
|
446
|
+
|
447
|
+
# Some domain logic
|
448
|
+
def value=(value)
|
449
|
+
@value = value if control_field?
|
450
|
+
end
|
451
|
+
|
452
|
+
def control_field?
|
453
|
+
/00\d/ === tag
|
454
|
+
end
|
455
|
+
|
456
|
+
# Yet another Hash-like reader method
|
457
|
+
def [](code)
|
458
|
+
occurences = subfields.find { |subfield| subfield.code == code }
|
459
|
+
occurences.first unless occurences.count > 1
|
460
|
+
end
|
461
|
+
|
462
|
+
# Used to track changes
|
463
|
+
def ==(other)
|
464
|
+
attributes == other.attributes
|
465
|
+
end
|
466
|
+
end
|
467
|
+
```
|
468
|
+
```ruby
|
469
|
+
class MARC::Record::Field::Subfield
|
470
|
+
include ActiveModel::Model
|
471
|
+
include ActiveModel::Attributes
|
472
|
+
include ActiveModel::Serializers::JSON
|
473
|
+
|
474
|
+
attribute :code, :string
|
475
|
+
attribute :value, :string
|
476
|
+
|
477
|
+
# Used to track changes
|
478
|
+
def ==(other)
|
479
|
+
attributes == other.attributes
|
480
|
+
end
|
481
|
+
end
|
482
|
+
```
|
483
|
+
```ruby
|
484
|
+
> record = MARC::Record.first
|
485
|
+
> record.fields.first.class
|
486
|
+
=> MARC::Record::Field
|
487
|
+
|
488
|
+
> record.fields.first.control_field?
|
489
|
+
=> true
|
490
|
+
|
491
|
+
> record.fields.first.subfields.first.class
|
492
|
+
=> MARC::Record::Field::Subfield
|
493
|
+
|
494
|
+
> record["245"]["a"].value
|
495
|
+
=> "Hamlet"
|
496
|
+
|
497
|
+
> record.changed?
|
498
|
+
=> false
|
499
|
+
|
500
|
+
> record["245"]["a"].value = "Romeo and Juliet"
|
501
|
+
> record["245"]["a"].value
|
502
|
+
=> "Romeo and Juliet"
|
503
|
+
|
504
|
+
> record.changed?
|
505
|
+
=> true
|
506
|
+
```
|
507
|
+
|
508
|
+
Et voilà ! Home-made associations !
|
509
|
+
|
510
|
+
If we want to go further, we can...
|
511
|
+
- Create our custom collection class to provide functionalities like ActiveRecord
|
512
|
+
collection proxies.
|
513
|
+
- Add support for nested attributes.
|
514
|
+
- Emulate persistence to update specific objects.
|
515
|
+
- Provide a way to resolve constants, so that we can use the relative name of a constant
|
516
|
+
instead of it's full name. For example, `"MARC::Record::Field"` could be referred as
|
517
|
+
`"Field"` in our example.
|
518
|
+
|
519
|
+
And that's what this extension does. (Nothing fancy, in fact the code is quite simple. So don't
|
520
|
+
be afraid to dive into it if you want to know how it was implemented !)
|
521
|
+
|
522
|
+
Here's the updated version with the extension:
|
523
|
+
```ruby
|
524
|
+
class MARC::Record < ApplicationRecord
|
525
|
+
include ActiveModel::Embedding::Associations
|
526
|
+
|
527
|
+
embeds_many :fields
|
528
|
+
|
529
|
+
# ...
|
530
|
+
end
|
531
|
+
```
|
532
|
+
```ruby
|
533
|
+
class MARC::Record::Field
|
534
|
+
include ActiveModel::Embedding::Document
|
535
|
+
|
536
|
+
# ...
|
537
|
+
|
538
|
+
embeds_many :subfields
|
539
|
+
|
540
|
+
# ...
|
541
|
+
end
|
542
|
+
```
|
543
|
+
```ruby
|
544
|
+
class MARC::Record::Field::Subfield
|
545
|
+
include ActiveModel::Embedding::Document
|
546
|
+
|
547
|
+
# ...
|
548
|
+
end
|
549
|
+
```
|
550
|
+
|
551
|
+
We can then use our embedded associations in the views as nested attributes:
|
552
|
+
```erb
|
553
|
+
# app/views/marc/records/_form.html.erb
|
554
|
+
<%= form_with model: @record do |record_form| %>
|
555
|
+
<% @record.fields.each do |field| %>
|
556
|
+
<%= record_form.fields_for :fields, field do |field_fields| %>
|
557
|
+
|
558
|
+
<%= field_fields.label :tag %>
|
559
|
+
<%= field_fields.text_field :tag %>
|
560
|
+
|
561
|
+
<% if field.control_field? %>
|
562
|
+
<%= field_fields.text_field :value %>
|
563
|
+
<% else %>
|
564
|
+
<%= field_fields.text_field :indicator1 %>
|
565
|
+
<%= field_fields.text_field :indicator2 %>
|
566
|
+
|
567
|
+
<%= field_fields.fields_for :subfields do |subfield_fields| %>
|
568
|
+
<%= subfield_fields.label :code %>
|
569
|
+
<%= subfield_fields.text_field :code %>
|
570
|
+
<%= subfield_fields.text_field :value %>
|
571
|
+
<% end %>
|
572
|
+
<% end %>
|
573
|
+
<% end %>
|
574
|
+
<% end %>
|
575
|
+
|
576
|
+
<%= record_form.submit %>
|
577
|
+
<% end %>
|
578
|
+
```
|
579
|
+
|
580
|
+
|
581
|
+
## Concepts
|
582
|
+
### Document
|
583
|
+
A JSON object mapped to a PORO which includes `ActiveModel::Embedding::Document`. Usually part of a
|
584
|
+
[collection](#collection).
|
585
|
+
|
586
|
+
### Collection
|
587
|
+
A JSON array mapped to an `ActiveModel::Embedding::Collection` (or any class that includes
|
588
|
+
`ActiveModel::Embedding::Collecting`). Stores collections of
|
589
|
+
[documents](#document).
|
590
|
+
|
591
|
+
### Embedded associations
|
592
|
+
Models structural hierarchies in [semi-structured data](#semi-structured-data), by "embedding"
|
593
|
+
the content of children directly in the parent, instead of using references like foreign keys. See
|
594
|
+
[Embedded Data
|
595
|
+
Models](https://docs.mongodb.com/manual/core/data-model-design/#embedded-data-models) from
|
596
|
+
MongoDB's docs.
|
597
|
+
|
598
|
+
### Semi-structured data
|
599
|
+
Data that don't fit in the [relational model](https://www.digitalocean.com/community/tutorials/what-is-the-relational-model).
|
600
|
+
> Semi-structured data is a form of structured data that does not obey the tabular structure of data models associated with relational databases or other forms of data tables, but nonetheless contains tags or other markers to separate semantic elements and enforce hierarchies of records and fields within the data. Therefore, it is also known as self-describing structure. - Wikipedia
|
601
|
+
|
602
|
+
|
603
|
+
## Components
|
604
|
+
### `ActiveModel::Type::Document`
|
605
|
+
A polymorphic cast type (registered as `:document`). Maps JSON objects to POROs that includes
|
606
|
+
`ActiveModel::Embedding::Document`. Provides support for defining [collections](#collection).
|
607
|
+
|
608
|
+
### `ActiveModel::Embedding::Associations`
|
609
|
+
API for defining [embedded associations](#embedded-associations). Uses the Attributes API with
|
610
|
+
the `:document` type.
|
611
|
+
|
612
|
+
### `ActiveModel::Embedding::Document`
|
613
|
+
A module which includes everything needed to work with the `:document` type
|
614
|
+
(`ActiveModel::Model`, `ActiveModel::Attributes`, `ActiveModel::Serializers::JSON`,
|
615
|
+
`ActiveModel::Embedding::Associations`). Provides an `id` attribute and implements methods like `#persisted?`
|
616
|
+
and `#save` to emulate persistence.
|
617
|
+
|
618
|
+
### `ActiveModel::Embedding::Collecting`
|
619
|
+
A module which provides capabailities similar to ActiveRecord collection proxies. Provides
|
620
|
+
support for nested attributes.
|
621
|
+
|
622
|
+
### `ActiveModel::Embedding::Collection`
|
623
|
+
Default collection class. Includes `ActiveModel::Embedding::Collecting`.
|
624
|
+
|
625
|
+
## Installation
|
626
|
+
Add this line to your application's Gemfile:
|
627
|
+
|
628
|
+
```ruby
|
629
|
+
gem 'activemodel-embedding'
|
630
|
+
```
|
631
|
+
|
632
|
+
And then execute:
|
633
|
+
```bash
|
634
|
+
$ bundle
|
635
|
+
```
|
636
|
+
|
637
|
+
Or install it yourself as:
|
638
|
+
```bash
|
639
|
+
$ gem install activemodel-embedding
|
640
|
+
```
|
641
|
+
|
642
|
+
## License
|
643
|
+
The gem is available as open source under the terms of the [MIT
|
644
|
+
License](https://opensource.org/licenses/MIT).
|
645
|
+
|
646
|
+
## Alternatives
|
647
|
+
Here's are some alternatives I came accross after I've started working on this gem:
|
648
|
+
- [attr_json](https://github.com/jrochkind/attr_json)
|
649
|
+
- [store_model](https://github.com/DmitryTsepelev/store_model)
|
650
|
+
|
651
|
+
Each one uses a different approach to solve the same problem.
|
data/Rakefile
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveModel
|
4
|
+
module Embedding
|
5
|
+
module Associations
|
6
|
+
def self.included(klass)
|
7
|
+
klass.class_eval do
|
8
|
+
extend ClassMethods
|
9
|
+
|
10
|
+
class_variable_set :@@embedded_associations, []
|
11
|
+
|
12
|
+
around_save :save_embedded_documents
|
13
|
+
|
14
|
+
def save_embedded_documents
|
15
|
+
klass = self.class
|
16
|
+
|
17
|
+
if klass.embedded_associations.present?
|
18
|
+
associations = klass.embedded_associations
|
19
|
+
|
20
|
+
targets = associations.map do |association_name|
|
21
|
+
public_send association_name
|
22
|
+
end.compact
|
23
|
+
|
24
|
+
targets.all?(&:save)
|
25
|
+
end
|
26
|
+
|
27
|
+
yield
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
module ClassMethods
|
33
|
+
def embeds_many(attr_name, class_name: nil, cast_type: nil, collection: nil)
|
34
|
+
class_name = cast_type ? nil : class_name || infer_class_name_from(attr_name)
|
35
|
+
|
36
|
+
attribute :"#{attr_name}", :document,
|
37
|
+
class_name: class_name,
|
38
|
+
cast_type: cast_type,
|
39
|
+
collection: collection || true,
|
40
|
+
context: self.to_s
|
41
|
+
|
42
|
+
register_embedded_association attr_name
|
43
|
+
|
44
|
+
nested_attributes_for attr_name
|
45
|
+
end
|
46
|
+
|
47
|
+
def embeds_one(attr_name, class_name: nil, cast_type: nil)
|
48
|
+
class_name = cast_type ? nil : class_name || infer_class_name_from(attr_name)
|
49
|
+
|
50
|
+
attribute :"#{attr_name}", :document,
|
51
|
+
class_name: class_name,
|
52
|
+
cast_type: cast_type,
|
53
|
+
context: self.to_s
|
54
|
+
|
55
|
+
register_embedded_association attr_name
|
56
|
+
|
57
|
+
nested_attributes_for attr_name
|
58
|
+
end
|
59
|
+
|
60
|
+
def embedded_associations
|
61
|
+
class_variable_get :@@embedded_associations
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def infer_class_name_from(attr_name)
|
67
|
+
attr_name.to_s.singularize.camelize
|
68
|
+
end
|
69
|
+
|
70
|
+
def register_embedded_association(name)
|
71
|
+
embedded_associations << name
|
72
|
+
end
|
73
|
+
|
74
|
+
def nested_attributes_for(attr_name)
|
75
|
+
delegate :attributes=, to: :"#{attr_name}", prefix: true
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveModel
|
4
|
+
module Embedding
|
5
|
+
module Collecting
|
6
|
+
include ActiveModel::ForbiddenAttributesProtection
|
7
|
+
|
8
|
+
attr_reader :documents, :document_class
|
9
|
+
alias_method :to_a, :documents
|
10
|
+
alias_method :to_ary, :to_a
|
11
|
+
|
12
|
+
def initialize(documents)
|
13
|
+
@documents = documents
|
14
|
+
@document_class = documents.first.class
|
15
|
+
end
|
16
|
+
|
17
|
+
def attributes=(documents_attributes)
|
18
|
+
documents_attributes = sanitize_for_mass_assignment(documents_attributes)
|
19
|
+
|
20
|
+
case documents_attributes
|
21
|
+
when Hash
|
22
|
+
documents_attributes.each do |index, document_attributes|
|
23
|
+
index = index.to_i
|
24
|
+
id = fetch_id(document_attributes) || index
|
25
|
+
document = find id if id
|
26
|
+
|
27
|
+
unless document
|
28
|
+
document = documents[index] || build
|
29
|
+
end
|
30
|
+
|
31
|
+
document.attributes = document_attributes
|
32
|
+
end
|
33
|
+
when Array
|
34
|
+
documents_attributes.each do |document_attributes|
|
35
|
+
id = fetch_id(document_attributes)
|
36
|
+
document = find id if id
|
37
|
+
|
38
|
+
unless document
|
39
|
+
document = build
|
40
|
+
end
|
41
|
+
|
42
|
+
document.attributes = document_attributes
|
43
|
+
end
|
44
|
+
else
|
45
|
+
raise_attributes_error
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def find(id)
|
50
|
+
documents.find { |document| document.id == id }
|
51
|
+
end
|
52
|
+
|
53
|
+
def build(attributes = {})
|
54
|
+
case attributes
|
55
|
+
when Hash
|
56
|
+
document = document_class.new(attributes)
|
57
|
+
|
58
|
+
append document
|
59
|
+
|
60
|
+
document
|
61
|
+
when Array
|
62
|
+
attributes.map do |document_attributes|
|
63
|
+
build(document_attributes)
|
64
|
+
end
|
65
|
+
else
|
66
|
+
raise_attributes_error
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def push(*new_documents)
|
71
|
+
new_documents = new_documents.flatten
|
72
|
+
|
73
|
+
valid_documents = new_documents.all? { |document| document.is_a? document_class }
|
74
|
+
|
75
|
+
unless valid_documents
|
76
|
+
raise ArgumentError, "Expect arguments to be of class #{document_class}"
|
77
|
+
end
|
78
|
+
|
79
|
+
@documents.push(*new_documents)
|
80
|
+
end
|
81
|
+
|
82
|
+
alias_method :<<, :push
|
83
|
+
alias_method :append, :push
|
84
|
+
|
85
|
+
def save
|
86
|
+
documents.all?(&:save)
|
87
|
+
end
|
88
|
+
|
89
|
+
def persisted?
|
90
|
+
documents.all?(&:persisted?)
|
91
|
+
end
|
92
|
+
|
93
|
+
def each
|
94
|
+
return self.to_enum unless block_given?
|
95
|
+
|
96
|
+
documents.each { |document| yield document }
|
97
|
+
end
|
98
|
+
|
99
|
+
def as_json
|
100
|
+
documents.as_json
|
101
|
+
end
|
102
|
+
|
103
|
+
def to_json
|
104
|
+
as_json.to_json
|
105
|
+
end
|
106
|
+
|
107
|
+
def ==(other)
|
108
|
+
documents.map(&:attributes) == other.map(&:attributes)
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
def fetch_id(attributes)
|
114
|
+
attributes["id"].to_i
|
115
|
+
end
|
116
|
+
|
117
|
+
def raise_attributes_error
|
118
|
+
raise ArgumentError, "Expect attributes to be a Hash or Array, but got a #{attributes.class}"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveModel
|
4
|
+
module Embedding
|
5
|
+
module Document
|
6
|
+
def self.included(klass)
|
7
|
+
klass.class_eval do
|
8
|
+
extend ActiveModel::Callbacks
|
9
|
+
|
10
|
+
define_model_callbacks :save
|
11
|
+
|
12
|
+
include ActiveModel::Model
|
13
|
+
include ActiveModel::Attributes
|
14
|
+
include ActiveModel::Serializers::JSON
|
15
|
+
include Embedding::Associations
|
16
|
+
|
17
|
+
attribute :id, :integer
|
18
|
+
|
19
|
+
def save
|
20
|
+
run_callbacks :save do
|
21
|
+
return false unless valid?
|
22
|
+
|
23
|
+
self.id = object_id unless persisted?
|
24
|
+
|
25
|
+
true
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def persisted?
|
30
|
+
id.present?
|
31
|
+
end
|
32
|
+
|
33
|
+
def ==(other)
|
34
|
+
attributes == other.attributes
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_model/embedding/version"
|
4
|
+
require "active_model/embedding/railtie"
|
5
|
+
|
6
|
+
require "active_model/type/document"
|
7
|
+
require "active_record/type/document"
|
8
|
+
|
9
|
+
module ActiveModel
|
10
|
+
module Embedding
|
11
|
+
require "active_model/embedding/associations"
|
12
|
+
require "active_model/embedding/document"
|
13
|
+
require "active_model/embedding/collecting"
|
14
|
+
require "active_model/embedding/collection"
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveModel
|
4
|
+
module Type
|
5
|
+
class Value
|
6
|
+
end
|
7
|
+
|
8
|
+
class Document < Value
|
9
|
+
attr_reader :document_class, :cast_type
|
10
|
+
attr_reader :collection
|
11
|
+
attr_reader :context
|
12
|
+
|
13
|
+
def initialize(class_name: nil, cast_type: nil, collection: false, context: nil)
|
14
|
+
@document_class = resolve_constant class_name, from: context if class_name
|
15
|
+
@cast_type = lookup_or_return cast_type if cast_type
|
16
|
+
@collection = collection
|
17
|
+
@context = context
|
18
|
+
end
|
19
|
+
|
20
|
+
def collection?
|
21
|
+
collection
|
22
|
+
end
|
23
|
+
|
24
|
+
def default_collection?
|
25
|
+
collection == true
|
26
|
+
end
|
27
|
+
|
28
|
+
def collection_class
|
29
|
+
return unless collection?
|
30
|
+
|
31
|
+
if default_collection?
|
32
|
+
@collection_class ||= ActiveModel::Embedding::Collection
|
33
|
+
else
|
34
|
+
@collection_class ||= resolve_constant collection, from: context
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def cast(value)
|
39
|
+
return unless value
|
40
|
+
|
41
|
+
if collection?
|
42
|
+
documents = value.map { |attributes| process attributes }
|
43
|
+
|
44
|
+
collection_class.new(documents)
|
45
|
+
else
|
46
|
+
process value
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def process(value)
|
51
|
+
cast_type ? cast_type.cast(value) : document_class.new(value)
|
52
|
+
end
|
53
|
+
|
54
|
+
def serialize(value)
|
55
|
+
value.to_json
|
56
|
+
end
|
57
|
+
|
58
|
+
def deserialize(json)
|
59
|
+
return unless json
|
60
|
+
|
61
|
+
value = ActiveSupport::JSON.decode(json)
|
62
|
+
|
63
|
+
cast value
|
64
|
+
end
|
65
|
+
|
66
|
+
def changed_in_place?(old_value, new_value)
|
67
|
+
deserialize(old_value) != new_value
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def resolve_constant(name, from: nil)
|
73
|
+
name = clean_scope(name)
|
74
|
+
|
75
|
+
if from
|
76
|
+
context = from.split("::")
|
77
|
+
|
78
|
+
context.each do
|
79
|
+
scope = context.join("::")
|
80
|
+
constant = "::#{scope}::#{name}".constantize rescue nil
|
81
|
+
|
82
|
+
return constant if constant
|
83
|
+
|
84
|
+
context.pop
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
"::#{name}".constantize
|
89
|
+
end
|
90
|
+
|
91
|
+
def clean_scope(name)
|
92
|
+
name.gsub(/^::/, "")
|
93
|
+
end
|
94
|
+
|
95
|
+
def lookup_or_return(cast_type)
|
96
|
+
case cast_type
|
97
|
+
when Symbol
|
98
|
+
begin
|
99
|
+
Type.lookup(cast_type)
|
100
|
+
rescue
|
101
|
+
ActiveRecord::Type.lookup(cast_type)
|
102
|
+
end
|
103
|
+
else
|
104
|
+
cast_type
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
register :document, Document
|
110
|
+
end
|
111
|
+
end
|
metadata
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: activemodel-embedding
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.5
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- mansakondo
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-09-30 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 6.1.4
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 6.1.4
|
27
|
+
description: An ActiveModel extension to model your semi-structured data using embedded
|
28
|
+
associations
|
29
|
+
email:
|
30
|
+
- mansakondo22@gmail.com
|
31
|
+
executables: []
|
32
|
+
extensions: []
|
33
|
+
extra_rdoc_files: []
|
34
|
+
files:
|
35
|
+
- MIT-LICENSE
|
36
|
+
- README.md
|
37
|
+
- Rakefile
|
38
|
+
- lib/active_model/embedding.rb
|
39
|
+
- lib/active_model/embedding/associations.rb
|
40
|
+
- lib/active_model/embedding/collecting.rb
|
41
|
+
- lib/active_model/embedding/collection.rb
|
42
|
+
- lib/active_model/embedding/document.rb
|
43
|
+
- lib/active_model/embedding/railtie.rb
|
44
|
+
- lib/active_model/embedding/version.rb
|
45
|
+
- lib/active_model/type/document.rb
|
46
|
+
- lib/active_record/type/document.rb
|
47
|
+
- lib/activemodel-embedding.rb
|
48
|
+
- lib/tasks/activemodel/embedding_tasks.rake
|
49
|
+
homepage: https://github.com/mansakondo/activemodel-embedding
|
50
|
+
licenses:
|
51
|
+
- MIT
|
52
|
+
metadata:
|
53
|
+
homepage_uri: https://github.com/mansakondo/activemodel-embedding
|
54
|
+
source_code_uri: https://github.com/mansakondo/activemodel-embedding
|
55
|
+
changelog_uri: https://github.com/mansakondo/activemodel-embedding/blob/main/CHANGELOG.md
|
56
|
+
post_install_message:
|
57
|
+
rdoc_options: []
|
58
|
+
require_paths:
|
59
|
+
- lib
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
61
|
+
requirements:
|
62
|
+
- - ">="
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: 2.5.0
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - ">="
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
requirements: []
|
71
|
+
rubygems_version: 3.2.15
|
72
|
+
signing_key:
|
73
|
+
specification_version: 4
|
74
|
+
summary: Embedded associations for your semi-structured data
|
75
|
+
test_files: []
|