activemodel-embedding 0.1.5

Sign up to get free protection for your applications and to get access to all the features.
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,13 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ require "rake/testtask"
6
+
7
+ Rake::TestTask.new(:test) do |t|
8
+ t.libs << 'test'
9
+ t.pattern = 'test/**/*_test.rb'
10
+ t.verbose = false
11
+ end
12
+
13
+ task default: :test
@@ -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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/embedding/collecting"
4
+
5
+ module ActiveModel
6
+ module Embedding
7
+ class Collection
8
+ include Enumerable
9
+ include Embedding::Collecting
10
+ end
11
+ end
12
+ 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,6 @@
1
+ module ActiveModel
2
+ module Embedding
3
+ class Railtie < ::Rails::Railtie
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveModel
2
+ module Embedding
3
+ VERSION = '0.1.5'
4
+ end
5
+ 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Type
5
+ Document = ActiveModel::Type::Document
6
+
7
+ register :document, Document, override: false
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/embedding"
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :activemodel_embedding do
3
+ # # Task goes here
4
+ # 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: []