activecypher 0.15.3 → 0.15.5

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.
@@ -112,6 +112,8 @@ module ActiveCypher
112
112
  end
113
113
  end
114
114
 
115
+ TRANSIENT_RETRIES = 3
116
+
115
117
  # Override run to execute queries using auto-commit mode.
116
118
  # Memgraph auto-commits each query, so we send RUN + PULL directly
117
119
  # without BEGIN/COMMIT wrapper. This avoids transaction state issues.
@@ -120,7 +122,26 @@ module ActiveCypher
120
122
  logger.debug { "[#{context}] #{cypher} #{params.inspect}" }
121
123
 
122
124
  instrument_query(cypher, params, context: context, metadata: { db: db, access_mode: access_mode }) do
123
- run_auto_commit(cypher, prepare_params(params))
125
+ with_transient_retry(context) { run_auto_commit(cypher, prepare_params(params)) }
126
+ end
127
+ end
128
+
129
+ # Retry write/write conflicts the server flagged as transient. Safe only
130
+ # because this path is auto-commit (the whole query rolled back); do NOT
131
+ # lift into the explicit-transaction path, where earlier statements may
132
+ # already have applied.
133
+ def with_transient_retry(context)
134
+ attempts = 0
135
+ begin
136
+ yield
137
+ rescue ActiveCypher::TransientError => e
138
+ attempts += 1
139
+ raise if attempts > TRANSIENT_RETRIES
140
+
141
+ # Full jitter, so conflicting writers don't back off in step.
142
+ sleep(rand * 0.05 * (2**(attempts - 1)))
143
+ logger.debug { "[#{context}] transient conflict, retry #{attempts}/#{TRANSIENT_RETRIES}: #{e.message}" }
144
+ retry
124
145
  end
125
146
  end
126
147
 
@@ -159,10 +180,7 @@ module ActiveCypher
159
180
  # End of results
160
181
  break
161
182
  when Bolt::Messaging::Failure
162
- code = msg.metadata['code']
163
- message = msg.metadata['message']
164
- connection.reset!
165
- raise QueryError, "Query failed: #{code} - #{message}"
183
+ raise_query_failure(msg)
166
184
  else
167
185
  raise ProtocolError, "Unexpected response during PULL: #{msg.class}"
168
186
  end
@@ -170,16 +188,21 @@ module ActiveCypher
170
188
 
171
189
  rows
172
190
  when Bolt::Messaging::Failure
173
- code = run_response.metadata['code']
174
- message = run_response.metadata['message']
175
- connection.reset!
176
- raise QueryError, "Query failed: #{code} - #{message}"
191
+ raise_query_failure(run_response)
177
192
  else
178
193
  raise ProtocolError, "Unexpected response to RUN: #{run_response.class}"
179
194
  end
180
195
  end
181
196
  end
182
197
 
198
+ # Reset the connection and raise a QueryError for a Bolt Failure message.
199
+ # @param failure [Bolt::Messaging::Failure]
200
+ # @raise [QueryError]
201
+ def raise_query_failure(failure)
202
+ connection.reset!
203
+ raise QueryError, "Query failed: #{failure.metadata['code']} - #{failure.metadata['message']}"
204
+ end
205
+
183
206
  # Memgraph defaults to **implicit auto‑commit** transactions
184
207
  # so we simply run the Cypher and return the rows.
185
208
  def execute_cypher(cypher, params = {}, ctx = 'Query')
@@ -276,7 +299,7 @@ module ActiveCypher
276
299
  module Persistence
277
300
  include PersistenceMethods
278
301
 
279
- module_function :create_record, :update_record, :destroy_record
302
+ module_function :create_record, :update_record, :destroy_record, :node_id_expr
280
303
  end
281
304
 
282
305
  class ProtocolHandler < AbstractProtocolHandler
@@ -133,7 +133,7 @@ module ActiveCypher
133
133
  module Persistence
134
134
  include PersistenceMethods
135
135
 
136
- module_function :create_record, :update_record, :destroy_record
136
+ module_function :create_record, :update_record, :destroy_record, :node_id_expr
137
137
  end
138
138
 
139
139
  protected
@@ -20,11 +20,7 @@ module ActiveCypher
20
20
  # OPTIMIZED: Use string template instead of Cyrel for known-safe CREATE pattern
