activecypher 0.0.0 → 0.2.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/associations/collection_proxy.rb +144 -0
- data/lib/active_cypher/associations.rb +537 -0
- data/lib/active_cypher/base.rb +47 -0
- data/lib/active_cypher/bolt/connection.rb +525 -0
- data/lib/active_cypher/bolt/driver.rb +144 -0
- data/lib/active_cypher/bolt/handlers.rb +10 -0
- data/lib/active_cypher/bolt/message_reader.rb +100 -0
- data/lib/active_cypher/bolt/message_writer.rb +53 -0
- data/lib/active_cypher/bolt/messaging.rb +307 -0
- data/lib/active_cypher/bolt/packstream.rb +319 -0
- data/lib/active_cypher/bolt/result.rb +82 -0
- data/lib/active_cypher/bolt/session.rb +201 -0
- data/lib/active_cypher/bolt/transaction.rb +211 -0
- data/lib/active_cypher/bolt/version_encoding.rb +41 -0
- data/lib/active_cypher/bolt.rb +7 -0
- data/lib/active_cypher/connection_adapters/abstract_adapter.rb +75 -0
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +178 -0
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +58 -0
- data/lib/active_cypher/connection_factory.rb +130 -0
- data/lib/active_cypher/connection_handler.rb +9 -0
- data/lib/active_cypher/connection_pool.rb +123 -0
- data/lib/active_cypher/connection_url_resolver.rb +137 -0
- data/lib/active_cypher/cypher_config.rb +50 -0
- data/lib/active_cypher/generators/install_generator.rb +23 -0
- data/lib/active_cypher/generators/node_generator.rb +32 -0
- data/lib/active_cypher/generators/relationship_generator.rb +33 -0
- data/lib/active_cypher/generators/templates/application_graph_node.rb +5 -0
- data/lib/active_cypher/generators/templates/application_graph_relationship.rb +5 -0
- data/lib/active_cypher/generators/templates/cypher_databases.yml +28 -0
- data/lib/active_cypher/generators/templates/node.rb.erb +10 -0
- data/lib/active_cypher/generators/templates/relationship.rb.erb +11 -0
- data/lib/active_cypher/logging.rb +44 -0
- data/lib/active_cypher/model/abstract.rb +87 -0
- data/lib/active_cypher/model/attributes.rb +24 -0
- data/lib/active_cypher/model/callbacks.rb +44 -0
- data/lib/active_cypher/model/connection_handling.rb +76 -0
- data/lib/active_cypher/model/connection_owner.rb +50 -0
- data/lib/active_cypher/model/core.rb +45 -0
- data/lib/active_cypher/model/countable.rb +30 -0
- data/lib/active_cypher/model/destruction.rb +49 -0
- data/lib/active_cypher/model/inspectable.rb +28 -0
- data/lib/active_cypher/model/persistence.rb +182 -0
- data/lib/active_cypher/model/querying.rb +67 -0
- data/lib/active_cypher/railtie.rb +34 -0
- data/lib/active_cypher/relation.rb +190 -0
- data/lib/active_cypher/relationship.rb +233 -0
- data/lib/active_cypher/runtime_registry.rb +8 -0
- data/lib/active_cypher/scoping.rb +97 -0
- data/lib/active_cypher/utils/logger.rb +100 -0
- data/lib/active_cypher/version.rb +5 -0
- data/lib/activecypher.rb +108 -0
- data/lib/cyrel/call_procedure.rb +29 -0
- data/lib/cyrel/clause/call.rb +46 -0
- data/lib/cyrel/clause/call_subquery.rb +40 -0
- data/lib/cyrel/clause/create.rb +33 -0
- data/lib/cyrel/clause/delete.rb +41 -0
- data/lib/cyrel/clause/limit.rb +33 -0
- data/lib/cyrel/clause/match.rb +40 -0
- data/lib/cyrel/clause/merge.rb +34 -0
- data/lib/cyrel/clause/order_by.rb +78 -0
- data/lib/cyrel/clause/remove.rb +75 -0
- data/lib/cyrel/clause/return.rb +90 -0
- data/lib/cyrel/clause/set.rb +97 -0
- data/lib/cyrel/clause/skip.rb +34 -0
- data/lib/cyrel/clause/where.rb +42 -0
- data/lib/cyrel/clause/with.rb +94 -0
- data/lib/cyrel/clause.rb +25 -0
- data/lib/cyrel/direction.rb +18 -0
- data/lib/cyrel/expression/alias.rb +27 -0
- data/lib/cyrel/expression/base.rb +101 -0
- data/lib/cyrel/expression/case.rb +45 -0
- data/lib/cyrel/expression/comparison.rb +60 -0
- data/lib/cyrel/expression/exists.rb +42 -0
- data/lib/cyrel/expression/function_call.rb +57 -0
- data/lib/cyrel/expression/literal.rb +33 -0
- data/lib/cyrel/expression/logical.rb +38 -0
- data/lib/cyrel/expression/operator.rb +27 -0
- data/lib/cyrel/expression/pattern_comprehension.rb +44 -0
- data/lib/cyrel/expression/property_access.rb +25 -0
- data/lib/cyrel/expression.rb +56 -0
- data/lib/cyrel/functions.rb +116 -0
- data/lib/cyrel/node.rb +397 -0
- data/lib/cyrel/parameterizable.rb +20 -0
- data/lib/cyrel/pattern/node.rb +66 -0
- data/lib/cyrel/pattern/path.rb +41 -0
- data/lib/cyrel/pattern/relationship.rb +74 -0
- data/lib/cyrel/pattern.rb +8 -0
- data/lib/cyrel/query.rb +497 -0
- data/lib/cyrel/return_only.rb +26 -0
- data/lib/cyrel/types/hash_type.rb +22 -0
- data/lib/cyrel/types/symbol_type.rb +13 -0
- data/lib/cyrel.rb +72 -0
- data/lib/tasks/active_cypher_tasks.rake +6 -0
- data/sig/activecypher.rbs +4 -0
- metadata +173 -10
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
# ConnectionFactory provides a simple API for creating connections to Cypher databases
|
5
|
+
# using the ConnectionUrlResolver to parse database URLs.
|
6
|
+
#
|
7
|
+
# This factory simplifies the process of creating database connections by:
|
8
|
+
# 1. Parsing connection URLs with ConnectionUrlResolver
|
9
|
+
# 2. Creating the appropriate adapter based on the URL
|
10
|
+
# 3. Establishing and configuring the connection with the right security settings
|
11
|
+
#
|
12
|
+
# Example:
|
13
|
+
# factory = ConnectionFactory.new("neo4j://user:pass@localhost:7687")
|
14
|
+
# driver = factory.create_driver
|
15
|
+
#
|
16
|
+
# driver.with_session do |session|
|
17
|
+
# result = session.run("RETURN 'Connected!' AS message")
|
18
|
+
# puts result.first[:message]
|
19
|
+
# end
|
20
|
+
class ConnectionFactory
|
21
|
+
# Initialize with a database URL
|
22
|
+
# @param url [String] A database connection URL
|
23
|
+
# @param options [Hash] Additional options for the connection
|
24
|
+
def initialize(url, options = {})
|
25
|
+
@url = url
|
26
|
+
@options = options
|
27
|
+
@config = resolve_url(url)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Create a Bolt driver based on the parsed URL
|
31
|
+
# @param pool_size [Integer] Size of the connection pool
|
32
|
+
# @return [ActiveCypher::Bolt::Driver, nil] The configured driver or nil if URL is invalid
|
33
|
+
def create_driver(pool_size: 5)
|
34
|
+
return nil unless @config
|
35
|
+
|
36
|
+
# Create the adapter based on the resolved configuration
|
37
|
+
adapter = create_adapter
|
38
|
+
return nil unless adapter
|
39
|
+
|
40
|
+
# Create and configure the Bolt driver
|
41
|
+
uri = build_uri
|
42
|
+
auth_token = build_auth_token
|
43
|
+
|
44
|
+
ActiveCypher::Bolt::Driver.new(
|
45
|
+
uri: uri,
|
46
|
+
adapter: adapter,
|
47
|
+
auth_token: auth_token,
|
48
|
+
pool_size: pool_size
|
49
|
+
)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get the parsed configuration
|
53
|
+
# @return [Hash, nil] The parsed configuration or nil if URL is invalid
|
54
|
+
attr_reader :config
|
55
|
+
|
56
|
+
# Verify if the URL is valid and supported
|
57
|
+
# @return [Boolean] True if the URL is valid and supported
|
58
|
+
def valid?
|
59
|
+
!@config.nil?
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
# Resolve the URL into a configuration hash
|
65
|
+
def resolve_url(url)
|
66
|
+
resolver = ConnectionUrlResolver.new(url)
|
67
|
+
resolver.to_hash
|
68
|
+
end
|
69
|
+
|
70
|
+
# Create the appropriate adapter based on the parsed URL
|
71
|
+
def create_adapter
|
72
|
+
case @config[:adapter]
|
73
|
+
when 'neo4j'
|
74
|
+
create_neo4j_adapter
|
75
|
+
when 'memgraph'
|
76
|
+
create_memgraph_adapter
|
77
|
+
else
|
78
|
+
nil
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Create a Neo4j adapter
|
83
|
+
def create_neo4j_adapter
|
84
|
+
ConnectionAdapters::Neo4jAdapter.new({
|
85
|
+
uri: build_uri,
|
86
|
+
username: @config[:username],
|
87
|
+
password: @config[:password],
|
88
|
+
database: @config[:database]
|
89
|
+
})
|
90
|
+
end
|
91
|
+
|
92
|
+
# Create a Memgraph adapter
|
93
|
+
def create_memgraph_adapter
|
94
|
+
ConnectionAdapters::MemgraphAdapter.new({
|
95
|
+
uri: build_uri,
|
96
|
+
username: @config[:username],
|
97
|
+
password: @config[:password],
|
98
|
+
database: @config[:database]
|
99
|
+
})
|
100
|
+
rescue NameError
|
101
|
+
# Fall back to Neo4j adapter if Memgraph adapter is not available
|
102
|
+
ConnectionAdapters::Neo4jAdapter.new({
|
103
|
+
uri: build_uri,
|
104
|
+
username: @config[:username],
|
105
|
+
password: @config[:password],
|
106
|
+
database: @config[:database]
|
107
|
+
})
|
108
|
+
end
|
109
|
+
|
110
|
+
# Build the URI string with the appropriate scheme based on SSL settings
|
111
|
+
def build_uri
|
112
|
+
scheme = if @config[:ssl]
|
113
|
+
@config[:ssc] ? 'bolt+ssc' : 'bolt+s'
|
114
|
+
else
|
115
|
+
'bolt'
|
116
|
+
end
|
117
|
+
|
118
|
+
"#{scheme}://#{@config[:host]}:#{@config[:port]}"
|
119
|
+
end
|
120
|
+
|
121
|
+
# Build the authentication token for the Bolt driver
|
122
|
+
def build_auth_token
|
123
|
+
{
|
124
|
+
scheme: 'basic',
|
125
|
+
principal: @config[:username],
|
126
|
+
credentials: @config[:password]
|
127
|
+
}
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveCypher
|
4
|
+
class ConnectionHandler
|
5
|
+
def initialize = @role_shard_map = Hash.new { |h, k| h[k] = {} }
|
6
|
+
def set(role, shard, pool) = (@role_shard_map[role][shard] = pool)
|
7
|
+
def pool(role, shard) = @role_shard_map.dig(role, shard)
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'concurrent/atomic/atomic_reference'
|
4
|
+
require 'timeout'
|
5
|
+
|
6
|
+
module ActiveCypher
|
7
|
+
class ConnectionPool
|
8
|
+
attr_reader :spec
|
9
|
+
|
10
|
+
def initialize(spec)
|
11
|
+
@spec = spec.symbolize_keys
|
12
|
+
|
13
|
+
# Set defaults for pool configuration
|
14
|
+
@spec[:pool_size] ||= ENV.fetch('BOLT_POOL_SIZE', 10).to_i
|
15
|
+
@spec[:pool_timeout] ||= ENV.fetch('BOLT_POOL_TIMEOUT', 5).to_i
|
16
|
+
@spec[:max_retries] ||= ENV.fetch('BOLT_MAX_RETRIES', 3).to_i
|
17
|
+
|
18
|
+
# Handle URL-based configuration if present
|
19
|
+
if @spec[:url] && !@spec.key?(:adapter)
|
20
|
+
resolver = ActiveCypher::ConnectionUrlResolver.new(@spec[:url])
|
21
|
+
resolved_config = resolver.to_hash
|
22
|
+
|
23
|
+
raise ArgumentError, "Invalid connection URL: #{@spec[:url]}" unless resolved_config
|
24
|
+
|
25
|
+
# Merge the resolved config with any additional options
|
26
|
+
@spec = resolved_config.merge(@spec.except(:url))
|
27
|
+
|
28
|
+
end
|
29
|
+
|
30
|
+
@conn_ref = Concurrent::AtomicReference.new # holds the adapter instance
|
31
|
+
@creation_mutex = Mutex.new # prevents multiple threads from creating connections simultaneously
|
32
|
+
@retry_count = Concurrent::AtomicReference.new(0)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Returns a live adapter, initialising it once in a thread‑safe way.
|
36
|
+
def connection
|
37
|
+
# Fast path —already connected and alive
|
38
|
+
conn = @conn_ref.value
|
39
|
+
return conn if conn&.active?
|
40
|
+
|
41
|
+
# Use mutex for the slow path to prevent thundering herd
|
42
|
+
@creation_mutex.synchronize do
|
43
|
+
# Check again inside the mutex in case another thread created it
|
44
|
+
conn = @conn_ref.value
|
45
|
+
return conn if conn&.active?
|
46
|
+
|
47
|
+
# Slow path —create a new connection with retry logic
|
48
|
+
retries = 0
|
49
|
+
max_retries = @spec[:max_retries]
|
50
|
+
|
51
|
+
begin
|
52
|
+
new_conn = build_connection
|
53
|
+
@conn_ref.set(new_conn)
|
54
|
+
@retry_count.set(0) # Reset retry count on success
|
55
|
+
return new_conn
|
56
|
+
rescue StandardError => e
|
57
|
+
retries += 1
|
58
|
+
if retries <= max_retries
|
59
|
+
# Exponential backoff
|
60
|
+
sleep_time = 0.1 * (2**(retries - 1))
|
61
|
+
sleep(sleep_time)
|
62
|
+
retry
|
63
|
+
else
|
64
|
+
# Track persistent failures
|
65
|
+
@retry_count.update { |count| count + 1 }
|
66
|
+
raise ConnectionError, "Failed to establish connection after #{max_retries} attempts: #{e.message}"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
alias checkout connection
|
72
|
+
|
73
|
+
# Check if the pool has a persistent connection issue
|
74
|
+
def troubled?
|
75
|
+
@retry_count.value >= @spec[:max_retries]
|
76
|
+
end
|
77
|
+
|
78
|
+
# Explicitly close and reset the connection
|
79
|
+
def disconnect
|
80
|
+
conn = @conn_ref.value
|
81
|
+
return unless conn
|
82
|
+
|
83
|
+
begin
|
84
|
+
conn.disconnect
|
85
|
+
rescue StandardError => e
|
86
|
+
# Log but don't raise to ensure cleanup continues
|
87
|
+
puts "Warning: Error disconnecting: #{e.message}" if ENV['DEBUG']
|
88
|
+
ensure
|
89
|
+
@conn_ref.set(nil)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def build_connection
|
96
|
+
adapter_name = @spec[:adapter]
|
97
|
+
raise ArgumentError, 'Missing adapter name in connection specification' unless adapter_name
|
98
|
+
|
99
|
+
adapter_class = ActiveCypher::ConnectionAdapters
|
100
|
+
.const_get("#{adapter_name}_adapter".camelize)
|
101
|
+
|
102
|
+
adapter = adapter_class.new(@spec)
|
103
|
+
|
104
|
+
# Use timeout to avoid hanging during connection
|
105
|
+
begin
|
106
|
+
Timeout.timeout(@spec[:pool_timeout]) do
|
107
|
+
adapter.connect
|
108
|
+
end
|
109
|
+
rescue Timeout::Error
|
110
|
+
begin
|
111
|
+
adapter.disconnect
|
112
|
+
rescue StandardError
|
113
|
+
nil
|
114
|
+
end
|
115
|
+
raise ConnectionError, "Connection timed out after #{@spec[:pool_timeout]} seconds"
|
116
|
+
end
|
117
|
+
|
118
|
+
adapter
|
119
|
+
rescue NameError
|
120
|
+
raise ActiveCypher::AdapterNotFoundError, "Could not find adapter class for '#{adapter_name}'"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'uri'
|
4
|
+
require 'cgi'
|
5
|
+
|
6
|
+
module ActiveCypher
|
7
|
+
# ConnectionUrlResolver accepts Cypher-based database URLs and converts them
|
8
|
+
# into a normalized configuration hash for adapter resolution.
|
9
|
+
#
|
10
|
+
# Supported URL prefixes:
|
11
|
+
# - neo4j://
|
12
|
+
# - neo4j+ssl://
|
13
|
+
# - neo4j+ssc://
|
14
|
+
# - memgraph://
|
15
|
+
# - memgraph+ssl://
|
16
|
+
# - memgraph+ssc://
|
17
|
+
#
|
18
|
+
# The output of to_hash follows a consistent pattern:
|
19
|
+
# {
|
20
|
+
# adapter: "neo4j", # or "memgraph"
|
21
|
+
# username: "user",
|
22
|
+
# password: "pass",
|
23
|
+
# host: "localhost",
|
24
|
+
# port: 7687,
|
25
|
+
# database: nil, # or optional
|
26
|
+
# ssl: true,
|
27
|
+
# ssc: false,
|
28
|
+
# options: {} # future-proof for params like '?timeout=30'
|
29
|
+
# }
|
30
|
+
class ConnectionUrlResolver
|
31
|
+
SUPPORTED_ADAPTERS = %w[neo4j memgraph].freeze
|
32
|
+
DEFAULT_PORT = 7687
|
33
|
+
|
34
|
+
# Initialize with a URL string
|
35
|
+
# @param url_string [String] A connection URL string
|
36
|
+
def initialize(url_string)
|
37
|
+
@url_string = url_string
|
38
|
+
@parsed = parse_url(url_string)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Convert the URL to a normalized hash configuration
|
42
|
+
# @return [Hash] Configuration hash with adapter, host, port, etc.
|
43
|
+
def to_hash
|
44
|
+
return nil unless @parsed
|
45
|
+
|
46
|
+
{
|
47
|
+
adapter: @parsed[:adapter],
|
48
|
+
host: @parsed[:host],
|
49
|
+
port: @parsed[:port],
|
50
|
+
username: @parsed[:username],
|
51
|
+
password: @parsed[:password],
|
52
|
+
database: @parsed[:database],
|
53
|
+
ssl: @parsed[:ssl],
|
54
|
+
ssc: @parsed[:ssc],
|
55
|
+
options: @parsed[:options]
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def parse_url(url_string)
|
62
|
+
return nil if url_string.nil? || url_string.empty?
|
63
|
+
|
64
|
+
# Extract scheme and potential modifiers (ssl, ssc)
|
65
|
+
scheme_parts = url_string.split('://', 2)
|
66
|
+
return nil if scheme_parts.size != 2
|
67
|
+
|
68
|
+
scheme = scheme_parts[0]
|
69
|
+
rest = scheme_parts[1].to_s
|
70
|
+
|
71
|
+
adapter, modifiers = extract_adapter_and_modifiers(scheme)
|
72
|
+
return nil unless adapter
|
73
|
+
|
74
|
+
# Parse the remaining part as a standard URI
|
75
|
+
uri_string = "#{adapter}://#{rest}"
|
76
|
+
begin
|
77
|
+
uri = URI.parse(uri_string)
|
78
|
+
rescue URI::InvalidURIError
|
79
|
+
return nil
|
80
|
+
end
|
81
|
+
|
82
|
+
# Extract query parameters, if any
|
83
|
+
options = extract_query_params(uri.query)
|
84
|
+
|
85
|
+
# Extract database from path, if present
|
86
|
+
database = uri.path.empty? ? nil : uri.path.sub(%r{^/}, '')
|
87
|
+
database = nil if database&.empty?
|
88
|
+
|
89
|
+
# The to_s conversion handles nil values
|
90
|
+
username = uri.user.to_s.empty? ? nil : CGI.unescape(uri.user)
|
91
|
+
password = uri.password.to_s.empty? ? nil : CGI.unescape(uri.password)
|
92
|
+
|
93
|
+
# When using SSC (self-signed certificates), SSL must also be enabled
|
94
|
+
use_ssl = modifiers.include?('ssl')
|
95
|
+
use_ssc = modifiers.include?('ssc')
|
96
|
+
|
97
|
+
# Self-signed certificates imply SSL is also enabled
|
98
|
+
use_ssl = true if use_ssc
|
99
|
+
|
100
|
+
{
|
101
|
+
adapter: adapter,
|
102
|
+
host: uri.host || 'localhost',
|
103
|
+
port: uri.port || DEFAULT_PORT,
|
104
|
+
username: username,
|
105
|
+
password: password,
|
106
|
+
database: database,
|
107
|
+
ssl: use_ssl,
|
108
|
+
ssc: use_ssc,
|
109
|
+
options: options
|
110
|
+
}
|
111
|
+
end
|
112
|
+
|
113
|
+
def extract_adapter_and_modifiers(scheme)
|
114
|
+
parts = scheme.split('+')
|
115
|
+
adapter = parts.shift
|
116
|
+
|
117
|
+
return nil unless SUPPORTED_ADAPTERS.include?(adapter)
|
118
|
+
|
119
|
+
modifiers = parts.select { |mod| %w[ssl ssc].include?(mod) }
|
120
|
+
|
121
|
+
# If there are parts that are neither the adapter nor valid modifiers, the URL is invalid
|
122
|
+
remaining_parts = parts - modifiers
|
123
|
+
return nil if remaining_parts.any?
|
124
|
+
|
125
|
+
[adapter, modifiers]
|
126
|
+
end
|
127
|
+
|
128
|
+
def extract_query_params(query_string)
|
129
|
+
return {} unless query_string
|
130
|
+
|
131
|
+
query_string.split('&').each_with_object({}) do |pair, hash|
|
132
|
+
key, value = pair.split('=', 2)
|
133
|
+
hash[key.to_sym] = value if key && !key.empty? && value && !value.empty?
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/active_cypher/cypher_config.rb
|
4
|
+
require 'active_support'
|
5
|
+
|
6
|
+
module ActiveCypher
|
7
|
+
module CypherConfig
|
8
|
+
#
|
9
|
+
# Read config/cypher_databases.yml the Rails‑native way and then
|
10
|
+
# pick a *named connection* (default :primary).
|
11
|
+
#
|
12
|
+
# Works **outside** Rails too by falling back to ActiveSupport::ConfigurationFile.
|
13
|
+
#
|
14
|
+
def self.for(name = :primary, env: nil, path: nil)
|
15
|
+
env ||= defined?(Rails) ? Rails.env : ENV.fetch('CY_ENV', 'development')
|
16
|
+
file = Pathname.new(path || default_path)
|
17
|
+
|
18
|
+
## ------------------------------------------------------------
|
19
|
+
## 1. Parse YAML using the same algorithm Rails::Application#config_for
|
20
|
+
## uses (shared‑section merge, ERB, symbolize_keys, etc.)
|
21
|
+
## ------------------------------------------------------------
|
22
|
+
merged =
|
23
|
+
if defined?(Rails::Application)
|
24
|
+
# Leverage the very method you pasted:
|
25
|
+
Rails.application.config_for(file, env: env).deep_dup
|
26
|
+
else
|
27
|
+
# Stand‑alone Ruby script: replicate the merge rules.
|
28
|
+
raw = ActiveSupport::ConfigurationFile.parse(file).deep_symbolize_keys
|
29
|
+
config = raw[env.to_sym] || {}
|
30
|
+
shared = raw[:shared] || {}
|
31
|
+
shared.deep_merge(config)
|
32
|
+
end
|
33
|
+
|
34
|
+
return merged.with_indifferent_access if name.to_s == '*'
|
35
|
+
|
36
|
+
merged.fetch(name.to_sym) do
|
37
|
+
raise KeyError,
|
38
|
+
"No '#{name}' connection in #{env} section of #{file}"
|
39
|
+
end.with_indifferent_access
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.default_path
|
43
|
+
if defined?(Rails)
|
44
|
+
Rails.root.join('config', 'cypher_databases.yml')
|
45
|
+
else
|
46
|
+
File.join(Dir.pwd, 'config', 'cypher_databases.yml')
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators/base'
|
4
|
+
|
5
|
+
module ActiveCypher
|
6
|
+
module Generators
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
8
|
+
desc 'Creates cypher_databases.yml and an initializer for ActiveCypher'
|
9
|
+
|
10
|
+
source_root File.expand_path('templates', __dir__)
|
11
|
+
|
12
|
+
def copy_configuration
|
13
|
+
template 'cypher_databases.yml', 'config/cypher_databases.yml'
|
14
|
+
end
|
15
|
+
|
16
|
+
def copy_base_classes
|
17
|
+
empty_directory 'app/graph'
|
18
|
+
template 'application_graph_node.rb', 'app/graph/application_graph_node.rb'
|
19
|
+
template 'application_graph_relationship.rb', 'app/graph/application_graph_relationship.rb'
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators/active_model'
|
4
|
+
require 'rails/generators/named_base'
|
5
|
+
|
6
|
+
module ActiveCypher
|
7
|
+
module Generators
|
8
|
+
class NodeGenerator < Rails::Generators::NamedBase
|
9
|
+
source_root File.expand_path('templates', __dir__)
|
10
|
+
|
11
|
+
argument :attributes, type: :array,
|
12
|
+
default: [], banner: 'name:type name:type'
|
13
|
+
|
14
|
+
class_option :labels, type: :string,
|
15
|
+
desc: 'Comma‑separated Cypher labels',
|
16
|
+
default: ''
|
17
|
+
|
18
|
+
def create_node_file
|
19
|
+
template 'node.rb.erb',
|
20
|
+
File.join('app/graph', class_path, "#{file_name}.rb")
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
# helper for ERB
|
26
|
+
def labels_list
|
27
|
+
lbls = options[:labels].split(',').map(&:strip).reject(&:blank?)
|
28
|
+
lbls.empty? ? [class_name.gsub(/Node$/, '')] : lbls
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators/active_model'
|
4
|
+
require 'rails/generators/named_base'
|
5
|
+
|
6
|
+
module ActiveCypher
|
7
|
+
module Generators
|
8
|
+
class RelationshipGenerator < Rails::Generators::NamedBase
|
9
|
+
source_root File.expand_path('templates', __dir__)
|
10
|
+
|
11
|
+
argument :attributes, type: :array,
|
12
|
+
default: [], banner: 'name:type name:type'
|
13
|
+
|
14
|
+
class_option :from, type: :string, required: true,
|
15
|
+
desc: 'Source node class (e.g. UserNode)'
|
16
|
+
class_option :to, type: :string, required: true,
|
17
|
+
desc: 'Target node class (e.g. CompanyNode)'
|
18
|
+
class_option :type, type: :string, default: '',
|
19
|
+
desc: 'Cypher relationship type (defaults to class name)'
|
20
|
+
|
21
|
+
def create_relationship_file
|
22
|
+
template 'relationship.rb.erb',
|
23
|
+
File.join('app/graph', class_path, "#{file_name}.rb")
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def relationship_type
|
29
|
+
(options[:type].presence || class_name).upcase
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
shared:
|
2
|
+
ssl: false # Because encryption is for people who deploy to the public internet on purpose
|
3
|
+
|
4
|
+
development:
|
5
|
+
primary: &local_memgraph
|
6
|
+
adapter: neo4j # Because you like your graphs with a touch of existential dread
|
7
|
+
uri: neo4j://localhost:7687 # Port 7687: also known as Neo4j’s VIP lounge
|
8
|
+
username: neo4j # Default username because who has time for originality
|
9
|
+
password: neo4j # Seriously. Change this. Right now. I can see you not doing it.
|
10
|
+
multi_db: false # One DB to rule them all, because multiple would involve effort
|
11
|
+
|
12
|
+
test:
|
13
|
+
primary:
|
14
|
+
<<: *local_memgraph # Reusing the dev config like a true copy-paste warrior
|
15
|
+
uri: bolt://localhost:9876 # Different port, same chaos
|
16
|
+
|
17
|
+
production:
|
18
|
+
primary:
|
19
|
+
adapter: memgraph # Yes, it’s still memgraph... for now...
|
20
|
+
uri: bolt+s://<%= ENV["MG_HOST"] %>:7687 # Now with SSL, because prod gets the good stuff
|
21
|
+
username: <%= ENV["MG_USER"] %> # You should probably not hardcode "admin"
|
22
|
+
password: <%= ENV["MG_PASS"] %> # Hopefully not "password123"
|
23
|
+
ssl: true # SSL: Because “oops, we got hacked” is a bad press release
|
24
|
+
multi_db: <%= ENV.fetch("MG_MULTI_DB", "false") %> # Default is "false" because complexity is scary and licensing is expensive
|
25
|
+
|
26
|
+
# Bonus content:
|
27
|
+
# Neo4j used to run prod but got demoted when Memgraph bought us lunch
|
28
|
+
# RIP Neo4j: gone but not forgotten... mostly because we still pay for the license
|
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
class <%= class_name %> < ApplicationGraphNode
|
3
|
+
<% labels_list.each do |lbl| %>
|
4
|
+
label :<%= lbl %>
|
5
|
+
<% end %>
|
6
|
+
|
7
|
+
<% attributes.each do |attr| -%>
|
8
|
+
attribute :<%= attr.name %>, :<%= attr.type || "string" %>
|
9
|
+
<% end -%>
|
10
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class <%= class_name %> < ApplicationGraphRelationship
|
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 -%>
|
11
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
require 'active_support/tagged_logging'
|
5
|
+
|
6
|
+
module ActiveCypher
|
7
|
+
module Logging
|
8
|
+
class << self
|
9
|
+
# The one true logger object
|
10
|
+
attr_accessor :backend
|
11
|
+
|
12
|
+
# Public accessor used by the mix‑in
|
13
|
+
def logger
|
14
|
+
self.backend ||= begin
|
15
|
+
base = Logger.new($stdout)
|
16
|
+
base.level = ENV.fetch('AC_LOG_LEVEL', 'info').upcase
|
17
|
+
.then do |lvl|
|
18
|
+
Logger.const_get(lvl)
|
19
|
+
rescue StandardError
|
20
|
+
Logger::INFO
|
21
|
+
end
|
22
|
+
ActiveSupport::TaggedLogging.new(base).tap { |l| l.tagged! 'ActiveCypher' }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# ------------------------------------------------------------------
|
28
|
+
# Instance helpers
|
29
|
+
# ------------------------------------------------------------------
|
30
|
+
def logger = Logging.logger
|
31
|
+
def log_debug(msg) = logger.debug { msg }
|
32
|
+
def log_info(msg) = logger.info { msg }
|
33
|
+
def log_warn(msg) = logger.warn { msg }
|
34
|
+
def log_error(msg) = logger.error { msg }
|
35
|
+
|
36
|
+
def log_bench(label)
|
37
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
38
|
+
yield
|
39
|
+
ensure
|
40
|
+
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1_000).round(2)
|
41
|
+
logger.debug { "#{label} (#{ms} ms)" }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|