activecypher 0.5.0 → 0.6.1

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 (37) 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/transaction.rb +9 -9
  5. data/lib/active_cypher/connection_adapters/abstract_adapter.rb +28 -0
  6. data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +1 -1
  7. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
  8. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +26 -0
  9. data/lib/active_cypher/connection_adapters/registry.rb +96 -0
  10. data/lib/active_cypher/connection_handler.rb +18 -3
  11. data/lib/active_cypher/connection_pool.rb +5 -23
  12. data/lib/active_cypher/connection_url_resolver.rb +14 -3
  13. data/lib/active_cypher/fixtures/dsl_context.rb +41 -0
  14. data/lib/active_cypher/fixtures/evaluator.rb +37 -0
  15. data/lib/active_cypher/fixtures/node_builder.rb +53 -0
  16. data/lib/active_cypher/fixtures/parser.rb +23 -0
  17. data/lib/active_cypher/fixtures/registry.rb +43 -0
  18. data/lib/active_cypher/fixtures/rel_builder.rb +96 -0
  19. data/lib/active_cypher/fixtures.rb +177 -0
  20. data/lib/active_cypher/generators/templates/application_graph_node.rb +1 -0
  21. data/lib/active_cypher/model/callbacks.rb +5 -13
  22. data/lib/active_cypher/model/connection_handling.rb +37 -52
  23. data/lib/active_cypher/model/connection_owner.rb +31 -38
  24. data/lib/active_cypher/model/core.rb +1 -63
  25. data/lib/active_cypher/model/destruction.rb +16 -18
  26. data/lib/active_cypher/model/labelling.rb +45 -0
  27. data/lib/active_cypher/model/persistence.rb +46 -40
  28. data/lib/active_cypher/model/querying.rb +47 -27
  29. data/lib/active_cypher/railtie.rb +40 -5
  30. data/lib/active_cypher/relationship.rb +77 -17
  31. data/lib/active_cypher/version.rb +1 -1
  32. data/lib/activecypher.rb +4 -1
  33. data/lib/cyrel/functions.rb +3 -1
  34. data/lib/cyrel/logging.rb +43 -0
  35. data/lib/cyrel/query.rb +6 -1
  36. metadata +11 -2
  37. data/lib/active_cypher/connection_factory.rb +0 -130
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Fixtures
5
+ # Parses a fixture profile file using instance_eval in a DSL context.
6
+ class Parser
7
+ attr_reader :file, :dsl_context
8
+
9
+ def initialize(file)
10
+ @file = file
11
+ @dsl_context = ActiveCypher::Fixtures::DSLContext.new
12
+ end
13
+
14
+ # Evaluates the profile file in the DSL context.
15
+ # Returns the DSLContext instance (which accumulates node/rel declarations).
16
+ def parse
17
+ code = File.read(file)
18
+ dsl_context.instance_eval(code, file)
19
+ dsl_context
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Fixtures
5
+ # Singleton registry for logical ref => model instance mapping
6
+ class Registry
7
+ @store = {}
8
+
9
+ class << self
10
+ # Store loaded instance
11
+ # @param ref [Symbol, String]
12
+ # @param obj [Object]
13
+ def add(ref, obj)
14
+ raise ArgumentError, "Duplicate fixture ref: #{ref.inspect}" if @store.key?(ref.to_sym)
15
+
16
+ @store[ref.to_sym] = obj
17
+ end
18
+
19
+ # Fetch in tests (`[]` delegate)
20
+ # @param ref [Symbol, String]
21
+ # @return [Object, nil]
22
+ def get(ref)
23
+ @store[ref.to_sym]
24
+ end
25
+
26
+ # Purge registry between loads
27
+ def reset!
28
+ @store.clear
29
+ end
30
+
31
+ # Allow bracket access: Registry[:foo]
32
+ def [](ref)
33
+ get(ref)
34
+ end
35
+
36
+ # For debugging or introspection
37
+ def all
38
+ @store.dup
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveCypher
4
+ module Fixtures
5
+ class RelBuilder
6
+ # Builds a relationship between two nodes, enforcing cross-DB safety.
7
+ #
8
+ # @param _ref [Symbol, String] Logical reference for this relationship (not used for DB, but for registry/uniqueness)
9
+ # @param from_ref [Symbol, String] Logical ref of the start node
10
+ # @param type [Symbol, String] Relationship type (e.g., :LIKES)
11
+ # @param to_ref [Symbol, String] Logical ref of the end node
12
+ # @param props [Hash] Optional properties for the relationship
13
+ # @raise [ActiveCypher::FixtureError] if cross-DB relationship is attempted
14
+ # @return [void]
15
+ def build(_ref, from_ref, type, to_ref, props = {})
16
+ from = Registry.get(from_ref)
17
+ to = Registry.get(to_ref)
18
+
19
+ raise FixtureError, "Missing from node: #{from_ref}" unless from
20
+ raise FixtureError, "Missing to node: #{to_ref}" unless to
21
+
22
+ from_conn = from.class.connection
23
+ to_conn = to.class.connection
24
+
25
+ raise FixtureError, 'Cross-database relationship? Sorry, your data has commitment issues.' if from_conn != to_conn
26
+
27
+ from_label = from.class.labels.first
28
+ to_label = to.class.labels.first
29
+
30
+ cypher = <<~CYPHER
31
+ MATCH (a:#{from_label} {name: $from_name}), (b:#{to_label} {name: $to_name})
32
+ CREATE (a)-[r:#{type} $props]->(b)
33
+ RETURN r
34
+ CYPHER
35
+
36
+ from_conn.execute_cypher(
37
+ cypher,
38
+ from_name: from.name,
39
+ to_name: to.name,
40
+ props: props
41
+ )
42
+
43
+ nil
44
+ end
45
+
46
+ # Bulk create relationships for performance using UNWIND batching
47
+ # @param rels [Array<Hash>] relationship definitions (from DSLContext)
48
+ # @param batch_size [Integer] batch size for UNWIND
49
+ def self.bulk_build(rels, batch_size: 200)
50
+ # Check all relationships for cross-DB violations first
51
+ rels.each do |rel|
52
+ from = Registry.get(rel[:from_ref])
53
+ to = Registry.get(rel[:to_ref])
54
+ raise FixtureError, "Both endpoints must exist: #{rel[:from_ref]}, #{rel[:to_ref]}" unless from && to
55
+
56
+ from_conn = from.class.connection
57
+ to_conn = to.class.connection
58
+ raise FixtureError, 'Cross-database relationship? Sorry, your data has commitment issues.' if from_conn != to_conn
59
+ end
60
+
61
+ # Group by connection
62
+ grouped = rels.group_by do |rel|
63
+ from = Registry.get(rel[:from_ref])
64
+ from.class.connection
65
+ end
66
+
67
+ grouped.each do |conn, group|
68
+ group.each_slice(batch_size) do |batch|
69
+ unwind_batch = batch.map do |rel|
70
+ from = Registry.get(rel[:from_ref])
71
+ to = Registry.get(rel[:to_ref])
72
+
73
+ {
74
+ from_name: from.name,
75
+ from_label: from.class.labels.first,
76
+ to_name: to.name,
77
+ to_label: to.class.labels.first,
78
+ props: rel[:props] || {},
79
+ type: rel[:type].to_s
80
+ }
81
+ end
82
+
83
+ cypher = <<~CYPHER
84
+ UNWIND $rows AS row
85
+ MATCH (a:#{unwind_batch.first[:from_label]} {name: row.from_name})
86
+ MATCH (b:#{unwind_batch.first[:to_label]} {name: row.to_name})
87
+ CREATE (a)-[r:#{unwind_batch.first[:type]} {props: row.props}]->(b)
88
+ CYPHER
89
+
90
+ conn.execute_cypher(cypher, rows: unwind_batch)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -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
@@ -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,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
@@ -3,63 +3,56 @@
3
3
  module ActiveCypher
4
4
  module Model
5
5
  # Mixin for anything that “owns” a connection (nodes, relationships, maybe
6
- # graph‑level service objects later). 100 % framework‑agnostic.
7
- # @note Because every object wants to feel important by "owning" something, even if it's just a connection.
8
- # Moroccan black magick and Ruby sorcery may be involved in keeping these connections alive.
6
+ # graph‑level service objects later). 100 % framework‑agnostic.
9
7
  module ConnectionOwner
10
8
  extend ActiveSupport::Concern
11
9
  include ActiveCypher::Logging
12
10
  include ActiveCypher::Model::ConnectionHandling
13
11
 
14
- included do
15
- # Every class gets its own adapter slot (overridden by establish_connection)
16
- # Because nothing says "flexibility" like a class variable you'll forget exists.
17
- # This is where the witchcraft happens: sometimes the right connection just appears.
18
- cattr_accessor :connection, instance_accessor: false
19
- end
20
-
21
12
  class_methods do
22
- delegate :current_role, :current_shard,
23
- to: ActiveCypher::RuntimeRegistry
24
-
25
13
  # One handler for all subclasses that include this concern
26
- # Because sharing is caring, except when it comes to connection pools.
27
- # Summoned by Ruby wizardry: this handler is conjured once and shared by all.
28
- @@connection_handler ||= ActiveCypher::ConnectionHandler.new # rubocop:disable Style/ClassVars
29
- def connection_handler = @@connection_handler
14
+ def connection_handler
15
+ if defined?(@connection_handler) && @connection_handler
16
+ @connection_handler
17
+ elsif superclass.respond_to?(:connection_handler)
18
+ superclass.connection_handler
19
+ else
20
+ @connection_handler = ActiveCypher::ConnectionHandler.new
21
+ end
22
+ end
30
23
 
31
24
  # Returns the adapter class being used by this model
32
- # @return [Class] The adapter class (e.g., Neo4jAdapter, MemgraphAdapter)
33
25
  def adapter_class
34
- conn = connection
35
- return nil unless conn
36
-
37
- conn.class
26
+ connection&.class
38
27
  end
39
28
 
40
- # Temporarily switches the current role and shard for the duration of the block.
41
- # @param role [Symbol, nil] The role to switch to
42
- # @param shard [Symbol] The shard to switch to
43
- # @yield The block to execute with the new context
44
- # @note Because sometimes you just want to pretend you're connected to something else for a while.
45
- # Warning: If you switch too often, you may summon unexpected spirits from the Ruby shadow dimension.
46
- def connected_to(role: nil, shard: :default)
47
- previous_role = current_role
48
- previous_shard = current_shard
49
- ActiveCypher::RuntimeRegistry.current_role = role || previous_role
50
- ActiveCypher::RuntimeRegistry.current_shard = shard || previous_shard
51
- yield
52
- ensure
53
- ActiveCypher::RuntimeRegistry.current_role = previous_role
54
- ActiveCypher::RuntimeRegistry.current_shard = previous_shard
29
+ # Always dynamically fetch the connection for the current db_key
30
+ def connection
31
+ handler = connection_handler
32
+
33
+ if respond_to?(:connects_to_mappings) && connects_to_mappings.is_a?(Hash)
34
+ db_key = connects_to_mappings[:writing] # Default to :writing mapping
35
+ if db_key && (pool = handler.pool(db_key))
36
+ return pool.connection
37
+ end
38
+ end
39
+
40
+ return superclass.connection if superclass.respond_to?(:connection)
41
+
42
+ raise ActiveCypher::ConnectionNotEstablished,
43
+ "No connection pool found for #{name}, db_key=#{db_key.inspect}"
55
44
  end
56
45
  end
57
46
 
58
47
  # Instance method to access the adapter class
59
- # @return [Class] The adapter class (e.g., Neo4jAdapter, MemgraphAdapter)
60
48
  def adapter_class
61
49
  self.class.adapter_class
62
50
  end
51
+
52
+ # Instance method to access the connection dynamically
53
+ def connection
54
+ self.class.connection
55
+ end
63
56
  end
64
57
  end
65
58
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_model'
4
-
5
3
  module ActiveCypher
6
4
  module Model
7
5
  # Core: The module that tries to make your graph model feel like it belongs in a relational world.
@@ -11,73 +9,13 @@ module ActiveCypher
11
9
  extend ActiveSupport::Concern
12
10
 
13
11
  included do
14
- include ActiveModel::API
15
- include ActiveModel::Attributes
16
- include ActiveModel::Dirty
17
12
  include ActiveCypher::Associations
18
13
  include ActiveCypher::Scoping
19
- include ActiveModel::Validations
20
14
 
21
- attribute :internal_id, :string
15
+ attribute :internal_id, :integer
22
16
 
23
- cattr_accessor :connection, instance_accessor: false
24
17
  class_attribute :configurations, instance_accessor: false,
25
18
  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
81
19
  end
82
20
 
83
21
  attr_reader :new_record