activecypher 0.3.0 → 0.5.0
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/base.rb +1 -1
- data/lib/active_cypher/bolt/session.rb +62 -50
- data/lib/active_cypher/bolt/transaction.rb +92 -87
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +40 -32
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +1 -1
- data/lib/active_cypher/cypher_config.rb +2 -1
- data/lib/active_cypher/generators/node_generator.rb +32 -3
- data/lib/active_cypher/generators/relationship_generator.rb +29 -2
- data/lib/active_cypher/generators/templates/node.rb.erb +10 -6
- data/lib/active_cypher/generators/templates/relationship.rb.erb +7 -6
- data/lib/active_cypher/instrumentation.rb +186 -0
- data/lib/active_cypher/model/connection_owner.rb +15 -0
- data/lib/active_cypher/model/core.rb +58 -4
- data/lib/active_cypher/model/countable.rb +10 -3
- data/lib/active_cypher/model/destruction.rb +17 -10
- data/lib/active_cypher/model/persistence.rb +28 -8
- data/lib/active_cypher/model/querying.rb +5 -1
- data/lib/active_cypher/relation.rb +10 -2
- data/lib/active_cypher/version.rb +1 -1
- data/lib/cyrel/clause/set.rb +20 -10
- data/lib/cyrel/expression/property_access.rb +2 -0
- data/lib/cyrel/plus.rb +11 -0
- data/lib/cyrel/query.rb +1 -0
- data/lib/cyrel.rb +77 -18
- metadata +3 -1
@@ -2,10 +2,11 @@
|
|
2
2
|
|
3
3
|
class <%= class_name %> < ApplicationGraphRelationship
|
4
4
|
from_class :<%= options[:from] %>
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
5
|
+
to_class :<%= options[:to] %>
|
6
|
+
type :<%= relationship_type %>
|
7
|
+
<% if attributes.any? -%>
|
8
|
+
<% attributes.each do |attr| -%>
|
9
|
+
attribute :<%= attr.name %>, :<%= attr.type || "string" %>
|
10
|
+
<% end -%>
|
11
|
+
<% end -%>
|
11
12
|
end
|
@@ -0,0 +1,186 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/notifications'
|
4
|
+
|
5
|
+
module ActiveCypher
|
6
|
+
# Instrumentation for ActiveCypher operations.
|
7
|
+
# Because every database operation needs a stopwatch and an audience.
|
8
|
+
module Instrumentation
|
9
|
+
# ------------------------------------------------------------------
|
10
|
+
# Core instrumentation method
|
11
|
+
# ------------------------------------------------------------------
|
12
|
+
|
13
|
+
# Instruments an operation and publishes an event with timing information.
|
14
|
+
# @param operation [String, Symbol] The operation name (prefixed with 'active_cypher.')
|
15
|
+
# @param payload [Hash] Additional context for the event
|
16
|
+
# @yield The operation to instrument
|
17
|
+
# @return [Object] The result of the block
|
18
|
+
def instrument(operation, payload = {})
|
19
|
+
# Start timing with monotonic clock for accuracy (because wall time is for amateurs)
|
20
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
21
|
+
|
22
|
+
# Run the actual operation
|
23
|
+
result = yield
|
24
|
+
|
25
|
+
# Calculate duration in milliseconds (because counting seconds is so 1990s)
|
26
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1_000).round(2)
|
27
|
+
|
28
|
+
# Add duration to payload
|
29
|
+
payload[:duration_ms] = duration_ms
|
30
|
+
|
31
|
+
# Publish event via ActiveSupport::Notifications
|
32
|
+
event_name = operation.to_s.start_with?('active_cypher.') ? operation.to_s : "active_cypher.#{operation}"
|
33
|
+
ActiveSupport::Notifications.instrument(event_name, payload)
|
34
|
+
|
35
|
+
# Also log if we have logging capabilities
|
36
|
+
log_instrumented_event(operation, payload) if respond_to?(:logger)
|
37
|
+
|
38
|
+
# Return the original result
|
39
|
+
result
|
40
|
+
end
|
41
|
+
|
42
|
+
# ------------------------------------------------------------------
|
43
|
+
# Specialized instrumentation methods
|
44
|
+
# ------------------------------------------------------------------
|
45
|
+
|
46
|
+
# Instruments a database query.
|
47
|
+
# @param cypher [String] The Cypher query
|
48
|
+
# @param params [Hash] Query parameters
|
49
|
+
# @param context [String] Additional context (e.g., "Model.find")
|
50
|
+
# @param metadata [Hash] Additional metadata
|
51
|
+
# @yield The query operation
|
52
|
+
# @return [Object] The result of the block
|
53
|
+
def instrument_query(cypher, params = {}, context: 'Query', metadata: {}, &)
|
54
|
+
truncated_cypher = cypher.to_s.gsub(/\s+/, ' ').strip
|
55
|
+
truncated_cypher = "#{truncated_cypher[0...97]}..." if truncated_cypher.length > 100
|
56
|
+
|
57
|
+
payload = metadata.merge(
|
58
|
+
cypher: truncated_cypher,
|
59
|
+
params: sanitize_params(params),
|
60
|
+
context: context
|
61
|
+
)
|
62
|
+
|
63
|
+
instrument('query', payload, &)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Instruments a connection operation.
|
67
|
+
# @param operation [Symbol] The connection operation (:connect, :disconnect, etc)
|
68
|
+
# @param config [Hash] Connection configuration
|
69
|
+
# @param metadata [Hash] Additional metadata
|
70
|
+
# @yield The connection operation
|
71
|
+
# @return [Object] The result of the block
|
72
|
+
def instrument_connection(operation, config = {}, metadata: {}, &)
|
73
|
+
payload = metadata.merge(
|
74
|
+
config: sanitize_config(config)
|
75
|
+
)
|
76
|
+
|
77
|
+
instrument("connection.#{operation}", payload, &)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Instruments a transaction operation.
|
81
|
+
# @param operation [Symbol] The transaction operation (:begin, :commit, :rollback)
|
82
|
+
# @param transaction_id [String, Integer] Transaction identifier (if available)
|
83
|
+
# @param metadata [Hash] Additional metadata
|
84
|
+
# @yield The transaction operation
|
85
|
+
# @return [Object] The result of the block
|
86
|
+
def instrument_transaction(operation, transaction_id = nil, metadata: {}, &)
|
87
|
+
payload = metadata.dup
|
88
|
+
payload[:transaction_id] = transaction_id if transaction_id
|
89
|
+
|
90
|
+
instrument("transaction.#{operation}", payload, &)
|
91
|
+
end
|
92
|
+
|
93
|
+
# ------------------------------------------------------------------
|
94
|
+
# Sanitization methods
|
95
|
+
# ------------------------------------------------------------------
|
96
|
+
|
97
|
+
# Sanitizes query parameters to remove sensitive values.
|
98
|
+
# @param params [Hash, Object] The parameters to sanitize
|
99
|
+
# @return [Hash, Object] Sanitized parameters
|
100
|
+
def sanitize_params(params)
|
101
|
+
return params unless params.is_a?(Hash)
|
102
|
+
|
103
|
+
params.each_with_object({}) do |(key, value), sanitized|
|
104
|
+
sanitized[key] = if sensitive_key?(key)
|
105
|
+
'[FILTERED]'
|
106
|
+
elsif value.is_a?(Hash)
|
107
|
+
sanitize_params(value)
|
108
|
+
else
|
109
|
+
value
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Sanitizes connection configuration to remove sensitive values.
|
115
|
+
# @param config [Hash] The configuration to sanitize
|
116
|
+
# @return [Hash] Sanitized configuration
|
117
|
+
def sanitize_config(config)
|
118
|
+
return {} unless config.is_a?(Hash)
|
119
|
+
|
120
|
+
config.each_with_object({}) do |(key, value), result|
|
121
|
+
result[key] = if sensitive_key?(key)
|
122
|
+
'[FILTERED]'
|
123
|
+
elsif value.is_a?(Hash)
|
124
|
+
sanitize_config(value)
|
125
|
+
else
|
126
|
+
value
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Determines if a key contains sensitive information that should be filtered.
|
132
|
+
# @param key [String, Symbol] The key to check
|
133
|
+
# @return [Boolean] True if the key contains sensitive information
|
134
|
+
def sensitive_key?(key)
|
135
|
+
return true if key.to_s.match?(/\b(password|token|secret|credential|key)\b/i)
|
136
|
+
|
137
|
+
# Check against Rails filter parameters if available
|
138
|
+
if defined?(Rails) && Rails.application
|
139
|
+
Rails.application.config.filter_parameters.any? do |pattern|
|
140
|
+
case pattern
|
141
|
+
when Regexp
|
142
|
+
key.to_s =~ pattern
|
143
|
+
when Symbol, String
|
144
|
+
key.to_s == pattern.to_s
|
145
|
+
else
|
146
|
+
false
|
147
|
+
end
|
148
|
+
end
|
149
|
+
else
|
150
|
+
false
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
# ------------------------------------------------------------------
|
157
|
+
# Logging integration
|
158
|
+
# ------------------------------------------------------------------
|
159
|
+
# Logs an instrumented event if logging is available.
|
160
|
+
# @param operation [String, Symbol] The operation name
|
161
|
+
# @param payload [Hash] The event payload
|
162
|
+
def log_instrumented_event(operation, payload)
|
163
|
+
return unless respond_to?(:log_debug)
|
164
|
+
|
165
|
+
# Format duration if available
|
166
|
+
duration_text = payload[:duration_ms] ? " (#{payload[:duration_ms]} ms)" : ''
|
167
|
+
operation_name = operation.to_s.sub(/^active_cypher\./, '')
|
168
|
+
|
169
|
+
case operation_name
|
170
|
+
when /query/
|
171
|
+
log_debug("QUERY#{duration_text}: #{payload[:cypher]}")
|
172
|
+
log_debug("PARAMS: #{payload[:params].inspect}") if payload[:params]
|
173
|
+
when /connection/
|
174
|
+
op = operation_name.sub(/^connection\./, '')
|
175
|
+
log_debug("CONNECTION #{op.upcase}#{duration_text}")
|
176
|
+
when /transaction/
|
177
|
+
op = operation_name.sub(/^transaction\./, '')
|
178
|
+
tx_id = payload[:transaction_id] ? " (ID: #{payload[:transaction_id]})" : ''
|
179
|
+
log_debug("TRANSACTION #{op.upcase}#{tx_id}#{duration_text}")
|
180
|
+
else
|
181
|
+
# Generic fallback, for when you just don't know how to categorize your problems
|
182
|
+
log_debug("#{operation_name.upcase}#{duration_text}")
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
@@ -28,6 +28,15 @@ module ActiveCypher
|
|
28
28
|
@@connection_handler ||= ActiveCypher::ConnectionHandler.new # rubocop:disable Style/ClassVars
|
29
29
|
def connection_handler = @@connection_handler
|
30
30
|
|
31
|
+
# Returns the adapter class being used by this model
|
32
|
+
# @return [Class] The adapter class (e.g., Neo4jAdapter, MemgraphAdapter)
|
33
|
+
def adapter_class
|
34
|
+
conn = connection
|
35
|
+
return nil unless conn
|
36
|
+
|
37
|
+
conn.class
|
38
|
+
end
|
39
|
+
|
31
40
|
# Temporarily switches the current role and shard for the duration of the block.
|
32
41
|
# @param role [Symbol, nil] The role to switch to
|
33
42
|
# @param shard [Symbol] The shard to switch to
|
@@ -45,6 +54,12 @@ module ActiveCypher
|
|
45
54
|
ActiveCypher::RuntimeRegistry.current_shard = previous_shard
|
46
55
|
end
|
47
56
|
end
|
57
|
+
|
58
|
+
# Instance method to access the adapter class
|
59
|
+
# @return [Class] The adapter class (e.g., Neo4jAdapter, MemgraphAdapter)
|
60
|
+
def adapter_class
|
61
|
+
self.class.adapter_class
|
62
|
+
end
|
48
63
|
end
|
49
64
|
end
|
50
65
|
end
|
@@ -4,10 +4,9 @@ require 'active_model'
|
|
4
4
|
|
5
5
|
module ActiveCypher
|
6
6
|
module Model
|
7
|
-
#
|
8
|
-
#
|
9
|
-
#
|
10
|
-
# # Most of this works thanks to a little Ruby sorcery, a dash of witchcraft, and—on rare occasions—some unexplained back magick.
|
7
|
+
# Core: The module that tries to make your graph model feel like it belongs in a relational world.
|
8
|
+
# Includes every concern under the sun, because why have one abstraction when you can have twelve?
|
9
|
+
# Most of this works thanks to a little Ruby sorcery, a dash of witchcraft, and—on rare occasions—some unexplained back magick.
|
11
10
|
module Core
|
12
11
|
extend ActiveSupport::Concern
|
13
12
|
|
@@ -24,6 +23,61 @@ module ActiveCypher
|
|
24
23
|
cattr_accessor :connection, instance_accessor: false
|
25
24
|
class_attribute :configurations, instance_accessor: false,
|
26
25
|
default: ActiveSupport::HashWithIndifferentAccess.new
|
26
|
+
|
27
|
+
# Use array instead of set to preserve insertion order of labels
|
28
|
+
class_attribute :custom_labels, default: []
|
29
|
+
end
|
30
|
+
|
31
|
+
class_methods do
|
32
|
+
# Define a label for the model. Can be called multiple times to add multiple labels.
|
33
|
+
# @param label_name [Symbol, String] The label name
|
34
|
+
# @return [Array] The collection of custom labels
|
35
|
+
#
|
36
|
+
# @example Single label
|
37
|
+
# class PetNode < ApplicationGraphNode
|
38
|
+
# label :Pet
|
39
|
+
# end
|
40
|
+
#
|
41
|
+
# @example Multiple labels
|
42
|
+
# class PetNode < ApplicationGraphNode
|
43
|
+
# label :Pet
|
44
|
+
# label :Animal
|
45
|
+
# end
|
46
|
+
def label(label_name)
|
47
|
+
# Convert to symbol for consistency
|
48
|
+
label_sym = label_name.to_sym
|
49
|
+
|
50
|
+
# Add to the collection if not already present
|
51
|
+
# Using array to preserve insertion order
|
52
|
+
self.custom_labels = custom_labels.dup << label_sym unless custom_labels.include?(label_sym)
|
53
|
+
|
54
|
+
custom_labels
|
55
|
+
end
|
56
|
+
|
57
|
+
# Get all labels for this model
|
58
|
+
# @return [Array<Symbol>] All labels for this model
|
59
|
+
def labels
|
60
|
+
# Return custom labels if any exist, otherwise use default label
|
61
|
+
custom_labels.empty? ? [default_label] : custom_labels
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns the primary label for the model
|
65
|
+
# @return [Symbol] The primary label
|
66
|
+
def label_name
|
67
|
+
# Use the first custom label if any exist
|
68
|
+
return custom_labels.first if custom_labels.any?
|
69
|
+
|
70
|
+
# Otherwise fall back to default behavior
|
71
|
+
default_label
|
72
|
+
end
|
73
|
+
|
74
|
+
# Computes the default label for the model based on class name
|
75
|
+
# Strips 'Node' or 'Record' suffix, returns as symbol, capitalized
|
76
|
+
def default_label
|
77
|
+
base = name.split('::').last
|
78
|
+
base = base.sub(/(Node|Record)\z/, '')
|
79
|
+
base.to_sym
|
80
|
+
end
|
27
81
|
end
|
28
82
|
|
29
83
|
attr_reader :new_record
|
@@ -16,9 +16,16 @@ module ActiveCypher
|
|
16
16
|
# If this returns the right number, thank the database gods—or maybe just the back magick hiding in the adapter.
|
17
17
|
def count
|
18
18
|
cypher, params =
|
19
|
-
if respond_to?(:label_name)
|
20
|
-
|
21
|
-
|
19
|
+
if respond_to?(:label_name) # ⇒ node class
|
20
|
+
if respond_to?(:labels) && labels.any?
|
21
|
+
# Use all custom labels for COUNT operations
|
22
|
+
label_string = labels.map { |l| ":#{l}" }.join
|
23
|
+
["MATCH (n#{label_string}) RETURN count(n) AS c", {}]
|
24
|
+
else
|
25
|
+
# Fall back to primary label
|
26
|
+
["MATCH (n:#{label_name}) RETURN count(n) AS c", {}]
|
27
|
+
end
|
28
|
+
else # ⇒ relationship class
|
22
29
|
["MATCH ()-[r:#{relationship_type}]-() RETURN count(r) AS c", {}] # ▲ undirected
|
23
30
|
end
|
24
31
|
|
@@ -22,23 +22,30 @@ module ActiveCypher
|
|
22
22
|
raise 'Cannot destroy a new record' if new_record?
|
23
23
|
raise 'Record already destroyed' if destroyed?
|
24
24
|
|
25
|
-
n
|
26
|
-
|
27
|
-
|
25
|
+
n = :n
|
26
|
+
# Use all labels for database operations
|
27
|
+
labels = self.class.respond_to?(:labels) ? self.class.labels : [self.class.label_name]
|
28
|
+
query = Cyrel.match(Cyrel.node(n, labels: labels))
|
29
|
+
.where(Cyrel.element_id(n).eq(internal_id))
|
28
30
|
.detach_delete(n)
|
31
|
+
.return_('count(*) as deleted')
|
29
32
|
|
30
|
-
cypher = query.to_cypher
|
31
|
-
params
|
33
|
+
cypher, params = query.to_cypher
|
34
|
+
params ||= {}
|
32
35
|
|
33
36
|
# Here lies the true sorcery: one line to erase a node from existence.
|
34
37
|
# If the database still remembers it, you may need to consult your local witch.
|
35
|
-
self.class.connection.execute_cypher(cypher, params, 'Destroy')
|
36
|
-
|
37
|
-
|
38
|
-
|
38
|
+
result = self.class.connection.execute_cypher(cypher, params, 'Destroy')
|
39
|
+
if result.present? && result.first.present? && (result.first[:deleted] || 0).positive?
|
40
|
+
@destroyed = true
|
41
|
+
freeze # To make sure you can't Frankenstein it back to life. Lightning not included.
|
42
|
+
true
|
43
|
+
else
|
44
|
+
false
|
45
|
+
end
|
39
46
|
end
|
40
47
|
rescue StandardError
|
41
|
-
false # Something went wrong. Don
|
48
|
+
false # Something went wrong. Don't ask. Just walk away. Or blame the database, that's always fun. If it keeps happening, suspect back magick.
|
42
49
|
end
|
43
50
|
|
44
51
|
# Returns true if this object has achieved full existential closure.
|
@@ -131,8 +131,12 @@ module ActiveCypher
|
|
131
131
|
def create_record
|
132
132
|
props = attributes_for_persistence
|
133
133
|
n = :n
|
134
|
-
|
135
|
-
|
134
|
+
|
135
|
+
# Use all labels for database operations
|
136
|
+
labels = self.class.respond_to?(:labels) ? self.class.labels : [self.class.label_name.to_s]
|
137
|
+
|
138
|
+
# Create node with all labels
|
139
|
+
node = Cyrel.node(n, labels: labels, properties: props)
|
136
140
|
query = Cyrel.create(node).return_(Cyrel.element_id(n).as(:internal_id))
|
137
141
|
cypher, params = query.to_cypher
|
138
142
|
params ||= {}
|
@@ -166,16 +170,32 @@ module ActiveCypher
|
|
166
170
|
return true if changes.empty?
|
167
171
|
|
168
172
|
n = :n
|
169
|
-
|
170
|
-
|
171
|
-
|
173
|
+
|
174
|
+
# Use all labels for database operations
|
175
|
+
labels = self.class.respond_to?(:labels) ? self.class.labels : [self.class.label_name]
|
176
|
+
|
177
|
+
# Match node with all labels
|
178
|
+
query = Cyrel.match(Cyrel.node(n, labels: labels))
|
179
|
+
.where(Cyrel.element_id(n).eq(internal_id)) # Use element_id explicitly
|
180
|
+
|
181
|
+
# Create separate SET clauses for each property to avoid overwriting existing properties
|
182
|
+
changes.each do |property, value|
|
183
|
+
query = query.set(Cyrel.prop(n, property) => value)
|
184
|
+
end
|
185
|
+
|
186
|
+
query = query.return_(n) # Return the updated node to confirm success
|
172
187
|
|
173
188
|
cypher, params = query.to_cypher
|
174
189
|
params ||= {}
|
175
190
|
|
176
|
-
self.class.connection.execute_cypher(cypher, params, 'Update')
|
177
|
-
|
178
|
-
|
191
|
+
result = self.class.connection.execute_cypher(cypher, params, 'Update')
|
192
|
+
|
193
|
+
if result.present? && result.first.present?
|
194
|
+
changes_applied
|
195
|
+
true
|
196
|
+
else
|
197
|
+
false
|
198
|
+
end
|
179
199
|
end
|
180
200
|
end
|
181
201
|
end
|
@@ -45,11 +45,15 @@ module ActiveCypher
|
|
45
45
|
def find(internal_db_id)
|
46
46
|
node_alias = :n
|
47
47
|
|
48
|
+
# Use all labels if available, otherwise fall back to the primary label
|
49
|
+
labels = respond_to?(:labels) ? self.labels : [label_name]
|
50
|
+
|
48
51
|
query = Cyrel
|
49
|
-
.match(Cyrel.node(
|
52
|
+
.match(Cyrel.node(node_alias, labels: labels))
|
50
53
|
.where(Cyrel.element_id(node_alias).eq(internal_db_id))
|
51
54
|
.return_(node_alias, Cyrel.element_id(node_alias).as(:internal_id))
|
52
55
|
.limit(1)
|
56
|
+
query.to_cypher
|
53
57
|
|
54
58
|
Relation.new(self, query).first or
|
55
59
|
raise ActiveCypher::RecordNotFound,
|
@@ -129,11 +129,19 @@ module ActiveCypher
|
|
129
129
|
# Because writing Cypher by hand is for people with too much free time.
|
130
130
|
# @return [Object] The default Cyrel query
|
131
131
|
def default_query
|
132
|
-
label = model_class.model_name.element
|
133
132
|
node_alias = :n
|
134
133
|
|
134
|
+
# Use all labels if available, otherwise fall back to primary label
|
135
|
+
labels = if model_class.respond_to?(:labels)
|
136
|
+
model_class.labels
|
137
|
+
elsif model_class.respond_to?(:label_name)
|
138
|
+
[model_class.label_name]
|
139
|
+
else
|
140
|
+
[model_class.model_name.element.to_sym]
|
141
|
+
end
|
142
|
+
|
135
143
|
Cyrel
|
136
|
-
.match(Cyrel.node(
|
144
|
+
.match(Cyrel.node(node_alias, labels: labels))
|
137
145
|
.return_(node_alias, Cyrel.element_id(node_alias).as(:internal_id))
|
138
146
|
end
|
139
147
|
|
data/lib/cyrel/clause/set.rb
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'cyrel/expression'
|
4
|
+
require 'cyrel/expression/property_access'
|
5
|
+
|
3
6
|
module Cyrel
|
4
7
|
module Clause
|
5
8
|
# Represents a SET clause in a Cypher query.
|
@@ -12,6 +15,7 @@ module Cyrel
|
|
12
15
|
# - Hash: { variable_or_prop_access => value_expression, ... }
|
13
16
|
# e.g., { Cyrel.prop(:n, :name) => "New Name", Cyrel.prop(:r, :weight) => 10 }
|
14
17
|
# e.g., { n: { name: "New Name", age: 30 } } # For SET n = properties or n += properties
|
18
|
+
# e.g., { Cyrel.plus(:n) => { name: "New Name" } } # For SET n += { name: ... }
|
15
19
|
# - Array: [[variable, label_string], ...] # For SET n:Label
|
16
20
|
# e.g., [[:n, "NewLabel"], [:m, "AnotherLabel"]]
|
17
21
|
# Note: Mixing hash and array styles in one call is not directly supported, use multiple SET clauses if needed.
|
@@ -47,17 +51,20 @@ module Cyrel
|
|
47
51
|
case assignments
|
48
52
|
when Hash
|
49
53
|
assignments.flat_map do |key, value|
|
50
|
-
|
54
|
+
case key
|
55
|
+
when Expression::PropertyAccess
|
51
56
|
# SET n.prop = value
|
52
57
|
[[:property, key, Expression.coerce(value)]]
|
53
|
-
|
54
|
-
# SET n = properties
|
55
|
-
# We need to decide which operator (= or +=). Defaulting to = for now.
|
56
|
-
# User might need to specify via a different method/option.
|
57
|
-
# Let's assume the value is a hash for this case.
|
58
|
+
when Symbol, String
|
59
|
+
# SET n = properties
|
58
60
|
raise ArgumentError, 'Value for variable assignment must be a Hash (for SET n = {props})' unless value.is_a?(Hash)
|
59
61
|
|
60
|
-
[[:variable_properties, key.to_sym, Expression.coerce(value)]]
|
62
|
+
[[:variable_properties, key.to_sym, Expression.coerce(value), :assign]]
|
63
|
+
when Cyrel::Plus
|
64
|
+
# SET n += properties
|
65
|
+
raise ArgumentError, 'Value for variable assignment must be a Hash (for SET n += {props})' unless value.is_a?(Hash)
|
66
|
+
|
67
|
+
[[:variable_properties, key.variable.to_sym, Expression.coerce(value), :merge]]
|
61
68
|
else
|
62
69
|
raise ArgumentError, "Invalid key type in SET assignments hash: #{key.class}"
|
63
70
|
end
|
@@ -78,15 +85,18 @@ module Cyrel
|
|
78
85
|
end
|
79
86
|
|
80
87
|
def render_assignment(assignment, query)
|
81
|
-
type, target, value = assignment
|
88
|
+
type, target, value, op = assignment
|
82
89
|
case type
|
83
90
|
when :property
|
84
91
|
# target is PropertyAccess, value is Expression
|
85
92
|
"#{target.render(query)} = #{value.render(query)}"
|
86
93
|
when :variable_properties
|
87
94
|
# target is variable symbol, value is Expression (Literal Hash)
|
88
|
-
|
89
|
-
|
95
|
+
if op == :merge
|
96
|
+
"#{target} += #{value.render(query)}"
|
97
|
+
else
|
98
|
+
"#{target} = #{value.render(query)}"
|
99
|
+
end
|
90
100
|
when :label
|
91
101
|
# target is variable symbol, value is label string
|
92
102
|
"#{target}:#{value}" # Labels are not parameterized
|
data/lib/cyrel/plus.rb
ADDED