activecypher 0.5.0 → 0.6.1

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_cypher/base.rb +28 -8
  3. data/lib/active_cypher/bolt/driver.rb +6 -16
  4. data/lib/active_cypher/bolt/transaction.rb +9 -9
  5. data/lib/active_cypher/connection_adapters/abstract_adapter.rb +28 -0
  6. data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +1 -1
  7. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
  8. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +26 -0
  9. data/lib/active_cypher/connection_adapters/registry.rb +96 -0
  10. data/lib/active_cypher/connection_handler.rb +18 -3
  11. data/lib/active_cypher/connection_pool.rb +5 -23
  12. data/lib/active_cypher/connection_url_resolver.rb +14 -3
  13. data/lib/active_cypher/fixtures/dsl_context.rb +41 -0
  14. data/lib/active_cypher/fixtures/evaluator.rb +37 -0
  15. data/lib/active_cypher/fixtures/node_builder.rb +53 -0
  16. data/lib/active_cypher/fixtures/parser.rb +23 -0
  17. data/lib/active_cypher/fixtures/registry.rb +43 -0
  18. data/lib/active_cypher/fixtures/rel_builder.rb +96 -0
  19. data/lib/active_cypher/fixtures.rb +177 -0
  20. data/lib/active_cypher/generators/templates/application_graph_node.rb +1 -0
  21. data/lib/active_cypher/model/callbacks.rb +5 -13
  22. data/lib/active_cypher/model/connection_handling.rb +37 -52
  23. data/lib/active_cypher/model/connection_owner.rb +31 -38
  24. data/lib/active_cypher/model/core.rb +1 -63
  25. data/lib/active_cypher/model/destruction.rb +16 -18
  26. data/lib/active_cypher/model/labelling.rb +45 -0
  27. data/lib/active_cypher/model/persistence.rb +46 -40
  28. data/lib/active_cypher/model/querying.rb +47 -27
  29. data/lib/active_cypher/railtie.rb +40 -5
  30. data/lib/active_cypher/relationship.rb +77 -17
  31. data/lib/active_cypher/version.rb +1 -1
  32. data/lib/activecypher.rb +4 -1
  33. data/lib/cyrel/functions.rb +3 -1
  34. data/lib/cyrel/logging.rb +43 -0
  35. data/lib/cyrel/query.rb +6 -1
  36. metadata +11 -2
  37. data/lib/active_cypher/connection_factory.rb +0 -130
@@ -2,9 +2,8 @@
2
2
 
3
3
  module ActiveCypher
4
4
  module Model
5
- # @!parse
6
- # # Destruction: The module that lets you banish records from existence with a single incantation.
7
- # # Uses a blend of Ruby sorcery, a dash of witchcraft, and—on rare occasions—some back magick when nothing else will do.
5
+ # Destruction: The module that lets you banish records from existence with a single incantation.
6
+ # Uses a blend of Ruby sorcery, a dash of witchcraft, and—on rare occasions—some back magick when nothing else will do.
8
7
  module Destruction
9
8
  extend ActiveSupport::Concern
10
9
 
@@ -13,7 +12,6 @@ module ActiveCypher
13
12
  # Runs a Cypher `DETACH DELETE` query on the node.
14
13
  # Freezes the object to prevent further use, as a kind of ceremonial burial.
15
14
  # Because nothing says "closure" like a frozen Ruby object.
16
- # If this works and you can't explain why, that's probably back magick.
17
15
  #
18
16
  # @raise [RuntimeError] if the record is new or already destroyed.
19
17
  # @return [Boolean] true if the record was successfully destroyed, false if something caught on fire.
@@ -23,29 +21,29 @@ module ActiveCypher
23
21
  raise 'Record already destroyed' if destroyed?
24
22
 
25
23
  n = :n
26
- # Use all labels for database operations
27
24
  labels = self.class.respond_to?(:labels) ? self.class.labels : [self.class.label_name]
28
- query = Cyrel.match(Cyrel.node(n, labels: labels))
29
- .where(Cyrel.element_id(n).eq(internal_id))
30
- .detach_delete(n)
31
- .return_('count(*) as deleted')
25
+ label_string = labels.map { |l| ":#{l}" }.join
32
26
 
