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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chronicle
|
|
4
|
+
module Datalog
|
|
5
|
+
class Compiler
|
|
6
|
+
attr_reader :relation, :model
|
|
7
|
+
|
|
8
|
+
def self.compile(relation)
|
|
9
|
+
new(relation).compile
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(relation)
|
|
13
|
+
@relation = relation
|
|
14
|
+
@model = relation.klass
|
|
15
|
+
@find_clause = [:find, '?e']
|
|
16
|
+
@in_clause = [:in, '$']
|
|
17
|
+
@where_clause = []
|
|
18
|
+
@bindings = []
|
|
19
|
+
@var_counter = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def compile
|
|
23
|
+
namespace = model.table_name.singularize
|
|
24
|
+
|
|
25
|
+
# 1. Process custom select / projections if specified
|
|
26
|
+
process_projections(namespace)
|
|
27
|
+
|
|
28
|
+
# 2. Fetch model attributes for hydration when no custom projection is requested
|
|
29
|
+
process_model_attributes(namespace)
|
|
30
|
+
|
|
31
|
+
# 3. Process includes / eager_load for preloading associations via Datomic pull
|
|
32
|
+
process_includes(namespace)
|
|
33
|
+
|
|
34
|
+
# 4. Process predicates in relation
|
|
35
|
+
process_where_clause(namespace)
|
|
36
|
+
|
|
37
|
+
if @where_clause.empty? && model.respond_to?(:datomic_attributes) && model.datomic_attributes.any?
|
|
38
|
+
attribute = model.datomic_attributes.keys.first
|
|
39
|
+
@where_clause << ['?e', :":#{namespace}/#{attribute}", '?value']
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 5. Fetch attributes needed to honor Active Record ordering
|
|
43
|
+
process_ordering(namespace)
|
|
44
|
+
|
|
45
|
+
# 6. Assemble Datalog data structure
|
|
46
|
+
datalog_query = @find_clause + @in_clause + [:where] + @where_clause
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
query: datalog_query,
|
|
50
|
+
bindings: @bindings,
|
|
51
|
+
find_vars: @find_clause[1..],
|
|
52
|
+
pull_spec: @pull_spec
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def process_includes(namespace)
|
|
59
|
+
includes_list = []
|
|
60
|
+
includes_list += relation.includes_values if relation.respond_to?(:includes_values) && relation.includes_values.present?
|
|
61
|
+
includes_list += relation.eager_load_values if relation.respond_to?(:eager_load_values) && relation.eager_load_values.present?
|
|
62
|
+
|
|
63
|
+
return if includes_list.blank?
|
|
64
|
+
|
|
65
|
+
@pull_spec = build_pull_spec(includes_list, namespace)
|
|
66
|
+
@find_clause = [:find, [:pull, '?e', @pull_spec]]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_pull_spec(includes_list, namespace)
|
|
70
|
+
spec = ['*']
|
|
71
|
+
includes_list.flatten.compact.each do |item|
|
|
72
|
+
case item
|
|
73
|
+
when Symbol, String
|
|
74
|
+
ident = ":#{namespace}/#{item}"
|
|
75
|
+
spec << { ident => ['*'] }
|
|
76
|
+
when Hash
|
|
77
|
+
item.each do |parent_key, child_item|
|
|
78
|
+
parent_ident = ":#{namespace}/#{parent_key}"
|
|
79
|
+
child_ns = parent_key.to_s.singularize
|
|
80
|
+
child_spec = build_pull_spec([child_item], child_ns)
|
|
81
|
+
spec << { parent_ident => child_spec }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
spec
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def process_projections(namespace)
|
|
89
|
+
select_values = relation.select_values if relation.respond_to?(:select_values)
|
|
90
|
+
return unless select_values.present? && select_values.none? { |s| s.to_s == '*' }
|
|
91
|
+
|
|
92
|
+
find_vars = select_values.map do |col|
|
|
93
|
+
col_name = col.is_a?(Arel::Nodes::Node) ? col.name : col.to_s
|
|
94
|
+
var_name = "?#{col_name}"
|
|
95
|
+
# Add attribute pattern to retrieve attribute value
|
|
96
|
+
@where_clause << ['?e', :":#{namespace}/#{col_name}", var_name]
|
|
97
|
+
var_name
|
|
98
|
+
end
|
|
99
|
+
@find_clause = [:find] + find_vars
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def process_model_attributes(namespace)
|
|
103
|
+
return if relation.select_values.present? || !model.respond_to?(:datomic_attributes)
|
|
104
|
+
return if model.datomic_attributes.empty?
|
|
105
|
+
|
|
106
|
+
@find_clause = [:find, [:pull, '?e', ['*']]]
|
|
107
|
+
attribute = model.datomic_attributes.keys.first
|
|
108
|
+
@where_clause << ['?e', :":#{namespace}/#{attribute}", '?value']
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def process_where_clause(namespace)
|
|
112
|
+
where_predicates = extract_predicates
|
|
113
|
+
|
|
114
|
+
where_predicates.each do |pred|
|
|
115
|
+
process_predicate(pred, namespace)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def process_ordering(namespace)
|
|
120
|
+
return unless relation.respond_to?(:order_values)
|
|
121
|
+
return if @find_clause[1].is_a?(Array)
|
|
122
|
+
|
|
123
|
+
relation.order_values.each do |order|
|
|
124
|
+
expression = order.respond_to?(:expr) ? order.expr : order
|
|
125
|
+
next unless expression.respond_to?(:name)
|
|
126
|
+
|
|
127
|
+
attribute = expression.name.to_s
|
|
128
|
+
next if attribute == 'id' || !model.datomic_attributes.key?(attribute.to_sym)
|
|
129
|
+
|
|
130
|
+
variable = "?#{attribute}"
|
|
131
|
+
next if @find_clause.include?(variable)
|
|
132
|
+
|
|
133
|
+
@find_clause << variable
|
|
134
|
+
@where_clause << ['?e', :":#{namespace}/#{attribute}", variable]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def extract_predicates
|
|
139
|
+
if relation.respond_to?(:where_clause)
|
|
140
|
+
relation.where_clause.send(:predicates)
|
|
141
|
+
else
|
|
142
|
+
[]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def process_predicate(pred, namespace)
|
|
147
|
+
case pred
|
|
148
|
+
when Arel::Nodes::Equality
|
|
149
|
+
attr_name = pred.left.name
|
|
150
|
+
val = unwrap_value(pred.right)
|
|
151
|
+
param_var = "?param_#{next_var_id}"
|
|
152
|
+
|
|
153
|
+
if attr_name.to_s == 'id'
|
|
154
|
+
@where_clause << [[:'=', '?e', param_var]]
|
|
155
|
+
@in_clause << param_var
|
|
156
|
+
@bindings << val
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
@where_clause << ['?e', :":#{namespace}/#{attr_name}", param_var]
|
|
161
|
+
@in_clause << param_var
|
|
162
|
+
@bindings << val
|
|
163
|
+
|
|
164
|
+
when Arel::Nodes::GreaterThan
|
|
165
|
+
attr_name = pred.left.name
|
|
166
|
+
val = unwrap_value(pred.right)
|
|
167
|
+
var_name = "?var_#{attr_name}"
|
|
168
|
+
|
|
169
|
+
@where_clause << ['?e', :":#{namespace}/#{attr_name}", var_name]
|
|
170
|
+
@where_clause << [[:>, var_name, val]]
|
|
171
|
+
|
|
172
|
+
when Arel::Nodes::GreaterThanOrEqual
|
|
173
|
+
attr_name = pred.left.name
|
|
174
|
+
val = unwrap_value(pred.right)
|
|
175
|
+
var_name = "?var_#{attr_name}"
|
|
176
|
+
|
|
177
|
+
@where_clause << ['?e', :":#{namespace}/#{attr_name}", var_name]
|
|
178
|
+
@where_clause << [[:>=, var_name, val]]
|
|
179
|
+
|
|
180
|
+
when Arel::Nodes::LessThan
|
|
181
|
+
attr_name = pred.left.name
|
|
182
|
+
val = unwrap_value(pred.right)
|
|
183
|
+
var_name = "?var_#{attr_name}"
|
|
184
|
+
|
|
185
|
+
@where_clause << ['?e', :":#{namespace}/#{attr_name}", var_name]
|
|
186
|
+
@where_clause << [[:<, var_name, val]]
|
|
187
|
+
|
|
188
|
+
when Arel::Nodes::LessThanOrEqual
|
|
189
|
+
attr_name = pred.left.name
|
|
190
|
+
val = unwrap_value(pred.right)
|
|
191
|
+
var_name = "?var_#{attr_name}"
|
|
192
|
+
|
|
193
|
+
@where_clause << ['?e', :":#{namespace}/#{attr_name}", var_name]
|
|
194
|
+
@where_clause << [[:<=, var_name, val]]
|
|
195
|
+
|
|
196
|
+
when Arel::Nodes::NotEqual
|
|
197
|
+
attr_name = pred.left.name
|
|
198
|
+
val = unwrap_value(pred.right)
|
|
199
|
+
param_var = "?param_#{next_var_id}"
|
|
200
|
+
var_name = "?var_#{attr_name}"
|
|
201
|
+
|
|
202
|
+
@where_clause << ['?e', :":#{namespace}/#{attr_name}", var_name]
|
|
203
|
+
@where_clause << [[:!=, var_name, param_var]]
|
|
204
|
+
@in_clause << param_var
|
|
205
|
+
@bindings << val
|
|
206
|
+
|
|
207
|
+
when Arel::Nodes::In
|
|
208
|
+
attr_name = pred.left.name
|
|
209
|
+
values = unwrap_value(pred.right).map { |v| unwrap_value(v) }
|
|
210
|
+
param_var = "?in_vec_#{next_var_id}"
|
|
211
|
+
val_var = "?var_#{attr_name}"
|
|
212
|
+
|
|
213
|
+
@where_clause << ['?e', :":#{namespace}/#{attr_name}", val_var]
|
|
214
|
+
@where_clause << [val_var, param_var]
|
|
215
|
+
@in_clause << [param_var, '...']
|
|
216
|
+
@bindings << values
|
|
217
|
+
|
|
218
|
+
when Arel::Nodes::HomogeneousIn
|
|
219
|
+
attr_name = pred.attribute.name
|
|
220
|
+
values = pred.values.map { |value| unwrap_value(value) }
|
|
221
|
+
param_var = "?in_vec_#{next_var_id}"
|
|
222
|
+
val_var = "?var_#{attr_name}"
|
|
223
|
+
|
|
224
|
+
@where_clause << ['?e', :":#{namespace}/#{attr_name}", val_var]
|
|
225
|
+
@where_clause << [val_var, param_var]
|
|
226
|
+
@in_clause << [param_var, '...']
|
|
227
|
+
@bindings << values
|
|
228
|
+
|
|
229
|
+
when String
|
|
230
|
+
@where_clause << pred
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def unwrap_value(val)
|
|
235
|
+
if val.respond_to?(:value)
|
|
236
|
+
val.value
|
|
237
|
+
else
|
|
238
|
+
val
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def next_var_id
|
|
243
|
+
@var_counter += 1
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chronicle
|
|
4
|
+
module Datalog
|
|
5
|
+
class Optimizer
|
|
6
|
+
class << self
|
|
7
|
+
def optimize(compiled_query, model = nil)
|
|
8
|
+
new(compiled_query, model).optimize
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :query, :bindings, :find_vars, :model
|
|
13
|
+
|
|
14
|
+
def initialize(compiled_query, model = nil)
|
|
15
|
+
@query = compiled_query[:query]
|
|
16
|
+
@bindings = compiled_query[:bindings]
|
|
17
|
+
@find_vars = compiled_query[:find_vars]
|
|
18
|
+
@model = model
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def optimize
|
|
22
|
+
# Extract clauses
|
|
23
|
+
find_idx = query.index(:find)
|
|
24
|
+
in_idx = query.index(:in)
|
|
25
|
+
where_idx = query.index(:where)
|
|
26
|
+
|
|
27
|
+
find_part = query[find_idx...in_idx]
|
|
28
|
+
in_part = query[in_idx...where_idx]
|
|
29
|
+
where_part = query[(where_idx + 1)..]
|
|
30
|
+
|
|
31
|
+
# Reorder where clauses for optimal AVET/EAVT index performance
|
|
32
|
+
optimized_where = reorder_where_clauses(where_part)
|
|
33
|
+
|
|
34
|
+
# Assemble optimized query
|
|
35
|
+
optimized_query = find_part + in_part + [:where] + optimized_where
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
query: optimized_query,
|
|
39
|
+
bindings:,
|
|
40
|
+
find_vars:,
|
|
41
|
+
optimized: true
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def reorder_where_clauses(clauses)
|
|
48
|
+
# Separate pattern triples [?e :attr ?val] from predicate expressions [[> ?var val]]
|
|
49
|
+
patterns = []
|
|
50
|
+
predicates = []
|
|
51
|
+
|
|
52
|
+
clauses.each do |c|
|
|
53
|
+
if c.is_a?(Array) && c.first.is_a?(Array)
|
|
54
|
+
# Predicate expression like [[:>, "?var_age", 21]] or [[":!=", ...]]
|
|
55
|
+
predicates << c
|
|
56
|
+
else
|
|
57
|
+
patterns << c
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Sort patterns: indexed/unique attributes first (AVET seek), then general attributes
|
|
62
|
+
sorted_patterns = patterns.sort_by do |pat|
|
|
63
|
+
attr_sym = pat[1]
|
|
64
|
+
if unique_or_indexed_attribute?(attr_sym)
|
|
65
|
+
0 # Highest priority for AVET index seek
|
|
66
|
+
else
|
|
67
|
+
1
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Patterns executed first to narrow candidate set, then predicates filter
|
|
72
|
+
sorted_patterns + predicates
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unique_or_indexed_attribute?(attr_sym)
|
|
76
|
+
return false unless model.respond_to?(:datomic_attributes)
|
|
77
|
+
|
|
78
|
+
attr_name = attr_sym.to_s.split('/').last&.to_sym
|
|
79
|
+
return false unless attr_name
|
|
80
|
+
|
|
81
|
+
meta = model.datomic_attributes[attr_name]
|
|
82
|
+
return false unless meta
|
|
83
|
+
|
|
84
|
+
opts = meta[:options] || {}
|
|
85
|
+
opts[:unique].present? || opts[:index] == true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/hash/keys'
|
|
4
|
+
require 'active_support/core_ext/object/blank'
|
|
5
|
+
require 'active_support/core_ext/string/inflections'
|
|
6
|
+
|
|
7
|
+
module Chronicle
|
|
8
|
+
class Hydrator
|
|
9
|
+
class << self
|
|
10
|
+
# Main entry point for hydrating raw Datomic query or pull results into Active Record instances
|
|
11
|
+
#
|
|
12
|
+
# @param klass [Class] The Active Record model class
|
|
13
|
+
# @param raw_results [Array] Results returned from Datomic transport #q or #pull
|
|
14
|
+
# @param compiled_meta [Hash] Options/metadata from Datalog compiler (e.g. find_vars, pluck)
|
|
15
|
+
# @return [Array<ActiveRecord::Base>, Array] Hydrated model instances or raw scalar values
|
|
16
|
+
def hydrate(klass, raw_results, compiled_meta = {})
|
|
17
|
+
return [] if raw_results.blank?
|
|
18
|
+
|
|
19
|
+
return hydrate_pluck(raw_results, compiled_meta) if compiled_meta[:pluck]
|
|
20
|
+
|
|
21
|
+
find_vars = compiled_meta[:find_vars] || ['?e']
|
|
22
|
+
|
|
23
|
+
raw_results.map do |row|
|
|
24
|
+
attributes, associations = extract_attributes_and_associations(klass, row, find_vars)
|
|
25
|
+
record = instantiate_model(klass, attributes)
|
|
26
|
+
hydrate_associations(record, associations) if record && associations.present?
|
|
27
|
+
record
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def hydrate_pluck(raw_results, compiled_meta)
|
|
34
|
+
find_vars = compiled_meta[:find_vars] || []
|
|
35
|
+
if find_vars.size == 1
|
|
36
|
+
raw_results.map { |row| row.is_a?(Array) ? row.first : row }
|
|
37
|
+
else
|
|
38
|
+
raw_results.map { |row| row.is_a?(Array) ? row : [row] }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def extract_attributes_and_associations(klass, row, find_vars)
|
|
43
|
+
attributes = {}
|
|
44
|
+
associations = {}
|
|
45
|
+
|
|
46
|
+
row = row.first if row.is_a?(Array) && row.size == 1 && row.first.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
if row.is_a?(Hash)
|
|
49
|
+
# Datomic Entity Map (from pull query or entity map)
|
|
50
|
+
row.each do |key, val|
|
|
51
|
+
clean_key = normalize_key(key, klass)
|
|
52
|
+
next unless clean_key
|
|
53
|
+
|
|
54
|
+
if val.is_a?(Hash) || (val.is_a?(Array) && val.first.is_a?(Hash))
|
|
55
|
+
# Nested entity reference (association)
|
|
56
|
+
associations[clean_key] = val
|
|
57
|
+
reflection = klass.reflect_on_association(clean_key.to_sym) if klass.respond_to?(:reflect_on_association)
|
|
58
|
+
if reflection.respond_to?(:foreign_key) && val.is_a?(Hash)
|
|
59
|
+
child_id = val[':db/id'] || val['db/id'] || val['id']
|
|
60
|
+
attributes[reflection.foreign_key] = child_id if child_id
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
attributes[clean_key] = val
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
elsif row.is_a?(Array)
|
|
67
|
+
# Tuple result corresponding to find_vars
|
|
68
|
+
find_vars.each_with_index do |var_name, idx|
|
|
69
|
+
attr_name = var_name.to_s.sub(/^\?/, '')
|
|
70
|
+
if attr_name == 'e'
|
|
71
|
+
attributes['id'] = row[idx]
|
|
72
|
+
else
|
|
73
|
+
val = row[idx]
|
|
74
|
+
if val.is_a?(Hash) || (val.is_a?(Array) && val.first.is_a?(Hash))
|
|
75
|
+
associations[attr_name] = val
|
|
76
|
+
else
|
|
77
|
+
attributes[attr_name] = val
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
else
|
|
82
|
+
# Single scalar (e.g. entity ID integer)
|
|
83
|
+
attributes['id'] = row
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
[attributes, associations]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_key(key, _klass)
|
|
90
|
+
str_key = key.to_s.sub(/^:/, '')
|
|
91
|
+
|
|
92
|
+
if ['db/id', 'id'].include?(str_key)
|
|
93
|
+
'id'
|
|
94
|
+
elsif str_key.include?('/')
|
|
95
|
+
# Namespace attribute like "user/name" -> "name"
|
|
96
|
+
parts = str_key.split('/')
|
|
97
|
+
parts.last.tr('-', '_')
|
|
98
|
+
else
|
|
99
|
+
str_key.tr('-', '_')
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def instantiate_model(klass, attributes)
|
|
104
|
+
string_attributes = attributes.stringify_keys
|
|
105
|
+
|
|
106
|
+
if klass.respond_to?(:instantiate)
|
|
107
|
+
record = klass.instantiate(string_attributes)
|
|
108
|
+
ensure_dirty_tracking_clean(record)
|
|
109
|
+
else
|
|
110
|
+
record = klass.allocate
|
|
111
|
+
if record.respond_to?(:init_with)
|
|
112
|
+
record.init_with('attributes' => string_attributes, 'new_record' => false)
|
|
113
|
+
else
|
|
114
|
+
string_attributes.each do |k, v|
|
|
115
|
+
setter = "#{k}="
|
|
116
|
+
record.public_send(setter, v) if record.respond_to?(setter)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
record
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def hydrate_associations(record, associations)
|
|
124
|
+
associations.each do |assoc_name, val|
|
|
125
|
+
reflection = (record.class.reflect_on_association(assoc_name.to_sym) if record.class.respond_to?(:reflect_on_association))
|
|
126
|
+
|
|
127
|
+
target_klass = reflection ? reflection.klass : assoc_name.to_s.classify.safe_constantize
|
|
128
|
+
|
|
129
|
+
next unless target_klass
|
|
130
|
+
|
|
131
|
+
if val.is_a?(Array)
|
|
132
|
+
child_records = val.map { |child_hash| hydrate(target_klass, [child_hash]).first }.compact
|
|
133
|
+
set_association_target(record, reflection, assoc_name, child_records, is_collection: true)
|
|
134
|
+
else
|
|
135
|
+
child_record = hydrate(target_klass, [val]).first
|
|
136
|
+
set_association_target(record, reflection, assoc_name, child_record, is_collection: false)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def set_association_target(record, reflection, assoc_name, target, is_collection:)
|
|
142
|
+
if reflection && record.respond_to?(:association)
|
|
143
|
+
if !is_collection && reflection.respond_to?(:foreign_key) && target.respond_to?(:id)
|
|
144
|
+
foreign_key = reflection.foreign_key
|
|
145
|
+
record._write_attribute(foreign_key, target.id) if record.has_attribute?(foreign_key)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
assoc = record.association(reflection.name)
|
|
149
|
+
assoc.target = target
|
|
150
|
+
assoc.loaded!
|
|
151
|
+
end
|
|
152
|
+
record.instance_variable_set("@#{assoc_name}", target)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def ensure_dirty_tracking_clean(record)
|
|
156
|
+
if record.respond_to?(:clear_changes_information)
|
|
157
|
+
record.clear_changes_information
|
|
158
|
+
elsif record.respond_to?(:changes_applied)
|
|
159
|
+
record.changes_applied
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chronicle
|
|
4
|
+
module Model
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
class_attribute :datomic_attributes, default: {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
def all(all_queries: nil)
|
|
13
|
+
super.extending(Relation)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def find(*ids)
|
|
17
|
+
return super unless connection.respond_to?(:transport)
|
|
18
|
+
return ids.map { |id| find(id) } if ids.size > 1
|
|
19
|
+
|
|
20
|
+
record = all.to_a.find { |item| item.id.to_s == ids.first.to_s }
|
|
21
|
+
return record if record
|
|
22
|
+
|
|
23
|
+
raise ActiveRecord::RecordNotFound, "Couldn't find #{name} with 'id'=#{ids.first}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def datomic_attribute(name, type = :string, options = {})
|
|
27
|
+
self.datomic_attributes = datomic_attributes.merge(name.to_sym => { type:, options: })
|
|
28
|
+
|
|
29
|
+
define_method(name) do
|
|
30
|
+
read_attribute(name)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
define_method("#{name}=") do |val|
|
|
34
|
+
write_attribute(name, val)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def as_of(time_or_t)
|
|
39
|
+
all.extending(TimeTravelExtension).spawn.tap do |rel|
|
|
40
|
+
rel.time_travel_options = { as_of: time_or_t }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def since(time_or_t)
|
|
45
|
+
all.extending(TimeTravelExtension).spawn.tap do |rel|
|
|
46
|
+
rel.time_travel_options = { since: time_or_t }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def chronicle_transport
|
|
51
|
+
adapter = connection
|
|
52
|
+
return adapter.transport if adapter.respond_to?(:transport)
|
|
53
|
+
|
|
54
|
+
Chronicle::Transport.client
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
module TimeTravelExtension
|
|
59
|
+
include Relation
|
|
60
|
+
|
|
61
|
+
attr_accessor :time_travel_options
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def datomic_entity_id
|
|
65
|
+
read_attribute(:id)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_datoms
|
|
69
|
+
datoms = []
|
|
70
|
+
self.class.datomic_attributes.each_key do |attr_name|
|
|
71
|
+
val = public_send(attr_name)
|
|
72
|
+
next if val.nil?
|
|
73
|
+
|
|
74
|
+
attr_ident = :":#{self.class.table_name.singularize}/#{attr_name}"
|
|
75
|
+
datoms << [:'db/add', datomic_entity_id || 'chronicle_temp_id', attr_ident, val]
|
|
76
|
+
end
|
|
77
|
+
datoms
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chronicle
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer 'chronicle.register_adapter' do
|
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
|
7
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
8
|
+
'datomic',
|
|
9
|
+
'ActiveRecord::ConnectionAdapters::DatomicAdapter',
|
|
10
|
+
'chronicle/connection_adapters/datomic_adapter'
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chronicle
|
|
4
|
+
module Relation
|
|
5
|
+
attr_accessor :time_travel_options
|
|
6
|
+
|
|
7
|
+
def as_of(timestamp_or_tx)
|
|
8
|
+
spawn.tap do |r|
|
|
9
|
+
r.time_travel_options = (r.time_travel_options || {}).merge(as_of: timestamp_or_tx)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def since(timestamp_or_tx)
|
|
14
|
+
spawn.tap do |r|
|
|
15
|
+
r.time_travel_options = (r.time_travel_options || {}).merge(since: timestamp_or_tx)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_datalog
|
|
20
|
+
Datalog::Compiler.compile(self)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def exec_queries
|
|
24
|
+
return @records if loaded?
|
|
25
|
+
|
|
26
|
+
# Compile Active Record relation into Datomic Datalog AST
|
|
27
|
+
compiled = Datalog::Compiler.compile(self)
|
|
28
|
+
|
|
29
|
+
# Obtain transport adapter and database snapshot
|
|
30
|
+
adapter = klass.respond_to?(:chronicle_transport) ? klass.chronicle_transport : Chronicle::Transport.client
|
|
31
|
+
klass.connection.ensure_model_schema(klass) if klass.connection.respond_to?(:ensure_model_schema)
|
|
32
|
+
db_snapshot = adapter.db(
|
|
33
|
+
as_of: time_travel_options&.dig(:as_of),
|
|
34
|
+
since: time_travel_options&.dig(:since)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Query Datomic endpoint
|
|
38
|
+
raw_results = if adapter.respond_to?(:pull) && compiled[:query][1].is_a?(Array) && compiled[:query][1].first == :pull
|
|
39
|
+
id_query = compiled[:query].dup
|
|
40
|
+
id_query[id_query.index(:find) + 1] = '?e'
|
|
41
|
+
entity_ids = adapter.q(id_query, db_snapshot, *compiled[:bindings]).map do |row|
|
|
42
|
+
row.is_a?(Array) ? row.first : row
|
|
43
|
+
end
|
|
44
|
+
entity_ids.map { |entity_id| adapter.pull(entity_id, db_snapshot) }
|
|
45
|
+
else
|
|
46
|
+
adapter.q(compiled[:query], db_snapshot, *compiled[:bindings])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Map raw Datoms/tuples back into Active Record model instances using Hydrator
|
|
50
|
+
@records = order_records(Hydrator.hydrate(klass, raw_results, compiled))
|
|
51
|
+
@loaded = true
|
|
52
|
+
@records
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def size
|
|
56
|
+
records.length
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def order_records(records)
|
|
62
|
+
orderings = order_values.filter_map do |order|
|
|
63
|
+
direction = order.is_a?(Arel::Nodes::Descending) ? -1 : 1
|
|
64
|
+
expression = order.respond_to?(:expr) ? order.expr : order
|
|
65
|
+
attribute = expression.name if expression.respond_to?(:name)
|
|
66
|
+
[attribute.to_s, direction] if attribute && (attribute.to_s == 'id' || klass.datomic_attributes.key?(attribute.to_sym))
|
|
67
|
+
end
|
|
68
|
+
return records if orderings.empty?
|
|
69
|
+
|
|
70
|
+
records.sort do |left, right|
|
|
71
|
+
comparison = 0
|
|
72
|
+
orderings.each do |attribute, direction|
|
|
73
|
+
left_value = left.read_attribute(attribute)
|
|
74
|
+
right_value = right.read_attribute(attribute)
|
|
75
|
+
comparison = compare_order_values(left_value, right_value, direction)
|
|
76
|
+
break unless comparison.zero?
|
|
77
|
+
end
|
|
78
|
+
comparison
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def compare_order_values(left, right, direction)
|
|
83
|
+
return 0 if left.nil? && right.nil?
|
|
84
|
+
return (left.nil? ? -1 : 1) * direction if left.nil? || right.nil?
|
|
85
|
+
|
|
86
|
+
(left <=> right || 0) * direction
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|