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.
@@ -2,10 +2,11 @@
2
2
 
3
3
  class <%= class_name %> < ApplicationGraphRelationship
4
4
  from_class :<%= options[:from] %>
5
- to_class :<%= options[:to] %>
6
- type :<%= relationship_type %>
7
-
8
- <% attributes.each do |attr| -%>
9
- attribute :<%= attr.name %>, :<%= attr.type || "string" %>
10
- <% end -%>
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
- # @!parse
8
- # # Core: The module that tries to make your graph model feel like it belongs in a relational world.
9
- # # Includes every concern under the sun, because why have one abstraction when you can have twelve?
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) # ⇒ node class
20
- ["MATCH (n:#{label_name}) RETURN count(n) AS c", {}]
21
- else # relationship class
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 = :n
26
- query = Cyrel.match(Cyrel.node(self.class.label_name).as(n))
27
- .where(Cyrel.id(n).eq(internal_id))
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 = { id: internal_id }
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
- @destroyed = true
37
- freeze # To make sure you can't Frankenstein it back to life. Lightning not included.
38
- true
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. Dont ask. Just walk away. Or blame the database, that's always fun. If it keeps happening, suspect back magick.
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
- label = self.class.label_name.to_s
135
- node = Cyrel.node(n, labels: [label], properties: props)
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
- query = Cyrel.match(Cyrel.node(self.class.label_name).as(n))
170
- .where(Cyrel.id(n).eq(internal_id))
171
- .set(n => changes)
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
- changes_applied
178
- true
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(label_name).as(node_alias))
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(label).as(node_alias))
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveCypher
4
- VERSION = '0.3.0'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -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
- if key.is_a?(Expression::PropertyAccess)
54
+ case key
55
+ when Expression::PropertyAccess
51
56
  # SET n.prop = value
52
57
  [[:property, key, Expression.coerce(value)]]
53
- elsif key.is_a?(Symbol) || key.is_a?(String)
54
- # SET n = properties or 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
- # Using '=' operator here. Could add support for '+=' later.
89
- "#{target} = #{value.render(query)}"
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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cyrel/expression/base'
4
+
3
5
  module Cyrel
4
6
  module Expression
5
7
  # Represents accessing a property on a variable (node or relationship alias).
data/lib/cyrel/plus.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ class Plus
5
+ attr_reader :variable
6
+
7
+ def initialize(variable)
8
+ @variable = variable
9
+ end
10
+ end
11
+ end
data/lib/cyrel/query.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cyrel/parameterizable'
3
4
  # Require necessary clause and pattern types
4
5
 
5
6
  # Require all clause types for DSL methods