activecypher 0.3.0 → 0.6.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 +28 -8
- data/lib/active_cypher/bolt/driver.rb +6 -16
- data/lib/active_cypher/bolt/session.rb +62 -50
- data/lib/active_cypher/bolt/transaction.rb +95 -90
- data/lib/active_cypher/connection_adapters/abstract_adapter.rb +28 -0
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +40 -32
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +27 -1
- data/lib/active_cypher/connection_adapters/registry.rb +94 -0
- data/lib/active_cypher/connection_handler.rb +18 -3
- data/lib/active_cypher/connection_pool.rb +5 -23
- data/lib/active_cypher/connection_url_resolver.rb +14 -3
- data/lib/active_cypher/cypher_config.rb +2 -1
- data/lib/active_cypher/fixtures/dsl_context.rb +41 -0
- data/lib/active_cypher/fixtures/evaluator.rb +37 -0
- data/lib/active_cypher/fixtures/node_builder.rb +53 -0
- data/lib/active_cypher/fixtures/parser.rb +23 -0
- data/lib/active_cypher/fixtures/registry.rb +43 -0
- data/lib/active_cypher/fixtures/rel_builder.rb +96 -0
- data/lib/active_cypher/fixtures.rb +177 -0
- 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/application_graph_node.rb +1 -0
- 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/callbacks.rb +5 -13
- data/lib/active_cypher/model/connection_handling.rb +37 -52
- data/lib/active_cypher/model/connection_owner.rb +41 -33
- data/lib/active_cypher/model/core.rb +4 -12
- data/lib/active_cypher/model/countable.rb +10 -3
- data/lib/active_cypher/model/destruction.rb +23 -18
- data/lib/active_cypher/model/labelling.rb +45 -0
- data/lib/active_cypher/model/persistence.rb +52 -26
- data/lib/active_cypher/model/querying.rb +49 -25
- data/lib/active_cypher/railtie.rb +40 -5
- data/lib/active_cypher/relation.rb +10 -2
- data/lib/active_cypher/relationship.rb +77 -17
- data/lib/active_cypher/version.rb +1 -1
- data/lib/activecypher.rb +4 -1
- data/lib/cyrel/clause/set.rb +20 -10
- data/lib/cyrel/expression/property_access.rb +2 -0
- data/lib/cyrel/functions.rb +3 -1
- data/lib/cyrel/logging.rb +43 -0
- data/lib/cyrel/plus.rb +11 -0
- data/lib/cyrel/query.rb +7 -1
- data/lib/cyrel.rb +77 -18
- metadata +13 -2
- data/lib/active_cypher/connection_factory.rb +0 -130
@@ -0,0 +1,177 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
module Fixtures
|
5
|
+
# Something went wrong with your test fixtures.
|
6
|
+
# Maybe they're on vacation. Maybe they're just imaginary.
|
7
|
+
class FixtureError < StandardError; end
|
8
|
+
|
9
|
+
# You asked for a fixture that doesn't exist.
|
10
|
+
# It's playing hide and seek. Mostly hide.
|
11
|
+
class FixtureNotFoundError < StandardError; end
|
12
|
+
|
13
|
+
# Load a graph fixture profile.
|
14
|
+
# @param profile [Symbol, String, nil] the profile name (default: :default)
|
15
|
+
# @return [void]
|
16
|
+
def self.load(profile: nil)
|
17
|
+
# 1. Resolve file
|
18
|
+
profile_name = (profile || :default).to_s
|
19
|
+
fixtures_dir = File.expand_path('test/fixtures/graph', Dir.pwd)
|
20
|
+
file = File.join(fixtures_dir, "#{profile_name}.rb")
|
21
|
+
raise FixtureNotFoundError, "Fixture profile not found: #{profile_name} (#{file})" unless File.exist?(file)
|
22
|
+
|
23
|
+
# 2. Reset registry
|
24
|
+
Registry.reset!
|
25
|
+
|
26
|
+
# 3. Parse the profile file (to discover which models are referenced)
|
27
|
+
parser = Parser.new(file)
|
28
|
+
dsl_context = parser.parse
|
29
|
+
|
30
|
+
# 4. Validate relationships upfront (cross-DB)
|
31
|
+
validate_relationships(dsl_context.relationships)
|
32
|
+
|
33
|
+
# 5. Gather unique connections for all model classes referenced in this profile
|
34
|
+
model_classes = dsl_context.nodes.map { |node| node[:model_class] }.uniq
|
35
|
+
connections = model_classes.map do |klass|
|
36
|
+
klass.connection
|
37
|
+
rescue StandardError
|
38
|
+
nil
|
39
|
+
end.compact.uniq
|
40
|
+
|
41
|
+
# 6. Wipe all nodes in each relevant connection
|
42
|
+
connections.each do |conn|
|
43
|
+
conn.execute_cypher('MATCH (n) DETACH DELETE n')
|
44
|
+
rescue StandardError => e
|
45
|
+
warn "[ActiveCypher::Fixtures.load] Failed to clear connection #{conn.inspect}: #{e.class}: #{e.message}"
|
46
|
+
end
|
47
|
+
|
48
|
+
# 7. Evaluate nodes and relationships (batched if large)
|
49
|
+
if dsl_context.nodes.size > 100 || dsl_context.relationships.size > 200
|
50
|
+
NodeBuilder.bulk_build(dsl_context.nodes)
|
51
|
+
# Create all nodes first, then validate relationships again with populated Registry
|
52
|
+
validate_relationships(dsl_context.relationships)
|
53
|
+
RelBuilder.bulk_build(dsl_context.relationships)
|
54
|
+
else
|
55
|
+
dsl_context.nodes.each do |node|
|
56
|
+
NodeBuilder.build(node[:ref], node[:model_class], node[:props])
|
57
|
+
end
|
58
|
+
rel_builder = RelBuilder.new
|
59
|
+
dsl_context.relationships.each do |rel|
|
60
|
+
rel_builder.build(rel[:ref], rel[:from_ref], rel[:type], rel[:to_ref], rel[:props])
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# 8. Return registry for convenience
|
65
|
+
Registry
|
66
|
+
end
|
67
|
+
|
68
|
+
# Clear all nodes in all known connections.
|
69
|
+
# @return [void]
|
70
|
+
def self.clear_all
|
71
|
+
# Find all concrete (non-abstract) model classes inheriting from ActiveCypher::Base
|
72
|
+
model_classes = []
|
73
|
+
ObjectSpace.each_object(Class) do |klass|
|
74
|
+
next unless klass < ActiveCypher::Base
|
75
|
+
next if klass.respond_to?(:abstract_class?) && klass.abstract_class?
|
76
|
+
|
77
|
+
model_classes << klass
|
78
|
+
end
|
79
|
+
|
80
|
+
# Gather unique connections from all model classes
|
81
|
+
connections = model_classes.map do |klass|
|
82
|
+
klass.connection
|
83
|
+
rescue StandardError
|
84
|
+
nil
|
85
|
+
end.compact.uniq
|
86
|
+
|
87
|
+
# Wipe all nodes in each connection
|
88
|
+
connections.each do |conn|
|
89
|
+
conn.execute_cypher('MATCH (n) DETACH DELETE n')
|
90
|
+
rescue StandardError => e
|
91
|
+
warn "[ActiveCypher::Fixtures.clear_all] Failed to clear connection #{conn.inspect}: #{e.class}: #{e.message}"
|
92
|
+
end
|
93
|
+
true
|
94
|
+
end
|
95
|
+
|
96
|
+
# Validates relationships for cross-DB issues
|
97
|
+
# @param relationships [Array<Hash>] array of relationship definitions
|
98
|
+
# @raise [FixtureError] if cross-DB relationship is found
|
99
|
+
def self.validate_relationships(relationships)
|
100
|
+
model_connections = {}
|
101
|
+
|
102
|
+
# First build a mapping of model class => connection details
|
103
|
+
ObjectSpace.each_object(Class) do |klass|
|
104
|
+
next unless klass < ActiveCypher::Base
|
105
|
+
next if klass.respond_to?(:abstract_class?) && klass.abstract_class?
|
106
|
+
|
107
|
+
begin
|
108
|
+
conn = klass.connection
|
109
|
+
# Store connection details for comparison
|
110
|
+
model_connections[klass] = {
|
111
|
+
adapter: conn.class.name,
|
112
|
+
config: conn.instance_variable_get(:@config),
|
113
|
+
object_id: conn.object_id
|
114
|
+
}
|
115
|
+
rescue StandardError
|
116
|
+
# Skip if can't get connection
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
relationships.each do |rel|
|
121
|
+
from_ref = rel[:from_ref]
|
122
|
+
to_ref = rel[:to_ref]
|
123
|
+
|
124
|
+
# Get node classes from DSL context
|
125
|
+
# In real data, nodes have already been created by this point
|
126
|
+
from_node = Registry.get(from_ref)
|
127
|
+
to_node = Registry.get(to_ref)
|
128
|
+
|
129
|
+
# Skip if we can't find both nodes yet (will be caught later)
|
130
|
+
next unless from_node && to_node
|
131
|
+
|
132
|
+
from_class = from_node.class
|
133
|
+
to_class = to_node.class
|
134
|
+
|
135
|
+
# Look up connection details for each class
|
136
|
+
from_conn_details = model_connections[from_class]
|
137
|
+
to_conn_details = model_connections[to_class]
|
138
|
+
|
139
|
+
# If either class isn't in our mapping, refresh it
|
140
|
+
unless from_conn_details
|
141
|
+
conn = from_class.connection
|
142
|
+
from_conn_details = {
|
143
|
+
adapter: conn.class.name,
|
144
|
+
config: conn.instance_variable_get(:@config),
|
145
|
+
object_id: conn.object_id
|
146
|
+
}
|
147
|
+
model_connections[from_class] = from_conn_details
|
148
|
+
end
|
149
|
+
|
150
|
+
unless to_conn_details
|
151
|
+
conn = to_class.connection
|
152
|
+
to_conn_details = {
|
153
|
+
adapter: conn.class.name,
|
154
|
+
config: conn.instance_variable_get(:@config),
|
155
|
+
object_id: conn.object_id
|
156
|
+
}
|
157
|
+
model_connections[to_class] = to_conn_details
|
158
|
+
end
|
159
|
+
|
160
|
+
# Compare connection details
|
161
|
+
next unless from_conn_details[:object_id] != to_conn_details[:object_id] ||
|
162
|
+
from_conn_details[:adapter] != to_conn_details[:adapter] ||
|
163
|
+
from_conn_details[:config][:database] != to_conn_details[:config][:database]
|
164
|
+
|
165
|
+
raise FixtureError, 'Cross-database relationship? Sorry, your data has commitment issues. ' \
|
166
|
+
"Nodes #{from_ref} (#{from_class}) and #{to_ref} (#{to_class}) use different databases."
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
# Fetch a node by logical ref.
|
171
|
+
# @param ref [Symbol, String]
|
172
|
+
# @return [Object]
|
173
|
+
def self.[](ref)
|
174
|
+
Registry[ref]
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
@@ -7,6 +7,11 @@ module ActiveCypher
|
|
7
7
|
module Generators
|
8
8
|
class NodeGenerator < Rails::Generators::NamedBase
|
9
9
|
source_root File.expand_path('templates', __dir__)
|
10
|
+
class_option :suffix, type: :string,
|
11
|
+
desc: 'Suffix for the node class (default: Node)',
|
12
|
+
default: 'Node'
|
13
|
+
|
14
|
+
check_class_collision suffix: 'Node'
|
10
15
|
|
11
16
|
argument :attributes, type: :array,
|
12
17
|
default: [], banner: 'name:type name:type'
|
@@ -16,16 +21,40 @@ module ActiveCypher
|
|
16
21
|
default: ''
|
17
22
|
|
18
23
|
def create_node_file
|
19
|
-
|
20
|
-
|
24
|
+
check_runtime_class_collision
|
25
|
+
template 'node.rb.erb', File.join('app/graph', class_path, "#{file_name}.rb")
|
21
26
|
end
|
22
27
|
|
23
28
|
private
|
24
29
|
|
30
|
+
def check_runtime_class_collision
|
31
|
+
suffix = node_suffix
|
32
|
+
base = name.camelize
|
33
|
+
class_name_with_suffix = base.end_with?(suffix) ? base : "#{base}#{suffix}"
|
34
|
+
return unless class_name_with_suffix.safe_constantize
|
35
|
+
|
36
|
+
raise Thor::Error, "Class collision: #{class_name_with_suffix} is already defined"
|
37
|
+
end
|
38
|
+
|
39
|
+
def node_suffix
|
40
|
+
options[:suffix] || 'Node'
|
41
|
+
end
|
42
|
+
|
43
|
+
def class_name
|
44
|
+
base = super
|
45
|
+
base.end_with?(node_suffix) ? base : "#{base}#{node_suffix}"
|
46
|
+
end
|
47
|
+
|
48
|
+
def file_name
|
49
|
+
base = super
|
50
|
+
suffix = "_#{node_suffix.underscore}"
|
51
|
+
base.end_with?(suffix) ? base : "#{base}#{suffix}"
|
52
|
+
end
|
53
|
+
|
25
54
|
# helper for ERB
|
26
55
|
def labels_list
|
27
56
|
lbls = options[:labels].split(',').map(&:strip).reject(&:blank?)
|
28
|
-
lbls.empty? ? [class_name.gsub(
|
57
|
+
lbls.empty? ? [class_name.gsub(/#{node_suffix}$/, '')] : lbls
|
29
58
|
end
|
30
59
|
end
|
31
60
|
end
|
@@ -7,6 +7,9 @@ module ActiveCypher
|
|
7
7
|
module Generators
|
8
8
|
class RelationshipGenerator < Rails::Generators::NamedBase
|
9
9
|
source_root File.expand_path('templates', __dir__)
|
10
|
+
class_option :suffix, type: :string,
|
11
|
+
desc: 'Suffix for the relationship class (default: Rel)',
|
12
|
+
default: 'Rel'
|
10
13
|
|
11
14
|
argument :attributes, type: :array,
|
12
15
|
default: [], banner: 'name:type name:type'
|
@@ -19,12 +22,36 @@ module ActiveCypher
|
|
19
22
|
desc: 'Cypher relationship type (defaults to class name)'
|
20
23
|
|
21
24
|
def create_relationship_file
|
22
|
-
|
23
|
-
|
25
|
+
check_runtime_class_collision
|
26
|
+
template 'relationship.rb.erb', File.join('app/graph', class_path, "#{file_name}.rb")
|
24
27
|
end
|
25
28
|
|
26
29
|
private
|
27
30
|
|
31
|
+
def relationship_suffix
|
32
|
+
options[:suffix] || 'Rel'
|
33
|
+
end
|
34
|
+
|
35
|
+
def class_name
|
36
|
+
base = super
|
37
|
+
base.end_with?(relationship_suffix) ? base : "#{base}#{relationship_suffix}"
|
38
|
+
end
|
39
|
+
|
40
|
+
def file_name
|
41
|
+
base = super
|
42
|
+
suffix = "_#{relationship_suffix.underscore}"
|
43
|
+
base.end_with?(suffix) ? base : "#{base}#{suffix}"
|
44
|
+
end
|
45
|
+
|
46
|
+
def check_runtime_class_collision
|
47
|
+
suffix = relationship_suffix
|
48
|
+
base = name.camelize
|
49
|
+
class_name_with_suffix = base.end_with?(suffix) ? base : "#{base}#{suffix}"
|
50
|
+
return unless class_name_with_suffix.safe_constantize.present?
|
51
|
+
|
52
|
+
raise Thor::Error, "Class collision: #{class_name_with_suffix} is already defined"
|
53
|
+
end
|
54
|
+
|
28
55
|
def relationship_type
|
29
56
|
(options[:type].presence || class_name).upcase
|
30
57
|
end
|
@@ -1,10 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
class <%= class_name %> < ApplicationGraphNode
|
3
|
-
|
4
|
+
<% if labels_list.any? -%>
|
5
|
+
<% labels_list.each do |lbl| -%>
|
4
6
|
label :<%= lbl %>
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
7
|
+
<% end -%>
|
8
|
+
<% end -%>
|
9
|
+
<% if attributes.any? -%>
|
10
|
+
<% attributes.each do |attr| -%>
|
11
|
+
attribute :<%= attr.name %>, :<%= attr.type || "string" %>
|
12
|
+
<% end -%>
|
13
|
+
<% end -%>
|
10
14
|
end
|
@@ -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
|
@@ -1,29 +1,21 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module ActiveCypher
|
4
|
-
# <= matches your other concerns
|
5
4
|
module Model
|
6
|
-
#
|
7
|
-
# # Model::Callbacks provides lifecycle hooks for your models, so you can pretend you have control over what happens and when.
|
8
|
-
# # Because nothing says "enterprise" like a callback firing at just the wrong moment.
|
9
|
-
# # Under the hood, it’s all just a little bit of Ruby sorcery, callback witchcraft, and the occasional forbidden incantation from the callback crypt.
|
5
|
+
# @note Now containing even more callback-related Ruby sorcery!
|
10
6
|
module Callbacks
|
11
7
|
extend ActiveSupport::Concern
|
12
8
|
|
13
9
|
EVENTS = %i[
|
14
|
-
initialize find
|
10
|
+
initialize find create update save destroy
|
15
11
|
].freeze
|
16
12
|
|
17
|
-
included do
|
18
|
-
|
19
|
-
define_callbacks(*EVENTS)
|
20
|
-
end
|
13
|
+
included do |base|
|
14
|
+
base.define_callbacks(*EVENTS)
|
21
15
|
|
22
|
-
class_methods do
|
23
16
|
%i[before after around].each do |kind|
|
24
17
|
EVENTS.each do |evt|
|
25
|
-
|
26
|
-
# This is where the callback coven gathers to cast their hooks.
|
18
|
+
base.define_singleton_method("#{kind}_#{evt}") do |*filters, &block|
|
27
19
|
set_callback(evt, kind, *filters, &block)
|
28
20
|
end
|
29
21
|
end
|
@@ -2,73 +2,58 @@
|
|
2
2
|
|
3
3
|
module ActiveCypher
|
4
4
|
module Model
|
5
|
-
# Handles connection logic for models, because
|
6
|
-
# @note
|
5
|
+
# Handles connection logic for models, because even graph nodes need to feel emotionally wired.
|
6
|
+
# @note Under the hood: it's just Ruby metaprogramming, config keys, and a dash of ActiveSupport pixie dust.
|
7
7
|
module ConnectionHandling
|
8
8
|
extend ActiveSupport::Concern
|
9
9
|
|
10
10
|
class_methods do
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
11
|
+
# Sets up database connections for different roles (e.g., writing, reading, analytics).
|
12
|
+
#
|
13
|
+
# Supports shorthand (just pass a symbol/string for writing role).
|
14
|
+
# If :reading isn’t provided, it defaults to the same DB as :writing. Because DRY is still cool.
|
15
|
+
#
|
16
|
+
# @param mapping [Hash] A role-to-database mapping. Keys are roles, values are DB keys or specs.
|
17
|
+
# Example:
|
18
|
+
# connects_to writing: :primary, reading: :replica
|
19
|
+
# @return [void]
|
20
|
+
def connects_to(mapping)
|
21
|
+
mapping = { writing: mapping } if mapping.is_a?(Symbol) || mapping.is_a?(String)
|
22
|
+
symbolized_mapping = mapping.deep_symbolize_keys
|
22
23
|
|
23
|
-
|
24
|
-
resolved_config = resolved_config.merge(cfg.except(:url)) if resolved_config
|
24
|
+
raise ArgumentError, 'The :writing role must be defined in connects_to mapping.' unless symbolized_mapping.key?(:writing)
|
25
25
|
|
26
|
-
|
27
|
-
|
26
|
+
# If you're lazy and don't specify :reading, it defaults to :writing. You're welcome.
|
27
|
+
symbolized_mapping[:reading] ||= symbolized_mapping[:writing]
|
28
28
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
# Traditional config with explicit adapter
|
33
|
-
adapter_name = cfg[:adapter] or raise ArgumentError, 'Missing :adapter'
|
34
|
-
resolved_config = cfg
|
35
|
-
end
|
29
|
+
symbolized_mapping.each do |role, db_key|
|
30
|
+
# db_key can be a symbol (config name) or a nested hash. We’re not judging.
|
31
|
+
spec_name = db_key.is_a?(Hash) ? db_key.values.first : db_key
|
36
32
|
|
37
|
-
|
38
|
-
|
33
|
+
spec = ActiveCypher::CypherConfig.for(spec_name) # Boom. Pulls your DB config.
|
34
|
+
config_for_adapter = spec.dup
|
39
35
|
|
40
|
-
|
41
|
-
adapter_class = ActiveCypher::ConnectionAdapters.const_get(class_name)
|
42
|
-
self.connection = adapter_class.new(resolved_config)
|
43
|
-
connection.connect
|
44
|
-
connection
|
45
|
-
rescue LoadError => e
|
46
|
-
raise AdapterLoadError, "Could not load ActiveCypher adapter '#{adapter_name}' (#{e.message})"
|
47
|
-
end
|
48
|
-
|
49
|
-
# Sets up multiple connections for different roles, because one pool is never enough.
|
50
|
-
# @param mapping [Hash] Role-to-database mapping
|
51
|
-
# @return [void]
|
52
|
-
# Sets up multiple connections for different roles, because one pool is never enough.
|
53
|
-
# @param mapping [Hash] Role-to-database mapping
|
54
|
-
# @return [void]
|
55
|
-
# @note This is where the Ruby gremlins really start dancing—multiple pools, one registry, and a sprinkle of connection witchcraft.
|
56
|
-
def connects_to(mapping)
|
57
|
-
mapping.deep_symbolize_keys.each do |role, db_key|
|
58
|
-
spec = ActiveCypher::CypherConfig.for(db_key) # ← may raise KeyError
|
59
|
-
|
60
|
-
# If spec contains a URL, use ConnectionFactory
|
36
|
+
# If the spec has a URL, parse it and let it override the boring YAML values.
|
61
37
|
if spec[:url]
|
62
|
-
|
63
|
-
|
38
|
+
resolver = ActiveCypher::ConnectionUrlResolver.new(spec[:url])
|
39
|
+
url_config = resolver.to_hash
|
40
|
+
raise ArgumentError, "Invalid connection URL: #{spec[:url]}" unless url_config
|
41
|
+
|
42
|
+
config_for_adapter = url_config.merge(spec.except(*url_config.keys))
|
64
43
|
end
|
65
44
|
|
66
|
-
pool
|
67
|
-
|
45
|
+
# Create a unique connection pool for this role/config combo.
|
46
|
+
pool = ActiveCypher::ConnectionPool.new(config_for_adapter)
|
47
|
+
|
48
|
+
# Register the pool under this spec name.
|
49
|
+
connection_handler.set(spec_name, pool)
|
68
50
|
rescue KeyError => e
|
69
51
|
raise ActiveCypher::UnknownConnectionError,
|
70
|
-
"connects_to
|
52
|
+
"connects_to role `#{role}`: database configuration key `#{spec_name.inspect}` not found in cypher_databases.yml – #{e.message}"
|
71
53
|
end
|
54
|
+
|
55
|
+
# Save the mapping for later — introspection, debugging, blaming, etc.
|
56
|
+
self.connects_to_mappings = symbolized_mapping
|
72
57
|
end
|
73
58
|
end
|
74
59
|
end
|