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 +4 -4
- data/lib/active_cypher/associations/collection_proxy.rb +2 -2
- data/lib/active_cypher/associations.rb +12 -12
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +50 -0
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +50 -4
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +37 -2
- data/lib/active_cypher/connection_adapters/persistence_methods.rb +88 -0
- data/lib/active_cypher/connection_url_resolver.rb +8 -2
- data/lib/active_cypher/fixtures/node_builder.rb +3 -2
- data/lib/active_cypher/generators/templates/cypher_databases.yml +3 -3
- data/lib/active_cypher/model/core.rb +5 -1
- data/lib/active_cypher/model/destruction.rb +3 -13
- data/lib/active_cypher/model/persistence.rb +7 -62
- data/lib/active_cypher/model/querying.rb +13 -4
- data/lib/active_cypher/relation.rb +1 -1
- data/lib/active_cypher/relationship.rb +7 -1
- data/lib/active_cypher/version.rb +1 -1
- data/lib/cyrel/functions.rb +11 -0
- data/lib/cyrel/pattern/node.rb +1 -1
- data/lib/cyrel.rb +5 -1
- data/lib/tasks/graphdb_migrate.rake +3 -1
- metadata +2 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bcfe9f62e0fdcf58b7d4f46fef0b7869bac550fc730f2e93a2d057938cad8a4e
|
4
|
+
data.tar.gz: a835e25b6ff10e6e9e4884c2855091e617086e1635931988cc018d9eac06afd8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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.
|
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.
|
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.
|
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.
|
229
|
-
.where(Cyrel.
|
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.
|
270
|
-
.where(Cyrel.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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
|
-
#
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
#
|
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,
|
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)
|
@@ -12,7 +12,11 @@ module ActiveCypher
|
|
12
12
|
include ActiveCypher::Associations
|
13
13
|
include ActiveCypher::Scoping
|
14
14
|
|
15
|
-
|
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
|
-
|
24
|
-
|
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
|
-
|
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
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
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
|
-
|
177
|
-
|
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
|
-
|
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
|
-
|
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
|
45
|
-
RETURN #{node_alias},
|
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.
|
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
|
-
|
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
|
data/lib/cyrel/functions.rb
CHANGED
@@ -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)
|
data/lib/cyrel/pattern/node.rb
CHANGED
@@ -7,7 +7,7 @@ module Cyrel
|
|
7
7
|
module Pattern
|
8
8
|
class Node
|
9
9
|
include ActiveModel::Model
|
10
|
-
include ActiveModel::Attributes
|
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.
|
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('%-
|
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.
|
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
|