21
21
  # Labels come from model class (safe), props are parameterized (safe)
22
22
  label_string = labels.map { |l| ":#{l}" }.join
23
- cypher = if adapter.id_function == 'elementId'
24
- "CREATE (n#{label_string} $props) RETURN elementId(n) AS internal_id"
25
- else
26
- "CREATE (n#{label_string} $props) RETURN id(n) AS internal_id"
27
- end
23
+ cypher = "CREATE (n#{label_string} $props) RETURN #{node_id_expr(adapter)} AS internal_id"
28
24
 
29
25
  data = model.connection.execute_cypher(cypher, { props: props }, 'Create')
30
26
 
@@ -59,11 +55,7 @@ module ActiveCypher
59
55
  label_string = labels.map { |l| ":#{l}" }.join
60
56
  set_clauses = changes.keys.map { |property| "n.#{property} = $#{property}" }.join(', ')
61
57
 
62
- cypher = if adapter.id_function == 'elementId'
63
- "MATCH (n#{label_string}) WHERE elementId(n) = $node_id SET #{set_clauses} RETURN n"
64
- else
65
- "MATCH (n#{label_string}) WHERE id(n) = $node_id SET #{set_clauses} RETURN n"
66
- end
58
+ cypher = "MATCH (n#{label_string}) WHERE #{node_id_expr(adapter)} = $node_id SET #{set_clauses} RETURN n"
67
59
 
68
60
  params = changes.merge(node_id: node_id_param)
69
61
  model.connection.execute_cypher(cypher, params, 'Update')
@@ -91,15 +83,20 @@ module ActiveCypher
91
83
  # Labels come from model class (safe)
92
84
  label_string = labels.map { |l| ":#{l}" }.join
93
85
 
94
- cypher = if adapter.id_function == 'elementId'
95
- "MATCH (n#{label_string}) WHERE elementId(n) = $node_id DETACH DELETE n RETURN count(*) AS deleted"
96
- else
97
- "MATCH (n#{label_string}) WHERE id(n) = $node_id DETACH DELETE n RETURN count(*) AS deleted"
98
- end
86
+ cypher = "MATCH (n#{label_string}) WHERE #{node_id_expr(adapter)} = $node_id DETACH DELETE n RETURN count(*) AS deleted"
99
87
 
100
88
  result = model.connection.execute_cypher(cypher, { node_id: node_id_param }, 'Destroy')
101
89
  result.present? && result.first[:deleted].to_i.positive?
102
90
  end
91
+
92
+ private
93
+
94
+ # The node-id function call for the adapter's dialect (Neo4j uses elementId, Memgraph id).
95
+ # @param adapter [#id_function] the connection's id handler
96
+ # @return [String] "elementId(n)" or "id(n)"
97
+ def node_id_expr(adapter)
98
+ adapter.id_function == 'elementId' ? 'elementId(n)' : 'id(n)'
99
+ end
103
100
  end
104
101
  end
105
102
  end
@@ -25,27 +25,40 @@ module ActiveCypher
25
25
  @spec = resolved_config.merge(@spec.except(:url))
26
26
  end
27
27
 
28
- @conn_ref = nil # holds the adapter instance
29
- @creation_mutex = Mutex.new # prevents multiple threads from creating connections simultaneously
28
+ # One connection per thread: Bolt is a stateful, ordered protocol, so
29
+ # sharing a socket across threads corrupts the stream. @connections
30
+ # exists only so disconnect can close them all; the thread-local is
31
+ # what the hot path reads.
32
+ @connections = {}
33
+ @creation_mutex = Mutex.new
30
34
  end
31
35
 
32
- # Returns a live adapter, initializing it once in a thread‑safe way.
36
+ # Returns a live adapter belonging to the calling thread.
33
37
  def connection
34
- # Fast path — already connected and alive
35
- conn = @conn_ref
38
+ conn = Thread.current[thread_key]
36
39
  return conn if conn&.active?
37
40
 
