activecypher 0.7.1 → 0.7.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5ccd00478564eeb903b76a423867e21973268d6c23af232de7329abe94263d5
4
- data.tar.gz: a6e74f587ad7c543123840d5f44440efca909a44efa0a59be5b8ce15d8d39f0f
3
+ metadata.gz: bcfe9f62e0fdcf58b7d4f46fef0b7869bac550fc730f2e93a2d057938cad8a4e
4
+ data.tar.gz: a835e25b6ff10e6e9e4884c2855091e617086e1635931988cc018d9eac06afd8
5
5
  SHA512:
6
- metadata.gz: 1b90a82d07ffc1504ddf9d07fcd88a80d3371d32ed09eb8f799c4d228958e090522a44cac2abd6bdf661983fda43ce95b898fe440f8e76c0427621f55ad342a0
7
- data.tar.gz: bd848d6485fdc502673f88ad3ea3dd776d486a1e11cf501592994efba1347cb799b28af0b6f06e5a29daf3a60c79c39072181fb03d36f0938df5ed52699bd881
6
+ metadata.gz: 4297ab6127aa159da2800dd2024102e8768cfac6604ce390fa35d71d95b393e3226a6d544e535ccc09c14d1632a23009c48df5067a62b6f158fc344b7e1ff21c
7
+ data.tar.gz: b929133db5f0be22b7fbd2e3923e54b0a6b041e748d28dc32bbd6f454ab0547ea48637a945262b480339ccee8e78551b23b64cd6aea8a276c5f3d5e766adbc8d
@@ -128,9 +128,9 @@ module ActiveCypher
128
128
  arrow = (dir == :both ? :'--' : :out)
129
129
  Cyrel
130
130
  .match(Cyrel.node(from_node.class.label_name).as(:a)
131
- .where(Cyrel.id(:a).eq(from_node.internal_id)))
131
+ .where(Cyrel.node_id(:a).eq(from_node.internal_id)))
132
132
  .match(Cyrel.node(to_node.class.label_name).as(:b)
133
- .where(Cyrel.id(:b).eq(to_node.internal_id)))
133
+ .where(Cyrel.node_id(:b).eq(to_node.internal_id)))
134
134
  .create(Cyrel.node(:a).rel(arrow, reflection[:relationship]).to(:b))
135
135
  .tap { |qry| owner.class.connection.execute_cypher(*qry.to_cypher, 'Create Association') }
136
136
  end
@@ -142,7 +142,7 @@ module ActiveCypher
142
142
  # Compose query MATCH – WHERE – RETURN
143
143
  query = Cyrel::Query.new
144
144
  .match(path)
145
- .where(Cyrel.id(a_alias).eq(internal_id))
145
+ .where(Cyrel.node_id(a_alias).eq(internal_id))
146
146
  .return_(b_alias)
147
147
 
148
148
  base_relation = Relation.new(target_class, query)
@@ -186,7 +186,7 @@ module ActiveCypher
186
186
 
187
187
  query = Cyrel::Query.new
188
188
  .match(path)
189
- .where(Cyrel.id(a_alias).eq(internal_id))
189
+ .where(Cyrel.node_id(a_alias).eq(internal_id))
190
190
  .return_(b_alias)
191
191
  .limit(1)
192
192
 
@@ -225,8 +225,8 @@ module ActiveCypher
225
225
  .rel(cyrel_direction, rel_type)
226
226
  .as(del_rel_alias)
227
227
  .to(del_end_alias))
228
- .where(Cyrel.id(del_start_alias).eq(del_start_node.internal_id))
229
- .where(Cyrel.id(del_end_alias).eq(del_end_node.internal_id))
228
+ .where(Cyrel.node_id(del_start_alias).eq(del_start_node.internal_id))
229
+ .where(Cyrel.node_id(del_end_alias).eq(del_end_node.internal_id))
230
230
  .delete(del_rel_alias)
231
231
 
