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.
@@ -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