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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_cypher/base.rb +28 -8
  3. data/lib/active_cypher/bolt/driver.rb +6 -16
  4. data/lib/active_cypher/bolt/session.rb +62 -50
  5. data/lib/active_cypher/bolt/transaction.rb +95 -90
  6. data/lib/active_cypher/connection_adapters/abstract_adapter.rb +28 -0
  7. data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +40 -32
  8. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
  9. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +27 -1
  10. data/lib/active_cypher/connection_adapters/registry.rb +94 -0
  11. data/lib/active_cypher/connection_handler.rb +18 -3
  12. data/lib/active_cypher/connection_pool.rb +5 -23
  13. data/lib/active_cypher/connection_url_resolver.rb +14 -3
  14. data/lib/active_cypher/cypher_config.rb +2 -1
  15. data/lib/active_cypher/fixtures/dsl_context.rb +41 -0
  16. data/lib/active_cypher/fixtures/evaluator.rb +37 -0
  17. data/lib/active_cypher/fixtures/node_builder.rb +53 -0
  18. data/lib/active_cypher/fixtures/parser.rb +23 -0
  19. data/lib/active_cypher/fixtures/registry.rb +43 -0
  20. data/lib/active_cypher/fixtures/rel_builder.rb +96 -0
  21. data/lib/active_cypher/fixtures.rb +177 -0
  22. data/lib/active_cypher/generators/node_generator.rb +32 -3
  23. data/lib/active_cypher/generators/relationship_generator.rb +29 -2
  24. data/lib/active_cypher/generators/templates/application_graph_node.rb +1 -0
  25. data/lib/active_cypher/generators/templates/node.rb.erb +10 -6
  26. data/lib/active_cypher/generators/templates/relationship.rb.erb +7 -6
  27. data/lib/active_cypher/instrumentation.rb +186 -0
  28. data/lib/active_cypher/model/callbacks.rb +5 -13
  29. data/lib/active_cypher/model/connection_handling.rb +37 -52
  30. data/lib/active_cypher/model/connection_owner.rb +41 -33
  31. data/lib/active_cypher/model/core.rb +4 -12
  32. data/lib/active_cypher/model/countable.rb +10 -3
  33. data/lib/active_cypher/model/destruction.rb +23 -18
  34. data/lib/active_cypher/model/labelling.rb +45 -0
  35. data/lib/active_cypher/model/persistence.rb +52 -26
  36. data/lib/active_cypher/model/querying.rb +49 -25
  37. data/lib/active_cypher/railtie.rb +40 -5
  38. data/lib/active_cypher/relation.rb +10 -2
  39. data/lib/active_cypher/relationship.rb +77 -17
  40. data/lib/active_cypher/version.rb +1 -1
  41. data/lib/activecypher.rb +4 -1
  42. data/lib/cyrel/clause/set.rb +20 -10
  43. data/lib/cyrel/expression/property_access.rb +2 -0
  44. data/lib/cyrel/functions.rb +3 -1
  45. data/lib/cyrel/logging.rb +43 -0
  46. data/lib/cyrel/plus.rb +11 -0
  47. data/lib/cyrel/query.rb +7 -1
  48. data/lib/cyrel.rb +77 -18
  49. metadata +13 -2
  50. 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
- template 'node.rb.erb',
20
- File.join('app/graph', class_path, "#{file_name}.rb")
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(/Node$/, '')] : lbls
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
- template 'relationship.rb.erb',
23
- File.join('app/graph', class_path, "#{file_name}.rb")
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
@@ -2,4 +2,5 @@
2
2
 
3
3
  class ApplicationGraphNode < ActiveCypher::Base
4
4
  # Adapter‑specific helpers are injected after connection
5
+ connects_to writing: :primary
5
6
  end
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  class <%= class_name %> < ApplicationGraphNode
3
- <% labels_list.each do |lbl| %>
4
+ <% if labels_list.any? -%>
5
+ <% labels_list.each do |lbl| -%>
4
6
  label :<%= lbl %>
5
- <% end %>
6
-
7
- <% attributes.each do |attr| -%>
8
- attribute :<%= attr.name %>, :<%= attr.type || "string" %>
9
- <% end -%>
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
- 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
@@ -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
- # @!parse
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 validate create update save destroy
10
+ initialize find create update save destroy
15
11
  ].freeze
16
12
 
17
- included do
18
- include ActiveSupport::Callbacks
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
- define_method("#{kind}_#{evt}") do |*filters, &block|
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 every ORM needs a way to feel connected.
6
- # @note All real connection magic is just Ruby sorcery, a dash of forbidden Ruby incantations, and a sprinkle of ActiveSupport witchcraft.
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
- # Establishes a connection for the model.
12
- # Because every model deserves a shot at disappointment.
13
- # @note Under the hood, this is just Ruby sorcery and a little forbidden Ruby wizardry to make your config work.
14
- def establish_connection(config)
15
- cfg = config.symbolize_keys
16
-
17
- # Handle both URL-based and traditional config
18
- if cfg[:url]
19
- # Use ConnectionUrlResolver for URL-based config
20
- resolver = ActiveCypher::ConnectionUrlResolver.new(cfg[:url])
21
- resolved_config = resolver.to_hash
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
- # Merge any additional config options
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
- # Bail if URL couldn't be parsed
27
- raise ArgumentError, "Invalid connection URL: #{cfg[:url]}" unless resolved_config
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
- # Get adapter name from resolved config
30
- adapter_name = resolved_config[:adapter]
31
- else
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
- path = "active_cypher/connection_adapters/#{adapter_name}_adapter"
38
- class_name = "#{adapter_name}_adapter".camelize
33
+ spec = ActiveCypher::CypherConfig.for(spec_name) # Boom. Pulls your DB config.
34
+ config_for_adapter = spec.dup
39
35
 
40
- require path
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
- factory = ActiveCypher::ConnectionFactory.new(spec[:url])
63
- spec = factory.config.merge(spec.except(:url)) if factory.valid?
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 = ActiveCypher::ConnectionPool.new(spec)
67
- connection_handler.set(role.to_sym, :default, pool)
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 #{role}: #{db_key.inspect} – #{e.message}"
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