38
- # Use mutex for the slow path to prevent thundering herd
39
- @creation_mutex.synchronize do
40
- # Check again inside the mutex in case another thread created it
41
- conn = @conn_ref
42
- return conn if conn&.active?
41
+ # Built outside the mutex: connecting does network IO.
42
+ new_conn = build_connection
43
+ Thread.current[thread_key] = new_conn
44
+
45
+ # Reap dead threads' connections, or each short-lived thread leaks a
46
+ # socket the server counts against max_connections.
47
+ orphaned = @creation_mutex.synchronize do
48
+ dead = @connections.reject { |t, _| t.alive? }
49
+ dead.each_key { |t| @connections.delete(t) }
50
+ @connections[Thread.current] = new_conn
51
+ dead.values
52
+ end
43
53
 
44
- # Create a new connection
45
- new_conn = build_connection
46
- @conn_ref = new_conn
47
- return new_conn
54
+ # Closed outside the mutex: disconnecting does IO too.
55
+ orphaned.each do |conn|
56
+ conn.disconnect
57
+ rescue StandardError => e
58
+ puts "Warning: Error disconnecting orphaned connection: #{e.message}" if ENV['DEBUG']
48
59
  end
60
+
61
+ new_conn
49
62
  end
50
63
  alias checkout connection
51
64
 
@@ -54,23 +67,31 @@ module ActiveCypher
54
67
  @retry_count >= @spec[:max_retries]
55
68
  end
56
69
 
57
- # Explicitly close and reset the connection
70
+ # Explicitly close every connection this pool handed out.
58
71
  def disconnect
59
- conn = @conn_ref
60
- return unless conn
72
+ conns = @creation_mutex.synchronize do
73
+ taken = @connections.values
74
+ @connections.clear
75
+ taken
76
+ end
61
77
 
62
- begin
78
+ conns.each do |conn|
63
79
  conn.disconnect
64
80
  rescue StandardError => e
65
81
  # Log but don't raise to ensure cleanup continues
66
82
  puts "Warning: Error disconnecting: #{e.message}" if ENV['DEBUG']
67
- ensure
68
- @conn_ref = nil
69
83
  end
84
+
85
+ Thread.current[thread_key] = nil
70
86
  end
71
87
 
72
88
  private
73
89
 
90
+ # Namespaced per pool instance so multiple databases don't share.
91
+ def thread_key
92
+ @thread_key ||= :"active_cypher_connection_#{object_id}"
93
+ end
94
+
74
95
  def build_connection
75
96
  adapter_name = @spec[:adapter]
76
97
  raise ArgumentError, 'Missing adapter name in connection specification' unless adapter_name
@@ -35,11 +35,7 @@ module ActiveCypher
35
35
  connections = model_classes.map(&:connection).compact.uniq
36
36
 
37
37
  # 6. Wipe all nodes in each relevant connection
38
- connections.each do |conn|
39
- conn.execute_cypher('MATCH (n) DETACH DELETE n')
40
- rescue StandardError => e
41
- warn "[ActiveCypher::Fixtures.load] Failed to clear connection #{conn.inspect}: #{e.class}: #{e.message}"
42
- end
38
+ wipe_connections(connections, 'load')
43
39
 
44
40
  # 7. Evaluate nodes and relationships (batched if large)
45
41
  if dsl_context.nodes.size > 100 || dsl_context.relationships.size > 200
@@ -77,12 +73,39 @@ module ActiveCypher
77
73
  connections = model_classes.map(&:connection).compact.uniq
78
74
 
79
75
  # Wipe all nodes in each connection
76
+ wipe_connections(connections, 'clear_all')
77
+ true
78
+ end
79
+
80
+ # Build a comparable connection fingerprint for cross-database detection.
81
+ # @param conn [Object] a model connection
82
+ # @return [Hash] adapter/config/object_id details
83
+ def self.connection_details(conn)
84
+ {
85
+ adapter: conn.class.name,
86
+ config: conn.instance_variable_get(:@config),
87
+ object_id: conn.object_id
88
+ }
89
+ end
90
+
91
+ # Memoized connection-details lookup for a model class.
92
+ # @param klass [Class] the model class
93
+ # @param cache [Hash] class => details mapping, populated on miss
94
+ # @return [Hash] the connection details
95
+ def self.conn_details_for(klass, cache)
96
+ cache[klass] ||= connection_details(klass.connection)
97
+ end
98
+
99
+ # Detach-delete every node across the given connections, logging per-connection failures.
100
+ # @param connections [Array] connections to wipe
101
+ # @param context [String] caller name used in the warning prefix
102
+ # @return [void]
103
+ def self.wipe_connections(connections, context)
80
104
  connections.each do |conn|