232
232
  self.class.connection.execute_cypher(
@@ -266,8 +266,8 @@ module ActiveCypher
266
266
  create_query = Cyrel
267
267
  .match(Cyrel.node(new_start_node.class.label_name).as(new_start_alias))
268
268
  .match(Cyrel.node(new_end_node.class.label_name).as(new_end_alias))
269
- .where(Cyrel.id(new_start_alias).eq(new_start_node.internal_id))
270
- .where(Cyrel.id(new_end_alias).eq(new_end_node.internal_id))
269
+ .where(Cyrel.node_id(new_start_alias).eq(new_start_node.internal_id))
270
+ .where(Cyrel.node_id(new_end_alias).eq(new_end_node.internal_id))
271
271
  .create(Cyrel.node(new_start_alias)
272
272
  .rel(arrow, rel_type)
273
273
  .to(new_end_alias))
@@ -322,7 +322,7 @@ module ActiveCypher
322
322
  target_node_alias = :target_node
323
323
 
324
324
  start_node_pattern = Cyrel.node(self.class.label_name).as(start_node_alias)
325
- .where(Cyrel.id(start_node_alias).eq(internal_id))
325
+ .where(Cyrel.node_id(start_node_alias).eq(internal_id))
326
326
  target_node_pattern = Cyrel.node(target_class.label_name).as(target_node_alias)
327
327
 
328
328
  rel_pattern = case direction
@@ -372,10 +372,10 @@ module ActiveCypher
372
372
  # Adjust direction for Cyrel pattern if needed
373
373
  cyrel_direction = direction == :in ? :out : direction
374
374
  del_query = Cyrel.match(Cyrel.node(del_start_node.class.label_name)
375
- .as(del_start_alias).where(Cyrel.id(del_start_alias)
375
+ .as(del_start_alias).where(Cyrel.node_id(del_start_alias)
376
376
  .eq(del_start_node.internal_id)))
377
377
  .match(Cyrel.node(del_end_node.class.label_name)
378
- .as(del_end_alias).where(Cyrel.id(del_end_alias)
378
+ .as(del_end_alias).where(Cyrel.node_id(del_end_alias)
379
379
  .eq(del_end_node.internal_id)))
380
380
  .match(Cyrel.node(del_start_alias).rel(cyrel_direction,
381
381
  rel_type).as(del_rel_alias).to(del_end_alias))
@@ -411,10 +411,10 @@ module ActiveCypher
411
411
  new_start_alias = :a
412
412
  new_end_alias = :b
413
413
  create_query = Cyrel.match(Cyrel.node(new_start_node.class.label_name)
414
- .as(new_start_alias).where(Cyrel.id(new_start_alias)
414
+ .as(new_start_alias).where(Cyrel.node_id(new_start_alias)
415
415
  .eq(new_start_node.internal_id)))
416
416
  .match(Cyrel.node(new_end_node.class.label_name)
417
- .as(new_end_alias).where(Cyrel.id(new_end_alias)
417
+ .as(new_end_alias).where(Cyrel.node_id(new_end_alias)
418
418
  .eq(new_end_node.internal_id)))
419
419
  .create(Cyrel.node(new_start_alias).rel(:out, rel_type).to(new_end_alias))
420
420
 
@@ -488,7 +488,7 @@ module ActiveCypher
488
488
 
489
489
  # Start node pattern
490
490
  start_node_pattern = Cyrel.node(self.class.label_name).as(start_node_alias)
491
- .where(Cyrel.id(start_node_alias).eq(internal_id))
491
+ .where(Cyrel.node_id(start_node_alias).eq(internal_id))
492
492
 
493
493
  # Intermediate node pattern (based on through_reflection)
494
494
  intermediate_node_pattern = Cyrel.node(intermediate_class.label_name).as(intermediate_node_alias)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'uri'
4
+ require 'async'
4
5
 
5
6
  module ActiveCypher
6
7
  module ConnectionAdapters
@@ -99,6 +100,55 @@ module ActiveCypher
99
100
  # Return handler for connection to store
100
101
  end
101
102
 
103
+ # Reset the connection state by sending a RESET message.
104
+ # This clears any pending work and returns the connection to a clean state.
105
+ # Useful for error recovery or connection pooling.
106
+ #
107
+ # @return [Boolean] true if reset succeeded, false otherwise
108
+ def reset!
109
+ return false unless active?
110
+
111
+ instrument_connection(:reset, config) do
112
+ # Wrap in async to handle the connection reset properly
113
+ result = nil
114
+ error = nil
115
+
116
+ Async do
117
+ begin
118
+ # Try to execute a simple query first
119
+ session = Bolt::Session.new(@connection)
120
+ session.run('RETURN 1 AS check', {})
121
+ session.close
122
+ result = true
123
+ rescue StandardError => e
124
+ # Query failed, need to reset the connection
125
+ logger.debug { "Connection needs reset: #{e.message}" }
126
+
127
+ # Send RESET message directly
128
+ begin
129
+ @connection.write_message(Bolt::Messaging::Reset.new)
130
+ response = @connection.read_message
131
+ result = response.is_a?(Bolt::Messaging::Success)
132
+ logger.debug { "Reset response: #{response.class}" }
133
+ rescue StandardError => reset_error
134
+ logger.error { "Reset failed: #{reset_error.message}" }
135
+ result = false
136
+ end
137
+ end
138
+ rescue StandardError => e
139
+ error = e
140
+ end.wait
141
+
142
+ raise error if error
143
+
144
+ result
145
+ end
146
+ rescue StandardError => e
147
+ # This is madness!
148
+ logger.error { "Failed to reset connection: #{e.message}" }
149
+ false
150
+ end
151
+
102
152
  protected
103
153
 
104
154
  # These must be defined by subclasses. If you don't override them,
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_cypher/schema/catalog'
4
-
5
3
  module ActiveCypher
6
4
  module ConnectionAdapters
7
5
  class MemgraphAdapter < AbstractBoltAdapter
@@ -41,15 +39,58 @@ module ActiveCypher
41
39
  'id(r) AS rid'
42
40
  end
43
41
 
42
+ # Additional helper methods for nodes
43
+ def self.node_id_where(alias_name, param_name = nil)
44
+ if param_name
45
+ "id(#{alias_name}) = $#{param_name}"
46
+ else
47
+ "id(#{alias_name})"
48
+ end
49
+ end
50
+
51
+ def self.node_id_equals_value(alias_name, value)
52
+ "id(#{alias_name}) = #{value}"
53
+ end
54
+
55
+ def self.return_node_id(alias_name, as_name = 'internal_id')
56
+ "id(#{alias_name}) AS #{as_name}"
57
+ end
58
+
59
+ def self.id_function
60
+ 'id'
61
+ end
62
+
44
63
  # Return self as id_handler for compatibility with tests
45
64
  def id_handler
46
65
  self.class
47
66
  end
48
67
 
49
- # Memgraph defaults to **implicit auto‑commit** transactions :contentReference[oaicite:1]{index=1},
68
+ # Override run to execute queries without explicit transactions
69
+ # Memgraph auto‑commits each query, so we send RUN + PULL directly
70
+ def run(cypher, params = {}, context: 'Query', db: nil, access_mode: :write)
71
+ connect
72
+ logger.debug { "[#{context}] #{cypher} #{params.inspect}" }
73
+
74
+ instrument_query(cypher, params, context: context, metadata: { db: db, access_mode: access_mode }) do
75
+ session = Bolt::Session.new(connection)
76
+
77
+ rows = session.run_transaction(access_mode, db: db) do |tx|
78
+ result = tx.run(cypher, prepare_params(params))
79
+ result.respond_to?(:to_a) ? result.to_a : result
80
+ end
81
+
82
+ session.close
83
+ rows
84
+ end
85
+ end
86
+
87
+ # Memgraph defaults to **implicit auto‑commit** transactions
50
88
  # so we simply run the Cypher and return the rows.
51
89
  def execute_cypher(cypher, params = {}, ctx = 'Query')
52
- rows = run(cypher.gsub(/\belementId\(/i, 'id('), params, context: ctx)
90
+ # Replace adapter-aware placeholder with Memgraph's id function
91
+ # Because Memgraph insists on being different and using id() instead of elementId()
92
+ cypher = cypher.gsub('__NODE_ID__', 'id')
93
+ rows = run(cypher, params, context: ctx)
53
94
  process_records(rows)
54
95
  end
55
96
 
@@ -122,6 +163,11 @@ module ActiveCypher
122
163
  end
123
164
  end
124
165
 
166
+ module Persistence
167
+ include PersistenceMethods
168
+ module_function :create_record, :update_record, :destroy_record
169
+ end
170
+
125
171
  class ProtocolHandler < AbstractProtocolHandler
126
172
  def extract_version(agent)
127
173
  agent[%r{Memgraph/([\d.]+)}, 1] || 'unknown'
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_cypher/schema/catalog'
4
-
5
3
  module ActiveCypher
6
4
  module ConnectionAdapters
7
5
  class Neo4jAdapter < AbstractBoltAdapter
@@ -63,8 +61,40 @@ module ActiveCypher
63
61
  'elementId(r) AS rid'
64
62
  end
65
63
 
64
+ # Additional helper methods for nodes
65
+ def self.node_id_where(alias_name, param_name = nil)
66
+ if param_name
67
+ "elementId(#{alias_name}) = $#{param_name}"
68
+ else
69
+ "elementId(#{alias_name})"
70
+ end
71
+ end
72
+
73
+ def self.node_id_equals_value(alias_name, value)
74
+ # Quote string values for Cypher because Neo4j is paranoid about injection attacks
75
+ # (As it should be, have you seen what people try to inject these days?)
76
+ quoted_value = value.is_a?(String) ? "'#{value}'" : value
77
+ "elementId(#{alias_name}) = #{quoted_value}"
78
+ end
79
+
80
+ def self.return_node_id(alias_name, as_name = 'internal_id')
81
+ "elementId(#{alias_name}) AS #{as_name}"
82
+ end
83
+
84
+ def self.id_function
85
+ 'elementId'
86
+ end
87
+
88
+ # Return self as id_handler for compatibility
89
+ def id_handler
90
+ self.class
91
+ end
92
+
66
93
  def execute_cypher(cypher, params = {}, ctx = 'Query')
67
94
  connect
95
+ # Replace adapter-aware placeholder with Neo4j's elementId function
96
+ cypher = cypher.gsub('__NODE_ID__', 'elementId')
97
+
68
98
  session = connection.session # thin wrapper around Bolt::Session
69
99
  result = session.write_transaction do |tx|
70
100
  logger.debug { "[#{ctx}] #{cypher} #{params.inspect}" }
@@ -100,6 +130,11 @@ module ActiveCypher
100
130
  metadata.compact
101
131
  end
102
132
 
133
+ module Persistence
134
+ include PersistenceMethods
135
+ module_function :create_record, :update_record, :destroy_record
136
+ end
137
+
103
138
  protected
104
139
 
105
140
  def protocol_handler_class = ProtocolHandler
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module ConnectionAdapters
5
+ # Common persistence helpers shared by adapters
6
+ module PersistenceMethods
7
+ # Create a record in the database and update model state.
8
+ # @param model [ActiveCypher::Base, ActiveCypher::Relationship] the model instance
9
+ # @return [Boolean] true if created successfully
10
+ def create_record(model)
11
+ props = model.send(:attributes_for_persistence)
12
+ labels = if model.class.respond_to?(:labels)
13
+ model.class.labels
14
+ else
15
+ [model.class.label_name.to_s]
16
+ end
17
+ label_string = labels.map { |l| ":#{l}" }.join
18
+
19
+ adapter = model.connection.id_handler
20
+ cypher = "CREATE (n#{label_string} $props) RETURN #{adapter.return_node_id('n')}"
21
+ data = model.connection.execute_cypher(cypher, { props: props }, 'Create')
22
+
23
+ return false if data.blank? || !data.first.key?(:internal_id)
24
+
25
+ model.internal_id = data.first[:internal_id]
26
+ model.instance_variable_set(:@new_record, false)
27
+ model.send(:changes_applied)
28
+ true
29
+ end
30
+
31
+ # Update a record in the database based on model changes.
32
+ # @param model [ActiveCypher::Base, ActiveCypher::Relationship] the model instance
33
+ # @return [Boolean] true if update succeeded
34
+ def update_record(model)
35
+ changes = model.send(:changes_to_save)
36
+ return true if changes.empty?
37
+
38
+ labels = if model.class.respond_to?(:labels)
39
+ model.class.labels
40
+ else
41
+ [model.class.label_name.to_s]
42
+ end
43
+
44
+ label_string = labels.map { |l| ":#{l}" }.join
45
+ set_clauses = changes.keys.map { |property| "n.#{property} = $#{property}" }.join(', ')
46
+
47
+ adapter = model.connection.id_handler
48
+ # Convert internal_id to its preferred existential format
49
+ # Neo4j wants strings because it's complicated, Memgraph wants integers because it's not
50
+ node_id_param = adapter.id_function == 'elementId' ? model.internal_id.to_s : model.internal_id.to_i
51
+ params = changes.merge(node_id: node_id_param)
52
+
53
+ cypher = "MATCH (n#{label_string}) WHERE #{adapter.node_id_where('n', 'node_id')} SET #{set_clauses} RETURN n"
54
+ model.connection.execute_cypher(cypher, params, 'Update')
55
+
56
+ model.send(:changes_applied)
57
+ true
58
+ end
59
+
60
+ # Destroy a record in the database.
61
+ # @param model [ActiveCypher::Base, ActiveCypher::Relationship] the model instance
62
+ # @return [Boolean] true if a record was deleted
63
+ def destroy_record(model)
64
+ labels = if model.class.respond_to?(:labels)
65
+ model.class.labels
66
+ else
67
+ [model.class.label_name]
68
+ end
69
+ label_string = labels.map { |l| ":#{l}" }.join
70
+
71
+ adapter = model.connection.id_handler
72
+ # Convert internal_id to whatever format makes the database feel validated
73
+ # It's like therapy, but for graph databases
74
+ node_id_param = adapter.id_function == 'elementId' ? model.internal_id.to_s : model.internal_id.to_i
75
+
76
+ cypher = <<~CYPHER
77
+ MATCH (n#{label_string})
78
+ WHERE #{adapter.node_id_where('n', 'node_id')}
79
+ DETACH DELETE n
80
+ RETURN count(*) AS deleted
81
+ CYPHER
82
+
83
+ result = model.connection.execute_cypher(cypher, { node_id: node_id_param }, 'Destroy')
84
+ result.present? && result.first[:deleted].to_i.positive?
85
+ end
86
+ end
87
+ end
88
+ end
@@ -9,9 +9,11 @@ module ActiveCypher
9
9
  #
10
10
  # Supported URL prefixes:
11
11
  # - neo4j://
12
+ # - neo4j+s:// (alias for neo4j+ssc://)
12
13
  # - neo4j+ssl://
13
14
  # - neo4j+ssc://
14
15
  # - memgraph://
16
+ # - memgraph+s:// (alias for memgraph+ssc://)
15
17
  # - memgraph+ssl://
16
18
  # - memgraph+ssc://
17
19
  #
@@ -138,10 +140,14 @@ module ActiveCypher
138
140
 
139
141
  return nil unless SUPPORTED_ADAPTERS.include?(adapter)
140
142
 
141
- modifiers = parts.select { |mod| %w[ssl ssc].include?(mod) }
143
+ # Map 's' to 'ssc' for Neo4j compatibility (self-signed certificates)
144
+ mapped_parts = parts.map { |mod| mod == 's' ? 'ssc' : mod }
145
+ modifiers = mapped_parts.select { |mod| %w[ssl ssc].include?(mod) }
142
146
 
143
147
  # If there are parts that are neither the adapter nor valid modifiers, the URL is invalid
144
- remaining_parts = parts - modifiers
148
+ # Check against original parts but also accept 's' as valid
149
+ valid_modifiers = %w[ssl ssc s]
150
+ remaining_parts = parts.reject { |part| valid_modifiers.include?(part) }
145
151
  return nil if remaining_parts.any?
146
152
 
147
153
  [adapter, modifiers]
@@ -17,10 +17,11 @@ module ActiveCypher
17
17
  label_clause = labels.map { |label| "`#{label}`" }.join(':')
18
18
 
19
19
  # Build and fire the CREATE query.
20
- # We use id(n) because elementId(n) lies to us with strings.
20
+ # Ask the adapter how it likes its IDs served - string or integer, sir?
21
+ adapter = conn.id_handler
21
22
  cypher = <<~CYPHER
22
23
  CREATE (n:#{label_clause} $props)
23
- RETURN n, id(n) AS internal_id, properties(n) AS props
24
+ RETURN n, #{adapter.return_node_id('n')}, properties(n) AS props
24
25
  CYPHER
25
26
 
26
27
  result = conn.execute_cypher(cypher, props: props)
@@ -1,11 +1,11 @@
1
1
  development:
2
2
  primary:
3
- url: ENV["GRAPH_URL"]
3
+ url: <%= ENV["GRAPHDB_URL"] %>
4
4
 
5
5
  test:
6
6
  primary:
7
- url: ENV["GRAPH_URL"]
7
+ url: <%= ENV["GRAPHDB_URL"] %>
8
8
 
9
9
  production:
10
10
  primary:
11
- url: ENV["GRAPH_URL"]
11
+ url: <%= ENV["GRAPHDB_URL"] %>
@@ -12,7 +12,11 @@ module ActiveCypher
12
12
  include ActiveCypher::Associations
13
13
  include ActiveCypher::Scoping
14
14
 
15
- attribute :internal_id, :integer
15
+ # internal_id: The ID that proves you exist in the graph's eyes
16
+ # String type because Neo4j needs UUIDs like "4:abc:xyz" to feel special
17
+ # while Memgraph just counts sheep like a normal database.
18
+ # ActiveModel will convert between them, hopefully without existential dread
19
+ attribute :internal_id, :string
16
20
 
17
21
  class_attribute :configurations, instance_accessor: false,
18
22
  default: ActiveSupport::HashWithIndifferentAccess.new
@@ -20,20 +20,10 @@ module ActiveCypher
20
20
  raise 'Cannot destroy a new record' if new_record?
21
21
  raise 'Record already destroyed' if destroyed?
22
22
 
23
- n = :n
24
- labels = self.class.respond_to?(:labels) ? self.class.labels : [self.class.label_name]
25
- label_string = labels.map { |l| ":#{l}" }.join
23
+ adapter = adapter_class
24
+ raise NotImplementedError, "#{adapter} does not implement Persistence" unless adapter&.const_defined?(:Persistence)
26
25
 
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
33
-
34
- result = self.class.connection.execute_cypher(cypher, {}, 'Destroy')
35
-
36
- if result.present? && result.first[:deleted].to_i.positive?
26
+ if adapter::Persistence.destroy_record(self)
37
27
  @destroyed = true
38
28
  freeze
39
29
  true
@@ -121,40 +121,10 @@ module ActiveCypher
121
121
  # Because nothing says "production ready" like a hand-crafted query.
122
122
  # If this method ever works on the first try, that's not engineering—it's back magick.
123
123
  def create_record
124
- props = attributes_for_persistence
125
- n = :n
126
-
127
- # Use all labels for database operations
128
- labels = self.class.respond_to?(:labels) ? self.class.labels : [self.class.label_name.to_s]
129
-
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')
151
-
152
- return false if data.blank? || !data.first.key?(:internal_id)
153
-
154
- self.internal_id = data.first[:internal_id]
155
- @new_record = false
156
- changes_applied
157
- true
124
+ adapter = adapter_class
125
+ raise NotImplementedError, "#{adapter} does not implement Persistence" unless adapter&.const_defined?(:Persistence)
126
+
127
+ adapter::Persistence.create_record(self)
158
128
  end
159
129
 
160
130
  # Returns a hash of attributes that have changed and their spicy new values.
@@ -173,35 +143,10 @@ module ActiveCypher
173
143
  #
174
144
  # @return [Boolean] true if we updated something, or just acted like we did.
175
145
  def update_record
176
- changes = changes_to_save
177
- return true if changes.empty?
178
-
179
- # Use all labels for database operations
180
- labels = self.class.respond_to?(:labels) ? self.class.labels : [self.class.label_name]
181
-
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(', ')
195
-
196
- cypher = "MATCH (n#{label_string}) " \
197
- "WHERE id(n) = #{internal_id} " \
198
- "SET #{set_clauses} " \
199
- 'RETURN n'
200
-
201
- self.class.connection.execute_cypher(cypher, {}, 'Update')
146
+ adapter = adapter_class
147
+ raise NotImplementedError, "#{adapter} does not implement Persistence" unless adapter&.const_defined?(:Persistence)
202
148
 
203
- changes_applied
204
- true
149
+ adapter::Persistence.update_record(self)
205
150
  end
206
151
  end
207
152
  end
@@ -33,16 +33,25 @@ module ActiveCypher
33
33
  # Find a node by internal DB ID. Returns the record or dies dramatically.
34
34
  # Because sometimes you want to find a node, and sometimes you want to find existential dread.
35
35
  def find(internal_db_id)
36
- internal_db_id = internal_db_id.to_i if internal_db_id.respond_to?(:to_i)
37
36
  node_alias = :n
38
37
 
39
38
  labels = respond_to?(:labels) ? self.labels : [label_name]
40
- Cyrel.match(Cyrel.node(node_alias, labels: labels)).limit(1)
39
+ adapter = connection.id_handler
41
40
  label_string = labels.map { |l| ":#{l}" }.join
41
+
42
+ # Handle ID format based on adapter's preferred flavor of existential crisis
43
+ # Neo4j insists on string IDs like "4:uuid:wtf" because simple integers are for peasants
44
+ # Memgraph keeps it real with numeric IDs because it doesn't need to prove anything
45
+ formatted_id = if adapter.id_function == 'elementId'
46
+ internal_db_id.to_s # String for Neo4j
47
+ else
48
+ internal_db_id.to_i # Numeric for Memgraph
49
+ end
50
+
42
51
  cypher = <<~CYPHER
43
52
  MATCH (#{node_alias}#{label_string})
44
- WHERE id(#{node_alias}) = #{internal_db_id}
45
- RETURN #{node_alias}, id(#{node_alias}) AS internal_id
53
+ WHERE #{adapter.node_id_equals_value(node_alias, formatted_id)}
54
+ RETURN #{node_alias}, #{adapter.return_node_id(node_alias)}
46
55
  LIMIT 1
47
56
  CYPHER
48
57
 
@@ -142,7 +142,7 @@ module ActiveCypher
142
142
 
143
143
  Cyrel
144
144
  .match(Cyrel.node(node_alias, labels: labels))
145
- .return_(node_alias, Cyrel.element_id(node_alias).as(:internal_id))
145
+ .return_(node_alias, Cyrel.node_id(node_alias).as(:internal_id))
146
146
  end
147
147
 
148
148
  # Actually loads the records from the database, shattering the illusion of laziness.
@@ -29,6 +29,9 @@ require 'active_support/core_ext/hash/indifferent_access'
29
29
 
30
30
  module ActiveCypher
31
31
  class Relationship
32
+ # Define connects_to_mappings as a class attribute to match ActiveCypher::Base
33
+ class_attribute :connects_to_mappings, default: {}
34
+
32
35
  # --------------------------------------------------------------
33
36
  # Mix‑ins
34
37
  # --------------------------------------------------------------
@@ -47,7 +50,10 @@ module ActiveCypher
47
50
  # --------------------------------------------------------------
48
51
  # Attributes
49
52
  # --------------------------------------------------------------
50
- attribute :internal_id, :integer
53
+ # internal_id: Your relationship's social security number, but less secure
54
+ # String because Neo4j relationships have commitment issues and need complex IDs
55
+ # Memgraph relationships just want a simple number, like the good old days with MS Access.
56
+ attribute :internal_id, :string
51
57
 
52
58
  # --------------------------------------------------------------
53
59
  # Connection fallback
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveCypher
4
- VERSION = '0.7.1'
4
+ VERSION = '0.7.3'
5
5
 
6
6
  def self.gem_version
7
7
  Gem::Version.new VERSION
@@ -18,14 +18,25 @@ module Cyrel
18
18
  # --- Common Cypher Functions ---
19
19
 
20
20
  # Use elementId() instead of deprecated id()
21
+ # @deprecated Use {#node_id} instead for adapter-aware ID handling
21
22
  def element_id(node_variable)
22
23
  Expression::FunctionCall.new(:elementId, Clause::Return::RawIdentifier.new(node_variable.to_s))
23
24
  end
24
25
 
26
+ # @deprecated Use {#node_id} instead for adapter-aware ID handling
25
27
  def id(node_variable)
26
28
  Expression::FunctionCall.new(:id, Clause::Return::RawIdentifier.new(node_variable.to_s))
27
29
  end
28
30
 
31
+ # Adapter-aware node ID function
32
+ # Generates a placeholder that will be replaced by the correct ID function
33
+ # at execution time based on the database adapter (Neo4j vs Memgraph)
34
+ # Because databases can't agree on how to name their ID functions,
35
+ # we'll just pretend they're all the same and fix it later
36
+ def node_id(node_variable)
37
+ Expression::FunctionCall.new(:__NODE_ID__, Clause::Return::RawIdentifier.new(node_variable.to_s))
38
+ end
39
+
29
40
  # Because apparently, COUNT(*) isn’t obvious enough.
30
41
  # Handles the 'give me everything and make it snappy' use case.
31
42
  def count(expression, distinct: false)
@@ -7,7 +7,7 @@ module Cyrel
7
7
  module Pattern
8
8
  class Node
9
9
  include ActiveModel::Model
10
- include ActiveModel::Attributes # :contentReference[oaicite:3]{index=3}
10
+ include ActiveModel::Attributes
11
11
  include Cyrel::Parameterizable
12
12
 
13
13
  attribute :alias_name, Cyrel::Types::SymbolType.new
data/lib/cyrel.rb CHANGED
@@ -35,11 +35,15 @@ module Cyrel
35
35
 
36
36
  # Cyrel DSL helper: returns the element id of a node/relationship.
37
37
  # Example: Cyrel.id(:n)
38
- def id(...) = Functions.element_id(...)
38
+ def id(...) = Functions.id(...)
39
39
 
40
40
  # Cyrel DSL helper: returns the element id of a node/relationship (alias).
41
41
  def element_id(...) = Functions.element_id(...)
42
42
 
43
+ # Cyrel DSL helper: adapter-aware node ID function
44
+ # Example: Cyrel.node_id(:n)
45
+ def node_id(...) = Functions.node_id(...)
46
+
43
47
  # Cyrel DSL helper: Cypher count() aggregation.
44
48
  # Example: Cyrel.count(:n)
45
49
  def count(...) = Functions.count(...)
@@ -1,16 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :graphdb do
4
+ # bin/rails graphdb:migrate
4
5
  desc 'Run graph database migrations'
5
6
  task migrate: :environment do
6
7
  ActiveCypher::Migrator.new.migrate!
7
8
  puts 'GraphDB migrations complete'
8
9
  end
9
10
 
11
+ # bin/rails graphdb:status
10
12
  desc 'Show graph database migration status'
11
13
  task status: :environment do
12
14
  ActiveCypher::Migrator.new.status.each do |m|
13
- puts format('%-4s %s %s', m[:status], m[:version], m[:name])
15
+ puts format('%-4<status>s %<version>s %<name>s', m)
14
16
  end
15
17
  end
16
18
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activecypher
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.7.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -119,6 +119,7 @@ files:
119
119
  - lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb
120
120
  - lib/active_cypher/connection_adapters/memgraph_adapter.rb
121
121
  - lib/active_cypher/connection_adapters/neo4j_adapter.rb
122
+ - lib/active_cypher/connection_adapters/persistence_methods.rb
122
123
  - lib/active_cypher/connection_adapters/registry.rb
123
124
  - lib/active_cypher/connection_handler.rb
124
125
  - lib/active_cypher/connection_pool.rb