activecypher 0.14.2 → 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 +5 -2
- data/lib/active_cypher/associations.rb +22 -32
- data/lib/active_cypher/base.rb +14 -2
- 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 +4 -24
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +0 -24
- data/lib/active_cypher/migration.rb +2 -3
- data/lib/active_cypher/model/connection_owner.rb +1 -2
- data/lib/active_cypher/model/core.rb +8 -0
- data/lib/active_cypher/railtie.rb +2 -2
- data/lib/active_cypher/relation.rb +3 -2
- data/lib/active_cypher/relationship.rb +19 -15
- 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 +2 -2
|
@@ -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
|
|
@@ -63,6 +63,10 @@ module ActiveCypher
|
|
|
63
63
|
self.class
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
def id_type_conversion(incoming)
|
|
67
|
+
incoming.to_i
|
|
68
|
+
end
|
|
69
|
+
|
|
66
70
|
# Memgraph uses different constraint syntax than Neo4j
|
|
67
71
|
def ensure_schema_migration_constraint
|
|
68
72
|
execute_ddl(<<~CYPHER)
|
|
@@ -200,30 +204,6 @@ module ActiveCypher
|
|
|
200
204
|
metadata.compact
|
|
201
205
|
end
|
|
202
206
|
|
|
203
|
-
# Hydrates attributes from a Memgraph record
|
|
204
|
-
# @param record [Hash] The raw record from Memgraph
|
|
205
|
-
# @param node_alias [Symbol] The alias used for the node in the query
|
|
206
|
-
# @return [Hash] The hydrated attributes
|
|
207
|
-
def hydrate_record(record, node_alias)
|
|
208
|
-
attrs = {}
|
|
209
|
-
node_data = record[node_alias] || record[node_alias.to_s]
|
|
210
|
-
|
|
211
|
-
if node_data.is_a?(Array) && node_data.length >= 2
|
|
212
|
-
properties_container = node_data[1]
|
|
213
|
-
if properties_container.is_a?(Array) && properties_container.length >= 3
|
|
214
|
-
properties = properties_container[2]
|
|
215
|
-
properties.each { |k, v| attrs[k.to_sym] = v } if properties.is_a?(Hash)
|
|
216
|
-
end
|
|
217
|
-
elsif node_data.is_a?(Hash)
|
|
218
|
-
node_data.each { |k, v| attrs[k.to_sym] = v }
|
|
219
|
-
elsif node_data.respond_to?(:properties)
|
|
220
|
-
attrs = node_data.properties.symbolize_keys
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
attrs[:internal_id] = record[:internal_id] || record['internal_id']
|
|
224
|
-
attrs
|
|
225
|
-
end
|
|
226
|
-
|
|
227
207
|
protected
|
|
228
208
|
|
|
229
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
|
|
@@ -7,7 +7,6 @@ module ActiveCypher
|
|
|
7
7
|
module ConnectionOwner
|
|
8
8
|
extend ActiveSupport::Concern
|
|
9
9
|
include ActiveCypher::Logging
|
|
10
|
-
include ActiveCypher::Model::ConnectionHandling
|
|
11
10
|
|
|
12
11
|
def self.db_key_for(mapping, role)
|
|
13
12
|
return nil unless mapping.is_a?(Hash)
|
|
@@ -66,7 +65,7 @@ module ActiveCypher
|
|
|
66
65
|
# Always dynamically fetch the connection for the current db_key
|
|
67
66
|
def connection
|
|
68
67
|
handler = connection_handler
|
|
69
|
-
mapping = connects_to_mappings
|
|
68
|
+
mapping = connects_to_mappings
|
|
70
69
|
role = ActiveCypher::RuntimeRegistry.current_role || :writing
|
|
71
70
|
db_key = ConnectionOwner.db_key_for(mapping, role)
|
|
72
71
|
db_key = db_key.to_sym if db_key.respond_to?(:to_sym)
|
|
@@ -36,6 +36,14 @@ module ActiveCypher
|
|
|
36
36
|
clear_changes_information
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
def ==(other)
|
|
41
|
+
# Compares by class and internal graph id only.
|
|
42
|
+
# Note: an unsaved modification will still compare equal to the persisted version.
|
|
43
|
+
return false unless other.instance_of?(self.class)
|
|
44
|
+
|
|
45
|
+
internal_id == other.internal_id
|
|
46
|
+
end
|
|
39
47
|
end
|
|
40
48
|
end
|
|
41
49
|
end
|
|
@@ -44,8 +44,8 @@ module ActiveCypher
|
|
|
44
44
|
# Find all abstract node base classes with connects_to mappings
|
|
45
45
|
ObjectSpace.each_object(Class) do |klass|
|
|
46
46
|
next unless klass < ActiveCypher::Base
|
|
47
|
-
next unless klass.
|
|
48
|
-
next
|
|
47
|
+
next unless klass.abstract_class?
|
|
48
|
+
next if klass.connects_to_mappings.empty?
|
|
49
49
|
|
|
50
50
|
# Register pools for each role in connects_to mapping
|
|
51
51
|
klass.connects_to_mappings.each_value do |conn_name|
|
|
@@ -164,8 +164,8 @@ module ActiveCypher
|
|
|
164
164
|
# 1. Pull out the node payload and the elementId string
|
|
165
165
|
# ------------------------------------------------------------
|
|
166
166
|
if row.is_a?(Hash)
|
|
167
|
-
node_payload = row[:n] || row['n'] || row
|
|
168
|
-
element_id = row[:internal_id] || row['internal_id']
|
|
167
|
+
node_payload = row[:n] || row['n'] || row[:target] || row['target'] || row
|
|
168
|
+
element_id = row[:internal_id] || row['internal_id'] || node_payload&.dig(1, 0)
|
|
169
169
|
else # Array row: [node, id]
|
|
170
170
|
node_payload, element_id = row
|
|
171
171
|
end
|
|
@@ -176,6 +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
180
|
node_payload = model_class.connection
|
|
180
181
|
.send(:process_node, node_payload)
|
|
181
182
|
end
|
|
@@ -29,9 +29,6 @@ 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
|
-
|
|
35
32
|
# --------------------------------------------------------------
|
|
36
33
|
# Mix‑ins
|
|
37
34
|
# --------------------------------------------------------------
|
|
@@ -44,7 +41,6 @@ module ActiveCypher
|
|
|
44
41
|
include Model::ConnectionOwner
|
|
45
42
|
include Logging
|
|
46
43
|
include Model::Abstract
|
|
47
|
-
include Model::ConnectionHandling
|
|
48
44
|
include Model::Callbacks
|
|
49
45
|
include Model::Countable
|
|
50
46
|
|
|
@@ -203,22 +199,24 @@ module ActiveCypher
|
|
|
203
199
|
|
|
204
200
|
rel_type = relationship_type
|
|
205
201
|
|
|
202
|
+
id_func = connection.class::ID_FUNCTION
|
|
203
|
+
|
|
206
204
|
# Build WHERE conditions for the attributes
|
|
207
205
|
conditions = []
|
|
208
206
|
params = {}
|
|
209
207
|
|
|
210
|
-
attributes.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
208
|
+
if attributes.key?(:internal_id)
|
|
209
|
+
where_clause = "#{id_func}(r) = $p1"
|
|
210
|
+
params['p1'] = attributes[:internal_id]
|
|
211
|
+
else
|
|
212
|
+
attributes.each_with_index do |(key, value), index|
|
|
213
|
+
param_name = :"p#{index + 1}"
|
|
214
|
+
conditions << "r.#{key} = $#{param_name}"
|
|
215
|
+
params[param_name] = value
|
|
216
|
+
end
|
|
217
|
+
where_clause = conditions.join(' AND ')
|
|
214
218
|
end
|
|
215
219
|
|
|
216
|
-
where_clause = conditions.join(' AND ')
|
|
217
|
-
|
|
218
|
-
# Determine ID function based on adapter type
|
|
219
|
-
adapter_class = connection.class
|
|
220
|
-
id_func = adapter_class.const_defined?(:ID_FUNCTION) ? adapter_class::ID_FUNCTION : 'id'
|
|
221
|
-
|
|
222
220
|
cypher = <<~CYPHER
|
|
223
221
|
MATCH ()-[r:#{rel_type}]-()
|
|
224
222
|
WHERE #{where_clause}
|
|
@@ -234,6 +232,12 @@ module ActiveCypher
|
|
|
234
232
|
# Extract relationship data and instantiate
|
|
235
233
|
rel_data = row[:r] || row['r']
|
|
236
234
|
rid = row[:rid] || row['rid']
|
|
235
|
+
from_node_id = (row[:from_node] || row['from_node'])&.dig(1, 0)
|
|
236
|
+
to_node_id = (row[:to_node] || row['to_node'])&.dig(1, 0)
|
|
237
|
+
|
|
238
|
+
# this is extra queries, but easier than navigating instantiation from the row data
|
|
239
|
+
from_node = Object.const_get(from_class).find(from_node_id)
|
|
240
|
+
to_node = Object.const_get(to_class).find(to_node_id)
|
|
237
241
|
|
|
238
242
|
# Extract properties from the relationship data
|
|
239
243
|
# Memgraph returns relationships wrapped as [type_code, [actual_data]]
|
|
@@ -256,7 +260,7 @@ module ActiveCypher
|
|
|
256
260
|
attrs = attrs.transform_keys(&:to_sym)
|
|
257
261
|
attrs[:internal_id] = rid if rid
|
|
258
262
|
|
|
259
|
-
instantiate(attrs)
|
|
263
|
+
instantiate(attrs, from_node: from_node, to_node: to_node)
|
|
260
264
|
end
|
|
261
265
|
|
|
262
266
|
# Find the first relationship or raise an exception
|
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.
|