activecypher 0.15.1 → 0.15.2
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 +1 -1
- data/lib/active_cypher/associations.rb +7 -19
- data/lib/active_cypher/base.rb +1 -1
- data/lib/active_cypher/bolt/connection.rb +16 -102
- data/lib/active_cypher/bolt/driver.rb +4 -4
- data/lib/active_cypher/bolt/messaging.rb +32 -104
- data/lib/active_cypher/bolt/packstream.rb +15 -23
- data/lib/active_cypher/connection_adapters/abstract_adapter.rb +17 -1
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +21 -23
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +1 -25
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +0 -24
- data/lib/active_cypher/migration.rb +2 -3
- data/lib/active_cypher/model/core.rb +1 -3
- data/lib/active_cypher/relation.rb +1 -1
- data/lib/active_cypher/relationship.rb +3 -3
- data/lib/active_cypher/version.rb +1 -1
- data/lib/cyrel/clause/create.rb +1 -5
- data/lib/cyrel/clause/match.rb +1 -7
- data/lib/cyrel/clause/merge.rb +1 -5
- data/lib/cyrel/expression/exists.rb +1 -4
- data/lib/cyrel/expression/pattern_comprehension.rb +1 -4
- data/lib/cyrel/pattern/node.rb +1 -1
- data/lib/cyrel/pattern.rb +10 -0
- data/lib/cyrel/query.rb +63 -117
- data/lib/cyrel.rb +14 -35
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc6fb1ecc443117671c36c2b287b44d0059d706c8543ef113915fe0c67e8063d
|
|
4
|
+
data.tar.gz: 7f7a6e862f2c146dc19e89827e2b0ab2333bcc58f801b07a78e3e0c89f17fc6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6cd38267fc15f40e53c9d9d79d11fe52138cc20aa93290043e2aae7834616b6d78afb952a7c1c1ad6baf8e08531170618e43f297d70074c9db806928cd89b807
|
|
7
|
+
data.tar.gz: c2b0186231f35c838a56272ff6222f64af8a7be53891c8ed90260e297acdccef42992f28b65729ff61410172570cdcd92209c3c817d920d30341cc88906be9ff
|
|
@@ -45,7 +45,7 @@ module ActiveCypher
|
|
|
45
45
|
# Returns the size, because counting things is the only certainty in life.
|
|
46
46
|
#
|
|
47
47
|
# @return [Integer] The number of records in the collection
|
|
48
|
-
def size
|
|
48
|
+
def size = load_target.size
|
|
49
49
|
alias length size
|
|
50
50
|
|
|
51
51
|
# Fully refresh from the database.
|
|
@@ -285,6 +285,12 @@ module ActiveCypher
|
|
|
285
285
|
instance_variable_set(instance_var, associate)
|
|
286
286
|
end
|
|
287
287
|
|
|
288
|
+
define_build_and_create_methods(name, target_class_name)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Defines build_<name> and create_<name> for singular associations
|
|
292
|
+
# (shared by belongs_to and has_one).
|
|
293
|
+
def define_build_and_create_methods(name, target_class_name)
|
|
288
294
|
# Define build method (e.g., build_author(name: "New Author"))
|
|
289
295
|
define_method("build_#{name}") do |attributes = {}|
|
|
290
296
|
target_class = target_class_name.constantize
|
|
@@ -431,25 +437,7 @@ module ActiveCypher
|
|
|
431
437
|
instance_variable_set(instance_var, associate)
|
|
432
438
|
end
|
|
433
439
|
|
|
434
|
-
|
|
435
|
-
define_method("build_#{name}") do |attributes = {}|
|
|
436
|
-
target_class = target_class_name.constantize
|
|
437
|
-
# TODO: Potentially set the inverse association reference here
|
|
438
|
-
# For now, just instantiate the target class
|
|
439
|
-
target_class.new(attributes)
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
# Define create method (e.g., create_profile(data: {...}))
|
|
443
|
-
define_method("create_#{name}") do |attributes = {}|
|
|
444
|
-
# Build the instance
|
|
445
|
-
instance = public_send("build_#{name}", attributes)
|
|
446
|
-
# Save the instance
|
|
447
|
-
instance.save
|
|
448
|
-
# If save is successful, associate it using the = method
|
|
449
|
-
public_send("#{name}=", instance) if instance.persisted?
|
|
450
|
-
# Return the instance
|
|
451
|
-
instance
|
|
452
|
-
end
|
|
440
|
+
define_build_and_create_methods(name, target_class_name)
|
|
453
441
|
end
|
|
454
442
|
end
|
|
455
443
|
|
data/lib/active_cypher/base.rb
CHANGED
|
@@ -485,7 +485,6 @@ module ActiveCypher
|
|
|
485
485
|
session(database: db).write_transaction(db: db, timeout: timeout, metadata: metadata, &)
|
|
486
486
|
end
|
|
487
487
|
|
|
488
|
-
|
|
489
488
|
# ────────────────────────────────────────────────────────────────────
|
|
490
489
|
# HEALTH AND VERSION DETECTION METHODS
|
|
491
490
|
# ────────────────────────────────────────────────────────────────────
|
|
@@ -514,21 +513,17 @@ module ActiveCypher
|
|
|
514
513
|
def health_check
|
|
515
514
|
return { healthy: false, response_time_ms: nil, details: 'Not connected' } unless connected?
|
|
516
515
|
|
|
517
|
-
|
|
516
|
+
nil
|
|
518
517
|
|
|
519
518
|
begin
|
|
520
|
-
|
|
521
|
-
case database_type
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
perform_generic_health_check
|
|
528
|
-
end
|
|
519
|
+
Sync do
|
|
520
|
+
query = case database_type
|
|
521
|
+
when :neo4j then 'RETURN 1 AS result'
|
|
522
|
+
when :memgraph then 'SHOW STORAGE INFO'
|
|
523
|
+
else 'RETURN 1'
|
|
524
|
+
end
|
|
525
|
+
perform_health_check_query(query)
|
|
529
526
|
end
|
|
530
|
-
|
|
531
|
-
result
|
|
532
527
|
rescue ConnectionError, ProtocolError => e
|
|
533
528
|
{ healthy: false, response_time_ms: nil, details: "Health check failed: #{e.message}" }
|
|
534
529
|
end
|
|
@@ -707,96 +702,15 @@ module ActiveCypher
|
|
|
707
702
|
}
|
|
708
703
|
end
|
|
709
704
|
|
|
710
|
-
# Performs
|
|
711
|
-
#
|
|
712
|
-
# @return [Hash] health check result
|
|
713
|
-
def perform_neo4j_health_check
|
|
714
|
-
start_time = Time.now
|
|
715
|
-
|
|
716
|
-
begin
|
|
717
|
-
write_message(Messaging::Run.new('RETURN 1 AS result', {}, {}), 'HEALTH_CHECK')
|
|
718
|
-
run_response = read_message
|
|
719
|
-
|
|
720
|
-
case run_response
|
|
721
|
-
when Messaging::Success
|
|
722
|
-
# Send PULL message to complete the transaction
|
|
723
|
-
write_message(Messaging::Pull.new({ 'n' => -1 }), 'PULL')
|
|
724
|
-
|
|
725
|
-
# Read messages until we get SUCCESS or FAILURE
|
|
726
|
-
response_time = nil
|
|
727
|
-
loop do
|
|
728
|
-
msg = read_message
|
|
729
|
-
case msg
|
|
730
|
-
when Messaging::Record
|
|
731
|
-
# Skip records, we just want to know if the query succeeded
|
|
732
|
-
next
|
|
733
|
-
when Messaging::Success
|
|
734
|
-
response_time = ((Time.now - start_time) * 1000).round(2)
|
|
735
|
-
return { healthy: true, response_time_ms: response_time, details: 'RETURN 1 succeeded' }
|
|
736
|
-
when Messaging::Failure
|
|
737
|
-
return { healthy: false, response_time_ms: nil, details: 'RETURN 1 failed with error' }
|
|
738
|
-
else
|
|
739
|
-
return { healthy: false, response_time_ms: nil, details: 'RETURN 1 unexpected response' }
|
|
740
|
-
end
|
|
741
|
-
end
|
|
742
|
-
else
|
|
743
|
-
{ healthy: false, response_time_ms: nil, details: 'RETURN 1 run failed' }
|
|
744
|
-
end
|
|
745
|
-
ensure
|
|
746
|
-
# Reset connection state after health check
|
|
747
|
-
reset!
|
|
748
|
-
end
|
|
749
|
-
end
|
|
750
|
-
|
|
751
|
-
# Performs Memgraph-specific health check using SHOW STORAGE INFO.
|
|
752
|
-
#
|
|
753
|
-
# @return [Hash] health check result
|
|
754
|
-
def perform_memgraph_health_check
|
|
755
|
-
start_time = Time.now
|
|
756
|
-
|
|
757
|
-
begin
|
|
758
|
-
write_message(Messaging::Run.new('SHOW STORAGE INFO', {}, {}), 'HEALTH_CHECK')
|
|
759
|
-
run_response = read_message
|
|
760
|
-
|
|
761
|
-
case run_response
|
|
762
|
-
when Messaging::Success
|
|
763
|
-
# Send PULL message to complete the transaction
|
|
764
|
-
write_message(Messaging::Pull.new({ 'n' => -1 }), 'PULL')
|
|
765
|
-
|
|
766
|
-
# Read messages until we get SUCCESS or FAILURE
|
|
767
|
-
response_time = nil
|
|
768
|
-
loop do
|
|
769
|
-
msg = read_message
|
|
770
|
-
case msg
|
|
771
|
-
when Messaging::Record
|
|
772
|
-
# Skip records, we just want to know if the query succeeded
|
|
773
|
-
next
|
|
774
|
-
when Messaging::Success
|
|
775
|
-
response_time = ((Time.now - start_time) * 1000).round(2)
|
|
776
|
-
return { healthy: true, response_time_ms: response_time, details: 'SHOW STORAGE INFO succeeded' }
|
|
777
|
-
when Messaging::Failure
|
|
778
|
-
return { healthy: false, response_time_ms: nil, details: 'SHOW STORAGE INFO failed with error' }
|
|
779
|
-
else
|
|
780
|
-
return { healthy: false, response_time_ms: nil, details: 'SHOW STORAGE INFO unexpected response' }
|
|
781
|
-
end
|
|
782
|
-
end
|
|
783
|
-
else
|
|
784
|
-
{ healthy: false, response_time_ms: nil, details: 'SHOW STORAGE INFO run failed' }
|
|
785
|
-
end
|
|
786
|
-
ensure
|
|
787
|
-
# Reset connection state after health check
|
|
788
|
-
reset!
|
|
789
|
-
end
|
|
790
|
-
end
|
|
791
|
-
|
|
792
|
-
# Performs generic health check using simple RETURN 1 query.
|
|
705
|
+
# Performs a health check by running the given query and draining the result.
|
|
793
706
|
#
|
|
707
|
+
# @param query [String] database-appropriate health check query
|
|
794
708
|
# @return [Hash] health check result
|
|
795
|
-
def
|
|
709
|
+
def perform_health_check_query(query)
|
|
796
710
|
start_time = Time.now
|
|
797
711
|
|
|
798
712
|
begin
|
|
799
|
-
write_message(Messaging::Run.new(
|
|
713
|
+
write_message(Messaging::Run.new(query, {}, {}), 'HEALTH_CHECK')
|
|
800
714
|
run_response = read_message
|
|
801
715
|
|
|
802
716
|
case run_response
|
|
@@ -814,15 +728,15 @@ module ActiveCypher
|
|
|
814
728
|
next
|
|
815
729
|
when Messaging::Success
|
|
816
730
|
response_time = ((Time.now - start_time) * 1000).round(2)
|
|
817
|
-
return { healthy: true, response_time_ms: response_time, details:
|
|
731
|
+
return { healthy: true, response_time_ms: response_time, details: "#{query} succeeded" }
|
|
818
732
|
when Messaging::Failure
|
|
819
|
-
return { healthy: false, response_time_ms: nil, details:
|
|
733
|
+
return { healthy: false, response_time_ms: nil, details: "#{query} failed with error" }
|
|
820
734
|
else
|
|
821
|
-
return { healthy: false, response_time_ms: nil, details:
|
|
735
|
+
return { healthy: false, response_time_ms: nil, details: "#{query} unexpected response" }
|
|
822
736
|
end
|
|
823
737
|
end
|
|
824
738
|
else
|
|
825
|
-
{ healthy: false, response_time_ms: nil, details:
|
|
739
|
+
{ healthy: false, response_time_ms: nil, details: "#{query} run failed" }
|
|
826
740
|
end
|
|
827
741
|
ensure
|
|
828
742
|
# Reset connection state after health check
|
|
@@ -40,9 +40,9 @@ module ActiveCypher
|
|
|
40
40
|
#
|
|
41
41
|
# @yieldparam session [Bolt::Session] The session to use
|
|
42
42
|
# @return [Object] The result of the block
|
|
43
|
-
def with_session(**kw, &
|
|
43
|
+
def with_session(**kw, &)
|
|
44
44
|
Sync do
|
|
45
|
-
_acquire_session(**kw, &
|
|
45
|
+
_acquire_session(**kw, &)
|
|
46
46
|
end
|
|
47
47
|
rescue Async::TimeoutError => e
|
|
48
48
|
raise ActiveCypher::ConnectionError, "Connection pool timeout: #{e.message}"
|
|
@@ -55,11 +55,11 @@ module ActiveCypher
|
|
|
55
55
|
#
|
|
56
56
|
# @yieldparam session [Bolt::Session] The session to use
|
|
57
57
|
# @return [Async::Task] A task that resolves to the block's result
|
|
58
|
-
def async_with_session(**kw, &
|
|
58
|
+
def async_with_session(**kw, &)
|
|
59
59
|
raise 'Cannot run async_with_session outside of an Async task' unless Async::Task.current?
|
|
60
60
|
|
|
61
61
|
Async do
|
|
62
|
-
_acquire_session(**kw, &
|
|
62
|
+
_acquire_session(**kw, &)
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
@@ -67,13 +67,12 @@ module ActiveCypher
|
|
|
67
67
|
end
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
# Base for messages whose single field is a normalized metadata map.
|
|
71
|
+
# Subclasses only need to define their SIGNATURE.
|
|
72
|
+
class MetadataMessage < Message
|
|
74
73
|
def initialize(metadata)
|
|
75
74
|
meta = Messaging.normalize_map(metadata)
|
|
76
|
-
super(SIGNATURE, [meta])
|
|
75
|
+
super(self.class::SIGNATURE, [meta])
|
|
77
76
|
end
|
|
78
77
|
|
|
79
78
|
def metadata
|
|
@@ -81,22 +80,27 @@ module ActiveCypher
|
|
|
81
80
|
end
|
|
82
81
|
end
|
|
83
82
|
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
# Base for messages that carry no fields at all.
|
|
84
|
+
# Subclasses only need to define their SIGNATURE.
|
|
85
|
+
class EmptyMessage < Message
|
|
88
86
|
def initialize
|
|
89
|
-
super(SIGNATURE, [])
|
|
87
|
+
super(self.class::SIGNATURE, [])
|
|
90
88
|
end
|
|
91
89
|
end
|
|
92
90
|
|
|
91
|
+
# The HELLO message. Because every protocol needs to start with a greeting before the disappointment.
|
|
92
|
+
class Hello < MetadataMessage
|
|
93
|
+
SIGNATURE = 0x01
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The GOODBYE message. For when you've had enough of this session, or life.
|
|
97
|
+
class Goodbye < EmptyMessage
|
|
98
|
+
SIGNATURE = 0x02
|
|
99
|
+
end
|
|
100
|
+
|
|
93
101
|
# The RESET message. Because sometimes you just want to pretend nothing ever happened.
|
|
94
|
-
class Reset <
|
|
102
|
+
class Reset < EmptyMessage
|
|
95
103
|
SIGNATURE = 0x0F
|
|
96
|
-
|
|
97
|
-
def initialize
|
|
98
|
-
super(SIGNATURE, [])
|
|
99
|
-
end
|
|
100
104
|
end
|
|
101
105
|
|
|
102
106
|
# The RUN message. Because what else would you do with a database connection?
|
|
@@ -148,115 +152,52 @@ module ActiveCypher
|
|
|
148
152
|
end
|
|
149
153
|
|
|
150
154
|
# The COMMIT message. For when you want to pretend your changes are permanent.
|
|
151
|
-
class Commit <
|
|
155
|
+
class Commit < EmptyMessage
|
|
152
156
|
SIGNATURE = 0x12
|
|
153
|
-
|
|
154
|
-
def initialize
|
|
155
|
-
super(SIGNATURE, [])
|
|
156
|
-
end
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
# The ROLLBACK message. Because sometimes you just want to undo your mistakes.
|
|
160
|
-
class Rollback <
|
|
160
|
+
class Rollback < EmptyMessage
|
|
161
161
|
SIGNATURE = 0x13
|
|
162
|
-
|
|
163
|
-
def initialize
|
|
164
|
-
super(SIGNATURE, [])
|
|
165
|
-
end
|
|
166
162
|
end
|
|
167
163
|
|
|
168
164
|
# The DISCARD message. For when you want to throw away results, or your hopes.
|
|
169
|
-
class Discard <
|
|
165
|
+
class Discard < MetadataMessage
|
|
170
166
|
SIGNATURE = 0x2F
|
|
171
167
|
|
|
172
168
|
# metadata: { n: <N>, qid: <QID> }, where n = -1 means all
|
|
173
|
-
def
|
|
174
|
-
|
|
175
|
-
super(SIGNATURE, [meta])
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def metadata = fields.first
|
|
179
|
-
def n = metadata[:n] || metadata['n']
|
|
180
|
-
def qid = metadata[:qid] || metadata['qid']
|
|
169
|
+
def n = metadata[:n] || metadata['n']
|
|
170
|
+
def qid = metadata[:qid] || metadata['qid']
|
|
181
171
|
end
|
|
182
172
|
|
|
183
173
|
# The PULL message. Because sometimes you just want to see what you got.
|
|
184
|
-
class Pull <
|
|
174
|
+
class Pull < MetadataMessage
|
|
185
175
|
SIGNATURE = 0x3F
|
|
186
|
-
|
|
187
|
-
def initialize(metadata)
|
|
188
|
-
meta = Messaging.normalize_map(metadata)
|
|
189
|
-
super(SIGNATURE, [meta])
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def metadata
|
|
193
|
-
fields.first
|
|
194
|
-
end
|
|
195
176
|
end
|
|
196
177
|
|
|
197
178
|
# The ROUTE message. For when you want to pretend you have control over routing.
|
|
198
|
-
class Route <
|
|
179
|
+
class Route < MetadataMessage
|
|
199
180
|
SIGNATURE = 0x66
|
|
200
|
-
|
|
201
|
-
def initialize(metadata)
|
|
202
|
-
meta = Messaging.normalize_map(metadata)
|
|
203
|
-
super(SIGNATURE, [meta])
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def metadata
|
|
207
|
-
fields.first
|
|
208
|
-
end
|
|
209
181
|
end
|
|
210
182
|
|
|
211
183
|
# The LOGON message. Because authentication is just another chance to be rejected.
|
|
212
|
-
class Logon <
|
|
184
|
+
class Logon < MetadataMessage
|
|
213
185
|
SIGNATURE = 0x6A
|
|
214
|
-
|
|
215
|
-
def initialize(metadata)
|
|
216
|
-
meta = Messaging.normalize_map(metadata)
|
|
217
|
-
super(SIGNATURE, [meta])
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def metadata
|
|
221
|
-
fields.first
|
|
222
|
-
end
|
|
223
186
|
end
|
|
224
187
|
|
|
225
188
|
# The LOGOFF message. For when you want to leave quietly, without making a scene.
|
|
226
|
-
class Logoff <
|
|
189
|
+
class Logoff < EmptyMessage
|
|
227
190
|
SIGNATURE = 0x6B
|
|
228
|
-
|
|
229
|
-
def initialize
|
|
230
|
-
super(SIGNATURE, [])
|
|
231
|
-
end
|
|
232
191
|
end
|
|
233
192
|
|
|
234
193
|
# The TELEMETRY message. Because someone, somewhere, cares about your metrics. Probably.
|
|
235
|
-
class Telemetry <
|
|
194
|
+
class Telemetry < MetadataMessage
|
|
236
195
|
SIGNATURE = 0x54
|
|
237
|
-
|
|
238
|
-
def initialize(metadata)
|
|
239
|
-
meta = Messaging.normalize_map(metadata)
|
|
240
|
-
super(SIGNATURE, [meta])
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def metadata
|
|
244
|
-
fields.first
|
|
245
|
-
end
|
|
246
196
|
end
|
|
247
197
|
|
|
248
198
|
# The SUCCESS message. The rarest of all Bolt messages.
|
|
249
|
-
class Success <
|
|
199
|
+
class Success < MetadataMessage
|
|
250
200
|
SIGNATURE = 0x70
|
|
251
|
-
|
|
252
|
-
def initialize(metadata)
|
|
253
|
-
meta = Messaging.normalize_map(metadata)
|
|
254
|
-
super(SIGNATURE, [meta])
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def metadata
|
|
258
|
-
fields.first
|
|
259
|
-
end
|
|
260
201
|
end
|
|
261
202
|
|
|
262
203
|
# The RECORD message. For when you actually get data back, against all odds.
|
|
@@ -273,27 +214,14 @@ module ActiveCypher
|
|
|
273
214
|
end
|
|
274
215
|
|
|
275
216
|
# The IGNORED message. For when the server just can't be bothered.
|
|
276
|
-
class Ignored <
|
|
217
|
+
class Ignored < EmptyMessage
|
|
277
218
|
SIGNATURE = 0x7E
|
|
278
|
-
|
|
279
|
-
def initialize
|
|
280
|
-
super(SIGNATURE, [])
|
|
281
|
-
end
|
|
282
219
|
end
|
|
283
220
|
|
|
284
221
|
# The FAILURE message. The most honest message in the protocol.
|
|
285
|
-
class Failure <
|
|
222
|
+
class Failure < MetadataMessage
|
|
286
223
|
SIGNATURE = 0x7F
|
|
287
224
|
|
|
288
|
-
def initialize(metadata)
|
|
289
|
-
meta = Messaging.normalize_map(metadata)
|
|
290
|
-
super(SIGNATURE, [meta])
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def metadata
|
|
294
|
-
fields.first
|
|
295
|
-
end
|
|
296
|
-
|
|
297
225
|
def code
|
|
298
226
|
metadata['code']
|
|
299
227
|
end
|
|
@@ -89,18 +89,7 @@ module ActiveCypher
|
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
def pack_map(map)
|
|
92
|
-
size
|
|
93
|
-
|
|
94
|
-
if size < 16 # TinyMap
|
|
95
|
-
write_marker([TINY_MAP_MARKER_BASE | size].pack('C'))
|
|
96
|
-
elsif size < 256 # MAP_8
|
|
97
|
-
write_marker([MAP_8_MARKER, size].pack('CC'))
|
|
98
|
-
elsif size < 65_536 # MAP_16
|
|
99
|
-
write_marker([MAP_16_MARKER, size].pack('Cn'))
|
|
100
|
-
else
|
|
101
|
-
raise ProtocolError, "Map too large to pack (size: #{size})"
|
|
102
|
-
# write_marker([MAP_32_MARKER, size].pack('CN>'))
|
|
103
|
-
end
|
|
92
|
+
write_collection_marker(map.size, TINY_MAP_MARKER_BASE, MAP_8_MARKER, MAP_16_MARKER, 'Map')
|
|
104
93
|
|
|
105
94
|
map.each do |key, value|
|
|
106
95
|
pack(key.to_s) # Keys must be strings
|
|
@@ -109,21 +98,24 @@ module ActiveCypher
|
|
|
109
98
|
end
|
|
110
99
|
|
|
111
100
|
def pack_list(list)
|
|
112
|
-
size
|
|
113
|
-
if size < 16 # TinyList
|
|
114
|
-
write_marker([TINY_LIST_MARKER_BASE | size].pack('C'))
|
|
115
|
-
elsif size < 256 # LIST_8
|
|
116
|
-
write_marker([LIST_8_MARKER, size].pack('CC'))
|
|
117
|
-
elsif size < 65_536 # LIST_16
|
|
118
|
-
write_marker([LIST_16_MARKER, size].pack('Cn')) # n is already network byte order
|
|
119
|
-
else
|
|
120
|
-
raise ProtocolError, "List too large to pack (size: #{size})"
|
|
121
|
-
# write_marker([LIST_32_MARKER, size].pack('CN>')) # Use N> for network byte order
|
|
122
|
-
end
|
|
101
|
+
write_collection_marker(list.size, TINY_LIST_MARKER_BASE, LIST_8_MARKER, LIST_16_MARKER, 'List')
|
|
123
102
|
|
|
124
103
|
list.each { |item| pack(item) }
|
|
125
104
|
end
|
|
126
105
|
|
|
106
|
+
# Writes the size marker shared by maps and lists (tiny / 8-bit / 16-bit).
|
|
107
|
+
def write_collection_marker(size, tiny_base, marker8, marker16, label)
|
|
108
|
+
if size < 16 # Tiny
|
|
109
|
+
write_marker([tiny_base | size].pack('C'))
|
|
110
|
+
elsif size < 256 # 8-bit size
|
|
111
|
+
write_marker([marker8, size].pack('CC'))
|
|
112
|
+
elsif size < 65_536 # 16-bit size ('n' is network byte order)
|
|
113
|
+
write_marker([marker16, size].pack('Cn'))
|
|
114
|
+
else
|
|
115
|
+
raise ProtocolError, "#{label} too large to pack (size: #{size})"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
127
119
|
def pack_integer(int)
|
|
128
120
|
if int.between?(TINY_INT_MIN, TINY_INT_MAX) # Tiny Integer
|
|
129
121
|
write_marker([int].pack('c')) # Signed char for range -128 to 127
|
|
@@ -54,7 +54,23 @@ module ActiveCypher
|
|
|
54
54
|
# @param node_alias [Symbol] The alias used for the node in the query
|
|
55
55
|
# @return [Hash] The hydrated attributes
|
|
56
56
|
def hydrate_record(record, node_alias)
|
|
57
|
-
|
|
57
|
+
attrs = {}
|
|
58
|
+
node_data = record[node_alias] || record[node_alias.to_s]
|
|
59
|
+
|
|
60
|
+
if node_data.is_a?(Array) && node_data.length >= 2
|
|
61
|
+
properties_container = node_data[1]
|
|
62
|
+
if properties_container.is_a?(Array) && properties_container.length >= 3
|
|
63
|
+
properties = properties_container[2]
|
|
64
|
+
properties.each { |k, v| attrs[k.to_sym] = v } if properties.is_a?(Hash)
|
|
65
|
+
end
|
|
66
|
+
elsif node_data.is_a?(Hash)
|
|
67
|
+
node_data.each { |k, v| attrs[k.to_sym] = v }
|
|
68
|
+
elsif node_data.respond_to?(:properties)
|
|
69
|
+
attrs = node_data.properties.symbolize_keys
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
attrs[:internal_id] = record[:internal_id] || record['internal_id']
|
|
73
|
+
attrs
|
|
58
74
|
end
|
|
59
75
|
|
|
60
76
|
# Turns rows into symbols, because Rubyists fear strings.
|
|
@@ -98,9 +98,9 @@ module ActiveCypher
|
|
|
98
98
|
#
|
|
99
99
|
# @yieldparam session [Bolt::Session] The session to use
|
|
100
100
|
# @return [Object] The result of the block
|
|
101
|
-
def with_session(**kw, &
|
|
101
|
+
def with_session(**kw, &)
|
|
102
102
|
connect
|
|
103
|
-
@driver.with_session(**kw, &
|
|
103
|
+
@driver.with_session(**kw, &)
|
|
104
104
|
end
|
|
105
105
|
|
|
106
106
|
# Asynchronously yields a Session from the connection pool.
|
|
@@ -108,9 +108,9 @@ module ActiveCypher
|
|
|
108
108
|
#
|
|
109
109
|
# @yieldparam session [Bolt::Session] The session to use
|
|
110
110
|
# @return [Async::Task] A task that resolves to the block's result
|
|
111
|
-
def async_with_session(**kw, &
|
|
111
|
+
def async_with_session(**kw, &)
|
|
112
112
|
connect
|
|
113
|
-
@driver.async_with_session(**kw, &
|
|
113
|
+
@driver.async_with_session(**kw, &)
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
# Runs a Cypher query via Bolt session.
|
|
@@ -169,26 +169,24 @@ module ActiveCypher
|
|
|
169
169
|
|
|
170
170
|
begin
|
|
171
171
|
result = Sync do
|
|
172
|
+
# Try to execute a simple query first
|
|
173
|
+
session = Bolt::Session.new(@connection)
|
|
174
|
+
session.run('RETURN 1 AS check', {})
|
|
175
|
+
session.close
|
|
176
|
+
true
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
# Query failed, need to reset the connection
|
|
179
|
+
logger.debug { "Connection needs reset: #{e.message}" }
|
|
180
|
+
|
|
181
|
+
# Send RESET message directly
|
|
172
182
|
begin
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
logger.debug { "Connection needs reset: #{e.message}" }
|
|
181
|
-
|
|
182
|
-
# Send RESET message directly
|
|
183
|
-
begin
|
|
184
|
-
@connection.write_message(Bolt::Messaging::Reset.new)
|
|
185
|
-
response = @connection.read_message
|
|
186
|
-
logger.debug { "Reset response: #{response.class}" }
|
|
187
|
-
response.is_a?(Bolt::Messaging::Success)
|
|
188
|
-
rescue StandardError => reset_error
|
|
189
|
-
logger.error { "Reset failed: #{reset_error.message}" }
|
|
190
|
-
false
|
|
191
|
-
end
|
|
183
|
+
@connection.write_message(Bolt::Messaging::Reset.new)
|
|
184
|
+
response = @connection.read_message
|
|
185
|
+
logger.debug { "Reset response: #{response.class}" }
|
|
186
|
+
response.is_a?(Bolt::Messaging::Success)
|
|
187
|
+
rescue StandardError => reset_error
|
|
188
|
+
logger.error { "Reset failed: #{reset_error.message}" }
|
|
189
|
+
false
|
|
192
190
|
end
|
|
193
191
|
end
|
|
194
192
|
rescue StandardError => e
|
|
@@ -64,7 +64,7 @@ module ActiveCypher
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def id_type_conversion(incoming)
|
|
67
|
-
|
|
67
|
+
incoming.to_i
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
# Memgraph uses different constraint syntax than Neo4j
|
|
@@ -204,30 +204,6 @@ module ActiveCypher
|
|
|
204
204
|
metadata.compact
|
|
205
205
|
end
|
|
206
206
|
|
|
207
|
-
# Hydrates attributes from a Memgraph record
|
|
208
|
-
# @param record [Hash] The raw record from Memgraph
|
|
209
|
-
# @param node_alias [Symbol] The alias used for the node in the query
|
|
210
|
-
# @return [Hash] The hydrated attributes
|
|
211
|
-
def hydrate_record(record, node_alias)
|
|
212
|
-
attrs = {}
|
|
213
|
-
node_data = record[node_alias] || record[node_alias.to_s]
|
|
214
|
-
|
|
215
|
-
if node_data.is_a?(Array) && node_data.length >= 2
|
|
216
|
-
properties_container = node_data[1]
|
|
217
|
-
if properties_container.is_a?(Array) && properties_container.length >= 3
|
|
218
|
-
properties = properties_container[2]
|
|
219
|
-
properties.each { |k, v| attrs[k.to_sym] = v } if properties.is_a?(Hash)
|
|
220
|
-
end
|
|
221
|
-
elsif node_data.is_a?(Hash)
|
|
222
|
-
node_data.each { |k, v| attrs[k.to_sym] = v }
|
|
223
|
-
elsif node_data.respond_to?(:properties)
|
|
224
|
-
attrs = node_data.properties.symbolize_keys
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
attrs[:internal_id] = record[:internal_id] || record['internal_id']
|
|
228
|
-
attrs
|
|
229
|
-
end
|
|
230
|
-
|
|
231
207
|
protected
|
|
232
208
|
|
|
233
209
|
def parse_schema(rows)
|
|
@@ -130,30 +130,6 @@ module ActiveCypher
|
|
|
130
130
|
metadata.compact
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
# Hydrates attributes from a Neo4j record
|
|
134
|
-
# @param record [Hash] The raw record from Neo4j
|
|
135
|
-
# @param node_alias [Symbol] The alias used for the node in the query
|
|
136
|
-
# @return [Hash] The hydrated attributes
|
|
137
|
-
def hydrate_record(record, node_alias)
|
|
138
|
-
attrs = {}
|
|
139
|
-
node_data = record[node_alias] || record[node_alias.to_s]
|
|
140
|
-
|
|
141
|
-
if node_data.is_a?(Array) && node_data.length >= 2
|
|
142
|
-
properties_container = node_data[1]
|
|
143
|
-
if properties_container.is_a?(Array) && properties_container.length >= 3
|
|
144
|
-
properties = properties_container[2]
|
|
145
|
-
properties.each { |k, v| attrs[k.to_sym] = v } if properties.is_a?(Hash)
|
|
146
|
-
end
|
|
147
|
-
elsif node_data.is_a?(Hash)
|
|
148
|
-
node_data.each { |k, v| attrs[k.to_sym] = v }
|
|
149
|
-
elsif node_data.respond_to?(:properties)
|
|
150
|
-
attrs = node_data.properties.symbolize_keys
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
attrs[:internal_id] = record[:internal_id] || record['internal_id']
|
|
154
|
-
attrs
|
|
155
|
-
end
|
|
156
|
-
|
|
157
133
|
module Persistence
|
|
158
134
|
include PersistenceMethods
|
|
159
135
|
|
|
@@ -92,14 +92,13 @@ module ActiveCypher
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
def create_uniqueness_constraint(label, *props, if_not_exists: true, name: nil)
|
|
95
|
+
props_clause = props.map { |p| "n.#{p}" }.join(', ')
|
|
95
96
|
cypher = if connection.vendor == :memgraph
|
|
96
97
|
# Memgraph syntax: CREATE CONSTRAINT ON (n:Label) ASSERT n.prop IS UNIQUE
|
|
97
98
|
# Note: Memgraph doesn't support IF NOT EXISTS or named constraints
|
|
98
|
-
props_clause = props.map { |p| "n.#{p}" }.join(', ')
|
|
99
99
|
"CREATE CONSTRAINT ON (n:#{label}) ASSERT #{props_clause} IS UNIQUE"
|
|
100
100
|
else
|
|
101
101
|
# Neo4j syntax
|
|
102
|
-
props_clause = props.map { |p| "n.#{p}" }.join(', ')
|
|
103
102
|
c = +'CREATE CONSTRAINT'
|
|
104
103
|
c << " #{name}" if name
|
|
105
104
|
c << ' IF NOT EXISTS' if if_not_exists
|
|
@@ -113,7 +112,7 @@ module ActiveCypher
|
|
|
113
112
|
cypher = if connection.vendor == :memgraph
|
|
114
113
|
# Memgraph TEXT INDEX syntax (requires --experimental-enabled='text-search')
|
|
115
114
|
# Memgraph only supports single property per text index, so create one per prop
|
|
116
|
-
props.map.with_index do |p,
|
|
115
|
+
props.map.with_index do |p, _i|
|
|
117
116
|
index_name = props.size > 1 ? "#{name}_#{p}" : name.to_s
|
|
118
117
|
"CREATE TEXT INDEX #{index_name} ON :#{label}(#{p})"
|
|
119
118
|
end
|
|
@@ -36,7 +36,7 @@ module ActiveCypher
|
|
|
36
36
|
clear_changes_information
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
|
-
|
|
39
|
+
|
|
40
40
|
def ==(other)
|
|
41
41
|
# Compares by class and internal graph id only.
|
|
42
42
|
# Note: an unsaved modification will still compare equal to the persisted version.
|
|
@@ -44,8 +44,6 @@ module ActiveCypher
|
|
|
44
44
|
|
|
45
45
|
internal_id == other.internal_id
|
|
46
46
|
end
|
|
47
|
-
|
|
48
|
-
|
|
49
47
|
end
|
|
50
48
|
end
|
|
51
49
|
end
|
|
@@ -176,7 +176,7 @@ module ActiveCypher
|
|
|
176
176
|
# ------------------------------------------------------------
|
|
177
177
|
if node_payload.is_a?(Array) && node_payload.first == 78
|
|
178
178
|
# Re‑use the adapter's private helper for consistency
|
|
179
|
-
# why is it private? This seems to be the only place it's called
|
|
179
|
+
# why is it private? This seems to be the only place it's called
|
|
180
180
|
node_payload = model_class.connection
|
|
181
181
|
.send(:process_node, node_payload)
|
|
182
182
|
end
|
|
@@ -234,10 +234,10 @@ module ActiveCypher
|
|
|
234
234
|
rid = row[:rid] || row['rid']
|
|
235
235
|
from_node_id = (row[:from_node] || row['from_node'])&.dig(1, 0)
|
|
236
236
|
to_node_id = (row[:to_node] || row['to_node'])&.dig(1, 0)
|
|
237
|
-
|
|
237
|
+
|
|
238
238
|
# this is extra queries, but easier than navigating instantiation from the row data
|
|
239
|
-
from_node = Object.const_get(
|
|
240
|
-
to_node = Object.const_get(
|
|
239
|
+
from_node = Object.const_get(from_class).find(from_node_id)
|
|
240
|
+
to_node = Object.const_get(to_class).find(to_node_id)
|
|
241
241
|
|
|
242
242
|
# Extract properties from the relationship data
|
|
243
243
|
# Memgraph returns relationships wrapped as [type_code, [actual_data]]
|
data/lib/cyrel/clause/create.rb
CHANGED
|
@@ -9,11 +9,7 @@ module Cyrel
|
|
|
9
9
|
# @param pattern [Cyrel::Pattern::Path, Cyrel::Pattern::Node, Cyrel::Pattern::Relationship]
|
|
10
10
|
# The pattern to create. Typically a Path or Node.
|
|
11
11
|
def initialize(pattern)
|
|
12
|
-
|
|
13
|
-
unless pattern.is_a?(Cyrel::Pattern::Path) || pattern.is_a?(Cyrel::Pattern::Node) || pattern.is_a?(Cyrel::Pattern::Relationship)
|
|
14
|
-
raise ArgumentError,
|
|
15
|
-
"CREATE pattern must be a Cyrel::Pattern::Path, Node, or Relationship, got #{pattern.class}"
|
|
16
|
-
end
|
|
12
|
+
Cyrel::Pattern.assert_pattern!(pattern, 'CREATE')
|
|
17
13
|
|
|
18
14
|
# NOTE: Creating relationships between existing nodes requires coordination.
|
|
19
15
|
# The pattern itself should reference existing aliases defined in a preceding MATCH/MERGE.
|
data/lib/cyrel/clause/match.rb
CHANGED
|
@@ -12,13 +12,7 @@ module Cyrel
|
|
|
12
12
|
# @param path_variable [Symbol, String, nil] An optional variable to assign to the matched path.
|
|
13
13
|
def initialize(pattern, optional: false, path_variable: nil)
|
|
14
14
|
super() # Call super for Base initialization
|
|
15
|
-
|
|
16
|
-
unless pattern.is_a?(Cyrel::Pattern::Path) ||
|
|
17
|
-
pattern.is_a?(Cyrel::Pattern::Node) ||
|
|
18
|
-
pattern.is_a?(Cyrel::Pattern::Relationship)
|
|
19
|
-
raise ArgumentError,
|
|
20
|
-
"Match pattern must be a Cyrel::Pattern::Path, Node, or Relationship, got #{pattern.class}"
|
|
21
|
-
end
|
|
15
|
+
Cyrel::Pattern.assert_pattern!(pattern, 'Match')
|
|
22
16
|
|
|
23
17
|
@pattern = pattern
|
|
24
18
|
@optional = optional
|
data/lib/cyrel/clause/merge.rb
CHANGED
|
@@ -12,11 +12,7 @@ module Cyrel
|
|
|
12
12
|
# @param pattern [Cyrel::Pattern::Path, Cyrel::Pattern::Node, Cyrel::Pattern::Relationship]
|
|
13
13
|
# The pattern to merge. Typically a Path or Node.
|
|
14
14
|
def initialize(pattern)
|
|
15
|
-
|
|
16
|
-
unless pattern.is_a?(Cyrel::Pattern::Path) || pattern.is_a?(Cyrel::Pattern::Node) || pattern.is_a?(Cyrel::Pattern::Relationship)
|
|
17
|
-
raise ArgumentError,
|
|
18
|
-
"MERGE pattern must be a Cyrel::Pattern::Path, Node, or Relationship, got #{pattern.class}"
|
|
19
|
-
end
|
|
15
|
+
Cyrel::Pattern.assert_pattern!(pattern, 'MERGE')
|
|
20
16
|
|
|
21
17
|
@pattern = pattern
|
|
22
18
|
end
|
|
@@ -13,10 +13,7 @@ module Cyrel
|
|
|
13
13
|
# @param pattern [Cyrel::Pattern::Path, Cyrel::Pattern::Node, Cyrel::Pattern::Relationship]
|
|
14
14
|
# The pattern to check for existence.
|
|
15
15
|
def initialize(pattern)
|
|
16
|
-
|
|
17
|
-
raise ArgumentError,
|
|
18
|
-
"EXISTS pattern must be a Cyrel::Pattern::Path, Node, or Relationship, got #{pattern.class}"
|
|
19
|
-
end
|
|
16
|
+
Cyrel::Pattern.assert_pattern!(pattern, 'EXISTS')
|
|
20
17
|
|
|
21
18
|
@pattern = pattern
|
|
22
19
|
end
|
|
@@ -13,10 +13,7 @@ module Cyrel
|
|
|
13
13
|
# @param projection_expression [Cyrel::Expression::Base, Object]
|
|
14
14
|
# The expression evaluated for each match of the pattern.
|
|
15
15
|
def initialize(pattern, projection_expression)
|
|
16
|
-
|
|
17
|
-
raise ArgumentError,
|
|
18
|
-
"Pattern Comprehension pattern must be a Path, Node, or Relationship, got #{pattern.class}"
|
|
19
|
-
end
|
|
16
|
+
Cyrel::Pattern.assert_pattern!(pattern, 'Pattern Comprehension')
|
|
20
17
|
|
|
21
18
|
@pattern = pattern
|
|
22
19
|
@projection_expression = Expression.coerce(projection_expression)
|
data/lib/cyrel/pattern/node.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Cyrel
|
|
|
12
12
|
|
|
13
13
|
attribute :alias_name, Cyrel::Types::SymbolType.new
|
|
14
14
|
attribute :labels, array: :string, default: []
|
|
15
|
-
attribute :or_labels, array: :string, default: []
|
|
15
|
+
attribute :or_labels, array: :string, default: [] # Memgraph 3.2+: (n:Label1|Label2)
|
|
16
16
|
attribute :properties, Cyrel::Types::HashType.new, default: -> { {} }
|
|
17
17
|
|
|
18
18
|
validates :alias_name, presence: true
|
data/lib/cyrel/pattern.rb
CHANGED
|
@@ -4,5 +4,15 @@ module Cyrel
|
|
|
4
4
|
# Namespace for classes representing structural components of Cypher patterns
|
|
5
5
|
# (nodes, relationships, paths).
|
|
6
6
|
module Pattern
|
|
7
|
+
# Validates that the given object is a usable pattern (Path, Node, or Relationship).
|
|
8
|
+
# @param pattern [Object] The object to validate.
|
|
9
|
+
# @param context [String] Label used in the error message (e.g. "CREATE", "MATCH").
|
|
10
|
+
# @raise [ArgumentError] When the object is not a pattern.
|
|
11
|
+
def self.assert_pattern!(pattern, context)
|
|
12
|
+
return pattern if pattern.is_a?(Path) || pattern.is_a?(Node) || pattern.is_a?(Relationship)
|
|
13
|
+
|
|
14
|
+
raise ArgumentError,
|
|
15
|
+
"#{context} pattern must be a Cyrel::Pattern::Path, Node, or Relationship, got #{pattern.class}"
|
|
16
|
+
end
|
|
7
17
|
end
|
|
8
18
|
end
|
data/lib/cyrel/query.rb
CHANGED
|
@@ -136,13 +136,7 @@ module Cyrel
|
|
|
136
136
|
# ------------------------------------------------------------------
|
|
137
137
|
processed_conditions = conditions.flat_map do |cond|
|
|
138
138
|
if cond.is_a?(Hash)
|
|
139
|
-
cond
|
|
140
|
-
Expression::Comparison.new(
|
|
141
|
-
Expression::PropertyAccess.new(@current_alias || infer_alias, key),
|
|
142
|
-
:'=',
|
|
143
|
-
value
|
|
144
|
-
)
|
|
145
|
-
end
|
|
139
|
+
hash_to_conditions(cond)
|
|
146
140
|
else
|
|
147
141
|
cond # already an expression (or coercible)
|
|
148
142
|
end
|
|
@@ -155,7 +149,7 @@ module Cyrel
|
|
|
155
149
|
# ------------------------------------------------------------------
|
|
156
150
|
# 2. Merge with an existing WHERE (if any)
|
|
157
151
|
# ------------------------------------------------------------------
|
|
158
|
-
existing_where_index =
|
|
152
|
+
existing_where_index = find_clause_index(Clause::Where, AST::WhereNode)
|
|
159
153
|
|
|
160
154
|
if existing_where_index
|
|
161
155
|
existing_clause = @clauses[existing_where_index]
|
|
@@ -258,7 +252,7 @@ module Cyrel
|
|
|
258
252
|
ast_clause = AST::ClauseAdapter.new(set_node)
|
|
259
253
|
|
|
260
254
|
# Check for existing SET clause to merge with
|
|
261
|
-
existing_set_index =
|
|
255
|
+
existing_set_index = find_clause_index(Clause::Set, AST::SetNode)
|
|
262
256
|
|
|
263
257
|
if existing_set_index
|
|
264
258
|
existing_clause = @clauses[existing_set_index]
|
|
@@ -321,58 +315,19 @@ module Cyrel
|
|
|
321
315
|
# @return [self]
|
|
322
316
|
# Because sometimes you want to pass things along, and sometimes you just want to pass the buck.
|
|
323
317
|
def with(*items, distinct: false, where: nil)
|
|
324
|
-
|
|
325
|
-
processed_items = items.flatten.map do |item|
|
|
326
|
-
case item
|
|
327
|
-
when Expression::Base
|
|
328
|
-
item
|
|
329
|
-
when Symbol
|
|
330
|
-
# Create a RawIdentifier for variable names
|
|
331
|
-
Clause::Return::RawIdentifier.new(item.to_s)
|
|
332
|
-
when String
|
|
333
|
-
# Check if string looks like property access (e.g. "person.name")
|
|
334
|
-
# If so, treat as raw identifier, otherwise parameterize
|
|
335
|
-
if item.match?(/\A\w+\.\w+\z/)
|
|
336
|
-
Clause::Return::RawIdentifier.new(item)
|
|
337
|
-
else
|
|
338
|
-
# String literals should be coerced to expressions (parameterized)
|
|
339
|
-
Expression.coerce(item)
|
|
340
|
-
end
|
|
341
|
-
else
|
|
342
|
-
Expression.coerce(item)
|
|
343
|
-
end
|
|
344
|
-
end
|
|
318
|
+
processed_items = process_projection_items(items)
|
|
345
319
|
|
|
346
320
|
# Process WHERE conditions if provided
|
|
347
321
|
where_conditions = case where
|
|
348
322
|
when nil then []
|
|
349
|
-
when Hash
|
|
350
|
-
# Convert hash to equality comparisons
|
|
351
|
-
where.map do |key, value|
|
|
352
|
-
Expression::Comparison.new(
|
|
353
|
-
Expression::PropertyAccess.new(@current_alias || infer_alias, key),
|
|
354
|
-
:'=',
|
|
355
|
-
value
|
|
356
|
-
)
|
|
357
|
-
end
|
|
323
|
+
when Hash then hash_to_conditions(where)
|
|
358
324
|
when Array then where
|
|
359
325
|
else [where] # Single condition
|
|
360
326
|
end
|
|
361
327
|
|
|
362
328
|
# Use AST-based implementation
|
|
363
329
|
with_node = AST::WithNode.new(processed_items, distinct: distinct, where_conditions: where_conditions)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
# Find and replace existing with or add new one
|
|
367
|
-
existing_with_index = @clauses.find_index { |c| c.is_a?(Clause::With) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WithNode)) }
|
|
368
|
-
|
|
369
|
-
if existing_with_index
|
|
370
|
-
@clauses[existing_with_index] = ast_clause
|
|
371
|
-
else
|
|
372
|
-
add_clause(ast_clause)
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
self
|
|
330
|
+
replace_or_add_clause(AST::ClauseAdapter.new(with_node), Clause::With, AST::WithNode)
|
|
376
331
|
end
|
|
377
332
|
|
|
378
333
|
# Adds a RETURN clause.
|
|
@@ -384,41 +339,11 @@ module Cyrel
|
|
|
384
339
|
# is a reserved keyword in Ruby. We're not crazy - we just want to provide
|
|
385
340
|
# a clean DSL while respecting Ruby's language constraints.
|
|
386
341
|
def return_(*items, distinct: false)
|
|
387
|
-
|
|
388
|
-
processed_items = items.flatten.map do |item|
|
|
389
|
-
case item
|
|
390
|
-
when Expression::Base
|
|
391
|
-
item
|
|
392
|
-
when Symbol
|
|
393
|
-
# Create a RawIdentifier for variable names
|
|
394
|
-
Clause::Return::RawIdentifier.new(item.to_s)
|
|
395
|
-
when String
|
|
396
|
-
# Check if string looks like property access (e.g. "person.name")
|
|
397
|
-
# If so, treat as raw identifier, otherwise parameterize
|
|
398
|
-
if item.match?(/\A\w+\.\w+\z/)
|
|
399
|
-
Clause::Return::RawIdentifier.new(item)
|
|
400
|
-
else
|
|
401
|
-
# String literals should be coerced to expressions (parameterized)
|
|
402
|
-
Expression.coerce(item)
|
|
403
|
-
end
|
|
404
|
-
else
|
|
405
|
-
Expression.coerce(item)
|
|
406
|
-
end
|
|
407
|
-
end
|
|
342
|
+
processed_items = process_projection_items(items)
|
|
408
343
|
|
|
409
344
|
# Use AST-based implementation
|
|
410
345
|
return_node = AST::ReturnNode.new(processed_items, distinct: distinct)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
# Find and replace existing return or add new one
|
|
414
|
-
existing_return_index = @clauses.find_index { |c| c.is_a?(Clause::Return) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::ReturnNode)) }
|
|
415
|
-
|
|
416
|
-
if existing_return_index
|
|
417
|
-
@clauses[existing_return_index] = ast_clause
|
|
418
|
-
else
|
|
419
|
-
add_clause(ast_clause)
|
|
420
|
-
end
|
|
421
|
-
self
|
|
346
|
+
replace_or_add_clause(AST::ClauseAdapter.new(return_node), Clause::Return, AST::ReturnNode)
|
|
422
347
|
end
|
|
423
348
|
|
|
424
349
|
# Adds or replaces the ORDER BY clause.
|
|
@@ -432,17 +357,7 @@ module Cyrel
|
|
|
432
357
|
|
|
433
358
|
# Use AST-based implementation
|
|
434
359
|
order_by_node = AST::OrderByNode.new(items_array)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
# Find and replace existing order by or add new one
|
|
438
|
-
existing_order_index = @clauses.find_index { |c| c.is_a?(Clause::OrderBy) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::OrderByNode)) }
|
|
439
|
-
|
|
440
|
-
if existing_order_index
|
|
441
|
-
@clauses[existing_order_index] = ast_clause
|
|
442
|
-
else
|
|
443
|
-
add_clause(ast_clause)
|
|
444
|
-
end
|
|
445
|
-
self
|
|
360
|
+
replace_or_add_clause(AST::ClauseAdapter.new(order_by_node), Clause::OrderBy, AST::OrderByNode)
|
|
446
361
|
end
|
|
447
362
|
|
|
448
363
|
# Adds or replaces the SKIP clause.
|
|
@@ -452,17 +367,7 @@ module Cyrel
|
|
|
452
367
|
def skip(amount)
|
|
453
368
|
# Use AST-based implementation
|
|
454
369
|
skip_node = AST::SkipNode.new(amount)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
# Find and replace existing skip or add new one
|
|
458
|
-
existing_skip_index = @clauses.find_index { |c| c.is_a?(Clause::Skip) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::SkipNode)) }
|
|
459
|
-
|
|
460
|
-
if existing_skip_index
|
|
461
|
-
@clauses[existing_skip_index] = ast_clause
|
|
462
|
-
else
|
|
463
|
-
add_clause(ast_clause)
|
|
464
|
-
end
|
|
465
|
-
self
|
|
370
|
+
replace_or_add_clause(AST::ClauseAdapter.new(skip_node), Clause::Skip, AST::SkipNode)
|
|
466
371
|
end
|
|
467
372
|
|
|
468
373
|
# Adds or replaces the LIMIT clause.
|
|
@@ -472,18 +377,7 @@ module Cyrel
|
|
|
472
377
|
def limit(amount)
|
|
473
378
|
# Use AST-based implementation
|
|
474
379
|
limit_node = AST::LimitNode.new(amount)
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
# Find and replace existing limit or add new one
|
|
478
|
-
existing_limit_index = @clauses.find_index { |c| c.is_a?(Clause::Limit) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::LimitNode)) }
|
|
479
|
-
|
|
480
|
-
if existing_limit_index
|
|
481
|
-
@clauses[existing_limit_index] = ast_clause
|
|
482
|
-
else
|
|
483
|
-
add_clause(ast_clause)
|
|
484
|
-
end
|
|
485
|
-
|
|
486
|
-
self
|
|
380
|
+
replace_or_add_clause(AST::ClauseAdapter.new(limit_node), Clause::Limit, AST::LimitNode)
|
|
487
381
|
end
|
|
488
382
|
|
|
489
383
|
# Adds a CALL procedure clause.
|
|
@@ -600,6 +494,58 @@ module Cyrel
|
|
|
600
494
|
add_clause(AST::ClauseAdapter.new(load_csv_node))
|
|
601
495
|
end
|
|
602
496
|
|
|
497
|
+
# Coerces projection items (symbols, strings, expressions) for WITH/RETURN clauses.
|
|
498
|
+
def process_projection_items(items)
|
|
499
|
+
items.flatten.map do |item|
|
|
500
|
+
case item
|
|
501
|
+
when Expression::Base
|
|
502
|
+
item
|
|
503
|
+
when Symbol
|
|
504
|
+
# Create a RawIdentifier for variable names
|
|
505
|
+
Clause::Return::RawIdentifier.new(item.to_s)
|
|
506
|
+
when String
|
|
507
|
+
# Check if string looks like property access (e.g. "person.name")
|
|
508
|
+
# If so, treat as raw identifier, otherwise parameterize
|
|
509
|
+
if item.match?(/\A\w+\.\w+\z/)
|
|
510
|
+
Clause::Return::RawIdentifier.new(item)
|
|
511
|
+
else
|
|
512
|
+
# String literals should be coerced to expressions (parameterized)
|
|
513
|
+
Expression.coerce(item)
|
|
514
|
+
end
|
|
515
|
+
else
|
|
516
|
+
Expression.coerce(item)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Converts a Hash into equality comparisons against the current alias.
|
|
522
|
+
def hash_to_conditions(hash)
|
|
523
|
+
hash.map do |key, value|
|
|
524
|
+
Expression::Comparison.new(
|
|
525
|
+
Expression::PropertyAccess.new(@current_alias || infer_alias, key),
|
|
526
|
+
:'=',
|
|
527
|
+
value
|
|
528
|
+
)
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Finds the index of an existing clause matching either the legacy clause
|
|
533
|
+
# class or an AST::ClauseAdapter wrapping the given AST node class.
|
|
534
|
+
def find_clause_index(clause_class, ast_node_class)
|
|
535
|
+
@clauses.find_index { |c| c.is_a?(clause_class) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(ast_node_class)) }
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Replaces an existing matching clause in place, or appends the new one.
|
|
539
|
+
def replace_or_add_clause(ast_clause, clause_class, ast_node_class)
|
|
540
|
+
existing_index = find_clause_index(clause_class, ast_node_class)
|
|
541
|
+
if existing_index
|
|
542
|
+
@clauses[existing_index] = ast_clause
|
|
543
|
+
else
|
|
544
|
+
add_clause(ast_clause)
|
|
545
|
+
end
|
|
546
|
+
self
|
|
547
|
+
end
|
|
548
|
+
|
|
603
549
|
# private
|
|
604
550
|
|
|
605
551
|
# Merges parameters from another query, ensuring keys are unique.
|
data/lib/cyrel.rb
CHANGED
|
@@ -93,55 +93,34 @@ module Cyrel
|
|
|
93
93
|
# When called like: node(:a) > rel(:r) > node(:b)
|
|
94
94
|
# The rel(:r) is evaluated first, then > is called
|
|
95
95
|
# So we need to modify the last relationship that was just added
|
|
96
|
-
|
|
97
|
-
# Replace the last relationship with one that has the correct direction
|
|
98
|
-
last_rel = @elements.pop
|
|
99
|
-
new_rel = Cyrel::Pattern::Relationship.new(
|
|
100
|
-
alias_name: last_rel.alias_name,
|
|
101
|
-
types: last_rel.types,
|
|
102
|
-
properties: last_rel.properties,
|
|
103
|
-
length: last_rel.length,
|
|
104
|
-
direction: :outgoing
|
|
105
|
-
)
|
|
106
|
-
@elements << new_rel
|
|
107
|
-
else
|
|
108
|
-
@pending_direction = :outgoing
|
|
109
|
-
end
|
|
110
|
-
self
|
|
96
|
+
apply_direction(:outgoing)
|
|
111
97
|
end
|
|
112
98
|
|
|
113
99
|
def <(_other)
|
|
114
100
|
# Same logic as > but for incoming direction
|
|
115
|
-
|
|
116
|
-
last_rel = @elements.pop
|
|
117
|
-
new_rel = Cyrel::Pattern::Relationship.new(
|
|
118
|
-
alias_name: last_rel.alias_name,
|
|
119
|
-
types: last_rel.types,
|
|
120
|
-
properties: last_rel.properties,
|
|
121
|
-
length: last_rel.length,
|
|
122
|
-
direction: :incoming
|
|
123
|
-
)
|
|
124
|
-
@elements << new_rel
|
|
125
|
-
else
|
|
126
|
-
@pending_direction = :incoming
|
|
127
|
-
end
|
|
128
|
-
self
|
|
101
|
+
apply_direction(:incoming)
|
|
129
102
|
end
|
|
130
103
|
|
|
131
104
|
def -(_other)
|
|
132
105
|
# Same logic as > but for bidirectional
|
|
106
|
+
apply_direction(:both)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def apply_direction(direction)
|
|
133
112
|
if @elements.last.is_a?(Cyrel::Pattern::Relationship)
|
|
113
|
+
# Replace the last relationship with one that has the correct direction
|
|
134
114
|
last_rel = @elements.pop
|
|
135
|
-
|
|
115
|
+
@elements << Cyrel::Pattern::Relationship.new(
|
|
136
116
|
alias_name: last_rel.alias_name,
|
|
137
117
|
types: last_rel.types,
|
|
138
118
|
properties: last_rel.properties,
|
|
139
119
|
length: last_rel.length,
|
|
140
|
-
direction:
|
|
120
|
+
direction: direction
|
|
141
121
|
)
|
|
142
|
-
@elements << new_rel
|
|
143
122
|
else
|
|
144
|
-
@pending_direction =
|
|
123
|
+
@pending_direction = direction
|
|
145
124
|
end
|
|
146
125
|
self
|
|
147
126
|
end
|
|
@@ -256,9 +235,9 @@ module Cyrel
|
|
|
256
235
|
# Example:
|
|
257
236
|
# Cyrel.exists_block { match(Cyrel.node(:a) > Cyrel.rel(:r) > Cyrel.node(:b, :Admin)) }
|
|
258
237
|
# # => EXISTS { MATCH (a)-[r]->(b:Admin) }
|
|
259
|
-
def exists_block(&
|
|
238
|
+
def exists_block(&)
|
|
260
239
|
subquery = Query.new
|
|
261
|
-
subquery.instance_eval(&
|
|
240
|
+
subquery.instance_eval(&)
|
|
262
241
|
Expression::ExistsBlock.new(subquery)
|
|
263
242
|
end
|
|
264
243
|
|