81
105
  conn.execute_cypher('MATCH (n) DETACH DELETE n')
82
106
  rescue StandardError => e
83
- warn "[ActiveCypher::Fixtures.clear_all] Failed to clear connection #{conn.inspect}: #{e.class}: #{e.message}"
107
+ warn "[ActiveCypher::Fixtures.#{context}] Failed to clear connection #{conn.inspect}: #{e.class}: #{e.message}"
84
108
  end
85
- true
86
109
  end
87
110
 
88
111
  # Validates relationships for cross-DB issues
@@ -96,13 +119,8 @@ module ActiveCypher
96
119
  next unless klass < ActiveCypher::Base
97
120
  next if klass.respond_to?(:abstract_class?) && klass.abstract_class?
98
121
 
99
- conn = klass.connection
100
122
  # Store connection details for comparison
101
- model_connections[klass] = {
102
- adapter: conn.class.name,
103
- config: conn.instance_variable_get(:@config),
104
- object_id: conn.object_id
105
- }
123
+ model_connections[klass] = connection_details(klass.connection)
106
124
  end
107
125
 
108
126
  relationships.each do |rel|
@@ -120,30 +138,9 @@ module ActiveCypher
120
138
  from_class = from_node.class
121
139
  to_class = to_node.class
122
140
 
123
- # Look up connection details for each class
124
- from_conn_details = model_connections[from_class]
125
- to_conn_details = model_connections[to_class]
126
-
127
- # If either class isn't in our mapping, refresh it
128
- unless from_conn_details
129
- conn = from_class.connection
130
- from_conn_details = {
131
- adapter: conn.class.name,
132
- config: conn.instance_variable_get(:@config),
133
- object_id: conn.object_id
134
- }
135
- model_connections[from_class] = from_conn_details
136
- end
137
-
138
- unless to_conn_details
139
- conn = to_class.connection
140
- to_conn_details = {
141
- adapter: conn.class.name,
142
- config: conn.instance_variable_get(:@config),
143
- object_id: conn.object_id
144
- }
145
- model_connections[to_class] = to_conn_details
146
- end
141
+ # Look up connection details for each class, refreshing the cache on miss
142
+ from_conn_details = conn_details_for(from_class, model_connections)
143
+ to_conn_details = conn_details_for(to_class, model_connections)
147
144
 
148
145
  # Compare connection details
149
146
  next unless from_conn_details[:object_id] != to_conn_details[:object_id] ||
@@ -40,14 +40,7 @@ module ActiveCypher
40
40
  composite = props.size > 1 if composite.nil?
41
41
 
42
42
  cypher = if connection.vendor == :memgraph
43
- if composite && props.size > 1
44
- # Memgraph 3.2+ composite index: CREATE INDEX ON :Label(prop1, prop2)
45
- props_list = props.join(', ')
46
- ["CREATE INDEX ON :#{label}(#{props_list})"]
47
- else
48
- # Memgraph single property indexes
49
- props.map { |p| "CREATE INDEX ON :#{label}(#{p})" }
50
- end
43
+ memgraph_index_statements('INDEX', label, props, composite)
51
44
  else
52
45
  # Neo4j syntax
53
46
  props_clause = props.map { |p| "n.#{p}" }.join(', ')
@@ -72,13 +65,7 @@ module ActiveCypher
72
65
  composite = props.size > 1 if composite.nil?
73
66
 
74
67
  cypher = if connection.vendor == :memgraph
75
- if composite && props.size > 1
76
- # Memgraph 3.2+ composite edge index
77
- props_list = props.join(', ')
78
- ["CREATE EDGE INDEX ON :#{rel_type}(#{props_list})"]
79
- else
80
- props.map { |p| "CREATE EDGE INDEX ON :#{rel_type}(#{p})" }
81
- end
68
+ memgraph_index_statements('EDGE INDEX', rel_type, props, composite)
82
69
  else
83
70
  # Neo4j syntax
84
71
  props_clause = props.map { |p| "r.#{p}" }.join(', ')
@@ -221,6 +208,22 @@ module ActiveCypher
221
208
 
222
209
  private
