universalid 0.0.1 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +959 -186
  3. data/Rakefile +1 -5
  4. data/config/default.yml +12 -0
  5. data/config/example.yml +45 -0
  6. data/lib/universal_id/contrib/active_record/base_message_pack_type.rb +11 -0
  7. data/lib/universal_id/contrib/active_record/base_packer.rb +130 -0
  8. data/lib/universal_id/contrib/active_record/base_unpacker.rb +52 -0
  9. data/lib/universal_id/contrib/active_record/relation_message_pack_type.rb +16 -0
  10. data/lib/universal_id/contrib/active_record.rb +4 -0
  11. data/lib/universal_id/contrib/active_support/time_with_zone_message_pack_type.rb +14 -0
  12. data/lib/universal_id/contrib/active_support.rb +3 -0
  13. data/lib/universal_id/contrib/global_id/global_id_model.rb +24 -0
  14. data/lib/universal_id/contrib/global_id/global_id_uid_extension.rb +36 -0
  15. data/lib/universal_id/contrib/global_id/message_pack_type.rb +15 -0
  16. data/lib/universal_id/contrib/global_id.rb +3 -0
  17. data/lib/universal_id/contrib/rails.rb +6 -0
  18. data/lib/universal_id/contrib/signed_global_id/message_pack_type.rb +8 -0
  19. data/lib/universal_id/contrib/signed_global_id.rb +3 -0
  20. data/lib/universal_id/encoder.rb +27 -0
  21. data/lib/universal_id/message_pack_factory.rb +51 -0
  22. data/lib/universal_id/message_pack_types/ruby/composites/open_struct.rb +8 -0
  23. data/lib/universal_id/message_pack_types/ruby/composites/set.rb +9 -0
  24. data/lib/universal_id/message_pack_types/ruby/composites/struct.rb +23 -0
  25. data/lib/universal_id/message_pack_types/ruby/scalars/complex.rb +8 -0
  26. data/lib/universal_id/message_pack_types/ruby/scalars/date.rb +8 -0
  27. data/lib/universal_id/message_pack_types/ruby/scalars/date_time.rb +8 -0
  28. data/lib/universal_id/message_pack_types/ruby/scalars/range.rb +20 -0
  29. data/lib/universal_id/message_pack_types/ruby/scalars/rational.rb +8 -0
  30. data/lib/universal_id/message_pack_types/ruby/scalars/regexp.rb +15 -0
  31. data/lib/universal_id/message_pack_types/uri/uid/type.rb +8 -0
  32. data/lib/universal_id/message_pack_types.rb +24 -0
  33. data/lib/universal_id/prepack_database_options.rb +63 -0
  34. data/lib/universal_id/prepack_options.rb +74 -0
  35. data/lib/universal_id/prepacker.rb +28 -0
  36. data/lib/universal_id/refinements/array_refinement.rb +17 -0
  37. data/lib/universal_id/refinements/hash_refinement.rb +19 -0
  38. data/lib/universal_id/refinements/kernel_refinement.rb +19 -0
  39. data/lib/universal_id/refinements/open_struct_refinement.rb +12 -0
  40. data/lib/universal_id/refinements/set_refinement.rb +12 -0
  41. data/lib/universal_id/refinements.rb +9 -0
  42. data/lib/universal_id/settings.rb +82 -0
  43. data/lib/universal_id/version.rb +1 -1
  44. data/lib/universal_id.rb +25 -10
  45. data/lib/uri/uid.rb +95 -0
  46. metadata +105 -28
  47. data/lib/universal_id/active_model_serializer.rb +0 -53
  48. data/lib/universal_id/config.rb +0 -18
  49. data/lib/universal_id/errors.rb +0 -11
  50. data/lib/universal_id/portable.rb +0 -24
  51. data/lib/universal_id/portable_hash.rb +0 -85
data/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  <p align="center">
2
- <h1 align="center">Universal ID 🌌</h1>
2
+ <h1 align="center">Universal ID</h1>
3
3
  <p align="center">
4
4
  <a href="http://blog.codinghorror.com/the-best-code-is-no-code-at-all/">
5
- <img alt="Lines of Code" src="https://img.shields.io/badge/loc-165-47d299.svg" />
5
+ <img alt="Lines of Code" src="https://img.shields.io/badge/loc-721-47d299.svg" />
6
6
  </a>
7
7
  <a href="https://codeclimate.com/github/hopsoft/universalid/maintainability">
8
- <img src="https://api.codeclimate.com/v1/badges/80bcd3acced072534a3a/maintainability" />
8
+ <img src="https://api.codeclimate.com/v1/badges/567624cbe733fafc2330/maintainability" />
9
9
  </a>
10
10
  <a href="https://rubygems.org/gems/universalid">
11
11
  <img alt="GEM Version" src="https://img.shields.io/gem/v/universalid?color=168AFE&include_prereleases&logo=ruby&logoColor=FE1616">
@@ -16,6 +16,9 @@
16
16
  <a href="https://github.com/testdouble/standard">
17
17
  <img alt="Ruby Style" src="https://img.shields.io/badge/style-standard-168AFE?logo=ruby&logoColor=FE1616" />
18
18
  </a>
19
+ <a href="https://gitpod.io/#https://github.com/hopsoft/universalid">
20
+ <img alt="Gitpod - Ready to Code" src="https://img.shields.io/badge/Gitpod-Ready--to--Code-green?style=flat&logo=gitpod&logoColor=white" />
21
+ </a>
19
22
  <a href="https://github.com/hopsoft/universalid/actions/workflows/tests.yml">
20
23
  <img alt="Tests" src="https://github.com/hopsoft/universalid/actions/workflows/tests.yml/badge.svg" />
21
24
  </a>
@@ -30,248 +33,1018 @@
30
33
  <img alt="Twitter Follow" src="https://img.shields.io/twitter/url?label=%40hopsoft&style=social&url=https%3A%2F%2Ftwitter.com%2Fhopsoft">
31
34
  </a>
32
35
  </p>
33
- <h2 align="center">GlobalID support for Array, Hash, ActiveRecord::Relation, and more.</h2>
34
- <h3 align="center">Simple, standardized, secure marshaling for peace-of-mind object portability</h3>
36
+ <h2 align="center">URL-Safe Portability for any Ruby Object</h2>
35
37
  </p>
36
38
 
