active_chronicle 0.1.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 +38 -0
- data/LICENSE +21 -0
- data/README.md +254 -0
- data/lib/active_chronicle.rb +54 -0
- data/lib/chronicle/connection_adapters/datomic_adapter.rb +286 -0
- data/lib/chronicle/datalog/compiler.rb +247 -0
- data/lib/chronicle/datalog/optimizer.rb +89 -0
- data/lib/chronicle/hydrator.rb +164 -0
- data/lib/chronicle/model.rb +80 -0
- data/lib/chronicle/railtie.rb +15 -0
- data/lib/chronicle/relation.rb +89 -0
- data/lib/chronicle/resilience.rb +149 -0
- data/lib/chronicle/schema/table_definition.rb +93 -0
- data/lib/chronicle/schema.rb +89 -0
- data/lib/chronicle/transaction_coordinator.rb +103 -0
- data/lib/chronicle/transport/base.rb +33 -0
- data/lib/chronicle/transport/cruby_client.rb +180 -0
- data/lib/chronicle/transport/jruby_client.rb +181 -0
- data/lib/chronicle/transport/jruby_peer.rb +91 -0
- data/lib/chronicle/transport.rb +30 -0
- data/lib/chronicle/version.rb +5 -0
- data/lib/generators/chronicle/initializer_generator.rb +32 -0
- data/lib/generators/chronicle/migration_generator.rb +46 -0
- data/lib/generators/chronicle/templates/migration.rb.erb +13 -0
- metadata +281 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6e9591048e619042697a08f52bca32f1eb5a29f799df5e2712821a72e4daa03f
|
|
4
|
+
data.tar.gz: 47508468ff233dd841e2e07295e130fb8bc4f84b8a857cc52eeeaac9e396ea6f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3792343f538b15c26a8bae3fb96e7aba9f0321e0ea011f6868eb218b96a53002acdb25e0285cbf3605c0961a5495c7fc38beb35e0fe65bb0cd3c782416d4167c
|
|
7
|
+
data.tar.gz: ffff69d99a41a44c0ac23493ef2cfad47e89a08b0bf39aa20df78fd7d1f5e9048987aebb4cb1140d9a270ce599c67d52454cacb995369061ccecef65d1c96445
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Core
|
|
6
|
+
|
|
7
|
+
- Added Active Record integration through `Chronicle::Model`.
|
|
8
|
+
- Added Datomic attribute declarations and schema migration helpers.
|
|
9
|
+
- Added Datalog compilation for predicates, projections, `IN` values, and pull queries.
|
|
10
|
+
- Added association hydration from Datomic pull results.
|
|
11
|
+
- Added `.as_of` and `.since` time-travel relations.
|
|
12
|
+
- Added coordinated Datomic and relational transactions with compensating retractions.
|
|
13
|
+
- Added retry backoff, jitter, circuit breaking, and Fiber-aware sleeping.
|
|
14
|
+
|
|
15
|
+
### Transports
|
|
16
|
+
|
|
17
|
+
- Added CRuby transport support for Datomic REST with EDN requests and responses.
|
|
18
|
+
- Added JRuby Client API support through a Datomic peer server.
|
|
19
|
+
- Added JRuby Peer API support for direct transactor connections.
|
|
20
|
+
- Added explicit peer transport selection with `transport: peer`.
|
|
21
|
+
- Added Datomic jar loading for JRuby containers.
|
|
22
|
+
|
|
23
|
+
### Examples
|
|
24
|
+
|
|
25
|
+
- Added `news_feed`, a CRuby Rails application that displays story revisions.
|
|
26
|
+
- Added `wallet`, a JRuby Rails application that uses the Datomic Client API to track balance changes.
|
|
27
|
+
- Added `animal_tracker`, a JRuby Rails application that uses the Datomic Peer API to draw historical paths.
|
|
28
|
+
- Added `cross_store`, a CRuby Rails application that references Datomic customer IDs from SQLite purchases.
|
|
29
|
+
- Added a real cross-store benchmark for coordinated Datomic and SQLite writes.
|
|
30
|
+
- Added a Docker Compose stack that installs the Datomic distribution in a container and runs the transactor, peer server, REST service, and all examples.
|
|
31
|
+
|
|
32
|
+
### Tooling and Release
|
|
33
|
+
|
|
34
|
+
- Raised the supported Ruby version to 3.4.5.
|
|
35
|
+
- Added RuboCop CI validation and a pre-commit quality hook.
|
|
36
|
+
- Added SimpleCov and Codecov reporting.
|
|
37
|
+
- Limited the published gem to library code and release documentation.
|
|
38
|
+
- Excluded specs, CI files, examples, and development files from the gem archive.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlos Cunha
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Active Chronicle
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Thrashattack/chronicle/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/Thrashattack/chronicle)
|
|
5
|
+
[](https://rubygems.org/gems/chronicle)
|
|
6
|
+
[](https://www.ruby-lang.org/)
|
|
7
|
+
|
|
8
|
+
Chronicle connects Datomic to Ruby on Rails Active Record. It maps Active Record models to Datomic facts and exposes immutable history through normal Rails query patterns. Check [Datomic](https://www.datomic.com/) page
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Ruby 3.4.5 or newer.
|
|
13
|
+
- Rails Active Record 7.0 or newer.
|
|
14
|
+
- Datomic Pro for the Docker examples and JRuby integrations.
|
|
15
|
+
- Java 17 for the Datomic container.
|
|
16
|
+
- Java 21 for JRuby 10 workloads.
|
|
17
|
+
|
|
18
|
+
## Capabilities
|
|
19
|
+
|
|
20
|
+
- Active Record integration through `Chronicle::Model`.
|
|
21
|
+
- Datomic attributes with types and schema options.
|
|
22
|
+
- Datalog compilation for equality, comparison, `IN`, projection, and pull queries.
|
|
23
|
+
- Association preloading through Datomic pull results.
|
|
24
|
+
- Time travel with `.as_of` and `.since`.
|
|
25
|
+
- Datomic schema and migration helpers.
|
|
26
|
+
- Cross-database transactions with compensating Datomic retractions.
|
|
27
|
+
- Retry handling with exponential backoff and jitter.
|
|
28
|
+
- Circuit breaker support.
|
|
29
|
+
- Fiber-aware backoff when a Ruby scheduler is active.
|
|
30
|
+
- CRuby transport through Datomic REST using EDN.
|
|
31
|
+
- JRuby Client API transport through a Datomic peer server.
|
|
32
|
+
- JRuby Peer API transport with direct access to the Datomic transactor.
|
|
33
|
+
- Rails initializer and migration generators.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Add Active Chronicle to the application Gemfile:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
gem "active_chronicle"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then install the bundle:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
bundle install
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Generate an initializer:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bin/rails generate chronicle:initializer
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Configure a Datomic connection in `config/database.yml`.
|
|
58
|
+
|
|
59
|
+
### CRuby and REST
|
|
60
|
+
|
|
61
|
+
Chronicle's CRuby transport uses Datomic's REST service. Datomic marks REST as a legacy interface, but it remains useful for existing integrations and supports the Ruby transport.
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
datomic:
|
|
65
|
+
adapter: datomic
|
|
66
|
+
uri: datomic:dev://localhost:4334/app_dev
|
|
67
|
+
client_endpoint: https://localhost:8001
|
|
68
|
+
rest: true
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### JRuby Client API
|
|
72
|
+
|
|
73
|
+
The Client API connects to a Datomic peer server. It requires the peer-server endpoint, access key, secret, and database name.
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
datomic:
|
|
77
|
+
adapter: datomic
|
|
78
|
+
uri: datomic:dev://localhost:4334/app_dev
|
|
79
|
+
client_endpoint: localhost:8998
|
|
80
|
+
access_key: chronicle-dev
|
|
81
|
+
secret: chronicle-dev-secret
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### JRuby Peer API
|
|
85
|
+
|
|
86
|
+
The Peer API connects directly to the transactor. Select it with `transport: peer`.
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
datomic:
|
|
90
|
+
adapter: datomic
|
|
91
|
+
uri: datomic:dev://localhost:4334/app_dev
|
|
92
|
+
transport: peer
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The included Compose examples mount the Datomic distribution at `/opt/datomic`.
|
|
96
|
+
Chronicle loads `peer-*.jar` from the distribution root and its dependencies from
|
|
97
|
+
`lib/*.jar`. Set `DATOMIC_HOME` when the distribution is installed elsewhere.
|
|
98
|
+
The Peer jar is not inside `lib`: for Datomic Pro 1.0.7705 it is
|
|
99
|
+
`/opt/datomic/peer-1.0.7705.jar`, corresponding to Maven artifact
|
|
100
|
+
`com.datomic:peer:1.0.7705`. Alternatively, resolve that artifact and its runtime
|
|
101
|
+
dependencies onto the application JVM classpath. Startup raises an error if
|
|
102
|
+
`datomic.Peer` cannot be resolved or `Peer.connect` returns no connection.
|
|
103
|
+
|
|
104
|
+
## Model Integration
|
|
105
|
+
|
|
106
|
+
Include `Chronicle::Model` and declare the attributes stored in Datomic:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
class DatomicRecord < ApplicationRecord
|
|
110
|
+
self.abstract_class = true
|
|
111
|
+
|
|
112
|
+
connects_to database: { writing: :datomic, reading: :datomic }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class HistoricalRecord < DatomicRecord
|
|
116
|
+
include Chronicle::Model
|
|
117
|
+
|
|
118
|
+
datomic_attribute :event_name, :string
|
|
119
|
+
datomic_attribute :user_id, :integer, index: true
|
|
120
|
+
datomic_attribute :payload, :string
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`connects_to` must be declared on an abstract Active Record class. Rails 8 rejects it on a concrete model. Keep SQLite-backed models on `ApplicationRecord` and inherit Datomic-backed models from the abstract Datomic base.
|
|
125
|
+
|
|
126
|
+
`Chronicle::Model` also provides `to_datoms`, `datomic_entity_id`, and model-level `.as_of` and `.since` query entry points.
|
|
127
|
+
|
|
128
|
+
## Schema and Migrations
|
|
129
|
+
|
|
130
|
+
Generate a Datomic migration:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
bin/rails generate chronicle:migration create_historical_records \
|
|
134
|
+
event_name:string user_id:integer:index
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A generated migration uses the Chronicle table definition:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
class CreateHistoricalRecords < ActiveRecord::Migration[8.1]
|
|
141
|
+
def change
|
|
142
|
+
create_datomic_schema :historical_record do |table|
|
|
143
|
+
table.string :event_name
|
|
144
|
+
table.integer :user_id, index: true
|
|
145
|
+
table.timestamps
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Run it with:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
bin/rails db:migrate
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Time Travel
|
|
158
|
+
|
|
159
|
+
Datomic never overwrites a fact. Each transaction produces a new database value and a transaction time. Chronicle exposes that history through relation scopes:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
past = HistoricalRecord.as_of(2.hours.ago).where(user_id: 42)
|
|
163
|
+
recent = HistoricalRecord.since(10040).where(event_name: "login")
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The transport applies `as_of` and `since` to the Datomic database snapshot before it executes the query.
|
|
167
|
+
|
|
168
|
+
## Cross-Database Transactions
|
|
169
|
+
|
|
170
|
+
`Chronicle::TransactionCoordinator` coordinates a Datomic write and a relational write. If the relational operation fails, it sends compensating retractions to Datomic and raises `Chronicle::TransactionError`.
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
Chronicle::TransactionCoordinator.transaction do |transaction|
|
|
174
|
+
transaction.datomic(record.to_datoms)
|
|
175
|
+
transaction.postgres do
|
|
176
|
+
AuditLog.create!(datomic_basis_t: transaction.basis_t)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Examples
|
|
182
|
+
|
|
183
|
+
The repository contains three Rails applications. They are excluded from the published gem.
|
|
184
|
+
|
|
185
|
+
| Example | Ruby | Datomic API | Port | Purpose |
|
|
186
|
+
| --- | --- | --- | ---: | --- |
|
|
187
|
+
| `news_feed` | CRuby | REST | 3001 | Publish stories and inspect revision history. |
|
|
188
|
+
| `wallet` | JRuby 10 | Client API | 3000 | Record deposits and withdrawals over time. |
|
|
189
|
+
| `animal_tracker` | JRuby 10 | Peer API | 3002 | Record coordinates and draw the historical path on a map. |
|
|
190
|
+
| `cross_store` | CRuby | REST + SQLite | 3003 | Reference a Datomic customer from a SQLite purchase and benchmark coordinated writes. |
|
|
191
|
+
|
|
192
|
+
The Compose stack downloads and installs Datomic inside the Datomic container. It runs the transactor, peer server, REST service, and all four applications:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
cd examples
|
|
196
|
+
docker compose up --build
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Then open:
|
|
200
|
+
|
|
201
|
+
- `http://localhost:3000` for the wallet.
|
|
202
|
+
- `http://localhost:3001` for the news feed.
|
|
203
|
+
- `http://localhost:3002` for the animal tracker.
|
|
204
|
+
- `http://localhost:3003` for the cross-store example.
|
|
205
|
+
|
|
206
|
+
The Datomic peer server listens on port `8998`. The REST service listens on port `8001`. The transactor uses ports `4334` and `4335`.
|
|
207
|
+
|
|
208
|
+
Run the cross-store benchmark with:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
docker compose -f examples/docker-compose.yml exec cross_store \
|
|
212
|
+
bundle exec ruby benchmark/cross_store_benchmark.rb
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
Run the full local validation:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
bundle exec rake quality
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
This runs RuboCop and RSpec. The tracked pre-commit hook runs the same checks:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
git config core.hooksPath .githooks
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The CI workflow runs RuboCop, RSpec, and uploads SimpleCov results to Codecov.
|
|
230
|
+
|
|
231
|
+
## Release Contents
|
|
232
|
+
|
|
233
|
+
The gem contains only `lib/`, `README.md`, `CHANGELOG.md`, and `LICENSE.txt`. It excludes specs, CI configuration, examples, the Gemfile, the Rakefile, and the gemspec.
|
|
234
|
+
|
|
235
|
+
Build the package with:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
gem build chronicle.gemspec
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Support Notes
|
|
242
|
+
|
|
243
|
+
- The CRuby REST transport and the JRuby Client API transport use different Datomic endpoints.
|
|
244
|
+
- The JRuby Peer API requires the Datomic distribution jars and a JVM.
|
|
245
|
+
- The Docker examples use Datomic Pro distribution downloads. Review Datomic licensing and distribution terms before use.
|
|
246
|
+
- JRuby and Datomic containers are not required to run the CRuby unit test suite.
|
|
247
|
+
|
|
248
|
+
## Special Thanks
|
|
249
|
+
|
|
250
|
+
- Thanks to the creators of [Diametric](https://github.com/relevance/diametric), it was the spark of inspiration for this project.
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
Chronicle is available under the MIT License.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
4
|
+
require 'active_support/all'
|
|
5
|
+
|
|
6
|
+
require_relative 'chronicle/version'
|
|
7
|
+
require_relative 'chronicle/resilience'
|
|
8
|
+
require_relative 'chronicle/transport'
|
|
9
|
+
require_relative 'chronicle/connection_adapters/datomic_adapter'
|
|
10
|
+
require_relative 'chronicle/datalog/compiler'
|
|
11
|
+
require_relative 'chronicle/datalog/optimizer'
|
|
12
|
+
require_relative 'chronicle/relation'
|
|
13
|
+
require_relative 'chronicle/hydrator'
|
|
14
|
+
require_relative 'chronicle/model'
|
|
15
|
+
require_relative 'chronicle/schema'
|
|
16
|
+
require_relative 'chronicle/transaction_coordinator'
|
|
17
|
+
|
|
18
|
+
require_relative 'chronicle/railtie' if defined?(Rails::Railtie)
|
|
19
|
+
|
|
20
|
+
module Chronicle
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
class ConnectionError < Error; end
|
|
23
|
+
class TransactionError < Error; end
|
|
24
|
+
class SchemaError < Error; end
|
|
25
|
+
class CircuitBreakerError < Error; end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_accessor :configuration
|
|
29
|
+
|
|
30
|
+
def configure
|
|
31
|
+
self.configuration ||= Configuration.new
|
|
32
|
+
yield(configuration) if block_given?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reset_configuration!
|
|
36
|
+
self.configuration = Configuration.new
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class Configuration
|
|
41
|
+
attr_accessor :uri, :client_endpoint, :secret, :pool_size,
|
|
42
|
+
:max_retries, :retry_base_delay, :retry_max_delay,
|
|
43
|
+
:circuit_breaker_threshold, :circuit_breaker_cooldown
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
@pool_size = 5
|
|
47
|
+
@max_retries = 5
|
|
48
|
+
@retry_base_delay = 0.1
|
|
49
|
+
@retry_max_delay = 2.0
|
|
50
|
+
@circuit_breaker_threshold = 5
|
|
51
|
+
@circuit_breaker_cooldown = 10.0
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_record/connection_adapters/abstract_adapter'
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module ConnectionAdapters
|
|
7
|
+
class DatomicAdapter < AbstractAdapter
|
|
8
|
+
ADAPTER_NAME = 'Datomic'
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def quote_column_name(name)
|
|
12
|
+
"\"#{name.to_s.gsub('"', '""')}\""
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def quote_table_name(name)
|
|
16
|
+
name.to_s.split('.').map { |part| quote_column_name(part) }.join('.')
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :transport
|
|
21
|
+
|
|
22
|
+
def initialize(config = {})
|
|
23
|
+
super
|
|
24
|
+
@config = config
|
|
25
|
+
@transport = Chronicle::Transport.build(config)
|
|
26
|
+
@transport.connect!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def adapter_name
|
|
30
|
+
ADAPTER_NAME
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def quote_column_name(name)
|
|
34
|
+
"\"#{name.to_s.gsub('"', '""')}\""
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def quote_table_name(name)
|
|
38
|
+
name.to_s.split('.').map { |part| quote_column_name(part) }.join('.')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def supports_migrations?
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def supports_primary_key?
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def supports_ddl_transactions?
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def tables(_name = nil)
|
|
54
|
+
datomic_models.map(&:table_name).uniq
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
alias data_sources tables
|
|
58
|
+
|
|
59
|
+
def data_source_exists?(name)
|
|
60
|
+
tables.include?(name.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
alias table_exists? data_source_exists?
|
|
64
|
+
|
|
65
|
+
def primary_keys(table_name)
|
|
66
|
+
data_source_exists?(table_name) ? ['id'] : []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def column_definitions(table_name)
|
|
70
|
+
model = ActiveRecord::Base.descendants.find { |klass| klass.table_name == table_name.to_s }
|
|
71
|
+
attributes = model.respond_to?(:datomic_attributes) ? model.datomic_attributes : {}
|
|
72
|
+
attributes.keys.unshift(:id).uniq.map do |name|
|
|
73
|
+
type = name == :id ? :bigint : attributes.dig(name, :type)
|
|
74
|
+
type = :datetime if type == :instant
|
|
75
|
+
column_type = %i[integer bigint].include?(type) ? ActiveRecord::Type.lookup(:integer, limit: 8) : ActiveRecord::Type.lookup(type || :value)
|
|
76
|
+
ActiveRecord::ConnectionAdapters::Column.new(name.to_s, column_type, nil)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def new_column_from_field(_table_name, field, _definitions)
|
|
81
|
+
return field if field.is_a?(ActiveRecord::ConnectionAdapters::Column)
|
|
82
|
+
|
|
83
|
+
ActiveRecord::ConnectionAdapters::Column.new(field.to_s, ActiveRecord::Type::Value.new, nil)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Executes a Datalog query or raw datom query
|
|
87
|
+
def execute(query, name = nil)
|
|
88
|
+
log(query.to_s, name || 'DATOMIC') do
|
|
89
|
+
db_snapshot = @transport.db
|
|
90
|
+
@transport.q(query, db_snapshot)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Standard Active Record exec_query interface
|
|
95
|
+
def exec_query(query, name = 'DATOMIC', binds = [], prepare: false, async: false, time_travel: nil, **_options)
|
|
96
|
+
log(query.to_s, name) do
|
|
97
|
+
db_snapshot = @transport.db(
|
|
98
|
+
as_of: time_travel&.dig(:as_of),
|
|
99
|
+
since: time_travel&.dig(:since)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
raw_results = @transport.q(query, db_snapshot, *binds)
|
|
103
|
+
format_results(raw_results)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# High-level query interface returning ActiveRecord::Result
|
|
108
|
+
def select_all(relation_or_query, name = nil, binds = [], prepare: false, async: false, time_travel: nil, **_options)
|
|
109
|
+
if relation_or_query.respond_to?(:to_datalog)
|
|
110
|
+
query_data = relation_or_query.to_datalog
|
|
111
|
+
exec_query(query_data[:query], name, query_data[:bindings], time_travel:)
|
|
112
|
+
elsif relation_or_query.is_a?(Hash) && relation_or_query[:query]
|
|
113
|
+
exec_query(relation_or_query[:query], name, relation_or_query[:bindings] || [], time_travel:)
|
|
114
|
+
else
|
|
115
|
+
exec_query(relation_or_query, name, binds, time_travel:)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def ensure_model_schema(model_class)
|
|
120
|
+
return if !model_class.respond_to?(:datomic_attributes) || model_class.datomic_attributes.empty?
|
|
121
|
+
|
|
122
|
+
namespace = model_class.table_name.singularize
|
|
123
|
+
attributes = model_class.datomic_attributes.dup
|
|
124
|
+
if model_class.respond_to?(:record_timestamps) && model_class.record_timestamps
|
|
125
|
+
attributes[:created_at] ||= { type: :instant, options: {} }
|
|
126
|
+
attributes[:updated_at] ||= { type: :instant, options: {} }
|
|
127
|
+
end
|
|
128
|
+
schema_datoms = attributes.map do |attribute, definition|
|
|
129
|
+
type = datomic_type(definition[:type])
|
|
130
|
+
raise Chronicle::SchemaError, "Unsupported Datomic attribute type: #{definition[:type]}" unless type
|
|
131
|
+
|
|
132
|
+
options = definition[:options] || {}
|
|
133
|
+
datom = Chronicle::Schema.create_attribute(namespace, attribute, type, options)
|
|
134
|
+
datom[':db/index'] = true if options[:index]
|
|
135
|
+
datom[':db/unique'] = ":db.unique/#{options[:unique]}" if %i[identity value].include?(options[:unique])
|
|
136
|
+
datom.transform_values { |value| value.is_a?(String) && value.start_with?(':') ? value.to_sym : value }
|
|
137
|
+
end
|
|
138
|
+
@transport.transact(schema_datoms)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Transacts new or updated entities into Datomic
|
|
142
|
+
def insert(model_or_statement, attributes_or_name = nil, *args, **options)
|
|
143
|
+
if model_or_statement.is_a?(Arel::InsertManager)
|
|
144
|
+
statement = model_or_statement.ast
|
|
145
|
+
table_name = statement.relation.name
|
|
146
|
+
model_class = ActiveRecord::Base.descendants.find { |klass| klass.table_name == table_name }
|
|
147
|
+
values = statement.values.expr.first
|
|
148
|
+
attributes = statement.columns.zip(values).to_h do |column, value|
|
|
149
|
+
normalized_value = value.respond_to?(:value) ? value.value : value
|
|
150
|
+
normalized_value = nil if normalized_value.is_a?(ActiveModel::Type::Value)
|
|
151
|
+
[column.name, normalized_value]
|
|
152
|
+
end
|
|
153
|
+
entity_id = insert_entity(model_class, attributes)
|
|
154
|
+
returning = options[:returning]
|
|
155
|
+
primary_key = args[0]
|
|
156
|
+
return returning.map { |column| column.to_s == primary_key.to_s ? entity_id : attributes[column.to_s] } if returning
|
|
157
|
+
|
|
158
|
+
return entity_id
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
insert_entity(model_or_statement, attributes_or_name)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def insert_entity(model_class, attributes)
|
|
165
|
+
ensure_model_schema(model_class)
|
|
166
|
+
namespace = model_class.table_name.singularize
|
|
167
|
+
temp_id = attributes['id'] || attributes[:id] || 'chronicle_temp_id'
|
|
168
|
+
|
|
169
|
+
datoms = attributes.map do |k, v|
|
|
170
|
+
next if k.to_s == 'id' || v.nil?
|
|
171
|
+
|
|
172
|
+
[:'db/add', temp_id, :":#{namespace}/#{k}", v]
|
|
173
|
+
end.compact
|
|
174
|
+
|
|
175
|
+
tx_result = @transport.transact(datoms)
|
|
176
|
+
tempids = tx_result&.dig(:tempids) || tx_result&.dig('tempids') || {}
|
|
177
|
+
tempids[temp_id] || tempids[temp_id.to_sym] || temp_id
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Updates existing entity attributes
|
|
181
|
+
def update(model_or_statement, entity_id_or_name = nil, attributes = nil)
|
|
182
|
+
if model_or_statement.is_a?(Arel::UpdateManager)
|
|
183
|
+
statement = model_or_statement.ast
|
|
184
|
+
table_name = statement.relation.name
|
|
185
|
+
model_class = ActiveRecord::Base.descendants.find { |klass| klass.table_name == table_name }
|
|
186
|
+
entity_id = statement.wheres.filter_map do |predicate|
|
|
187
|
+
next unless predicate.is_a?(Arel::Nodes::Equality) && predicate.left.name.to_s == 'id'
|
|
188
|
+
|
|
189
|
+
value = predicate.right
|
|
190
|
+
value.respond_to?(:value) ? value.value : value
|
|
191
|
+
end.first
|
|
192
|
+
values = statement.values.to_h do |assignment|
|
|
193
|
+
[assignment.left.expr.name, assignment.right.value]
|
|
194
|
+
end
|
|
195
|
+
update_entity(model_class, entity_id, values)
|
|
196
|
+
return 1
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
update_entity(model_or_statement, entity_id_or_name, attributes)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def update_entity(model_class, entity_id, attributes)
|
|
203
|
+
ensure_model_schema(model_class)
|
|
204
|
+
namespace = model_class.table_name.singularize
|
|
205
|
+
|
|
206
|
+
datoms = attributes.map do |k, v|
|
|
207
|
+
next if k.to_s == 'id'
|
|
208
|
+
|
|
209
|
+
[:'db/add', entity_id, :":#{namespace}/#{k}", v]
|
|
210
|
+
end.compact
|
|
211
|
+
|
|
212
|
+
@transport.transact(datoms)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Retracts an entity entirely from current database state
|
|
216
|
+
def delete(statement_or_entity_id, *_args)
|
|
217
|
+
if statement_or_entity_id.is_a?(Arel::DeleteManager)
|
|
218
|
+
statement = statement_or_entity_id.ast
|
|
219
|
+
table_name = statement.relation.name
|
|
220
|
+
model_class = ActiveRecord::Base.descendants.find { |klass| klass.table_name == table_name }
|
|
221
|
+
entity_id = statement.wheres.filter_map do |predicate|
|
|
222
|
+
next unless predicate.is_a?(Arel::Nodes::Equality) && predicate.left.name.to_s == 'id'
|
|
223
|
+
|
|
224
|
+
value = predicate.right
|
|
225
|
+
value.respond_to?(:value) ? value.value : value
|
|
226
|
+
end.first
|
|
227
|
+
return delete_entity(model_class, entity_id)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
delete_entity(nil, statement_or_entity_id)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def delete_entity(_model_class, entity_id)
|
|
234
|
+
datoms = [[:'db/retractEntity', entity_id]]
|
|
235
|
+
@transport.transact(datoms)
|
|
236
|
+
1
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def active?
|
|
240
|
+
@transport.connected?
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def reconnect!
|
|
244
|
+
@transport.connect!
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def disconnect!
|
|
248
|
+
@transport.disconnect!
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
def datomic_models
|
|
254
|
+
ActiveRecord::Base.descendants.reject(&:abstract_class?)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def datomic_type(type)
|
|
258
|
+
{
|
|
259
|
+
string: 'string', integer: 'long', bigint: 'long', float: 'double', boolean: 'boolean',
|
|
260
|
+
instant: 'instant', datetime: 'instant', ref: 'ref', uuid: 'uuid', bytes: 'bytes', uri: 'uri'
|
|
261
|
+
}[type.to_sym]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def format_results(raw_results)
|
|
265
|
+
return ActiveRecord::Result.new([], []) if raw_results.blank?
|
|
266
|
+
|
|
267
|
+
if raw_results.first.is_a?(Array)
|
|
268
|
+
cols = (0...raw_results.first.size).map { |i| "col_#{i}" }
|
|
269
|
+
ActiveRecord::Result.new(cols, raw_results)
|
|
270
|
+
elsif raw_results.first.is_a?(Hash)
|
|
271
|
+
cols = raw_results.first.keys.map(&:to_s)
|
|
272
|
+
rows = raw_results.map(&:values)
|
|
273
|
+
ActiveRecord::Result.new(cols, rows)
|
|
274
|
+
else
|
|
275
|
+
ActiveRecord::Result.new(['value'], raw_results.map { |v| [v] })
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Register adapter with ActiveRecord
|
|
283
|
+
if defined?(ActiveRecord::ConnectionAdapters) && ActiveRecord::ConnectionAdapters.respond_to?(:register)
|
|
284
|
+
ActiveRecord::ConnectionAdapters.register('datomic', 'ActiveRecord::ConnectionAdapters::DatomicAdapter',
|
|
285
|
+
'chronicle/connection_adapters/datomic_adapter')
|
|
286
|
+
end
|