223
210
 
211
+ # Build Memgraph CREATE [EDGE] INDEX statements for a label/type and properties.
212
+ # @param index_keyword [String] "INDEX" for nodes, "EDGE INDEX" for relationships
213
+ # @param label [Symbol, String] node label or relationship type
214
+ # @param props [Array<Symbol, String>] properties to index
215
+ # @param composite [Boolean] emit a single composite index when more than one prop
216
+ # @return [Array<String>] one or more Cypher statements
217
+ def memgraph_index_statements(index_keyword, label, props, composite)
218
+ if composite && props.size > 1
219
+ # Memgraph 3.2+ composite index: CREATE [EDGE] INDEX ON :Label(prop1, prop2)
220
+ ["CREATE #{index_keyword} ON :#{label}(#{props.join(', ')})"]
221
+ else
222
+ # Single property indexes
223
+ props.map { |p| "CREATE #{index_keyword} ON :#{label}(#{p})" }
224
+ end
225
+ end
226
+
224
227
  def execute_operations
225
228
  if connection.vendor == :memgraph
226
229
  # Memgraph requires auto-commit for DDL operations
@@ -115,24 +115,10 @@ module ActiveCypher
115
115
  models = []
116
116
 
117
117
  # Find all Node classes (ActiveCypher::Base descendants)
118
- if defined?(::ActiveCypher::Base)
119
- ObjectSpace.each_object(Class) do |klass|
120
- next unless klass < ::ActiveCypher::Base
121
- next if klass == ::ActiveCypher::Base
122
-
123
- models << klass
124
- end
125
- end
118
+ models.concat(descendants_of(::ActiveCypher::Base)) if defined?(::ActiveCypher::Base)
126
119
 
127
120
  # Find all Relationship classes (ActiveCypher::Relationship descendants)
128
- if defined?(::ActiveCypher::Relationship)
129
- ObjectSpace.each_object(Class) do |klass|
130
- next unless klass < ::ActiveCypher::Relationship
131
- next if klass == ::ActiveCypher::Relationship
132
-
133
- models << klass
134
- end
135
- end
121
+ models.concat(descendants_of(::ActiveCypher::Relationship)) if defined?(::ActiveCypher::Relationship)
136
122
 
137
123
  # Filter out abstract classes unless requested
138
124
  models.reject! { |m| m.respond_to?(:abstract_class?) && m.abstract_class? } unless options[:include_abstract]
@@ -152,6 +138,19 @@ module ActiveCypher
152
138
  models.sort_by { |m| m.name || '' }
153
139
  end
154
140
 
141
+ # Collect every loaded subclass of +base+ (excluding +base+ itself).
142
+ # @param base [Class] the ancestor to scan for
143
+ # @return [Array<Class>] strict descendants of +base+
144
+ def descendants_of(base)
145
+ [].tap do |found|
146
+ ObjectSpace.each_object(Class) do |klass|
147
+ next unless klass < base
148
+
149
+ found << klass
150
+ end
151
+ end
152
+ end
153
+
155
154
  # Eager load graph models from Rails app
156
155
  def eager_load_graph_models
157
156
  return unless defined?(Rails) && Rails.respond_to?(:root)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveCypher
4
- VERSION = '0.15.3'
4
+ VERSION = '0.15.5'
5
5
 
6
6
  def self.gem_version
7
7
  Gem::Version.new VERSION
data/lib/activecypher.rb CHANGED
@@ -52,6 +52,14 @@ module ActiveCypher
52
52
  # Could be you. Could be Cypher. Could be fate.
53
53
  class QueryError < Error; end
54
54
 
55
+ # A uniqueness or existence constraint said no.
56
+ # Rescue this to ignore duplicates without swallowing every QueryError.
57
+ class ConstraintError < QueryError; end
58
+
59
+ # Two writers touched the same node. Nothing is wrong with your query.
60
+ # The server is politely asking you to try again.
61
+ class TransientError < QueryError; end
62
+
55
63
  # Your Cypher syntax is... interpretive.
56
64
  # Unfortunately, the parser isn’t in the mood for interpretive dance.
57
65
  class CypherSyntaxError < QueryError; end
@@ -84,10 +84,7 @@ module Cyrel
84
84
  @output << 'RETURN '
