activecypher 0.5.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/transaction.rb +9 -9
- data/lib/active_cypher/connection_adapters/abstract_adapter.rb +28 -0
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +1 -1
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +26 -0
- 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/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/templates/application_graph_node.rb +1 -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 +31 -38
- data/lib/active_cypher/model/core.rb +1 -63
- data/lib/active_cypher/model/destruction.rb +16 -18
- data/lib/active_cypher/model/labelling.rb +45 -0
- data/lib/active_cypher/model/persistence.rb +46 -40
- data/lib/active_cypher/model/querying.rb +47 -27
- data/lib/active_cypher/railtie.rb +40 -5
- 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/functions.rb +3 -1
- data/lib/cyrel/logging.rb +43 -0
- data/lib/cyrel/query.rb +6 -1
- metadata +11 -2
- 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
|
@@ -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
|
@@ -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
|
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
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
35
|
-
return nil unless conn
|
36
|
-
|
37
|
-
conn.class
|
26
|
+
connection&.class
|
38
27
|
end
|
39
28
|
|
40
|
-
#
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
ActiveCypher::
|
54
|
-
|
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, :
|
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
|