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.
@@ -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, &block)
101
+ def with_session(**kw, &)
102
102
  connect
103
- @driver.with_session(**kw, &block)
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, &block)
111
+ def async_with_session(**kw, &)
112
112
  connect
113
- @driver.async_with_session(**kw, &block)
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
- # Try to execute a simple query first
174
- session = Bolt::Session.new(@connection)
175
- session.run('RETURN 1 AS check', {})
176
- session.close
177
- true
178
- rescue StandardError => e
179
- # Query failed, need to reset the connection
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, i|
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 if respond_to?(: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.respond_to?(:abstract_class?) && klass.abstract_class?
48
- next unless klass.respond_to?(:connects_to_mappings) && klass.connects_to_mappings.present?
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.each_with_index do |(key, value), index|
211
- param_name = :"p#{index + 1}"
212
- conditions << "r.#{key} = $#{param_name}"
213
- params[param_name] = value
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveCypher
4
- VERSION = '0.14.2'
4
+ VERSION = '0.15.2'
5
5
 
6
6
  def self.gem_version
7
7
  Gem::Version.new VERSION
@@ -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
- # Ensure pattern is a valid type for CREATE
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.
@@ -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
- # Ensure pattern is a valid type
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
@@ -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
- # Ensure pattern is a valid type for MERGE
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
- unless pattern.is_a?(Cyrel::Pattern::Path) || pattern.is_a?(Cyrel::Pattern::Node) || pattern.is_a?(Cyrel::Pattern::Relationship)
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
- unless pattern.is_a?(Cyrel::Pattern::Path) || pattern.is_a?(Cyrel::Pattern::Node) || pattern.is_a?(Cyrel::Pattern::Relationship)
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)
@@ -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: [] # Memgraph 3.2+: (n:Label1|Label2)
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.map do |key, value|
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 = @clauses.find_index { |c| c.is_a?(Clause::Where) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WhereNode)) }
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 = @clauses.find_index { |c| c.is_a?(Clause::Set) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::SetNode)) }
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
- # Process items similar to existing Return clause
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
- ast_clause = AST::ClauseAdapter.new(with_node)
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
- # Process items similar to existing Return clause
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
- ast_clause = AST::ClauseAdapter.new(return_node)
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
- ast_clause = AST::ClauseAdapter.new(order_by_node)
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
- ast_clause = AST::ClauseAdapter.new(skip_node)
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
- ast_clause = AST::ClauseAdapter.new(limit_node)
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.