85
85
  @output << 'DISTINCT ' if node.distinct
86
86
 
87
- node.items.each_with_index do |item, index|
88
- @output << ', ' if index.positive?
89
- render_expression(item)
90
- end
87
+ render_comma_separated(node.items)
91
88
  end
92
89
 
93
90
  # Visit a SET node
@@ -109,10 +106,7 @@ module Cyrel
109
106
  @output << 'WITH '
110
107
  @output << 'DISTINCT ' if node.distinct
111
108
 
112
- node.items.each_with_index do |item, index|
113
- @output << ', ' if index.positive?
114
- render_expression(item)
115
- end
109
+ render_comma_separated(node.items)
116
110
 
117
111
  # Add WHERE clause if present
118
112
  return unless node.where_conditions && !node.where_conditions.empty?
@@ -139,13 +133,8 @@ module Cyrel
139
133
  if node.expression.is_a?(Array)
140
134
  # Array literal
141
135
  @output << format_array_literal(node.expression)
142
- elsif node.expression.is_a?(Symbol)
143
- # Parameter reference
144
- param_key = register_parameter(node.expression)
145
- @output << "$#{param_key}"
146
136
  else
147
- # Other expressions
148
- render_expression(node.expression)
137
+ render_param_or_expression(node.expression)
149
138
  end
150
139
 
151
140
  @output << " AS #{node.alias_name}"
@@ -280,11 +269,11 @@ module Cyrel
280
269
 
281
270
  subquery_compiler = QueryIntegratedCompiler.new(parameter_proxy)
282
271
  clause_cypher, = subquery_compiler.compile(clause.ast_node)
283
- @output << clause_cypher.split("\n").map { |line| " #{line}" }.join("\n")
272
+ @output << indent_block(clause_cypher)
284
273
  else
285
274
  # For legacy clauses, render normally
286
275
  clause_output = clause.render(subquery)
287
- @output << clause_output.split("\n").map { |line| " #{line}" }.join("\n") unless clause_output.blank?
276
+ @output << indent_block(clause_output) unless clause_output.blank?
288
277
 
289
278
  # Merge subquery parameters
290
279
  subquery.parameters.each_value do |value|
@@ -346,18 +335,12 @@ module Cyrel
346
335
  def visit_foreach_node(node)
347
336
  @output << "FOREACH (#{node.variable} IN "
348
337
 
349
- # Handle the expression - could be an array literal or an expression
338
+ # Handle the expression - could be an array literal or an expression.
339
+ # An array is parameterized whole; everything else defers to the shared helper.
350
340
  if node.expression.is_a?(Array)
351
- # Array literal - convert to parameter
352
- param_key = register_parameter(node.expression)
353
- @output << "$#{param_key}"
354
- elsif node.expression.is_a?(Symbol)
355
- # Symbol reference to parameter
356
- param_key = register_parameter(node.expression)
357
- @output << "$#{param_key}"
341
+ @output << "$#{register_parameter(node.expression)}"
358
342
  else
359
- # Other expressions
360
- render_expression(node.expression)
343
+ render_param_or_expression(node.expression)
361
344
  end
362
345
 
363
346
  @output << ' | '
@@ -392,8 +375,6 @@ module Cyrel
392
375
  inner_compiler.instance_variable_set(:@loop_variables, @loop_variables.dup)
393
376
  clause_cypher, = inner_compiler.compile([clause.ast_node])
394
377
  @output << clause_cypher
395
-
396
- # For other clause types, render directly
397
378
  end
398
379
 
399
380
  # Restore previous loop variables context
@@ -518,6 +499,34 @@ module Cyrel
518
499
  end
519
500
  end
520
501
 
502
+ # Render a list of items separated by commas (RETURN/WITH projections).
503
+ # @param items [Array] the expressions to render
504
+ # @return [void]
505
+ def render_comma_separated(items)
506
+ items.each_with_index do |item, index|
507
+ @output << ', ' if index.positive?
508
+ render_expression(item)
509
+ end
510
+ end
511
+
512
+ # Indent every line of a Cypher fragment by two spaces (subquery nesting).
513
+ # @param text [String] the fragment to indent
514
+ # @return [String] the indented fragment
515
+ def indent_block(text)
516
+ text.split("\n").map { |line| " #{line}" }.join("\n")
517
+ end
518
+
519
+ # Render a symbol as a parameter reference, otherwise delegate to {#render_expression}.
520
+ # @param expression [Object] the value to render
521
+ # @return [void]
522
+ def render_param_or_expression(expression)
523
+ if expression.is_a?(Symbol)
524
+ @output << "$#{register_parameter(expression)}"
525
+ else
526
+ render_expression(expression)
527
+ end
528
+ end
529
+
521
530
  # Render an expression (could be a literal, parameter, property access, etc.)