33
- cypher, params = query.to_cypher
34
- params ||= {}
27
+ cypher = <<~CYPHER
28
+ MATCH (#{n}#{label_string})
29
+ WHERE id(#{n}) = #{internal_id}
30
+ DETACH DELETE #{n}
31
+ RETURN count(*) AS deleted
32
+ CYPHER
35
33
 
36
- # Here lies the true sorcery: one line to erase a node from existence.
37
- # If the database still remembers it, you may need to consult your local witch.
38
- result = self.class.connection.execute_cypher(cypher, params, 'Destroy')
39
- if result.present? && result.first.present? && (result.first[:deleted] || 0).positive?
34
+ result = self.class.connection.execute_cypher(cypher, {}, 'Destroy')
35
+
36
+ if result.present? && result.first[:deleted].to_i.positive?
40
37
  @destroyed = true
41
- freeze # To make sure you can't Frankenstein it back to life. Lightning not included.
38
+ freeze
42
39
  true
43
40
  else
44
41
  false
45
42
  end
46
43
  end
47
- rescue StandardError
48
- false # Something went wrong. Don't ask. Just walk away. Or blame the database, that's always fun. If it keeps happening, suspect back magick.
44
+ rescue StandardError => e
45
+ warn "[Destruction] Destroy failed: #{e.class}: #{e.message}"
46
+ false
49
47
  end
50
48
 
51
49
  # Returns true if this object has achieved full existential closure.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Model
5
+ module Labelling
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ # Use array instead of set to preserve insertion order of labels
10
+ class_attribute :custom_labels, default: []
11
+ end
12
+
13
+ class_methods do
14
+ # Define a label for the model. Can be called multiple times to add multiple labels.
15
+ # @param label_name [Symbol, String] The label name
16
+ # @return [Array] The collection of custom labels
17
+ def label(label_name)
18
+ label_sym = label_name.to_sym
19
+ self.custom_labels = custom_labels.dup << label_sym unless custom_labels.include?(label_sym)
20
+ custom_labels
21
+ end
22
+
23
+ # Get all labels for this model
24
+ # @return [Array<Symbol>] All labels for this model
25
+ def labels
26
+ custom_labels.empty? ? [default_label] : custom_labels
27
+ end
28
+
29
+ # Returns the primary label for the model
30
+ # @return [Symbol] The primary label
31
+ def label_name
32
+ custom_labels.any? ? custom_labels.first : default_label
33
+ end
34
+
35
+ # Computes the default label for the model based on class name
36
+ # Strips 'Node' or 'Record' suffix, returns as symbol, capitalized
37
+ def default_label
38
+ base = name.split('::').last
39
+ base = base.sub(/(Node|Record)\z/, '')
40
+ base.to_sym
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -14,10 +14,13 @@ module ActiveCypher
14
14
  # If it already exists, we patch up its regrets.
15
15
  # If it fails, we return false, like cowards.
16
16
  #
17
+ # @param validate [Boolean] whether to run validations (default: true)
17
18
  # @return [Boolean] true if saved successfully, false if the database ghosted us.
18
19
  # Because nothing says "robust" like pretending persistence is easy.
19
20
  # If this works and you can't explain why, that's probably back magick.
20
- def save
21
+ def save(validate: true)
22
+ return false if validate && !valid?
23
+
21
24
  # before_/after_create
22
25
  _run(:save) do
23
26
  if new_record?
@@ -112,17 +115,6 @@ module ActiveCypher
112
115
 
113
116
  private
114
117
 
115
- # # Internal method to initialize a record from the database
116
- # # @param attributes [Hash] The attributes from the database
117
- # # @return [self]
118
- # def init_with_attributes(attrs)
119
- # binding.irb
120
- # assign_attributes(**attrs) if attrs
121
- # @new_record = false # Mark as not new when loading from DB
122
- # clear_changes_information
123
- # self
124
- # end
125
-
126
118
  # Creates the record in the database using Cypher.
127
119
  #
128
120
  # @return [Boolean] true if the database accepted your offering.
@@ -135,16 +127,31 @@ module ActiveCypher
135
127
  # Use all labels for database operations
136
128
  labels = self.class.respond_to?(:labels) ? self.class.labels : [self.class.label_name.to_s]
137
129
 
138
- # Create node with all labels
139
- node = Cyrel.node(n, labels: labels, properties: props)
140
- query = Cyrel.create(node).return_(Cyrel.element_id(n).as(:internal_id))
141
- cypher, params = query.to_cypher
142
- params ||= {}
130
+ # For Memgraph, construct direct Cypher query
131
+ label_string = labels.map { |l| ":#{l}" }.join
132
+
133
+ # Handle properties for Cypher query
134
+ props_str = props.map do |k, v|
135
+ value_str = if v.nil?
136
+ 'NULL'
137
+ elsif v.is_a?(String)
138
+ "'#{v.gsub("'", "\\\\'")}'"
139
+ elsif v.is_a?(Numeric) || v.is_a?(TrueClass) || v.is_a?(FalseClass)
140
+ v.to_s
141
+ else
142
+ "'#{v.to_s.gsub("'", "\\\\'")}'"
143
+ end
144
+ "#{k}: #{value_str}"
145
+ end.join(', ')
146
+
147
+ cypher = "CREATE (#{n}#{label_string} {#{props_str}}) " \
148
+ "RETURN id(#{n}) AS internal_id"
149
+
150
+ data = self.class.connection.execute_cypher(cypher, {}, 'Create')
143
151
 
144
- data = self.class.connection.execute_cypher(cypher, params, 'Create')
145
152
  return false if data.blank? || !data.first.key?(:internal_id)
146
153
 
147
- self.internal_id = data.first[:internal_id].to_s
154
+ self.internal_id = data.first[:internal_id]
148
155
  @new_record = false
149
156
  changes_applied
150
157
  true
@@ -169,33 +176,32 @@ module ActiveCypher
169
176
  changes = changes_to_save
170
177
  return true if changes.empty?
171
178
 
172
- n = :n
173
-
174
179
  # Use all labels for database operations
175
180
  labels = self.class.respond_to?(:labels) ? self.class.labels : [self.class.label_name]
176
181
 
177
- # Match node with all labels
178
- query = Cyrel.match(Cyrel.node(n, labels: labels))
179
- .where(Cyrel.element_id(n).eq(internal_id)) # Use element_id explicitly
180
-
181
- # Create separate SET clauses for each property to avoid overwriting existing properties
182
- changes.each do |property, value|
183
- query = query.set(Cyrel.prop(n, property) => value)
184
- end
185
-
186
- query = query.return_(n) # Return the updated node to confirm success
182
+ label_string = labels.map { |l| ":#{l}" }.join
183
+ set_clauses = changes.map do |property, value|
184
+ # Handle different value types appropriately
185
+ if value.nil?
186
+ "n.#{property} = NULL"
187
+ elsif value.is_a?(String)
188
+ "n.#{property} = '#{value.gsub("'", "\\\\'")}'"
189
+ elsif value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass)
190
+ "n.#{property} = #{value}"
191
+ else
192
+ "n.#{property} = '#{value.to_s.gsub("'", "\\\\'")}'"
193
+ end
194
+ end.join(', ')
187
195
 
188
- cypher, params = query.to_cypher
189
- params ||= {}
196
+ cypher = "MATCH (n#{label_string}) " \
197
+ "WHERE id(n) = #{internal_id} " \
198
+ "SET #{set_clauses} " \
199
+ 'RETURN n'
190
200
 
191
- result = self.class.connection.execute_cypher(cypher, params, 'Update')
201
+ self.class.connection.execute_cypher(cypher, {}, 'Update')
192
202
 
193
- if result.present? && result.first.present?
194
- changes_applied
195
- true
196
- else
197
- false
198
- end
203
+ changes_applied
204
+ true
199
205
  end
200
206
  end
201
207
  end
@@ -2,19 +2,14 @@
2
2
 
3
3
  module ActiveCypher
4
4
  module Model
5
- # @!parse
6
- # # Querying: The module that lets you pretend your graph is just a really weird table.
7
- # # Because what’s more fun than chaining scopes and pretending you’re not writing Cypher by hand?
5
+ # Querying: The module that lets you pretend your graph is just a really weird table.
6
+ # Because what's more fun than chaining scopes and pretending you're not writing Cypher by hand?
8
7
  module Querying
9
8
  extend ActiveSupport::Concern
10
9
 
11
10
  class_methods do
12
- # -- default label -----------------------------------------------
13
- # Returns the symbolic label name for the model, e.g., :user_node
14
- # @return [Symbol] The label name for the model
15
- def label_name = model_name.element.to_sym
11
+ # -- Basic Query Builders ----------------------------------------
16
12
 
17
- # -- basic query builders ----------------------------------------
18
13
  # Return a base Relation, applying the default scope if it exists
19
14
  # @return [Relation] The base relation for the model
20
15
  # Because nothing says "default" like a scope you forgot you set.
@@ -29,35 +24,38 @@ module ActiveCypher
29
24
  # @return [Relation]
30
25
  def where(conditions) = all.where(conditions)
31
26
 
32
- # @param val [Integer] The limit value
33
- # @return [Relation]
34
- def limit(val) = all.limit(val)
27
+ def limit(val) = all.limit(val)
35
28
 
36
- # @return [Relation]
37
- def order(*) = all.order(*)
29
+ def order(*) = all.order(*)
30
+
31
+ # -- find / create ------------------------------------------------
38
32
 
39
- # -- find / create -----------------------------------------------
40
33
  # Find a node by internal DB ID. Returns the record or dies dramatically.
41
- # @param internal_db_id [String] The internal database ID
42
- # @return [Object] The found record
43
- # @raise [ActiveCypher::RecordNotFound] if not found
44
34
  # Because sometimes you want to find a node, and sometimes you want to find existential dread.
45
35
  def find(internal_db_id)
36
+ internal_db_id = internal_db_id.to_i if internal_db_id.respond_to?(:to_i)
46
37
  node_alias = :n
47
38
 
48
- # Use all labels if available, otherwise fall back to the primary label
49
39
  labels = respond_to?(:labels) ? self.labels : [label_name]
40
+ Cyrel.match(Cyrel.node(node_alias, labels: labels)).limit(1)
41
+ label_string = labels.map { |l| ":#{l}" }.join
42
+ cypher = <<~CYPHER
43
+ MATCH (#{node_alias}#{label_string})
44
+ WHERE id(#{node_alias}) = #{internal_db_id}
45
+ RETURN #{node_alias}, id(#{node_alias}) AS internal_id
46
+ LIMIT 1
47
+ CYPHER
50
48
 
51
- query = Cyrel
52
- .match(Cyrel.node(node_alias, labels: labels))
53
- .where(Cyrel.element_id(node_alias).eq(internal_db_id))
54
- .return_(node_alias, Cyrel.element_id(node_alias).as(:internal_id))
55
- .limit(1)
56
- query.to_cypher
49
+ result = connection.execute_cypher(cypher)
50
+ record = result.first
57
51
 
58
- Relation.new(self, query).first or
59
- raise ActiveCypher::RecordNotFound,
60
- "#{name} with internal_id=#{internal_db_id.inspect} not found"
52
+ if record
53
+ attrs = _hydrate_attributes_from_memgraph_record(record, node_alias)
54
+ return instantiate(attrs)
55
+ end
56
+
57
+ raise ActiveCypher::RecordNotFound,
58
+ "#{name} with internal_id=#{internal_db_id.inspect} not found. Perhaps it's in another castle, or just being 'graph'-ty."
61
59
  end
62
60
 
63
61
  # Instantiates and immediately saves a new record. YOLO mode.
@@ -65,6 +63,28 @@ module ActiveCypher
65
63
  # @return [Object] The new, possibly persisted record
66
64
  # Because sometimes you just want to live dangerously.
67
65
  def create(attrs = {}) = new(attrs).tap(&:save)
66
+
67
+ private
68
+
69
+ def _hydrate_attributes_from_memgraph_record(record, node_alias)
70
+ attrs = {}
71
+ node_data = record[node_alias] || record[node_alias.to_s]
72
+
73
+ if node_data.is_a?(Array) && node_data.length >= 2
74
+ properties_container = node_data[1]
75
+ if properties_container.is_a?(Array) && properties_container.length >= 3
76
+ properties = properties_container[2]
77
+ properties.each { |k, v| attrs[k.to_sym] = v } if properties.is_a?(Hash)
78
+ end
79
+ elsif node_data.is_a?(Hash)
80
+ node_data.each { |k, v| attrs[k.to_sym] = v }
81
+ elsif node_data.respond_to?(:properties)
82
+ attrs = node_data.properties.symbolize_keys
83
+ end
84
+
85
+ attrs[:internal_id] = record[:internal_id] || record['internal_id']
86
+ attrs
87
+ end
68
88
  end
69
89
  end
70
90
  end
@@ -16,12 +16,47 @@ module ActiveCypher
16
16
  end
17
17
 
18
18
  initializer 'active_cypher.load_multi_db' do |_app|
19
- configs = ActiveCypher::CypherConfig.for('*') # entire merged hash
20
- %i[writing reading analytics].each do |role|
21
- next unless (cfg = configs[role])
19
+ configs = ActiveCypher::CypherConfig.for('*')
22
20
 
23
- pool = ActiveCypher::ConnectionPool.new(cfg)
24
- ActiveCypher::Base.connection_handler.set(role, :default, pool)
21
+ # First, create pools for all configurations
22
+ connection_pools = {}
23
+ configs.each do |name, cfg|
24
+ connection_pools[name.to_sym] = ActiveCypher::ConnectionPool.new(cfg)
25
+ end
26
+
27
+ # Register all pools under their own names with the database key
28
+ connection_pools.each do |name, pool|
29
+ # Store the pool with db_key only
30
+ ActiveCypher::Base.connection_handler.set(name, pool)
31
+ end
32
+
33
+ # Register default roles (writing, reading)
34
+ # Use primary pool if available, otherwise use the first pool
35
+ default_db_key = :primary
36
+ default_pool = connection_pools[:primary] || connection_pools.values.first
37
+ default_db_key = connection_pools.keys.first unless connection_pools.key?(:primary)
38
+
39
+ if default_pool
40
+ # Store the default pool with db_key only
41
+ ActiveCypher::Base.connection_handler.set(default_db_key, default_pool)
42
+ end
43
+
44
+ # Find all abstract node base classes with connects_to mappings
45
+ ObjectSpace.each_object(Class) do |klass|
46
+ next unless klass < ActiveCypher::Base
47
+ next unless klass.respond_to?(:abstract_class?) && klass.abstract_class?
48
+ next unless klass.respond_to?(:connects_to_mappings) && klass.connects_to_mappings.present?
49
+
50
+ # Register pools for each role in connects_to mapping
51
+ klass.connects_to_mappings.each_value do |conn_name|
52
+ conn_name = conn_name.to_s.to_sym
53
+ pool = connection_pools[conn_name]
54
+ next unless pool
55
+
56
+ # Only create a new pool if one doesn't exist for this role and db_key combination
57
+ # Format: set(db_key, role, shard, pool)
58
+ ActiveCypher::Base.connection_handler.set(conn_name, pool) unless ActiveCypher::Base.connection_handler.pool(conn_name)
59
+ end
25
60
  end
26
61
  end
27
62
 
@@ -47,7 +47,7 @@ module ActiveCypher
47
47
  # --------------------------------------------------------------
48
48
  # Attributes
49
49
  # --------------------------------------------------------------
50
- attribute :internal_id, :string
50
+ attribute :internal_id, :integer
51
51
 
52
52
  # --------------------------------------------------------------
53
53
  # Connection fallback
@@ -59,6 +59,11 @@ module ActiveCypher
59
59
  # WorksAtRelationship.connection # -> PersonNode.connection
60
60
  #
61
61
  def self.connection
62
+ # If a node_base_class is set (directly or by convention), always delegate to its connection
63
+ if (klass = node_base_class)
64
+ return klass.connection
65
+ end
66
+
62
67
  return @connection if defined?(@connection) && @connection
63
68
 
64
69
  begin
@@ -74,10 +79,51 @@ module ActiveCypher
74
79
  class_attribute :_from_class_name, instance_writer: false
75
80
  class_attribute :_to_class_name, instance_writer: false
76
81
  class_attribute :_relationship_type, instance_writer: false
82
+ class_attribute :_node_base_class, instance_writer: false
77
83
 
78
84
  class << self
79
85
  attr_reader :last_internal_id
80
86
 
87
+ # DSL for setting or getting the node base class for connection delegation
88
+ def node_base_class(klass = nil)
89
+ if klass.nil?
90
+ # If not set, try convention: XxxRelationship -> XxxNode
91
+ return _node_base_class if _node_base_class
92
+
93
+ if name&.end_with?('Relationship')
94
+ node_base_name = name.sub(/Relationship\z/, 'Node')
95
+ begin
96
+ node_base_klass = node_base_name.constantize
97
+ if node_base_klass.respond_to?(:abstract_class?) && node_base_klass.abstract_class?
98
+ self._node_base_class = node_base_klass
99
+ return node_base_klass
100
+ end
101
+ rescue NameError
102
+ # Do nothing, fallback to nil
103
+ end
104
+ end
105
+ return _node_base_class
106
+ end
107
+ # Only allow setting on abstract relationship base classes
108
+ raise "Cannot set node_base_class on non-abstract relationship class #{name}" unless abstract_class?
109
+ unless klass.respond_to?(:abstract_class?) && klass.abstract_class?
110
+ raise ArgumentError, "node_base_class must be an abstract node base class (got #{klass})"
111
+ end
112
+
113
+ self._node_base_class = klass
114
+ end
115
+
116
+ # Prevent subclasses from overriding node_base_class
117
+ def inherited(subclass)
118
+ super
119
+ return unless _node_base_class
120
+
121
+ subclass._node_base_class = _node_base_class
122
+ def subclass.node_base_class(*)
123
+ raise "Cannot override node_base_class in subclass #{name}; it is locked to #{_node_base_class}"
124
+ end
125
+ end
126
+
81
127
  # -- endpoints ------------------------------------------------
82
128
  def from_class(value = nil)
83
129
  return _from_class_name if value.nil?
@@ -160,8 +206,10 @@ module ActiveCypher
160
206
  raise 'Cannot destroy a new relationship' if new_record?
161
207
  raise 'Relationship already destroyed' if destroyed?
162
208
 
163
- cypher = 'MATCH ()-[r]-() WHERE elementId(r) = $id DELETE r'
164
- params = { id: internal_id }
209
+ adapter = self.class.connection.id_handler
210
+
211
+ cypher = "MATCH ()-[r]-() WHERE #{adapter.with_direct_id(internal_id)} DELETE r"
212
+ params = {}
165
213
 
166
214
  self.class.connection.execute_cypher(cypher, params, 'Destroy Relationship')
167
215
  @destroyed = true
@@ -186,22 +234,31 @@ module ActiveCypher
186
234
  rel_ty = self.class.relationship_type
187
235
  arrow = '->' # outgoing by default
188
236
 
189
- parts = []
190
- parts << 'MATCH (a) WHERE elementId(a) = $from_id'
191
- parts << 'MATCH (b) WHERE elementId(b) = $to_id'
192
- parts << "CREATE (a)-[r:#{rel_ty}]#{arrow}(b)"
237
+ adapter = self.class.connection.id_handler
238
+ parts = []
239
+
240
+ # Build the Cypher query based on the adapter
241
+ id_clause = adapter.with_direct_node_ids(from_node.internal_id, to_node.internal_id)
242
+ parts << "MATCH (p), (h) WHERE #{id_clause}"
243
+ parts << "CREATE (p)-[r:#{rel_ty}]#{arrow}(h)"
193
244
  parts << 'SET r += $props' unless props.empty? # only if we have props
194
- parts << 'RETURN elementId(r) AS rid'
245
+ parts << "RETURN #{adapter.return_id}"
195
246
 
196
247
  cypher = parts.join(' ')
197
- params = {
198
- from_id: from_node.internal_id,
199
- to_id: to_node.internal_id,
200
- props: props
201
- }
248
+ params = { props: props }
249
+
250
+ # Execute Cypher query
251
+ result = self.class.connection.execute_cypher(cypher, params, 'Create Relationship')
252
+
253
+ row = result.first
202
254
 
203
- row = self.class.connection.execute_cypher(cypher, params, 'Create Relationship').first
204
- rid = row && (row[:rid] || row['rid']) or raise 'Relationship creation returned no id'
255
+ # Try different ways to access the ID
256
+ rid_sym = row && row[:rid]
257
+ rid_str = row && row['rid']
258
+
259
+ rid = rid_sym || rid_str
260
+
261
+ raise 'Relationship creation returned no id' if rid.nil?
205
262
 
206
263
  self.internal_id = rid
207
264
  self.class.instance_variable_set(:@last_internal_id, rid)
@@ -217,11 +274,14 @@ module ActiveCypher
217
274
  changes = changes_to_save
218
275
  return true if changes.empty?
219
276
 
277
+ adapter = self.class.connection.id_handler
278
+
220
279
  cypher = <<~CYPHER
221
- MATCH ()-[r]-() WHERE elementId(r) = $id
280
+ MATCH ()-[r]-() WHERE #{adapter.with_direct_id(internal_id)}
222
281
  SET r += $props
223
282
  CYPHER
224
- params = { id: internal_id, props: changes }
283
+
284
+ params = { props: changes }
225
285
 
226
286
  self.class.connection.execute_cypher(cypher, params, 'Update Relationship')
227
287
  changes_applied
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveCypher
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.1'
5
5
  end
data/lib/activecypher.rb CHANGED
@@ -101,7 +101,10 @@ loader.ignore("#{__dir__}/active_cypher/railtie.rb")
101
101
  loader.ignore("#{__dir__}/active_cypher/generators")
102
102
  loader.ignore("#{__dir__}/activecypher.rb")
103
103
  loader.ignore("#{__dir__}/cyrel.rb")
104
- loader.inflector.inflect('activecypher' => 'ActiveCypher')
104
+ loader.inflector.inflect(
105
+ 'activecypher' => 'ActiveCypher',
106
+ 'dsl_context' => 'DSLContext'
107
+ )
105
108
  loader.push_dir("#{__dir__}/cyrel", namespace: Cyrel)
106
109
  loader.setup
107
110
 
@@ -22,7 +22,9 @@ module Cyrel
22
22
  Expression::FunctionCall.new(:elementId, Clause::Return::RawIdentifier.new(node_variable.to_s))
23
23
  end
24
24
 
25
- alias id element_id
25
+ def id(node_variable)
26
+ Expression::FunctionCall.new(:id, Clause::Return::RawIdentifier.new(node_variable.to_s))
27
+ end
26
28
 
27
29
  # Because apparently, COUNT(*) isn’t obvious enough.
28
30
  # Handles the 'give me everything and make it snappy' use case.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'active_support/tagged_logging'
5
+
6
+ module Cyrel
7
+ # Basic logging support for Cyrel.
8
+ # Debug logging is disabled by default.
9
+ module Logging
10
+ LOG_TAG = 'Cyrel'
11
+
12
+ class << self
13
+ # @return [Logger] the configured logger
14
+ attr_accessor :backend
15
+
16
+ def resolve_log_level(log_level_str)
17
+ Logger.const_get(log_level_str.upcase)
18
+ rescue StandardError
19
+ Logger::UNKNOWN
20
+ end
21
+
22
+ def logger
23
+ self.backend ||= begin
24
+ log_level = ENV.fetch('CYREL_LOG_LEVEL', 'unknown')
25
+ logger_base = Logger.new($stdout)
26
+ logger_base.level = resolve_log_level(log_level)
27
+
28
+ # Return a TaggedLogging instance without calling 'tagged!'
29
+ ActiveSupport::TaggedLogging.new(logger_base)
30
+ end
31
+ end
32
+ end
33
+
34
+ def logger
35
+ Logging.logger.tagged(LOG_TAG)
36
+ end
37
+
38
+ def log_debug(msg) = logger.debug { msg }
39
+ def log_info(msg) = logger.info { msg }
40
+ def log_warn(msg) = logger.warn { msg }
41
+ def log_error(msg) = logger.error { msg }
42
+ end
43
+ end
data/lib/cyrel/query.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Base DSL components
3
4
  require 'cyrel/parameterizable'
4
- # Require necessary clause and pattern types
5
+ require 'cyrel/logging'
5
6
 
6
7
  # Require all clause types for DSL methods
7
8
 
@@ -19,6 +20,7 @@ module Cyrel
19
20
  # # Manages clauses, parameters, and final query generation, because string interpolation is for amateurs.
20
21
  class Query
21
22
  include Parameterizable
23
+ include Logging
22
24
  attr_reader :parameters, :clauses # Expose clauses for merge logic
23
25
 
24
26
  def initialize
@@ -59,6 +61,9 @@ module Cyrel
59
61
  .reject(&:blank?)
60
62
  .join("\n")
61
63
 
64
+ log_debug("QUERY: #{cypher_string}")
65
+ log_debug("PARAMS: #{@parameters.inspect}") unless @parameters.empty?
66
+
62
67
  [cypher_string, @parameters]
63
68
  end
64
69
  end