37
- Portability has never been easier.
38
- UniversalID simplifies marshaling by bringing [`GlobalID`'s](https://github.com/rails/globalid) powerful features to more Ruby objects...
39
- including unsaved ActiveModels. 🤯
39
+ **Universal ID introduces a paradigm shift in Ruby development with powerful recursive serialization.**
40
+ This innovative library transforms any Ruby object into a URL-safe string, enabling efficient encoding and seamless data transfer across process boundaries. By simplifying complex serialization tasks, Universal ID enhances both the developer and end-user experience, paving the way for a wide range of use cases—from state preservation in web apps to inter-service communication.
40
41
 
41
- ## Sponsors
42
+ It leverages both [MessagePack](https://msgpack.org/) and [Brotli](https://github.com/google/brotli) _(a combo built for speed and best-in-class data compression)_.
43
+ MessagePack + Brotli is up to 30% faster and within 2-5% compression rates compared to Protobuf. <a title="Source" href="https://g.co/bard/share/e5bdb17aee91">↗</a>
42
44
 
43
- <p align="center">
44
- <em>Proudly sponsored by</em>
45
- </p>
46
- <p align="center">
47
- <a href="https://www.clickfunnels.com?utm_source=hopsoft&utm_medium=open-source&utm_campaign=universalid">
48
- <img src="https://images.clickfunnel.com/uploads/digital_asset/file/176632/clickfunnels-dark-logo.svg" width="575" />
49
- </a>
50
- </p>
45
+ ## Example Use Cases
46
+
47
+ - **State Preservation in Web Apps**: Maintain the state of a user's session in web applications without storing data server-side
48
+ - **API Data Transfer**: Serialize complex data structures into a URI format for easy and efficient transfer via RESTful APIs
49
+ - **Bookmarkable Configurations**: Allow users to bookmark configurations of a web application by embedding the state in the URL
50
+ - **Deep Linking in Web Apps**: Create deep links that carry complex state information, allowing users to return to a specific state within an application
51
+ - **Debugging Tools**: Serialize objects and their state for logging purposes, aiding in debugging and error tracking
52
+ - **Shareable Reports and Views**: Encode the state of reports or customized views in web applications, making them shareable
53
+ - **Inter-Service Communication**: Facilitate communication between different services by passing complex objects in a standardized, URL-safe format
54
+ - **Client-Side Storage Optimization**: Reduce the need for client-side storage by keeping serialized state in URLs or Cookies
55
+ - **Versioning Serialized Objects**: Enable versioning of serialized objects in URLs, allowing users to access different states or versions of data
56
+ - **Data Export/Import**: Simplify the export and import process of complex objects between different environments or systems by using URI-encoded data
57
+
58
+ This is just a fraction of what's possible with Universal ID. It's an invaluable tool for a range of development needs. API design, data management, user experience, and more. **Endless possibilities!**
51
59
 
52
60
  <!-- Tocer[start]: Auto-generated, don't remove. -->
53
61
 
54
62
  ## Table of Contents
55
63
 
56
- - [What is Global ID?](#what-is-global-id)
57
- - [Global ID Examples](#global-id-examples)
58
- - [What is Universal ID?](#what-is-universal-id)
59
- - [Why Expand Global ID?](#why-expand-global-id)
60
- - [Summary of Benefits](#summary-of-benefits)
61
- - [GlobalID](#globalid)
62
- - [SignedGlobalID](#signedglobalid)
63
- - [Hash](#hash)
64
- - [ActiveModel](#activemodel)
65
- - [Benchmarks](#benchmarks)
64
+ - [Supported Data Types](#supported-data-types)
65
+ - [Scalars](#scalars)
66
+ - [Composites](#composites)
67
+ - [Contributed Types](#contributed-types)
68
+ - [ActiveRecord](#activerecord)
69
+ - [Why Universal ID with ActiveRecord?](#why-universal-id-with-activerecord)
70
+ - [Custom Datatypes](#custom-datatypes)
71
+ - [Settings and Prepack Options](#settings-and-prepack-options)
72
+ - [Advanced ActiveRecord](#advanced-activerecord)
73
+ - [ActiveRecord::Relation Support](#activerecordrelation-support)
74
+ - [SignedGlobalID](#signedglobalid)
75
+ - [Performance and Benchmarks](#performance-and-benchmarks)
76
+ - [Sponsors](#sponsors)
66
77
  - [License](#license)
67
78
 
68
79
  <!-- Tocer[finish]: Auto-generated, don't remove. -->
69
80
 
70
- ## What is Global ID?
81
+ > :rocket: **Ready to Dive In?**: All the code examples below can be tested on your local machine. Simply clone the repo and run `bin/console` to begin exploring. Don't forget to execute `bundle` first to ensure all dependencies are up to date. Happy coding!
82
+
83
+
84
+ ## Supported Data Types
85
+
86
+ ### Scalars
71
87
 
72
- A GlobalID is an URI that uniquely identifies a model instance.
73
- It was designed to make ActiveRecord models portable across process boundaries.
74
- For example, passing a model instance as an argument _(from the web server)_ to a background job.
75
- They also facilitate use-cases like interleaved search results that mix multiple classes into a single unified result.
88
+ Universal ID supports most Ruby primitives.
76
89
 
77
- GlobalIDs can also be [signed](https://github.com/rails/globalid#signed-global-ids) and dedicated to a
78
- [purpose](https://github.com/rails/globalid#signed-global-ids) with an [expiration](https://github.com/rails/globalid#signed-global-ids) policy.
90
+ - `NilClass`
91
+ - `Complex`
92
+ - `Date`
93
+ - `DateTime`
94
+ - `FalseClass`
95
+ - `Float`
96
+ - `Integer`
97
+ - `NilClass`
98
+ - `Range`
99
+ - `Rational`
100
+ - `Regexp`
101
+ - `String`
102
+ - `Symbol`
103
+ - `Time`
104
+ - `TrueClass`
79
105
 
80
- ### Global ID Examples
106
+ You can use Universal ID for individual primitives if desired, but scalar support is really the foundation for more serious use cases.
107
+ _See below..._
81
108
 
82
109
  ```ruby
83
- # Basic GlobalIDs
84
- campaign = Campaign.create(name: "Example")
85
- campaign.to_gid #............ #<GlobalID:0x000... @uri=#<URI::GID gid://UniversalID/Campaign/1>>
86
- campaign.to_gid.uri #........ #<URI::GID gid://UniversalID/Campaign/1>
87
- campaign.to_gid.to_s #....... gid://UniversalID/Campaign/1
88
- campaign.to_gid_param #...... Z2lkOi8vVW5pdmVyc2FsSUQvQ2FtcGFpZ24vMQ
89
-
90
- gid = GlobalID.parse("Z2lkOi8vVW5pdmVyc2FsSUQvQ2FtcGFpZ24vMQ") # #<GlobalID:0x000... @uri=#<URI::GID gid://UniversalID/Campaign/1>>
91
- campaign == gid.find # true
110
+ uri = URI::UID.build(:demo).to_s
111
+ #=> "uid://universal-id/iwKA1gBkZW1vAw"
112
+
113
+ uid = URI::UID.parse(uri)
114
+ #=> #<URI::UID uid://universal-id/iwKA1gBkZW1vAw>
115
+
116
+ uid.decode
117
+ #=> :demo
92
118
  ```
93
119
 
120
+ ### Composites
121
+
122
+ Composite support is where things start to get interesting. All of the composite datatypes listed below can be recursively transformed into a Universal ID.
123
+
124
+ <details>
125
+ <summary><b><code>[]</code> Array</b>... ▾</summary>
126
+ <p></p>
127
+
128
+ ```ruby
129
+ array = [1, 2, 3, [:a, :b, :c, [true]]]
130
+
131
+ uri = URI::UID.build(array).to_s
132
+ #=> "uid://universal-id/iweAlAECA5TUAGHUAGLUAGORwwM"
133
+
134
+ uid = URI::UID.parse(uri)
135
+ #=> #<URI::UID uid://universal-id/iweAlAECA5TUAGHUAGLUAGORwwM>
136
+
137
+ uid.decode
138
+ #=> [1, 2, 3, [:a, :b, :c, [true]]]
139
+ ```
140
+ </details>
141
+
142
+ <details>
143
+ <summary><b><code>{}</code> Hash</b>... ▾</summary>
144
+ <p></p>
145
+
146
+ ```ruby
147
+ hash = {a: 1, b: 2, c: 3, array: [1, 2, 3, [:a, :b, :c, [true]]]}
148
+
149
+ uri = URI::UID.build(hash).to_s
150
+ #=> "uid://universal-id/CxKAhNQAYQHUAGIC1ABjA8cFAGFycmF5lAEC..."
151
+
152
+ uid = URI::UID.parse(uri)
153
+ #=> #<URI::UID uid://universal-id/CxKAhNQAYQHUAGIC1ABjA8cFAGFycmF5lAECA5TUAGHUAGLUAGORwwM>
154
+
155
+ uid.decode
156
+ #=> {:a=>1, :b=>2, :c=>3, :array=>[1, 2, 3, [:a, :b, :c, [true]]]}
157
+ ```
158
+ </details>
159
+
160
+ <details>
161
+ <summary><b><code><></code> Open Struct</b>... ▾</summary>
162
+ <p></p>
163
+
164
+ ```ruby
165
+ ostruct = OpenStruct.new(
166
+ name: "Wireless Keyboard",
167
+ price: 49.99,
168
+ category: "Electronics",
169
+ in_stock: true
170
+ )
171
+
172
+ uri = URI::UID.build(ostruct).to_s
173
+ #=> "uid://universal-id/iyaAx0sMhNYAbmFtZbFXaXJlbGVzcyBLZXlib2FyZMcFAHByaWNly0BI_rhR64Uf1wBjYXRlZ29ye..."
174
+
175
+ uid = URI::UID.parse(uri)
176
+ #=> #<URI::UID scheme=uid, host=universal-id, payload=iyaAx0sMhNYAbmFtZbFXaXJlbGVzcyBLZXlib2FyZMcFAHByaWNly0BI_rhR64Uf1wBjYXRlZ29ye...>
177
+
178
+ uid.decode
179
+ #=> #<OpenStruct name="Wireless Keyboard", price=49.99, category="Electronics", in_stock=true>
180
+ ```
181
+ </details>
182
+
183
+ <details>
184
+ <summary><b><code>()</code> Set</b>... ▾</summary>
185
+ <p></p>
186
+
187
+ ```ruby
188
+ set = Set.new([1, 2, 3, [:a, :b, :c, [true]]])
189
+
190
+ uri = URI::UID.build(set).to_s
191
+ #=> "uid://universal-id/iwiA2AuUAQIDlNQAYdQAYtQAY5HDAw"
192
+
193
+ uid = URI::UID.parse(uri)
194
+ #=> #<URI::UID uid://universal-id/iwiA2AuUAQIDlNQAYdQAYtQAY5HDAw>
195
+
196
+ URI::UID.parse(uri).decode
197
+ #=> #<Set: {1, 2, 3, [:a, :b, :c, [true]]}>
198
+ ```
199
+ </details>
200
+
201
+ <details>
202
+ <summary><b><code><></code> Struct</b>... ▾</summary>
203
+ <p></p>
204
+
205
+ ```ruby
206
+ Book = Struct.new(:title, :author, :isbn, :published_year)
207
+ book = Book.new("The Great Gatsby", "F. Scott Fitzgerald", "9780743273565", 1925)
208
+
209
+ uri = URI::UID.build(book).to_s
210
+ #=> "uid://universal-id/G2YAoGTomv9N_4RV2oJRxRvZdC1wNJ0H3Ipu45kVcSrAxtg6Wjtogpi6GV1XXQAOAXoNR3BrCg9AQ..."
211
+
212
+ uid = URI::UID.parse(uri)
213
+ #=> #<URI::UID scheme=uid, host=universal-id, payload=G2YAoGTomv9N_4RV2oJRxRvZdC1wNJ0H3Ipu45kVcSrAxtg6Wjtogpi6GV1XXQAOAXoNR3BrCg9AQ...>
214
+
215
+ uid.decode
216
+ #=> #<struct Book title="The Great Gatsby", author="F. Scott Fitzgerald", isbn="9780743273565", published_year=1925>
217
+ ```
218
+ </details>
219
+
220
+ ### Contributed Types
221
+
222
+ Universal ID is designed to be highly extensible, allowing for third-party contributions to enhance its capabilities.
223
+ These contributions can introduce support for additional data types, further broadening the scope of Universal ID’s utility.
224
+ The following are some notable contrib extensions:
225
+
226
+ - **ActiveRecord::Base**: Integrates Universal ID with ActiveRecord base models, enabling intelligent serialization of database records
227
+ - **ActiveRecord::Relation**: Supports the serialization of ActiveRecord relations, making it possible to encode complex query structures
228
+ - **ActiveSupport::TimeWithZone**: Adds the ability to serialize ActiveSupport's TimeWithZone objects
229
+ - **GlobalID**: Extends support to include GlobalIDs
230
+ - **SignedGlobalID**: Extends support to include SignedGlobalIDs
231
+
232
+ #### Requiring Contributed Types
233
+
234
+ To utilize the contributed types, you must explicitly require them in your application.
235
+ This ensures the extensions are loaded and available for use.
236
+ Here is an example illustrating how to include contributed types:
237
+
94
238
  ```ruby
95
- # Signed GlobalIDs
96
- campaign = Campaign.create(name: "Example")
97
- campaign.to_sgid #......... #<SignedGlobalID:0x000... @expires_at=nil, @purpose="...", @uri=#<URI::GID gid://UniversalID/Campaign/1>, ...>
98
- campaign.to_sgid_param #... BAh7CEkiCGdpZAY6BkVUSSIhZ2lkOi8vVW...
239
+ # load contrib types
240
+ require "universal_id/contrib/active_record"
241
+ require "universal_id/contrib/active_support"
242
+ require "universal_id/contrib/global_id"
243
+ require "universal_id/contrib/signed_global_id"
99
244
 
100
- sgid = SignedGlobalID.parse("BAh7CEkiCGdpZAY6BkVUSSIhZ2lkOi8vVW...") # #<SignedGlobalID:0x000... @expires_at=nil, @purpose="...", @uri=#<URI::GID gid://UniversalID/Campaign/1>
101
- campaign == sgid.find # true
245
+ # or simply
246
+ require "universal_id/contrib/rails"
102
247
  ```
103
248
 
104
- ## What is Universal ID?
249
+ > :bulb: **Implicit Contribs**: Whenever the `Rails` constant is defined, the related contribs are auto-loaded.
105
250
 
106
- UniversalID extends GlobalID functionality to more objects.
251
+ ### ActiveRecord
107
252
 
108
- - Array
109
- - Hash
110
- - ActiveModel _(unsaved)_
111
- - ActiveRecord::Relation
112
- - etc.
253
+ > :information_source: **Broad Compatibility**: Universal ID has built-in support for ActiveRecord, yet it maintains independence from Rails-specific dependencies. This versatile design enables integration into **any Ruby project**.
113
254
 
114
- ### Why Expand Global ID?
255
+ #### Why Universal ID with ActiveRecord?
115
256
 
116
- A variety of additional use-cases can be handled easily _(with minimal code)_ by extending GlobalID to objects like Hash.
117
- Consider a multi-step form or wizard where users incrementally build up a complex set of related ActiveRecord instances.
257
+ While ActiveRecord already supports GlobalID, a robust library for serializing individual ActiveRecord models, Universal ID extends this functionality to cover a wider range of use cases. Here are a few reasons you may want to consider Universal ID.
118
258
 
119
- - When do we save to the database?
120
- - What about validations? Will they be a problem if we save early?
121
- - Should we persist the data in cache instead?
122
- - What if the user abandons the process?
123
- - How do we cleanup abandoned data?
124
- - Should we consider full-stack-frontend to manage state client side before saving? 😱
259
+ - **Support for New Records**: Unlike GlobalID, Universal ID can serialize models that haven't been saved to the database yet
260
+ - **Capturing Unsaved Changes**: It can serialize ActiveRecord models with unsaved changes, ensuring that even transient states are captured
261
+ - **Association Handling**: Universal ID goes beyond single models. It can serialize associated records, including those with unsaved changes, creating a comprehensive snapshot of complex object states
262
+ - **Cloning Existing Records**: Need to make a copy of a record, including its associations? Universal ID handles this effortlessly, making it ideal for duplicating complex datasets
263
+ - **Granular Data Control**: With Universal ID, you gain explicit control over the serialization process. You can precisely choose which columns to include or exclude, allowing for tailored, optimized payloads that fit your specific needs
264
+ - **Efficient Query Serialization**: Universal ID extends its capabilities to ActiveRecord relations, enabling the serialization of complex queries and scopes. This feature allows for seamless sharing of query logic between processes, ensuring consistency and reducing redundancy in data handling tasks.
125
265
 
126
- **Don't fret!** UniversalID supports safely marshaling unsaved ActiveModels between steps.
266
+ In summary, while GlobalID excels in its specific use case, Universal ID offers extended capabilities, particularly useful in scenarios involving unsaved records, complex associations, and data cloning.
127
267
 
128
- ```ruby
129
- # 1. Start multi-step form (partial data)
130
- campaign = Campaign.new(name: "Example") #....... unsaved data
131
- param = campaign.to_portable_hash_sgid_param #... make it portable (assign this to a hidden field, querystrig etc.)
268
+ <details>
269
+ <summary><b>How to Convert Records to UIDs</b>... ▾</summary>
270
+ <p></p>
132
271
 
133
- # HTTP request / crossing a process boundary / etc.
272
+ ```ruby
273
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
134
274
 
135
- # Step 2. Continue multi-step form (enrich partial data)
136
- campaign = Campaign.new_from_portable_hash(param) #................................... unsaved data
137
- campaign.emails << campaign.emails.build(subject: "First Email", body: "Welcome") #... unsaved data
138
- param = campaign.to_portable_hash_sgid_param #........................................ make it portable
275
+ ActiveRecord::Schema.define do
276
+ create_table :campaigns do |t|
277
+ t.column :name, :string
278
+ t.timestamps
279
+ end
280
+ end
139
281
 
140
- # HTTP request / crossing a process boundary / etc.
282
+ class Campaign < ApplicationRecord
283
+ end
141
284
 
142
- # Step 3-N. Continue multi-step form (enrich partial data)
143
- # ...
285
+ # ---
144
286
 
145
- # Final Step. Save the data
146
- campaign = Campaign.new_from_portable_hash(param)
147
- campaign.save!
148
- ```
287
+ campaign = Campaign.create(name: "Marketing Campaign")
149
288
 
150
- **And... this is just one use-case!**
289
+ uri = URI::UID.build(campaign).to_s
290
+ #=> "uid://universal-id/CwiAxw4EqENhbXBhaWdugaJpZAMD"
151
291
 
152
- UniversalID can be used to solve a multitude of problems with minimal effort.
292
+ uid = URI::UID.parse(uri)
293
+ #=> #<URI::UID uid://universal-id/CwiAxw4EqENhbXBhaWdugaJpZAMD>
153
294
 
154
- - Sharable artifacts (reports, configurations, etc.)
155
- - Data versioning
156
- - Digital products
157
- - etc. *the only limit is your imagination*
295
+ URI::UID.parse(uri).decode
296
+ ##<Campaign:0x000000011cc67da8 id: 1, name: "Marketing Campaign", ...>
297
+ ```
298
+ </details>
158
299
 
159
- ### Summary of Benefits
300
+ ### Custom Datatypes
160
301
 
161
- #### GlobalID
302
+ Universal ID is **extensible** so you can register your own datatypes with specialized serialization rules.
303
+ It couldn't be simpler. Just convert the required data to a Ruby scalar or composite value.
162
304
 
163
- - Standardizes marshaling _(fewer bespoke solutions)_
164
- - Reduces complexity and lines-of-code
165
- - Simplifies derivative works _(works with existing data-models/object-structures)_
166
- - Encapsulates portability across processes and systems _(self-contained)_
167
- - Creates opportunity for generic solutions _(meta-programming)_
168
- - Minimizes data storage needs for incomplete or ephemeral data _(URL safe string)_
169
- - Facilitates easy rollback when incomplete or ephemeral data is abandoned
305
+ <details>
306
+ <summary><b>How to Register your own Datatype</b>... ▾</summary>
307
+ <p></p>
170
308
 
171
- #### SignedGlobalID
309
+ ```ruby
310
+ class UserSettings
311
+ attr_accessor :user_id, :preferences
172
312
 
173
- - Enhances security _(can't be tampered with, prevents MITM attacks, etc.)_
174
- - Provides scoping for a specific purpose _(via `for`)_
175
- - Supports versioning _(via purpose)_
176
- - Includes scarcity _(via expiration)_
177
- - Enables productization _(an SGID string is a digital "product")_
313
+ def initialize(user_id, preferences = {})
314
+ @user_id = user_id
315
+ @preferences = preferences
316
+ end
317
+ end
178
318
 
179
- ### Hash
319
+ UniversalID::MessagePackFactory.register(
320
+ type: UserSettings,
321
+ packer: ->(user_preferences, packer) do
322
+ packer.write user_preferences.user_id
323
+ packer.write user_preferences.preferences
324
+ end,
325
+ unpacker: ->(unpacker) do
326
+ user_id = unpacker.read
327
+ preferences = unpacker.read
328
+ UserSettings.new user_id, preferences
329
+ end
330
+ )
180
331
 
181
- ```ruby
182
- hash = {name: "Example", list: [1,2,3], object: {nested: true}}
183
- portable = UniversalID::PortableHash.new(hash)
184
- gid_param = portable.to_gid_param #..... Z2lkOi8vVW5pdmVyc2FsSUQvVW5pdmVyc2FsSUQ6OlBvcnRhYmxlSGFzaC9lTnFyVnNwTHpFMVZzbEp5clVqTUxj...
185
- sgid_param = portable.to_sgid_param #... BAh7CEkiCGdpZAY6BkVUSSIBg2dpZDovL1VuaXZlcnNhbElEL1VuaXZlcnNhbElEOjpQb3J0YWJsZUhhc2gvZU5x...
332
+ settings = UserSettings.new(1,
333
+ theme: "dark",
334
+ notifications: "email",
335
+ language: "en",
336
+ layout: "grid",
337
+ privacy: "private"
338
+ )
186
339
 
340
+ uri = URI::UID.build(settings).to_s
341
+ #=> "uid://universal-id/G1QAQAT-bfcGW1QOgadJwJF06yL8gDnGgfs1Xdti20TDDvG5STPqzbYcQ6TBqVKhdZ39CdQZUwEGe..."
187
342
 
188
- UniversalID::PortableHash.parse_gid(gid_param).find
189
- {"name"=>"Example", "list"=>[1, 2, 3], "object"=>{"nested"=>true}}
343
+ uid = URI::UID.parse(uri)
344
+ #=> #<URI::UID uid://universal-id/G1QAQAT-bfcGW1QOgadJwJF06yL8gDnGgfs1Xdti20TDDvG5STPqzbYcQ6TBqVKhdZ39CdQZUwEGe..."
190
345
 
346
+ uid.decode
347
+ => #<UserSettings:0x0000000139157dd8 @preferences={:theme=>"dark", :notifications=>"email", :language=>"en", :layout=>"grid", :privacy=>"private"}, @user_id=1>
348
+ ```
349
+ </details>
191
350
 
192
- UniversalID::PortableHash.parse_gid(sgid_param).find
193
- {"name"=>"Example", "list"=>[1, 2, 3], "object"=>{"nested"=>true}}
194
- ```
351
+ ## Settings and Prepack Options
195
352
 
196
- ### ActiveModel
353
+ Universal ID supports a small but powerful set of configuration options for transforming objects before being
354
+ handed off to MessagePack for serialization.
197
355
 
198
- ```ruby
199
- email = Email.new(subject: "Example", body: "Hi there...") # unsaved
200
- gid = email.to_portable_hash_gid_param #..... Z2lkOi8vVW5pdmVyc2FsSUQvVW5pdmVyc2FsSUQ6OlBvcnRhYmxlSGFzaC9lTnFyVmlvdVRjcEtUUz...
201
- sgid = email.to_portable_hash_sgid_param #... BAh7CEkiCGdpZAY6BkVUSSJzZ2lkOi8vVW5pdmVyc2FsSUQvVW5pdmVyc2FsSUQ6OlBvcnRhYmxlSG...
356
+ Prepacking gives you explicit control over what data to include in the Universal ID.
202
357
 
203
- copy = Email.new_from_portable_hash(gid)
204
- signed_copy = Email.new_from_portable_hash(sgid)
205
358
 
206
- email.save!
359
+ <details>
360
+ <summary><b>View All Settings and Prepack Options</b>... ▾</summary>
361
+ <p></p>
207
362
 
208
- options = {portable_hash_options: {except: [:id, :created_at, :updated_at]}}
209
- # NOTE: Options can be configured globally via UniversalID.config.portable_hash
363
+ ```yml
364
+ prepack:
365
+ # ..........................................................................................................
366
+ # A list of attributes to exclude (for objects like Hash, OpenStruct, Struct, etc.)
367
+ # Takes prescedence over the`include` list
368
+ exclude: []
210
369
 
211
- gid = email.to_portable_hash_gid_param(options)
212
- sgid = email.to_portable_hash_gid_param(options)
370
+ # ..........................................................................................................
371
+ # A list of attributes to include (for objects like Hash, OpenStruct, Struct, etc.)
372
+ include: []
213
373
 
214
- # Copies are new records and don't include values for id, created_at, or updated_at
215
- copy = Email.new_from_portable_hash(gid)
216
- signed_copy = Email.new_from_portable_hash(sgid)
217
- ```
374
+ # ..........................................................................................................
375
+ # Whether or not to omit blank values when packing (nil, {}, [], "", etc.)
376
+ include_blank: true
218
377
 
219
- ### Benchmarks
378
+ # ==========================================================================================================
379
+ # Database records
380
+ database:
381
+ # ......................................................................................................
382
+ # Whether or not to include primary/foreign keys
383
+ # Setting this to `false` can be used to make a copy of an existing record
384
+ include_keys: true
220
385
 
221
- ```
222
- # Simple Campaign with 3 associated Email records (nested attributes)
223
- ==================================================================================================
224
- Benchmarking 10000 iterations
225
- ==================================================================================================
226
- user system total real
227
- PortableHash.new 0.194428 0.000358 0.194786 ( 0.194788)
228
- Average 0.000019 0.000000 0.000019 ( 0.000019)
229
- ..................................................................................................
230
- user system total real
231
- PortableHash.new w/ options 0.191249 0.000421 0.191670 ( 0.191677)
232
- Average 0.000019 0.000000 0.000019 ( 0.000019)
233
- ..................................................................................................
234
- user system total real
235
- PortableHash.find 0.061181 0.002219 0.063400 ( 0.063401)
236
- Average 0.000006 0.000000 0.000006 ( 0.000006)
237
- ..................................................................................................
238
- user system total real
239
- PortableHash#id 0.342809 0.001674 0.344483 ( 0.344494)
240
- Average 0.000034 0.000000 0.000034 ( 0.000034)
241
- ..................................................................................................
242
- user system total real
243
- PortableHash#to_gid 0.422914 0.001586 0.424500 ( 0.424498)
244
- Average 0.000042 0.000000 0.000042 ( 0.000042)
245
- ..................................................................................................
246
- user system total real
247
- PortableHash#to_gid_param 0.437669 0.001826 0.439495 ( 0.439509)
248
- Average 0.000044 0.000000 0.000044 ( 0.000044)
249
- ..................................................................................................
250
- user system total real
251
- PortableHash#to_sgid 0.430941 0.002055 0.432996 ( 0.433020)
252
- Average 0.000043 0.000000 0.000043 ( 0.000043)
253
- ..................................................................................................
254
- user system total real
255
- PortableHash#to_sgid_param 0.492965 0.001968 0.494933 ( 0.494978)
256
- Average 0.000049 0.000000 0.000049 ( 0.000049)
257
- ..................................................................................................
258
- user system total real
259
- ActiveModelSerializer.new_from_portable_hash 3.009667 0.006939 3.016606 ( 3.017388)
260
- Average 0.000301 0.000001 0.000302 ( 0.000302)
261
- ..................................................................................................
262
- user system total real
263
- ActiveModelSerializer.new_from_portable_hash (signed) 3.189282 0.006650 3.195932 ( 3.196076)
264
- Average 0.000319 0.000001 0.000320 ( 0.000320)
265
- ..................................................................................................
266
- user system total real
267
- ActiveModelSerializer#to_portable_hash_gid_param 0.923773 0.002653 0.926426 ( 0.926427)
268
- Average 0.000092 0.000000 0.000093 ( 0.000093)
269
- ..................................................................................................
270
- user system total real
271
- ActiveModelSerializer#to_portable_hash_sgid_param 0.991336 0.002782 0.994118 ( 0.994133)
272
- Average 0.000099 0.000000 0.000099 ( 0.000099)
273
- ..................................................................................................
274
- ```
386
+ # ......................................................................................................
387
+ # Whether or not to include date/time timestamps (created_at, updated_at, etc.)
388
+ # Setting this to `false` can be used to make a copy of an existing record
389
+ include_timestamps: true
390
+
391
+ # ......................................................................................................
392
+ # Whether or not to include unsaved changes
393
+ # Assign to `true` when packing new records
394
+ include_unsaved_changes: false
395
+
396
+ # ......................................................................................................
397
+ # Whether or not to include loaded in-memory descendants (i.e. child associations)
398
+ include_descendants: false
399
+
400
+ # ......................................................................................................
401
+ # The max depth (number) of loaded in-memory descendants to include when `include_descendants == true`
402
+ # For example, a value of (3) would include the following:
403
+ # Parent > Child > Grandchild
404
+ descendant_depth: 0
405
+ ```
406
+ </details>
407
+
408
+ Prepack options can be applied when creating a Universal ID and can be passed in structured or flat format.
409
+
410
+ <details>
411
+ <summary><b>How to Apply Prepack Options when Creating UIDs</b>... ▾</summary>
412
+ <p></p>
413
+
414
+ ```ruby
415
+ person = {
416
+ full_name: "Jane Doe",
417
+ email: "janedoe@example.com",
418
+ birthdate: "1980-05-15",
419
+ phone_number: "555-6789",
420
+ ssn: "123-45-6789",
421
+ children: [
422
+ {
423
+ full_name: "Alice Doe",
424
+ email: "alicedoe@example.com",
425
+ birthdate: nil,
426
+ phone_number: "555-1234",
427
+ ssn: "987-65-4321"
428
+ },
429
+ {
430
+ full_name: "Bob Doe",
431
+ email: "bobdoe@example.com",
432
+ birthdate: "2008-11-21",
433
+ phone_number: nil,
434
+ ssn: "456-12-1234"
435
+ }
436
+ ]
437
+ }
438
+
439
+ uid = URI::UID.build(person, include_blank: false, exclude: [:phone_number, :ssn])
440
+ uid.decode
441
+
442
+ # Note that the decoded payload is smaller due to the prepack options
443
+ # Also note that the options were applied recursively
444
+
445
+ {
446
+ full_name: "Jane Doe",
447
+ email: "janedoe@example.com",
448
+ birthdate: "1980-05-15",
449
+ children: [
450
+ {
451
+ full_name: "Alice Doe",
452
+ email: "alicedoe@example.com"
453
+ },
454
+ {
455
+ full_name: "Bob Doe",
456
+ email: "bobdoe@example.com",
457
+ birthdate: "2008-11-21"
458
+ }
459
+ ]
460
+ }
461
+ ```
462
+ </details>
463
+
464
+ It's also possible to register frequently used options as reusable settings to further simplify creating UIDs.
465
+
466
+ <details>
467
+ <summary><b>How to Register Prepack Options as Preconfigured Settings</b>... ▾</summary>
468
+ <p></p>
469
+
470
+ ```yaml
471
+ # app/config/unsaved.yml
472
+ prepack:
473
+ include_blank: false
474
+
475
+ database:
476
+ include_unsaved_changes: true
477
+ include_timestamps: false
478
+ ```
479
+
480
+ ```ruby
481
+ UniversalID::Settings.register :unsaved, YAML.safe_load("app/config/unsaved.yml")
482
+ URI::UID.build @record, UniversalID::Settings[:small_record]
483
+ ```
484
+ </details>
485
+
486
+ ## Advanced ActiveRecord
487
+
488
+ Universal ID includes some advanced capabilities when used with ActiveRecord.
489
+
490
+ - [x] **Include loaded associations**
491
+ Universal ID supports including `loaded` associations when a model is transformed into a UID.
492
+ <small><em>Note that associations must be `loaded?` to be considered candidates for inclusion. There are multiple ways to achieve this, so be sure to [read up on associations](https://guides.rubyonrails.org/association_basics.html).</em></small>
493
+
494
+ - [x] **Include unsaved changes**
495
+ Universal ID supports capturing unsaved change, for both new and persisted records, when a model is transformed into a UID.
496
+ <small><em>This allows you to marshal complex unsaved data that can be restored at a later time. This feature supports several use cases, like allowing users to pause their work and resume at any point in the future without the need to store partial records in your database. And, because UIDs are web safe, you can hold this data in URLs, browser Cookies, Local/SessionStorage, etc.</em></small>
497
+
498
+ - [x] **Exclude keys** to make copies of existing records
499
+ Universal ID supports making copies of individual records or entire collections by opt'ing to exclude keys when transorming to UID.
500
+ <small><em>This allows you to make data sharable. Consider a sencario with complex infrastructure (db sharding, etc.). You can leverage Universal ID to move entire subsets of data across physical data stores.</em></small>
501
+
502
+ First, let's establish the schema structure and data we'll be working with.
503
+ We'll limit ourselves to 3 tables here, but Universal ID can support much more complex data models.
504
+
505
+ - Campaign
506
+ - Email
507
+ - Attachment
508
+
509
+ We'll use 1 campaign with 3 emails that have 2 attachments each.
510
+
511
+ <details>
512
+ <summary><b>Setup the Schema</b>... ▾</summary>
513
+ <p></p>
514
+
515
+ ```ruby
516
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
517
+
518
+ ActiveRecord::Schema.define do
519
+ create_table :campaigns do |t|
520
+ t.column :name, :string
521
+ t.column :description, :text
522
+ t.column :trigger, :string
523
+ t.timestamps
524
+ end
525
+
526
+ create_table :emails do |t|
527
+ t.column :campaign_id, :integer
528
+ t.column :subject, :string
529
+ t.column :body, :text
530
+ t.column :wait, :integer
531
+ t.timestamps
532
+ end
533
+
534
+ create_table :attachments do |t|
535
+ t.column :email_id, :integer
536
+ t.column :file_name, :string
537
+ t.column :content_type, :string
538
+ t.column :file_size, :integer
539
+ t.column :file_data, :binary
540
+ t.timestamps
541
+ end
542
+ end
543
+ ```
544
+ </details>
545
+
546
+ <details>
547
+ <summary><b>Setup the Models</b>... ▾</summary>
548
+ <p></p>
549
+
550
+ ```ruby
551
+ class Campaign < ApplicationRecord
552
+ has_many :emails, dependent: :destroy
553
+ end
554
+
555
+ class Email < ApplicationRecord
556
+ belongs_to :campaign
557
+ has_many :attachments, dependent: :destroy
558
+ end
559
+
560
+ class Attachment < ApplicationRecord
561
+ belongs_to :email
562
+ end
563
+ ```
564
+ </details>
565
+
566
+
567
+ <details>
568
+ <summary><b>Setup the Model Instances</b>... ▾</summary>
569
+ <p></p>
570
+
571
+ ```ruby
572
+ campaign = Campaign.new(
573
+ name: "Summer Sale Campaign",
574
+ description: "A campaign for the summer sale, targeting our loyal customers.",
575
+ trigger: "SummerStart"
576
+ )
577
+
578
+ # NOTE: Assigning campaign.emails via `=` to ensure ActiveRecord flags the association as `loaded`
579
+ campaign.emails = 3.times.map do |i|
580
+ email = campaign.emails.build(
581
+ subject: "Summer Sale Special Offer #{i + 1}",
582
+ body: "Dear Customer, check out our exclusive summer sale offers! #{i + 1}",
583
+ wait: rand(1..14)
584
+ )
585
+
586
+ # NOTE: Assigning email.attachments via `=` to ensure ActiveRecord flags the association as `loaded`
587
+ email.tap do |e|
588
+ e.attachments = 2.times.map do |j|
589
+ data = SecureRandom.random_bytes(rand(500..1500))
590
+ e.attachments.build(
591
+ file_name: "summer_sale_#{i + 1}_attachment_#{j + 1}.pdf",
592
+ content_type: "application/pdf",
593
+ file_size: data.size,
594
+ file_data: data
595
+ )
596
+ end
597
+ end
598
+ end
599
+
600
+ # demonstrate that we have new unsaved records
601
+
602
+ #campaign
603
+ campaign.new_record? # true
604
+ campaign.changed? # true
605
+
606
+ # emails
607
+ campaign.emails.each do |email|
608
+ email.new_record? # true
609
+ email.changed? # true
610
+
611
+ email.attachments.each do |attachment|
612
+ attachment.new_record? # true
613
+ attachment.changed? # true
614
+ end
615
+ end
616
+ ```
617
+ </details>
618
+
619
+ Now let's look at how to leverage Universal ID with ActiveRecord.
620
+
621
+ <details>
622
+ <summary><b>How to Include Unsaved Changes for New Records</b>... ▾</summary>
623
+ <p></p>
624
+
625
+ ```ruby
626
+ # prepack options
627
+ options = {
628
+ include_unsaved_changes: true,
629
+ include_descendants: true,
630
+ descendant_depth: 2
631
+ }
632
+
633
+ # NOTE: The campaign model instance was setup earlier in the "Model Instances" section above
634
+ campaign.new_record? # true
635
+ campaign.changes
636
+ # {
637
+ # "name"=>[nil, "Summer Sale Campaign"],
638
+ # "description"=>[nil, "A campaign for the summer sale, targeting our loyal customers."],
639
+ # "trigger"=>[nil, "SummerStart"]
640
+ # }
641
+
642
+ campaign.emails.each do |email|
643
+ email.new_record? # true
644
+ email.changes
645
+ # {
646
+ # "subject"=>[nil, "Summer Sale Special Offer ..."],
647
+ # "body"=>[nil, "Dear Customer, check out our exclusive summer sale offers! ..."],
648
+ # "wait"=>[nil, ...]
649
+ # }
650
+
651
+ email.attachments.each do |attachment|
652
+ attachment.new_record? # true
653
+ attachment.changes
654
+ # {
655
+ # "file_name"=>[nil, "summer_sale_..._attachment_....pdf"],
656
+ # "content_type"=>[nil, "application/pdf"],
657
+ # "file_size"=>[nil, ...],
658
+ # "file_data"=>[nil, "..."]
659
+ # }
660
+ end
661
+ end
662
+
663
+ encoded = URI::UID.build(campaign, options).to_s
664
+ restored = URI::UID.parse(encoded).decode
665
+
666
+ restored.new_record? # true
667
+ restored.changes
668
+ # {
669
+ # "name"=>[nil, "Summer Sale Campaign"],
670
+ # "description"=>[nil, "A campaign for the summer sale, targeting our loyal customers."],
671
+ # "trigger"=>[nil, "SummerStart"]
672
+ # }
673
+
674
+ restored.emails.each do |email|
675
+ email.new_record? # true
676
+ email.changes
677
+ # {
678
+ # "subject"=>[nil, "Summer Sale Special Offer ..."],
679
+ # "body"=>[nil, "Dear Customer, check out our exclusive summer sale offers! ..."],
680
+ # "wait"=>[nil, ...]
681
+ # }
682
+
683
+ email.attachments.each do |attachment|
684
+ attachment.new_record? # true
685
+ attachment.changes
686
+ # {
687
+ # "file_name"=>[nil, "summer_sale_..._attachment_....pdf"],
688
+ # "content_type"=>[nil, "application/pdf"],
689
+ # "file_size"=>[nil, ...],
690
+ # "file_data"=>[nil, "..."]
691
+ # }
692
+ end
693
+ end
694
+ ```
695
+ </details>
696
+
697
+ <details>
698
+ <summary><b>How to Include Unsaved Changes for Persisted Records</b>... ▾</summary>
699
+ <p></p>
700
+
701
+ ```ruby
702
+ # NOTE: The campaign model instance was setup earlier in the "Model Instances" section above
703
+ # persist the model and its associations
704
+ campaign.save!
705
+
706
+ # make some unsaved changes to the records
707
+ campaign.name = "Changed Name #{SecureRandom.hex}"
708
+ campaign.emails.each do |email|
709
+ email.subject = "Changed Subject #{SecureRandom.hex}"
710
+ email.attachments.each do |attachment|
711
+ attachment.file_name = "changed_file_name#{SecureRandom.hex}.pdf"
712
+ end
713
+ end
714
+
715
+ campaign.persisted? # true
716
+ campaign.changes
717
+ # {"name"=>["Summer Sale Campaign", "Changed Name ..."]}
718
+
719
+ campaign.emails.each do |email|
720
+ email.persisted? # true
721
+ email.changes
722
+ # {"subject"=>["Summer Sale Special Offer 1", "Changed Subject ..."]}
723
+
724
+ email.attachments.each do |attachment|
725
+ attachment.persisted? # true
726
+ attachment.changes
727
+ # {"file_name"=>["summer_sale_..._attachment_....pdf", "changed_file_name....pdf"]}
728
+ end
729
+ end
730
+
731
+ # prepack options
732
+ options = {
733
+ include_unsaved_changes: true,
734
+ include_descendants: true,
735
+ descendant_depth: 2
736
+ }
737
+
738
+ encoded = URI::UID.build(campaign, options).to_s
739
+ restored = URI::UID.parse(encoded).decode
740
+
741
+ restored.persisted? # true
742
+ restored.changes
743
+ # {"name"=>["Summer Sale Campaign", "Changed Name ..."]}
744
+
745
+ restored.emails.each do |email|
746
+ email.persisted? # true
747
+ email.changes
748
+ # {"subject"=>["Summer Sale Special Offer 1", "Changed Subject ..."]}
749
+
750
+ email.attachments.each do |attachment|
751
+ attachment.persisted? # true
752
+ attachment.changes
753
+ # {"file_name"=>["summer_sale_..._attachment_....pdf", "changed_file_name....pdf"]}
754
+ end
755
+ end
756
+ ```
757
+ </details>
758
+
759
+ <details>
760
+ <summary><b>How to Copy Persisted Records</b>... ▾</summary>
761
+ <p></p>
762
+
763
+ ```ruby
764
+ # NOTE: The campaign model instance was setup earlier in the "Model Instances" section above
765
+ # persist the model and its associations
766
+ campaign.save!
767
+
768
+ options = {
769
+ include_keys: false,
770
+ include_timestamps: false,
771
+ include_unsaved_changes: true,
772
+ include_descendants: true,
773
+ descendant_depth: 2
774
+ }
775
+
776
+ encoded = URI::UID.build(campaign, options).to_s
777
+ copy = URI::UID.parse(encoded).decode
778
+
779
+ campaign.persisted? # false
780
+ copy.new_record? # true
781
+ copy.save!
782
+
783
+ copy == campaign # false
784
+
785
+ campaign.emails.each do |email|
786
+ copy_email_ids = copy.emails.map(&:id)
787
+ campaign_email_ids = campaign.emails.map(&:id)
788
+ (copy_email_ids && campaign_email_ids).any? # false
789
+
790
+ copy_attachment_ids = copy.emails.map(&:attachments).flatten.map(&:id)
791
+ campaign_attachment_ids = campaign.emails.map(&:attachments).flatten.map(&:id)
792
+ (copy_attachment_ids & campaign_attachment_ids).any? # false
793
+ end
794
+ ```
795
+ </details>
796
+
797
+ ## ActiveRecord::Relation Support
798
+
799
+ Universal ID seamlessly handles the serialization of ActiveRecord relations and scopes, striking the perfect balance between efficiency and functionality. It paves the way for easy, optimized, and effective sharing of database queries. This capability transforms query management, allowing developers to encapsulate complex query structures into a reliable, portable, and reusable format that ensures query consistency across different parts of the application.
800
+
801
+ > :bulb: **Optimized Payloads**: When handling `ActiveRecord::Relations`, Universal ID intelligently clears cached data within the relation before encoding. This approach minimizes payload size, ensuring efficient data transfer without sacrificing the integrity of the original query logic.
802
+
803
+ <details>
804
+ <summary><b>How to work with ActiveRecord::Relations</b>... ▾</summary>
805
+ <p></p>
806
+
807
+ ```ruby
808
+ # Assuming we have multiple campaigns already stored in the database
809
+ relation = Campaign.joins(:emails).where("emails.subject LIKE ?", "Flash Sale%")
810
+
811
+ # force load the relation
812
+ relation.load
813
+ relation.loaded? # true
814
+
815
+ encoded = URI::UID.build(relation).to_s
816
+ decoded = URI::UID.parse(encoded).decode
817
+
818
+ decoded.is_a? ActiveRecord::Relation # true
819
+ decoded.loaded? # false
820
+ decoded == relation # true
821
+ decoded.size == relation.size # true
822
+ decoded.to_a == relation.to_a # true
823
+ ```
824
+ </details>
825
+
826
+ ## SignedGlobalID
827
+
828
+ Features like `signing` _(to prevent tampering)_, `purpose`, and `expiration` are provided by SignedGlobalIDs.
829
+ These features _(and more)_ will eventually be added to UniversalID, but until then...
830
+ simply convert your UniversalID to a SignedGlobalID to add these features to any Universal ID.
831
+
832
+ <details>
833
+ <summary><b>How to Convert a UID to/from a SignedGlobalID</b>... ▾</summary>
834
+ <p></p>
835
+
836
+ ```ruby
837
+ product = {
838
+ name: "Wireless Bluetooth Headphones",
839
+ price: 79.99,
840
+ category: "Electronics"
841
+ }
842
+
843
+ uid = URI::UID.build(product)
844
+ #=> #<URI::UID scheme="uid", host="universal-id", path="/G0sAgBypU587HsjkLpEnGHiaWfPQEyiiuH6j...">
845
+
846
+ sgid = uid.to_sgid_param(for: "cart-123", expires_in: 1.hour)
847
+ #=> "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaEpJZ0d4WjJsa09pOHZkVzVwZG1WeWMyRnNMV2xrTDFWU1NUbzZWVWxFT2pwSGJHOWlZV3hKUkZKbFkyOXlaQzlITUhO..."
848
+
849
+ URI::UID.from_sgid(sgid, for: "cart-123").decode
850
+ #=> {
851
+ # name: "Wireless Bluetooth Headphones",
852
+ # price: 79.99,
853
+ # category: "Electronics"
854
+ # }
855
+
856
+
857
+ # mismatched purpose returns nil... as expected
858
+ URI::UID.from_sgid(sgid, for: "mismatch")
859
+ #=> nil
860
+ ```
861
+ </details>
862
+
863
+ ## Performance and Benchmarks
864
+
865
+ <details>
866
+ <summary><b>View Benchmarks</b>... ▾</summary>
867
+ <p></p>
868
+
869
+ Benchmarks can be performed by cloning the project and running `bin/bench`.
870
+ The run below was performed on the following hardware.
871
+
872
+ ```
873
+ Model Name: MacBook Air
874
+ Model Identifier: MacBookAir10,1
875
+ Chip: Apple M1
876
+ Total Number of Cores: 8 (4 performance and 4 efficiency)
877
+ Memory: 16 GB
878
+ ```
879
+
880
+ ```
881
+ Benchmarking with the following ActiveRecord/Hash data...
882
+ ==================================================================================================
883
+ {
884
+ "id" => 1,
885
+ "name" => "Production",
886
+ "description" => "RISC is good Well then get your shit together. Get it all together and put it in a backpack, all your shit, so it's together. ...and if you gotta take it somewhere, take it somewhere ya know? Take it to the shit store and sell it, or put it in a shit museum. I don't care what you do, you just gotta get it together... Get your shit together. Mark it zero! What you do not smell is called Iocane Power. You wanna hear a lie? ... I...think you're great. You're my best friend.",
887
+ "trigger" => "Political Organization enhance web-enabled architectures",
888
+ "created_at" => "2023-11-11T01:28:46.657Z",
889
+ "updated_at" => "2023-11-11T01:28:46.657Z",
890
+ "emails" => [
891
+ [0] {
892
+ "id" => 1,
893
+ "campaign_id" => 1,
894
+ "subject" => "drive synergistic web-readiness",
895
+ "body" => "But first things first. To the death! I feel like all my kids grew up, and then they married each other. It's every parents' dream. Koona t'chuta Solo? (Going somewhere Solo?)",
896
+ "wait" => nil,
897
+ "created_at" => "2023-11-11T01:28:46.661Z",
898
+ "updated_at" => "2023-11-11T01:28:46.675Z",
899
+ "attachments" => [
900
+ [0] {
901
+ "id" => 1,
902
+ "email_id" => 1,
903
+ "file_name" => "Schneider and Sons",
904
+ "content_type" => "Enterprise-wide 4th generation complexity",
905
+ "file_size" => nil,
906
+ "file_data" => nil,
907
+ "created_at" => "2023-11-11T01:28:46.664Z",
908
+ "updated_at" => "2023-11-11T01:28:46.670Z"
909
+ },
910
+ [1] {
911
+ "id" => 2,
912
+ "email_id" => 1,
913
+ "file_name" => "Devolved solution-oriented circuit",
914
+ "content_type" => "revolutionize magnetic bandwidth Intelligent Paper Gloves",
915
+ "file_size" => nil,
916
+ "file_data" => nil,
917
+ "created_at" => "2023-11-11T01:28:46.664Z",
918
+ "updated_at" => "2023-11-11T01:28:46.670Z"
919
+ }
920
+ ]
921
+ },
922
+ [1] {
923
+ "id" => 2,
924
+ "campaign_id" => 1,
925
+ "subject" => "Marketing",
926
+ "body" => "I'll explain and I'll use small words so that you'll be sure to understand, you warthog faced buffoon. Well then get your shit together. Get it all together and put it in a backpack, all your shit, so it's together. ...and if you gotta take it somewhere, take it somewhere ya know? Take it to the shit store and sell it, or put it in a shit museum. I don't care what you do, you just gotta get it together... Get your shit together. I am running away from my responsibilities. And it feels good.",
927
+ "wait" => nil,
928
+ "created_at" => "2023-11-11T01:28:46.671Z",
929
+ "updated_at" => "2023-11-11T01:28:46.675Z",
930
+ "attachments" => [
931
+ [0] {
932
+ "id" => 3,
933
+ "email_id" => 2,
934
+ "file_name" => "Weber-Schulist benchmark open-source applications",
935
+ "content_type" => "Enormous Linen Shoes synthesize customized e-services",
936
+ "file_size" => nil,
937
+ "file_data" => nil,
938
+ "created_at" => "2023-11-11T01:28:46.672Z",
939
+ "updated_at" => "2023-11-11T01:28:46.672Z"
940
+ },
941
+ [1] {
942
+ "id" => 4,
943
+ "email_id" => 2,
944
+ "file_name" => "thought leadership",
945
+ "content_type" => "Business Development Enhanced logistical collaboration",
946
+ "file_size" => nil,
947
+ "file_data" => nil,
948
+ "created_at" => "2023-11-11T01:28:46.672Z",
949
+ "updated_at" => "2023-11-11T01:28:46.672Z"
950
+ }
951
+ ]
952
+ },
953
+ [2] {
954
+ "id" => 3,
955
+ "campaign_id" => 1,
956
+ "subject" => "Mediocre Aluminum Car",
957
+ "body" => "Don’t even trip dawg. Stay away from my special lady friend, man.",
958
+ "wait" => nil,
959
+ "created_at" => "2023-11-11T01:28:46.672Z",
960
+ "updated_at" => "2023-11-11T01:28:46.675Z",
961
+ "attachments" => [
962
+ [0] {
963
+ "id" => 5,
964
+ "email_id" => 3,
965
+ "file_name" => "Import and Export",
966
+ "content_type" => "Heavy Duty Paper Bench Project Management",
967
+ "file_size" => nil,
968
+ "file_data" => nil,
969
+ "created_at" => "2023-11-11T01:28:46.673Z",
970
+ "updated_at" => "2023-11-11T01:28:46.674Z"
971
+ },
972
+ [1] {
973
+ "id" => 6,
974
+ "email_id" => 3,
975
+ "file_name" => "synthesize ubiquitous architectures Corporate Communications",
976
+ "content_type" => "Durable Rubber Watch",
977
+ "file_size" => nil,
978
+ "file_data" => nil,
979
+ "created_at" => "2023-11-11T01:28:46.674Z",
980
+ "updated_at" => "2023-11-11T01:28:46.674Z"
981
+ }
982
+ ]
983
+ }
984
+ ]
985
+ }
986
+ ==================================================================================================
987
+ Benchmarking 5000 iterations
988
+ ==================================================================================================
989
+ user system total real
990
+ URI::UID.build Hash 14.770667 0.102535 14.873202 ( 14.898856)
991
+ Average 0.002954 0.000021 0.002975 ( 0.002980)
992
+ ..................................................................................................
993
+ user system total real
994
+ URI::UID.build Hash, include_blank: false 13.821420 0.066910 13.888330 ( 13.892066)
995
+ Average 0.002764 0.000013 0.002778 ( 0.002778)
996
+ ..................................................................................................
997
+ user system total real
998
+ URI::UID.parse HASH/UID 0.075566 0.000411 0.075977 ( 0.076035)
999
+ Average 0.000015 0.000000 0.000015 ( 0.000015)
1000
+ ..................................................................................................
1001
+ user system total real
1002
+ URI::UID.decode HASH/UID 0.111007 0.003572 0.114579 ( 0.114587)
1003
+ Average 0.000022 0.000001 0.000023 ( 0.000023)
1004
+ ..................................................................................................
1005
+ user system total real
1006
+ URI::UID.build ActiveRecord 0.984594 0.010059 0.994653 ( 0.994662)
1007
+ Average 0.000197 0.000002 0.000199 ( 0.000199)
1008
+ ..................................................................................................
1009
+ user system total real
1010
+ URI::UID.build ActiveRecord, exclude_blank 0.953653 0.006692 0.960345 ( 0.960765)
1011
+ Average 0.000191 0.000001 0.000192 ( 0.000192)
1012
+ ..................................................................................................
1013
+ user system total real
1014
+ URI::UID.build ActiveRecord, include_descendants 44.958468 0.170125 45.128593 ( 45.176116)
1015
+ Average 0.008992 0.000034 0.009026 ( 0.009035)
1016
+ ..................................................................................................
1017
+ user system total real
1018
+ URI::UID.parse ActiveRecord/UID 0.119030 0.000319 0.119349 ( 0.119525)
1019
+ Average 0.000024 0.000000 0.000024 ( 0.000024)
1020
+ ..................................................................................................
1021
+ user system total real
1022
+ URI::UID.decode HASH/UID 5.198092 0.024652 5.222744 ( 5.282794)
1023
+ Average 0.001040 0.000005 0.001045 ( 0.001057)
1024
+ ..................................................................................................
1025
+ user system total real
1026
+ UID > GID > UID.decode include_descendants 55.612061 0.398193 56.010254 ( 57.372350)
1027
+ Average 0.011122 0.000080 0.011202 ( 0.011474)
1028
+ ..................................................................................................
1029
+ user system total real
1030
+ UID > SGID > UID.decode include_descendants 55.406590 0.260552 55.667142 ( 56.432082)
1031
+ Average 0.011081 0.000052 0.011133 ( 0.011286)
1032
+ ..................................................................................................
1033
+ ```
1034
+ </details>
1035
+
1036
+ ## Sponsors
1037
+
1038
+ <p align="center">
1039
+ <em>Proudly sponsored by</em>
1040
+ </p>
1041
+ <p align="center">
1042
+ <a href="https://www.clickfunnels.com?utm_source=hopsoft&utm_medium=open-source&utm_campaign=universalid">
1043
+ <img src="https://images.clickfunnel.com/uploads/digital_asset/file/176632/clickfunnels-dark-logo.svg" width="575" />
1044
+ </a>
1045
+ </p>
1046
+
1047
+ [Add your company...](https://github.com/sponsors/hopsoft/sponsorships?sponsor=hopsoft&tier_id=23918&preview=false)
275
1048
 
276
1049
  ## License
277
1050