522
531
  def render_expression(expr)
523
532
  case expr
@@ -17,34 +17,14 @@ module Cyrel
17
17
  # e.g., [[:n, "NewLabel"], [:m, "AnotherLabel"]]
18
18
  # Note: Mixing hash and array styles in one call is not directly supported, use multiple SET clauses if needed.
19
19
  def initialize(assignments)
20
- @assignments = process_assignments(assignments)
20
+ @assignments = self.class.normalize_assignments(assignments)
21
21
  end
22
22
 
23
- # Renders the SET clause.
24
- # @param query [Cyrel::Query] The query object for rendering expressions.
25
- # @return [String, nil] The Cypher string fragment, or nil if no assignments exist.
26
- def render(query)
27
- return nil if @assignments.empty?
28
-
29
- set_parts = @assignments.map do |assignment|
30
- render_assignment(assignment, query)
31
- end
32
-
33
- "SET #{set_parts.join(', ')}"
34
- end
35
-
36
- # Merges assignments from another Set clause.
37
- # @param other_set [Cyrel::Clause::Set] The other Set clause to merge.
38
- def merge!(other_set)
39
- # Simple concatenation, assumes no conflicting assignments on the same property.
40
- # More sophisticated merging might be needed depending on requirements.
41
- @assignments.concat(other_set.assignments)
42
- self
43
- end
44
-
45
- private
46
-
47
- def process_assignments(assignments)
23
+ # Normalize raw SET assignments (Hash of props/labels or Array of label pairs)
24
+ # into the internal tuple form consumed by both Clause::Set and Query#set.
25
+ # @param assignments [Hash, Array]
26
+ # @return [Array<Array>] tuples like [:property, ...], [:variable_properties, ...], [:label, ...]
27
+ def self.normalize_assignments(assignments)
48
28
  case assignments
49
29
  when Hash
50
30
  assignments.flat_map do |key, value|
@@ -68,19 +48,43 @@ module Cyrel
68
48
  end
69
49
  when Array
70
50
  assignments.map do |item|
71
- unless item.is_a?(Array) && item.length == 2 && item[0].is_a?(Symbol) && item[1].is_a?(String)
51
+ unless item.is_a?(Array) && item.length == 2
72
52
  raise ArgumentError,
73
53
  "Invalid label assignment format. Expected [[:variable, 'Label'], ...], got #{item.inspect}"
74
54
  end
75
55
 
76
56
  # SET n:Label
77
- [:label, item[0], item[1]]
57
+ [:label, item[0].to_sym, item[1]]
78
58
  end
79
59
  else
80
60
  raise ArgumentError, "Invalid assignments type for SET clause: #{assignments.class}"
81
61
  end
82
62
  end
83
63
 
64
+ # Renders the SET clause.
65
+ # @param query [Cyrel::Query] The query object for rendering expressions.
66
+ # @return [String, nil] The Cypher string fragment, or nil if no assignments exist.
67
+ def render(query)
68
+ return nil if @assignments.empty?
69
+
70
+ set_parts = @assignments.map do |assignment|
71
+ render_assignment(assignment, query)
72
+ end
73
+
74
+ "SET #{set_parts.join(', ')}"
75
+ end
76
+
77
+ # Merges assignments from another Set clause.
78
+ # @param other_set [Cyrel::Clause::Set] The other Set clause to merge.
79
+ def merge!(other_set)
80
+ # Simple concatenation, assumes no conflicting assignments on the same property.
81
+ # More sophisticated merging might be needed depending on requirements.
82
+ @assignments.concat(other_set.assignments)
83
+ self
84
+ end
85
+
86
+ private
87
+
84
88
  def render_assignment(assignment, query)
85
89
  type, target, value, op = assignment
86
90
  case type