delta_core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTORS.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +321 -0
- data/Rakefile +17 -0
- data/lib/delta_core/adapters/active_record.rb +26 -0
- data/lib/delta_core/adapters/base.rb +19 -0
- data/lib/delta_core/comparator.rb +72 -0
- data/lib/delta_core/configuration.rb +35 -0
- data/lib/delta_core/context.rb +60 -0
- data/lib/delta_core/delta_result.rb +30 -0
- data/lib/delta_core/dsl.rb +45 -0
- data/lib/delta_core/mapping.rb +29 -0
- data/lib/delta_core/renderer/base.rb +11 -0
- data/lib/delta_core/snapshot.rb +41 -0
- data/lib/delta_core/state_builder.rb +66 -0
- data/lib/delta_core/strategies/base.rb +17 -0
- data/lib/delta_core/strategies/merge.rb +27 -0
- data/lib/delta_core/strategies/quantity.rb +25 -0
- data/lib/delta_core/strategies/replace.rb +13 -0
- data/lib/delta_core/version.rb +5 -0
- data/lib/delta_core.rb +39 -0
- data/sig/delta_core.rbs +4 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d119d8a4706a021cd570aace06eaa040f0b6280e69d983860afde44b9628ed22
|
|
4
|
+
data.tar.gz: 35a89d6eda069c7e4e1346eda7d6eff7629225cbfc097f736b3985db2d788b40
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2aee8cff4e928582ae2b9e535f4dcd57b0b368feda2da2a8e85cec8a6cde3c24a595eeff12b3c0ba316d24580d3e91de8403f8bec5b3b6f1c933dbeb46173698
|
|
7
|
+
data.tar.gz: 46d1b0e0f5032b78888ca79f7f582f0670a81cf503a4b27e817c81b689833706859897b0f5c0c58eec91806ff66515b17c3c51237a32a9c05c3f19bf89f8c03f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
## [1.0.0] - 2026-02-21
|
|
3
|
+
|
|
4
|
+
- First stable release of DeltaCore (1.0.0).
|
|
5
|
+
- Added Rails DSL (`DeltaCore::DSL`) for declaring `snapshot_column` and `map` mappings.
|
|
6
|
+
- Implemented `StateBuilder` to serialize model associations into plain Hash state.
|
|
7
|
+
- Implemented `Comparator` with three built-in strategies: `:quantity`, `:replace`, and `:merge`.
|
|
8
|
+
- Added `Snapshot` persistence via `Adapters::ActiveRecord` and JSON column storage.
|
|
9
|
+
- Added `Context` flows: `delta_result` (calculate delta), `confirm_snapshot!` (update snapshot), and `with_delta_transaction`.
|
|
10
|
+
- Support for custom strategies and mapping extensions via registration APIs.
|
|
11
|
+
|
|
12
|
+
## [0.1.0] - 2026-02-20
|
|
13
|
+
|
|
14
|
+
- Initial release
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"delta_core" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["mmarusyk1@gmail.com"](mailto:"mmarusyk1@gmail.com").
|
data/CONTRIBUTORS.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mykhailo Marusyk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# DeltaCore
|
|
2
|
+
|
|
3
|
+
DeltaCore persists explicit snapshots of confirmed class state and compares them
|
|
4
|
+
against current state to produce structured, deterministic delta results. It distinguishes added,
|
|
5
|
+
removed, and modified entities, supports pluggable comparison strategies (quantity, replace, and
|
|
6
|
+
partial merge), and integrates with Rails via a configurable DSL with transactional safety and
|
|
7
|
+
idempotent delta generation.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Install the gem and add it to the application's Gemfile by executing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle add delta_core
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
gem install delta_core
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Rails DSL
|
|
26
|
+
|
|
27
|
+
Include the DSL in any class to configure snapshot behaviour and association mapping rules:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class Order < ApplicationRecord
|
|
31
|
+
include DeltaCore::DSL
|
|
32
|
+
|
|
33
|
+
delta_core do
|
|
34
|
+
snapshot_column :order_delta_data
|
|
35
|
+
|
|
36
|
+
map :items,
|
|
37
|
+
key: :product_id,
|
|
38
|
+
fields: [:quantity, :unit_price],
|
|
39
|
+
strategy: :quantity,
|
|
40
|
+
relations: {
|
|
41
|
+
price_changes: {
|
|
42
|
+
key: :id,
|
|
43
|
+
fields: [:amount, :type],
|
|
44
|
+
strategy: :replace
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Configuration options:**
|
|
52
|
+
|
|
53
|
+
- `snapshot_column` — the column used to persist the serialized snapshot JSON on the record.
|
|
54
|
+
- `map` — declares a top-level association to track. Accepts:
|
|
55
|
+
- `key:` — the unique identifier field used to match entities across snapshots.
|
|
56
|
+
- `fields:` — the list of comparable fields whose changes are detected. Only changes in these fields are captured.
|
|
57
|
+
- `strategy:` — comparison strategy for how changes are detected. One of:
|
|
58
|
+
- `:quantity` — Detects which items are added/removed by key, and which items have field changes.
|
|
59
|
+
- `:replace` — Treats the entire collection as a unit; if anything changes, the full collection is marked as added/removed.
|
|
60
|
+
- `:merge` — Like quantity, but also tracks which specific fields changed in modified items.
|
|
61
|
+
- `relations:` — optional nested association mapping, using the same key/fields/strategy options. Enables multi-level tracking.
|
|
62
|
+
|
|
63
|
+
### Building State and Computing Deltas
|
|
64
|
+
|
|
65
|
+
DeltaCore provides methods to inspect current state and compute deltas without persisting:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# Get the structured state representation as a Hash
|
|
69
|
+
state = order.delta_state
|
|
70
|
+
# => { items: [...], price_changes: [...] }
|
|
71
|
+
|
|
72
|
+
# Get the delta between the last snapshot and current state
|
|
73
|
+
delta = order.delta_result
|
|
74
|
+
delta.added # => entities present in current state but absent from snapshot
|
|
75
|
+
delta.removed # => entities present in snapshot but absent from current state
|
|
76
|
+
delta.modified # => entities present in both with changed field values
|
|
77
|
+
delta.empty? # => true when no differences exist (idempotent guard)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Persisting Snapshots
|
|
81
|
+
|
|
82
|
+
Snapshots are only persisted after external confirmation to avoid premature state capture. Choose
|
|
83
|
+
based on whether you need to confirm the delta before persisting:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# Simple snapshot capture (raises EmptyDeltaError if no changes)
|
|
87
|
+
order.confirm_snapshot!
|
|
88
|
+
|
|
89
|
+
# Transactional flow with delta confirmation
|
|
90
|
+
result = order.with_delta_transaction do |delta|
|
|
91
|
+
# Inspect delta, perform external operations
|
|
92
|
+
# Snapshot is persisted only if block completes successfully
|
|
93
|
+
external_api.submit(delta)
|
|
94
|
+
{ success: true }
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Resetting Delta Flags
|
|
99
|
+
|
|
100
|
+
Clear any dirty-tracking metadata (extension point for custom implementations):
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
order.reset_delta_flags!
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Architecture
|
|
107
|
+
|
|
108
|
+
The diagram below covers every component in the system and the data flowing between them.
|
|
109
|
+
Dashed arrows (`-.->`) denote interface implementation; solid arrows denote runtime data flow.
|
|
110
|
+
|
|
111
|
+
```mermaid
|
|
112
|
+
flowchart TD
|
|
113
|
+
subgraph setup["Setup Time"]
|
|
114
|
+
DSL["DSL: delta_core { }"]
|
|
115
|
+
Cfg["Configuration: snapshot_column · map"]
|
|
116
|
+
Map["Mapping: key · fields · strategy"]
|
|
117
|
+
DSL --> Cfg
|
|
118
|
+
Cfg --> Map
|
|
119
|
+
Map -->|nested relations| Map
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
Model(["AR Model Instance"])
|
|
123
|
+
setup -->|config stored on class| Model
|
|
124
|
+
|
|
125
|
+
Ctx["Context: build_state · calculate_delta · update_snapshot · with_delta_transaction"]
|
|
126
|
+
Model --> Ctx
|
|
127
|
+
|
|
128
|
+
subgraph storage["Storage"]
|
|
129
|
+
AB["Adapters::Base: interface"]
|
|
130
|
+
AAR["Adapters::ActiveRecord: load_snapshot · persist · lock_record"]
|
|
131
|
+
Snap["Snapshot: parse · serialize · empty?"]
|
|
132
|
+
DB[("PostgreSQL")]
|
|
133
|
+
AB -. implements .-> AAR
|
|
134
|
+
AAR <-->|load / persist| Snap
|
|
135
|
+
Snap <-->|read / write| DB
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
subgraph state["State Building"]
|
|
139
|
+
SB["StateBuilder: extract AR associations → plain Hash"]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
subgraph comparison["Comparison"]
|
|
143
|
+
Cmp["Comparator: resolve strategy · merge results"]
|
|
144
|
+
SBase["Strategies::Base: interface"]
|
|
145
|
+
SQty["Strategies::Quantity: by key with field changes"]
|
|
146
|
+
SRep["Strategies::Replace: full replacement on any change"]
|
|
147
|
+
SMrg["Strategies::Merge: by key + track changed fields"]
|
|
148
|
+
Cmp -->|delegates to| SBase
|
|
149
|
+
SBase -. implements .-> SQty
|
|
150
|
+
SBase -. implements .-> SRep
|
|
151
|
+
SBase -. implements .-> SMrg
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
DR(["DeltaResult: added · removed · modified"])
|
|
155
|
+
|
|
156
|
+
Ctx -->|build current state| SB
|
|
157
|
+
Ctx -->|load · lock · persist| AAR
|
|
158
|
+
Map -->|mapping rules| SB
|
|
159
|
+
Map -->|mapping rules| Cmp
|
|
160
|
+
SB -->|current state Hash| Cmp
|
|
161
|
+
Snap -->|snapshot state Hash| Cmp
|
|
162
|
+
Cmp -->|merged output| DR
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Key flows
|
|
166
|
+
|
|
167
|
+
**`build_state`** — Calls `StateBuilder` to extract a plain Hash representation of all mapped
|
|
168
|
+
associations and their fields from the model. Used by both delta computation and snapshot updates.
|
|
169
|
+
|
|
170
|
+
**`calculate_delta`** (or `delta_result`) — `Context` calls `StateBuilder` to produce current
|
|
171
|
+
state, loads the persisted `Snapshot` via `Adapters::ActiveRecord`, then passes both into
|
|
172
|
+
`Comparator`. For each `Mapping`, `Comparator` resolves the configured strategy
|
|
173
|
+
(`Quantity` / `Replace` / `Merge`) via `Strategies::Base` and merges the results into a
|
|
174
|
+
`DeltaResult`.
|
|
175
|
+
|
|
176
|
+
**`update_snapshot`** (or `confirm_snapshot!`) — Calculates delta and raises `EmptyDeltaError`
|
|
177
|
+
if no changes exist. Acquires a record lock through `Adapters::ActiveRecord`, rebuilds current
|
|
178
|
+
state via `StateBuilder`, serializes it through `Snapshot`, and persists the JSON back to the
|
|
179
|
+
PostgreSQL column. The snapshot never advances if transmission fails.
|
|
180
|
+
|
|
181
|
+
**`with_delta_transaction`** — Combines delta calculation and snapshot persistence in a single
|
|
182
|
+
transactional block. Acquires a lock, calculates the delta, yields to the block for external
|
|
183
|
+
processing, and only persists the snapshot if the block completes successfully without raising.
|
|
184
|
+
|
|
185
|
+
## Extension Points
|
|
186
|
+
|
|
187
|
+
### Custom Strategies
|
|
188
|
+
|
|
189
|
+
Register custom comparison strategies using the global strategy registry:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# Define a custom strategy
|
|
193
|
+
module MyStrategy
|
|
194
|
+
def self.call(snapshot_collection, current_collection, mapping)
|
|
195
|
+
# Return hash with :added, :removed, :modified keys
|
|
196
|
+
{ added: [], removed: [], modified: [] }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Register it
|
|
201
|
+
DeltaCore.register_strategy(:my_strategy, MyStrategy)
|
|
202
|
+
|
|
203
|
+
# Use it in configuration
|
|
204
|
+
class Order < ApplicationRecord
|
|
205
|
+
include DeltaCore::DSL
|
|
206
|
+
|
|
207
|
+
delta_core do
|
|
208
|
+
snapshot_column :delta_data
|
|
209
|
+
map :items, key: :id, fields: [:amount], strategy: :my_strategy
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Mapping Extensions
|
|
215
|
+
|
|
216
|
+
Extend the state builder to add computed fields or custom transformations to entities:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
DeltaCore.register_mapping_extension(->(entity, record, mapping) {
|
|
220
|
+
# Add computed fields to entity
|
|
221
|
+
entity[:computed_field] = record.some_method
|
|
222
|
+
entity
|
|
223
|
+
})
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Custom Adapters
|
|
227
|
+
|
|
228
|
+
Implement the `Adapters::Base` interface to use a different persistence backend:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class MyAdapter
|
|
232
|
+
def load_snapshot(model)
|
|
233
|
+
# Return a Snapshot instance
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def persist(model, serialized_json)
|
|
237
|
+
# Save the JSON somewhere
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def lock_record(model, &block)
|
|
241
|
+
# Ensure thread-safe execution
|
|
242
|
+
yield
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Use it
|
|
247
|
+
config = DeltaCore::Configuration.new
|
|
248
|
+
config.snapshot_column :delta_data
|
|
249
|
+
context = DeltaCore::Context.new(config, adapter: MyAdapter.new)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Comparison Strategies
|
|
253
|
+
|
|
254
|
+
DeltaCore provides three built-in comparison strategies for detecting changes in collections:
|
|
255
|
+
|
|
256
|
+
### `:quantity`
|
|
257
|
+
|
|
258
|
+
Detects additions and removals by matching entities on the configured `:key` field. For matched
|
|
259
|
+
pairs, compares the `:fields` and reports any that changed.
|
|
260
|
+
|
|
261
|
+
**Use when:** You want to track individual entity changes and need to distinguish between
|
|
262
|
+
added, removed, and modified entities within a collection.
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
# Example: Track order items by product_id
|
|
266
|
+
map :items, key: :product_id, fields: [:quantity, :price], strategy: :quantity
|
|
267
|
+
# Result: Shows which items were added/removed and which had quantity/price changes
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### `:replace`
|
|
271
|
+
|
|
272
|
+
Treats the entire collection as a single unit. If any element in the collection differs,
|
|
273
|
+
the entire collection is reported as removed (old) and added (new).
|
|
274
|
+
|
|
275
|
+
**Use when:** The collection should be treated as an atomic whole, such as a JSON array
|
|
276
|
+
or blob that's either changed entirely or not at all.
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
# Example: Track metadata that's stored as a complete JSON structure
|
|
280
|
+
map :tags, key: :name, fields: [:value], strategy: :replace
|
|
281
|
+
# Result: Either the full tags collection is reported as modified, or nothing changed
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### `:merge`
|
|
285
|
+
|
|
286
|
+
Like `:quantity`, but additionally tracks which specific `:fields` changed in each modified
|
|
287
|
+
entity. The `modified` results include a `changed_fields` array.
|
|
288
|
+
|
|
289
|
+
**Use when:** You need fine-grained information about exactly which fields changed per entity.
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
# Example: Track price adjustments with detailed field-level changes
|
|
293
|
+
map :prices, key: :currency, fields: [:amount, :type], strategy: :merge
|
|
294
|
+
# Result: Shows which prices changed AND which fields (amount vs type) were modified
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Development
|
|
298
|
+
|
|
299
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to
|
|
300
|
+
run the tests. You can also run `bin/console` for an interactive prompt that will allow you to
|
|
301
|
+
experiment.
|
|
302
|
+
|
|
303
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new
|
|
304
|
+
version, update the version number in `version.rb`, and then run `bundle exec rake release`,
|
|
305
|
+
which will create a git tag for the version, push git commits and the created tag, and push the
|
|
306
|
+
`.gem` file to [rubygems.org](https://rubygems.org).
|
|
307
|
+
|
|
308
|
+
## Contributing
|
|
309
|
+
|
|
310
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/mmarusyk/delta_core.
|
|
311
|
+
This project is intended to be a safe, welcoming space for collaboration, and contributors are
|
|
312
|
+
expected to adhere to the [code of conduct](https://github.com/mmarusyk/delta_core/blob/main/CODE_OF_CONDUCT.md).
|
|
313
|
+
|
|
314
|
+
## License
|
|
315
|
+
|
|
316
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
317
|
+
|
|
318
|
+
## Code of Conduct
|
|
319
|
+
|
|
320
|
+
Everyone interacting in the DeltaCore project's codebases, issue trackers, chat rooms and mailing
|
|
321
|
+
lists is expected to follow the [code of conduct](https://github.com/mmarusyk/delta_core/blob/main/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
# Set SOURCE_DATE_EPOCH to the last git commit date for reproducible builds
|
|
7
|
+
# This ensures Ruby Toolbox and other tools can properly detect release dates
|
|
8
|
+
# See: https://github.com/rubytoolbox/rubytoolbox/issues/1653
|
|
9
|
+
ENV["SOURCE_DATE_EPOCH"] ||= `git log -1 --format=%cd --date=unix`.chomp
|
|
10
|
+
|
|
11
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
12
|
+
|
|
13
|
+
require "rubocop/rake_task"
|
|
14
|
+
|
|
15
|
+
RuboCop::RakeTask.new
|
|
16
|
+
|
|
17
|
+
task default: %i[spec rubocop]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
module Adapters
|
|
5
|
+
class ActiveRecord
|
|
6
|
+
include Base
|
|
7
|
+
|
|
8
|
+
def initialize(config)
|
|
9
|
+
@config = config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def load_snapshot(model)
|
|
13
|
+
raw = model.public_send(@config.snapshot_column)
|
|
14
|
+
Snapshot.new(raw)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def persist(model, serialized_json)
|
|
18
|
+
model.update_column(@config.snapshot_column, serialized_json)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def lock_record(model, &)
|
|
22
|
+
model.with_lock(&)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
module Adapters
|
|
5
|
+
module Base
|
|
6
|
+
def load_snapshot(_model)
|
|
7
|
+
raise NotImplementedError, "#{self.class}#load_snapshot not implemented"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def persist(_model, _serialized_json)
|
|
11
|
+
raise NotImplementedError, "#{self.class}#persist not implemented"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def lock_record(_model)
|
|
15
|
+
raise NotImplementedError, "#{self.class}#lock_record not implemented"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
class Comparator
|
|
5
|
+
BUILT_IN_STRATEGIES = {
|
|
6
|
+
quantity: Strategies::Quantity,
|
|
7
|
+
replace: Strategies::Replace,
|
|
8
|
+
merge: Strategies::Merge
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def self.compare(snapshot_state, current_state, config)
|
|
12
|
+
new(snapshot_state, current_state, config).compare
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(snapshot_state, current_state, config)
|
|
16
|
+
@snapshot_state = snapshot_state
|
|
17
|
+
@current_state = current_state
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def compare
|
|
22
|
+
@config.mappings.reduce(DeltaResult.new) do |result, mapping|
|
|
23
|
+
snap_coll = Array(@snapshot_state[mapping.name])
|
|
24
|
+
curr_coll = Array(@current_state[mapping.name])
|
|
25
|
+
|
|
26
|
+
strategy = resolve_strategy(mapping.strategy)
|
|
27
|
+
raw = strategy.call(snap_coll, curr_coll, mapping)
|
|
28
|
+
partial = DeltaResult.new(**raw)
|
|
29
|
+
nested = compare_nested(snap_coll, curr_coll, mapping)
|
|
30
|
+
|
|
31
|
+
result.merge(partial).merge(nested)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def resolve_strategy(name)
|
|
38
|
+
BUILT_IN_STRATEGIES[name] ||
|
|
39
|
+
DeltaCore.strategy_registry[name] ||
|
|
40
|
+
raise(ArgumentError, "Unknown strategy: #{name.inspect}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def compare_nested(snap_coll, curr_coll, mapping)
|
|
44
|
+
return DeltaResult.new if mapping.relations.empty?
|
|
45
|
+
|
|
46
|
+
snap_idx = Strategies::Base.index_by_key(snap_coll, mapping.key)
|
|
47
|
+
curr_idx = Strategies::Base.index_by_key(curr_coll, mapping.key)
|
|
48
|
+
|
|
49
|
+
mapping.relations.reduce(DeltaResult.new) do |rel_result, (rel_name, rel_mapping)|
|
|
50
|
+
curr_idx.reduce(rel_result) do |inner_result, (_, curr_entity)|
|
|
51
|
+
key_val = curr_entity[mapping.key]
|
|
52
|
+
snap_entity = snap_idx[key_val] || {}
|
|
53
|
+
snap_nested = Array(snap_entity[rel_name])
|
|
54
|
+
curr_nested = Array(curr_entity[rel_name])
|
|
55
|
+
|
|
56
|
+
nested_snap = { rel_mapping.name => snap_nested }
|
|
57
|
+
nested_curr = { rel_mapping.name => curr_nested }
|
|
58
|
+
nested_cfg = build_nested_config(rel_mapping)
|
|
59
|
+
|
|
60
|
+
partial = self.class.compare(nested_snap, nested_curr, nested_cfg)
|
|
61
|
+
inner_result.merge(partial)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_nested_config(rel_mapping)
|
|
67
|
+
cfg = Configuration.new
|
|
68
|
+
cfg.instance_variable_set(:@mappings, [rel_mapping])
|
|
69
|
+
cfg
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_reader :mappings
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@snapshot_column = nil
|
|
9
|
+
@mappings = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def snapshot_column(column_name = nil)
|
|
13
|
+
if column_name
|
|
14
|
+
@snapshot_column = column_name.to_sym
|
|
15
|
+
else
|
|
16
|
+
@snapshot_column
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def map(name, key:, strategy:, fields: [], relations: {})
|
|
21
|
+
sym = name.to_sym
|
|
22
|
+
raise ArgumentError, "Duplicate mapping: #{sym}" if mapping_names.include?(sym)
|
|
23
|
+
|
|
24
|
+
mapping = Mapping.new(sym, key: key, fields: fields, strategy: strategy, relations: relations)
|
|
25
|
+
@mappings << mapping
|
|
26
|
+
mapping
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def mapping_names
|
|
32
|
+
@mappings.map(&:name)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
class Context
|
|
5
|
+
def initialize(config, adapter: nil)
|
|
6
|
+
@config = config
|
|
7
|
+
@adapter = adapter || Adapters::ActiveRecord.new(config)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def build_state(model)
|
|
11
|
+
StateBuilder.build(model, @config)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def calculate_delta(model)
|
|
15
|
+
snapshot = @adapter.load_snapshot(model)
|
|
16
|
+
current_state = build_state(model)
|
|
17
|
+
Comparator.compare(snapshot.state, current_state, @config)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update_snapshot(model)
|
|
21
|
+
delta = calculate_delta(model)
|
|
22
|
+
raise EmptyDeltaError, "Cannot update snapshot: delta is empty" if delta.empty?
|
|
23
|
+
|
|
24
|
+
@adapter.lock_record(model) do
|
|
25
|
+
current_state = build_state(model)
|
|
26
|
+
serialized = Snapshot.new.serialize(current_state)
|
|
27
|
+
@adapter.persist(model, serialized)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def with_delta_transaction(model)
|
|
32
|
+
raise ArgumentError, "Block required" unless block_given?
|
|
33
|
+
|
|
34
|
+
result = nil
|
|
35
|
+
|
|
36
|
+
@adapter.lock_record(model) do
|
|
37
|
+
current_state = build_state(model)
|
|
38
|
+
delta = Comparator.compare(
|
|
39
|
+
@adapter.load_snapshot(model).state,
|
|
40
|
+
current_state,
|
|
41
|
+
@config
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
raise EmptyDeltaError, "Delta is empty — nothing to transmit" if delta.empty?
|
|
45
|
+
|
|
46
|
+
result = yield delta
|
|
47
|
+
|
|
48
|
+
serialized = Snapshot.new.serialize(current_state)
|
|
49
|
+
@adapter.persist(model, serialized)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reset_flags(_model)
|
|
56
|
+
# Extension point: clears any dirty-tracking metadata.
|
|
57
|
+
# Default implementation is a no-op.
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
class DeltaResult
|
|
5
|
+
attr_reader :added, :removed, :modified
|
|
6
|
+
|
|
7
|
+
def initialize(added: [], removed: [], modified: [])
|
|
8
|
+
@added = Array(added).freeze
|
|
9
|
+
@removed = Array(removed).freeze
|
|
10
|
+
@modified = Array(modified).freeze
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def empty?
|
|
15
|
+
added.empty? && removed.empty? && modified.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def merge(other)
|
|
19
|
+
self.class.new(
|
|
20
|
+
added: added + other.added,
|
|
21
|
+
removed: removed + other.removed,
|
|
22
|
+
modified: modified + other.modified
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render(renderer)
|
|
27
|
+
renderer.call(self)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
module DSL
|
|
5
|
+
module ClassMethods
|
|
6
|
+
def delta_core(&)
|
|
7
|
+
config = Configuration.new
|
|
8
|
+
config.instance_eval(&)
|
|
9
|
+
@delta_core_config = config
|
|
10
|
+
|
|
11
|
+
include InstanceMethods
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def delta_core_config
|
|
15
|
+
@delta_core_config
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module InstanceMethods
|
|
20
|
+
def delta_state
|
|
21
|
+
DeltaCore::Context.new(self.class.delta_core_config).build_state(self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def delta_result
|
|
25
|
+
DeltaCore::Context.new(self.class.delta_core_config).calculate_delta(self)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def confirm_snapshot!
|
|
29
|
+
DeltaCore::Context.new(self.class.delta_core_config).update_snapshot(self)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reset_delta_flags!
|
|
33
|
+
DeltaCore::Context.new(self.class.delta_core_config).reset_flags(self)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.included(base)
|
|
38
|
+
base.extend(ClassMethods)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Model = DSL
|
|
43
|
+
|
|
44
|
+
::ActiveRecord::Base.include(DSL) if defined?(::ActiveRecord::Base)
|
|
45
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
class Mapping
|
|
5
|
+
attr_reader :name, :key, :fields, :strategy, :relations
|
|
6
|
+
|
|
7
|
+
def initialize(name, key:, strategy:, fields: [], relations: {})
|
|
8
|
+
@name = name.to_sym
|
|
9
|
+
@key = key.to_sym
|
|
10
|
+
@fields = Array(fields).map(&:to_sym)
|
|
11
|
+
@strategy = strategy.to_sym
|
|
12
|
+
@relations = build_relations(relations || {})
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def build_relations(raw)
|
|
18
|
+
raw.each_with_object({}) do |(assoc_name, opts), result|
|
|
19
|
+
result[assoc_name.to_sym] = Mapping.new(
|
|
20
|
+
assoc_name,
|
|
21
|
+
key: opts.fetch(:key),
|
|
22
|
+
fields: opts.fetch(:fields, []),
|
|
23
|
+
strategy: opts.fetch(:strategy),
|
|
24
|
+
relations: opts.fetch(:relations, {})
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
class Snapshot
|
|
5
|
+
FORMAT_VERSION = 1
|
|
6
|
+
VERSION_KEY = "_v"
|
|
7
|
+
|
|
8
|
+
attr_reader :state
|
|
9
|
+
|
|
10
|
+
def initialize(raw = nil)
|
|
11
|
+
@state = parse(raw)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def empty?
|
|
15
|
+
@state.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def serialize(current_state)
|
|
19
|
+
payload = { VERSION_KEY => FORMAT_VERSION }
|
|
20
|
+
payload.merge!(stringify_keys(current_state))
|
|
21
|
+
JSON.generate(payload)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def parse(raw)
|
|
27
|
+
return {} if raw.nil? || raw.to_s.strip.empty?
|
|
28
|
+
|
|
29
|
+
parsed = JSON.parse(raw.to_s, symbolize_names: true)
|
|
30
|
+
return {} unless parsed.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
parsed.except(:_v)
|
|
33
|
+
rescue JSON::ParserError
|
|
34
|
+
{}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stringify_keys(hash)
|
|
38
|
+
hash.transform_keys(&:to_s)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
class StateBuilder
|
|
5
|
+
@extensions = []
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
attr_reader :extensions
|
|
9
|
+
|
|
10
|
+
def build(model, config)
|
|
11
|
+
new(model, config).build
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(model, config)
|
|
16
|
+
@model = model
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build
|
|
21
|
+
@config.mappings.each_with_object({}) do |mapping, state|
|
|
22
|
+
state[mapping.name] = build_collection(mapping)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def build_collection(mapping)
|
|
29
|
+
collection = @model.public_send(mapping.name)
|
|
30
|
+
Array(collection)
|
|
31
|
+
.map { |record| build_record(record, mapping) }
|
|
32
|
+
.sort_by { |h| h[mapping.key].to_s }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_record(record, mapping)
|
|
36
|
+
entity = { mapping.key => extract_value(record, mapping.key) }
|
|
37
|
+
|
|
38
|
+
mapping.fields.each do |field|
|
|
39
|
+
entity[field] = extract_value(record, field)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
mapping.relations.each do |assoc_name, rel_mapping|
|
|
43
|
+
entity[assoc_name] = build_nested(record, rel_mapping)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
apply_extensions(entity, record, mapping)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_nested(record, rel_mapping)
|
|
50
|
+
collection = record.public_send(rel_mapping.name)
|
|
51
|
+
Array(collection)
|
|
52
|
+
.map { |r| build_record(r, rel_mapping) }
|
|
53
|
+
.sort_by { |h| h[rel_mapping.key].to_s }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def extract_value(record, field)
|
|
57
|
+
record.public_send(field)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def apply_extensions(entity, record, mapping)
|
|
61
|
+
self.class.extensions.reduce(entity) do |e, ext|
|
|
62
|
+
ext.respond_to?(:call) ? ext.call(e, record, mapping) : e
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
module Strategies
|
|
5
|
+
module Base
|
|
6
|
+
def self.call(_snapshot_collection, _current_collection, _mapping)
|
|
7
|
+
raise NotImplementedError, "#{name} must implement .call"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.index_by_key(collection, key)
|
|
11
|
+
collection.each_with_object({}) do |entity, idx|
|
|
12
|
+
idx[entity[key]] = entity
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
module Strategies
|
|
5
|
+
module Merge
|
|
6
|
+
def self.call(snapshot_collection, current_collection, mapping)
|
|
7
|
+
snap_index = Base.index_by_key(snapshot_collection, mapping.key)
|
|
8
|
+
curr_index = Base.index_by_key(current_collection, mapping.key)
|
|
9
|
+
|
|
10
|
+
added = curr_index.reject { |k, _| snap_index.key?(k) }.values
|
|
11
|
+
removed = snap_index.reject { |k, _| curr_index.key?(k) }.values
|
|
12
|
+
|
|
13
|
+
modified = curr_index.filter_map do |k, curr|
|
|
14
|
+
snap = snap_index[k]
|
|
15
|
+
next unless snap
|
|
16
|
+
|
|
17
|
+
changed_fields = mapping.fields.reject { |f| curr[f] == snap[f] }
|
|
18
|
+
next if changed_fields.empty?
|
|
19
|
+
|
|
20
|
+
{ current: curr, snapshot: snap, changed_fields: changed_fields }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
{ added: added, removed: removed, modified: modified }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
module Strategies
|
|
5
|
+
module Quantity
|
|
6
|
+
def self.call(snapshot_collection, current_collection, mapping)
|
|
7
|
+
snap_index = Base.index_by_key(snapshot_collection, mapping.key)
|
|
8
|
+
curr_index = Base.index_by_key(current_collection, mapping.key)
|
|
9
|
+
|
|
10
|
+
added = curr_index.reject { |k, _| snap_index.key?(k) }.values
|
|
11
|
+
removed = snap_index.reject { |k, _| curr_index.key?(k) }.values
|
|
12
|
+
|
|
13
|
+
modified = curr_index.filter_map do |k, curr|
|
|
14
|
+
snap = snap_index[k]
|
|
15
|
+
next unless snap
|
|
16
|
+
|
|
17
|
+
changed = mapping.fields.any? { |f| curr[f] != snap[f] }
|
|
18
|
+
{ current: curr, snapshot: snap } if changed
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
{ added: added, removed: removed, modified: modified }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeltaCore
|
|
4
|
+
module Strategies
|
|
5
|
+
module Replace
|
|
6
|
+
def self.call(snapshot_collection, current_collection, _mapping)
|
|
7
|
+
return { added: [], removed: [], modified: [] } if snapshot_collection == current_collection
|
|
8
|
+
|
|
9
|
+
{ added: current_collection, removed: snapshot_collection, modified: [] }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/delta_core.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "delta_core/version"
|
|
6
|
+
require_relative "delta_core/mapping"
|
|
7
|
+
require_relative "delta_core/configuration"
|
|
8
|
+
require_relative "delta_core/delta_result"
|
|
9
|
+
require_relative "delta_core/snapshot"
|
|
10
|
+
require_relative "delta_core/state_builder"
|
|
11
|
+
require_relative "delta_core/strategies/base"
|
|
12
|
+
require_relative "delta_core/strategies/quantity"
|
|
13
|
+
require_relative "delta_core/strategies/replace"
|
|
14
|
+
require_relative "delta_core/strategies/merge"
|
|
15
|
+
require_relative "delta_core/comparator"
|
|
16
|
+
require_relative "delta_core/adapters/base"
|
|
17
|
+
require_relative "delta_core/adapters/active_record"
|
|
18
|
+
require_relative "delta_core/context"
|
|
19
|
+
require_relative "delta_core/renderer/base"
|
|
20
|
+
require_relative "delta_core/dsl"
|
|
21
|
+
|
|
22
|
+
module DeltaCore
|
|
23
|
+
class Error < StandardError; end
|
|
24
|
+
class EmptyDeltaError < Error; end
|
|
25
|
+
|
|
26
|
+
@strategy_registry = {}
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
attr_reader :strategy_registry
|
|
30
|
+
|
|
31
|
+
def register_strategy(name, klass)
|
|
32
|
+
@strategy_registry[name.to_sym] = klass
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def register_mapping_extension(callable)
|
|
36
|
+
StateBuilder.extensions << callable
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/sig/delta_core.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: delta_core
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mykhailo Marusyk
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-02-21 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: DeltaCore persists explicit snapshots of confirmed class state and compares
|
|
13
|
+
them against current state to produce structured, deterministic delta results. It
|
|
14
|
+
distinguishes added, removed, and modified entities, supports pluggable comparison
|
|
15
|
+
strategies (quantity, replace, merge), and integrates with Rails via a configurable
|
|
16
|
+
DSL with transactional safety and idempotent delta generation.
|
|
17
|
+
email:
|
|
18
|
+
- mmarusyk1@gmail.com
|
|
19
|
+
executables: []
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- CHANGELOG.md
|
|
24
|
+
- CODE_OF_CONDUCT.md
|
|
25
|
+
- CONTRIBUTORS.md
|
|
26
|
+
- LICENSE.txt
|
|
27
|
+
- README.md
|
|
28
|
+
- Rakefile
|
|
29
|
+
- lib/delta_core.rb
|
|
30
|
+
- lib/delta_core/adapters/active_record.rb
|
|
31
|
+
- lib/delta_core/adapters/base.rb
|
|
32
|
+
- lib/delta_core/comparator.rb
|
|
33
|
+
- lib/delta_core/configuration.rb
|
|
34
|
+
- lib/delta_core/context.rb
|
|
35
|
+
- lib/delta_core/delta_result.rb
|
|
36
|
+
- lib/delta_core/dsl.rb
|
|
37
|
+
- lib/delta_core/mapping.rb
|
|
38
|
+
- lib/delta_core/renderer/base.rb
|
|
39
|
+
- lib/delta_core/snapshot.rb
|
|
40
|
+
- lib/delta_core/state_builder.rb
|
|
41
|
+
- lib/delta_core/strategies/base.rb
|
|
42
|
+
- lib/delta_core/strategies/merge.rb
|
|
43
|
+
- lib/delta_core/strategies/quantity.rb
|
|
44
|
+
- lib/delta_core/strategies/replace.rb
|
|
45
|
+
- lib/delta_core/version.rb
|
|
46
|
+
- sig/delta_core.rbs
|
|
47
|
+
homepage: https://github.com/mmarusyk/delta_core
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
allowed_push_host: https://rubygems.org
|
|
52
|
+
homepage_uri: https://github.com/mmarusyk/delta_core
|
|
53
|
+
source_code_uri: https://github.com/mmarusyk/delta_core
|
|
54
|
+
changelog_uri: https://github.com/mmarusyk/delta_core/blob/main/CHANGELOG.md
|
|
55
|
+
rubygems_mfa_required: 'true'
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: 3.2.0
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 4.0.3
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: Snapshot, compare, and diff class state with structured delta results.
|
|
73
|
+
test_files: []
|