activecypher 0.12.1 → 0.13.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/bolt/connection.rb +0 -9
- data/lib/active_cypher/bolt/driver.rb +34 -12
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +38 -3
- data/lib/active_cypher/rails_lens_ext/annotator.rb +220 -0
- data/lib/active_cypher/rails_lens_ext/extension.rb +409 -0
- data/lib/active_cypher/rails_lens_ext/model_source.rb +43 -0
- data/lib/active_cypher/railtie.rb +6 -0
- data/lib/active_cypher/relationship.rb +4 -0
- data/lib/active_cypher/version.rb +1 -1
- data/lib/activecypher.rb +1 -0
- metadata +32 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96c24fcfa519e4e44a2e8c8d1e84ae0b975d0d73d25492b025f4968e148a2090
|
|
4
|
+
data.tar.gz: f9ef843ec7859048e7284731122be13ec519f9d44066471db70db1d4d326198f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0afa400aeab7bfc05a7842cf4361f064b052ec52c8d2fc402da767da882277e8ad1b2c14a2b3c6396c3d34cc436caa45cb43978727d4cd02da80e589af197b10
|
|
7
|
+
data.tar.gz: e76af4465bdfb80f6b388137e849b2b2705e1f9fd8ae5afd81369f19d40c144819cc5eba6f106677a37932b7a0bbab864951fef2e5fb2450aca982e81971065b
|
|
@@ -485,15 +485,6 @@ module ActiveCypher
|
|
|
485
485
|
session(database: db).write_transaction(db: db, timeout: timeout, metadata: metadata, &)
|
|
486
486
|
end
|
|
487
487
|
|
|
488
|
-
# Asynchronously execute a read transaction.
|
|
489
|
-
def async_read_transaction(db: nil, timeout: nil, metadata: nil, &)
|
|
490
|
-
session(database: db).async_read_transaction(db: db, timeout: timeout, metadata: metadata, &)
|
|
491
|
-
end
|
|
492
|
-
|
|
493
|
-
# Asynchronously execute a write transaction.
|
|
494
|
-
def async_write_transaction(db: nil, timeout: nil, metadata: nil, &)
|
|
495
|
-
session(database: db).async_write_transaction(db: db, timeout: timeout, metadata: metadata, &)
|
|
496
|
-
end
|
|
497
488
|
|
|
498
489
|
# ────────────────────────────────────────────────────────────────────
|
|
499
490
|
# HEALTH AND VERSION DETECTION METHODS
|
|
@@ -40,19 +40,9 @@ module ActiveCypher
|
|
|
40
40
|
#
|
|
41
41
|
# @yieldparam session [Bolt::Session] The session to use
|
|
42
42
|
# @return [Object] The result of the block
|
|
43
|
-
def with_session(**kw)
|
|
43
|
+
def with_session(**kw, &block)
|
|
44
44
|
Sync do
|
|
45
|
-
|
|
46
|
-
conn.mark_used!
|
|
47
|
-
session = Bolt::Session.new(conn, **kw)
|
|
48
|
-
|
|
49
|
-
yield session
|
|
50
|
-
ensure
|
|
51
|
-
# Make sure any open transaction is cleaned up before returning the
|
|
52
|
-
# connection to the pool, so the next borrower doesn't inherit
|
|
53
|
-
# IN_TRANSACTION state.
|
|
54
|
-
session&.close
|
|
55
|
-
end
|
|
45
|
+
_acquire_session(**kw, &block)
|
|
56
46
|
end
|
|
57
47
|
rescue Async::TimeoutError => e
|
|
58
48
|
raise ActiveCypher::ConnectionError, "Connection pool timeout: #{e.message}"
|
|
@@ -60,6 +50,19 @@ module ActiveCypher
|
|
|
60
50
|
raise ActiveCypher::ConnectionError, "Connection error: #{e.message}"
|
|
61
51
|
end
|
|
62
52
|
|
|
53
|
+
# Asynchronously yields a Session. Each call acquires its own connection from the pool,
|
|
54
|
+
# making it safe for concurrent use across fibers.
|
|
55
|
+
#
|
|
56
|
+
# @yieldparam session [Bolt::Session] The session to use
|
|
57
|
+
# @return [Async::Task] A task that resolves to the block's result
|
|
58
|
+
def async_with_session(**kw, &block)
|
|
59
|
+
raise 'Cannot run async_with_session outside of an Async task' unless Async::Task.current?
|
|
60
|
+
|
|
61
|
+
Async do
|
|
62
|
+
_acquire_session(**kw, &block)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
63
66
|
# Checks if the database is alive, or just faking it for your benefit.
|
|
64
67
|
#
|
|
65
68
|
# @return [Boolean]
|
|
@@ -78,6 +81,25 @@ module ActiveCypher
|
|
|
78
81
|
|
|
79
82
|
private
|
|
80
83
|
|
|
84
|
+
# Internal: acquires a connection and yields a session.
|
|
85
|
+
# @yieldparam session [Bolt::Session]
|
|
86
|
+
# @return [Object] The result of the block
|
|
87
|
+
def _acquire_session(**kw)
|
|
88
|
+
@pool.acquire do |conn|
|
|
89
|
+
conn.mark_used!
|
|
90
|
+
session = Bolt::Session.new(conn, **kw)
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
yield session
|
|
94
|
+
ensure
|
|
95
|
+
# Make sure any open transaction is cleaned up before returning the
|
|
96
|
+
# connection to the pool, so the next borrower doesn't inherit
|
|
97
|
+
# IN_TRANSACTION state.
|
|
98
|
+
session&.close
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
81
103
|
# Builds a new connection, because the old one just wasn't good enough.
|
|
82
104
|
#
|
|
83
105
|
# @return [Connection]
|
|
@@ -11,11 +11,12 @@ module ActiveCypher
|
|
|
11
11
|
class AbstractBoltAdapter < AbstractAdapter
|
|
12
12
|
include Instrumentation
|
|
13
13
|
|
|
14
|
-
attr_reader :connection
|
|
14
|
+
attr_reader :connection, :driver
|
|
15
15
|
|
|
16
16
|
# Returns the raw Bolt connection object
|
|
17
17
|
# This is useful for accessing low-level connection methods like
|
|
18
|
-
# read_transaction, write_transaction,
|
|
18
|
+
# read_transaction, write_transaction, etc.
|
|
19
|
+
# NOTE: For concurrent async operations, use with_session or async_with_session instead.
|
|
19
20
|
def raw_connection
|
|
20
21
|
@connection
|
|
21
22
|
end
|
|
@@ -54,6 +55,18 @@ module ActiveCypher
|
|
|
54
55
|
}
|
|
55
56
|
end
|
|
56
57
|
|
|
58
|
+
# Create the driver with connection pool for concurrent operations
|
|
59
|
+
@driver = Bolt::Driver.new(
|
|
60
|
+
uri: "bolt://#{host}:#{port}",
|
|
61
|
+
adapter: self,
|
|
62
|
+
auth_token: auth,
|
|
63
|
+
pool_size: config.fetch(:pool_size, 10),
|
|
64
|
+
secure: ssl_params[:secure],
|
|
65
|
+
verify_cert: ssl_params[:verify_cert]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Also create a single connection for backwards compatibility
|
|
69
|
+
# This connection is used for simple synchronous operations
|
|
57
70
|
@connection = Bolt::Connection.new(
|
|
58
71
|
host, port, self,
|
|
59
72
|
auth_token: auth,
|
|
@@ -72,12 +85,34 @@ module ActiveCypher
|
|
|
72
85
|
# Clean disconnection. Resets the internal state.
|
|
73
86
|
def disconnect
|
|
74
87
|
instrument_connection(:disconnect) do
|
|
75
|
-
@
|
|
88
|
+
@driver&.close
|
|
89
|
+
@driver = nil
|
|
90
|
+
@connection&.close
|
|
76
91
|
@connection = nil
|
|
77
92
|
true
|
|
78
93
|
end
|
|
79
94
|
end
|
|
80
95
|
|
|
96
|
+
# Yields a Session from the connection pool. Safe for concurrent use.
|
|
97
|
+
# Each call acquires its own connection from the pool.
|
|
98
|
+
#
|
|
99
|
+
# @yieldparam session [Bolt::Session] The session to use
|
|
100
|
+
# @return [Object] The result of the block
|
|
101
|
+
def with_session(**kw, &block)
|
|
102
|
+
connect
|
|
103
|
+
@driver.with_session(**kw, &block)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Asynchronously yields a Session from the connection pool.
|
|
107
|
+
# Each call acquires its own connection, making it safe for concurrent fibers.
|
|
108
|
+
#
|
|
109
|
+
# @yieldparam session [Bolt::Session] The session to use
|
|
110
|
+
# @return [Async::Task] A task that resolves to the block's result
|
|
111
|
+
def async_with_session(**kw, &block)
|
|
112
|
+
connect
|
|
113
|
+
@driver.async_with_session(**kw, &block)
|
|
114
|
+
end
|
|
115
|
+
|
|
81
116
|
# Runs a Cypher query via Bolt session.
|
|
82
117
|
# Automatically handles connect, logs query, cleans up session. Very adult.
|
|
83
118
|
def run(cypher, params = {}, context: 'Query', db: nil, access_mode: :write)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'extension'
|
|
4
|
+
|
|
5
|
+
module ActiveCypher
|
|
6
|
+
module RailsLensExt
|
|
7
|
+
# Annotator for ActiveCypher graph models
|
|
8
|
+
# Discovers and annotates Node and Relationship classes
|
|
9
|
+
# Uses RailsLens-compatible TOML format and markers
|
|
10
|
+
class Annotator
|
|
11
|
+
# Use RailsLens-compatible marker format
|
|
12
|
+
MARKER_FORMAT = 'rails-lens:graph'
|
|
13
|
+
ANNOTATION_BEGIN = "# <#{MARKER_FORMAT}:begin>".freeze
|
|
14
|
+
ANNOTATION_END = "# <#{MARKER_FORMAT}:end>".freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Annotate all ActiveCypher models
|
|
18
|
+
# @param options [Hash] Options for annotation
|
|
19
|
+
# @option options [Boolean] :include_abstract Include abstract classes
|
|
20
|
+
# @option options [Array<String>] :only Only annotate these models
|
|
21
|
+
# @option options [Array<String>] :except Skip these models
|
|
22
|
+
# @return [Hash] Results with :annotated, :skipped, :failed keys
|
|
23
|
+
def annotate_all(options = {})
|
|
24
|
+
results = { annotated: [], skipped: [], failed: [] }
|
|
25
|
+
|
|
26
|
+
models = discover_models(options)
|
|
27
|
+
|
|
28
|
+
models.each do |model|
|
|
29
|
+
result = annotate_model(model, options)
|
|
30
|
+
case result[:status]
|
|
31
|
+
when :annotated
|
|
32
|
+
results[:annotated] << result
|
|
33
|
+
when :skipped
|
|
34
|
+
results[:skipped] << result
|
|
35
|
+
when :failed
|
|
36
|
+
results[:failed] << result
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
results
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Remove annotations from all ActiveCypher models
|
|
44
|
+
# @param options [Hash] Options for removal
|
|
45
|
+
# @return [Hash] Results with :removed, :skipped keys
|
|
46
|
+
def remove_all(options = {})
|
|
47
|
+
results = { removed: [], skipped: [] }
|
|
48
|
+
|
|
49
|
+
models = discover_models(options.merge(include_abstract: true))
|
|
50
|
+
|
|
51
|
+
models.each do |model|
|
|
52
|
+
result = remove_annotation(model)
|
|
53
|
+
if result[:status] == :removed
|
|
54
|
+
results[:removed] << result
|
|
55
|
+
else
|
|
56
|
+
results[:skipped] << result
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
results
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Annotate a single model
|
|
64
|
+
# @param model [Class] The model class to annotate
|
|
65
|
+
# @param options [Hash] Options
|
|
66
|
+
# @return [Hash] Result with :status, :model, :file, :message keys
|
|
67
|
+
def annotate_model(model, _options = {})
|
|
68
|
+
file_path = model_file_path(model)
|
|
69
|
+
|
|
70
|
+
return { status: :skipped, model: model.name, file: nil, message: 'File not found' } unless file_path && File.exist?(file_path)
|
|
71
|
+
|
|
72
|
+
extension = Extension.new(model)
|
|
73
|
+
annotation = extension.annotate
|
|
74
|
+
|
|
75
|
+
return { status: :skipped, model: model.name, file: file_path, message: 'No annotation generated' } unless annotation
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
write_annotation(file_path, model, annotation)
|
|
79
|
+
{ status: :annotated, model: model.name, file: file_path, message: 'Annotated successfully' }
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
{ status: :failed, model: model.name, file: file_path, message: e.message }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Remove annotation from a single model
|
|
86
|
+
# @param model [Class] The model class
|
|
87
|
+
# @return [Hash] Result with :status, :model, :file keys
|
|
88
|
+
def remove_annotation(model)
|
|
89
|
+
file_path = model_file_path(model)
|
|
90
|
+
|
|
91
|
+
return { status: :skipped, model: model.name, file: nil } unless file_path && File.exist?(file_path)
|
|
92
|
+
|
|
93
|
+
content = File.read(file_path)
|
|
94
|
+
|
|
95
|
+
if content.include?(ANNOTATION_BEGIN)
|
|
96
|
+
new_content = ::RailsLens::FileInsertionHelper.remove_after_frozen_string_literal(
|
|
97
|
+
content, '<rails-lens:graph:begin>', '<rails-lens:graph:end>'
|
|
98
|
+
)
|
|
99
|
+
new_content = new_content.gsub(/\n{3,}/, "\n\n")
|
|
100
|
+
|
|
101
|
+
File.write(file_path, new_content)
|
|
102
|
+
{ status: :removed, model: model.name, file: file_path }
|
|
103
|
+
else
|
|
104
|
+
{ status: :skipped, model: model.name, file: file_path }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Discover all ActiveCypher models
|
|
111
|
+
def discover_models(options = {})
|
|
112
|
+
# Eager load all graph models
|
|
113
|
+
eager_load_graph_models
|
|
114
|
+
|
|
115
|
+
models = []
|
|
116
|
+
|
|
117
|
+
# Find all Node classes (ActiveCypher::Base descendants)
|
|
118
|
+
if defined?(::ActiveCypher::Base)
|
|
119
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
120
|
+
next unless klass < ::ActiveCypher::Base
|
|
121
|
+
next if klass == ::ActiveCypher::Base
|
|
122
|
+
|
|
123
|
+
models << klass
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Find all Relationship classes (ActiveCypher::Relationship descendants)
|
|
128
|
+
if defined?(::ActiveCypher::Relationship)
|
|
129
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
130
|
+
next unless klass < ::ActiveCypher::Relationship
|
|
131
|
+
next if klass == ::ActiveCypher::Relationship
|
|
132
|
+
|
|
133
|
+
models << klass
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Filter out abstract classes unless requested
|
|
138
|
+
models.reject! { |m| m.respond_to?(:abstract_class?) && m.abstract_class? } unless options[:include_abstract]
|
|
139
|
+
|
|
140
|
+
# Filter by :only option
|
|
141
|
+
if options[:only]
|
|
142
|
+
only_names = Array(options[:only]).map(&:to_s)
|
|
143
|
+
models.select! { |m| only_names.include?(m.name) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Filter by :except option
|
|
147
|
+
if options[:except]
|
|
148
|
+
except_names = Array(options[:except]).map(&:to_s)
|
|
149
|
+
models.reject! { |m| except_names.include?(m.name) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
models.sort_by { |m| m.name || '' }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Eager load graph models from Rails app
|
|
156
|
+
def eager_load_graph_models
|
|
157
|
+
return unless defined?(Rails) && Rails.respond_to?(:root)
|
|
158
|
+
|
|
159
|
+
# Common paths for graph models
|
|
160
|
+
graph_paths = [
|
|
161
|
+
Rails.root.join('app', 'graph'),
|
|
162
|
+
Rails.root.join('app', 'models', 'graph'),
|
|
163
|
+
Rails.root.join('app', 'graphs')
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
graph_paths.each do |path|
|
|
167
|
+
next unless path.exist?
|
|
168
|
+
|
|
169
|
+
Dir.glob(path.join('**', '*.rb')).each do |file|
|
|
170
|
+
require file
|
|
171
|
+
rescue LoadError, StandardError => e
|
|
172
|
+
warn "[ActiveCypher] Failed to load #{file}: #{e.message}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get the file path for a model
|
|
178
|
+
def model_file_path(model)
|
|
179
|
+
# Try const_source_location first (Ruby 2.7+)
|
|
180
|
+
if model.respond_to?(:const_source_location)
|
|
181
|
+
location = Object.const_source_location(model.name)
|
|
182
|
+
return location&.first
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Fallback: try to find via instance method
|
|
186
|
+
if model.instance_methods(false).any?
|
|
187
|
+
method = model.instance_method(model.instance_methods(false).first)
|
|
188
|
+
return method.source_location&.first
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
nil
|
|
192
|
+
rescue StandardError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Write annotation to file using RailsLens FileInsertionHelper
|
|
197
|
+
def write_annotation(file_path, model, annotation)
|
|
198
|
+
annotation_block = build_annotation_block(annotation)
|
|
199
|
+
|
|
200
|
+
::RailsLens::FileInsertionHelper.insert_at_class_definition(
|
|
201
|
+
file_path,
|
|
202
|
+
model.name.split('::').last,
|
|
203
|
+
annotation_block
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Build the annotation block with markers
|
|
208
|
+
def build_annotation_block(annotation)
|
|
209
|
+
lines = [ANNOTATION_BEGIN]
|
|
210
|
+
annotation.each_line do |line|
|
|
211
|
+
content = line.chomp
|
|
212
|
+
lines << (content.empty? ? '#' : "# #{content}")
|
|
213
|
+
end
|
|
214
|
+
lines << ANNOTATION_END
|
|
215
|
+
lines.join("\n")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RailsLens extension for ActiveCypher graph models
|
|
4
|
+
# Provides annotation support for Node and Relationship classes
|
|
5
|
+
#
|
|
6
|
+
# This extension detects ActiveCypher models and generates annotations
|
|
7
|
+
# including labels, attributes, associations, and relationship metadata.
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
require 'rails_lens/extensions/base'
|
|
11
|
+
rescue LoadError
|
|
12
|
+
# RailsLens not available - define a stub Base class
|
|
13
|
+
module RailsLens
|
|
14
|
+
module Extensions
|
|
15
|
+
class Base
|
|
16
|
+
INTERFACE_VERSION = '1.0'
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def gem_name = raise(NotImplementedError)
|
|
20
|
+
def detect? = raise(NotImplementedError)
|
|
21
|
+
def interface_version = INTERFACE_VERSION
|
|
22
|
+
def compatible? = true
|
|
23
|
+
|
|
24
|
+
def gem_available?(name)
|
|
25
|
+
Gem::Specification.find_by_name(name)
|
|
26
|
+
true
|
|
27
|
+
rescue Gem::LoadError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :model_class
|
|
33
|
+
|
|
34
|
+
def initialize(model_class)
|
|
35
|
+
@model_class = model_class
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def annotate = nil
|
|
39
|
+
def notes = []
|
|
40
|
+
def erd_additions = { relationships: [], badges: [], attributes: {} }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module ActiveCypher
|
|
47
|
+
# RailsLens extension module for annotating ActiveCypher graph models
|
|
48
|
+
#
|
|
49
|
+
# Detects and annotates:
|
|
50
|
+
# - Node classes (inheriting from ActiveCypher::Base)
|
|
51
|
+
# - Relationship classes (inheriting from ActiveCypher::Relationship)
|
|
52
|
+
#
|
|
53
|
+
# Generates annotations for:
|
|
54
|
+
# - Graph labels
|
|
55
|
+
# - Attributes with types
|
|
56
|
+
# - Associations (has_many, belongs_to, has_one)
|
|
57
|
+
# - Relationship endpoints and types
|
|
58
|
+
# - Connection configuration
|
|
59
|
+
module RailsLensExt
|
|
60
|
+
class Extension < ::RailsLens::Extensions::Base
|
|
61
|
+
INTERFACE_VERSION = '1.0'
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
def gem_name
|
|
65
|
+
'activecypher'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def detect?
|
|
69
|
+
return false unless gem_available?(gem_name)
|
|
70
|
+
|
|
71
|
+
# Ensure ActiveCypher is loaded
|
|
72
|
+
require 'activecypher' unless defined?(::ActiveCypher::Base)
|
|
73
|
+
true
|
|
74
|
+
rescue LoadError
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Generate annotation string for ActiveCypher models
|
|
80
|
+
def annotate
|
|
81
|
+
return nil unless active_cypher_model?
|
|
82
|
+
|
|
83
|
+
lines = []
|
|
84
|
+
|
|
85
|
+
if node_class?
|
|
86
|
+
lines.concat(node_annotation_lines)
|
|
87
|
+
elsif relationship_class?
|
|
88
|
+
lines.concat(relationship_annotation_lines)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
return nil if lines.empty?
|
|
92
|
+
|
|
93
|
+
lines.join("\n")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Generate analysis notes for best practices
|
|
97
|
+
def notes
|
|
98
|
+
return [] unless active_cypher_model?
|
|
99
|
+
|
|
100
|
+
notes = []
|
|
101
|
+
|
|
102
|
+
if node_class?
|
|
103
|
+
notes.concat(node_notes)
|
|
104
|
+
elsif relationship_class?
|
|
105
|
+
notes.concat(relationship_notes)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
notes
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Generate ERD additions for graph visualization
|
|
112
|
+
def erd_additions
|
|
113
|
+
return default_erd_additions unless active_cypher_model?
|
|
114
|
+
|
|
115
|
+
if node_class?
|
|
116
|
+
node_erd_additions
|
|
117
|
+
elsif relationship_class?
|
|
118
|
+
relationship_erd_additions
|
|
119
|
+
else
|
|
120
|
+
default_erd_additions
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def default_erd_additions
|
|
127
|
+
{ relationships: [], badges: [], attributes: {} }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ============================================================
|
|
131
|
+
# Detection Methods
|
|
132
|
+
# ============================================================
|
|
133
|
+
|
|
134
|
+
def active_cypher_model?
|
|
135
|
+
node_class? || relationship_class?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def node_class?
|
|
139
|
+
return false unless defined?(::ActiveCypher::Base)
|
|
140
|
+
|
|
141
|
+
model_class < ::ActiveCypher::Base
|
|
142
|
+
rescue StandardError
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def relationship_class?
|
|
147
|
+
return false unless defined?(::ActiveCypher::Relationship)
|
|
148
|
+
|
|
149
|
+
model_class < ::ActiveCypher::Relationship
|
|
150
|
+
rescue StandardError
|
|
151
|
+
false
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def abstract_class?
|
|
155
|
+
model_class.respond_to?(:abstract_class?) && model_class.abstract_class?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ============================================================
|
|
159
|
+
# Node Annotation (TOML format)
|
|
160
|
+
# ============================================================
|
|
161
|
+
|
|
162
|
+
def node_annotation_lines
|
|
163
|
+
lines = []
|
|
164
|
+
lines << 'model_type = "node"'
|
|
165
|
+
lines << 'abstract = true' if abstract_class?
|
|
166
|
+
|
|
167
|
+
# Labels
|
|
168
|
+
if model_class.respond_to?(:labels) && model_class.labels.any?
|
|
169
|
+
labels = model_class.labels.map { |l| "\"#{l}\"" }.join(', ')
|
|
170
|
+
lines << "labels = [#{labels}]"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Attributes
|
|
174
|
+
lines.concat(attribute_lines)
|
|
175
|
+
|
|
176
|
+
# Associations
|
|
177
|
+
lines.concat(association_lines) if model_class.respond_to?(:_reflections)
|
|
178
|
+
|
|
179
|
+
# Connection info
|
|
180
|
+
lines.concat(connection_lines)
|
|
181
|
+
|
|
182
|
+
lines
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# ============================================================
|
|
186
|
+
# Relationship Annotation (TOML format)
|
|
187
|
+
# ============================================================
|
|
188
|
+
|
|
189
|
+
def relationship_annotation_lines
|
|
190
|
+
lines = []
|
|
191
|
+
lines << 'model_type = "relationship"'
|
|
192
|
+
lines << 'abstract = true' if abstract_class?
|
|
193
|
+
|
|
194
|
+
# Relationship type
|
|
195
|
+
lines << "type = \"#{model_class.relationship_type}\"" if model_class.respond_to?(:relationship_type) && model_class.relationship_type
|
|
196
|
+
|
|
197
|
+
# Endpoints
|
|
198
|
+
lines << "from_class = \"#{model_class.from_class_name}\"" if model_class.respond_to?(:from_class_name) && model_class.from_class_name
|
|
199
|
+
|
|
200
|
+
lines << "to_class = \"#{model_class.to_class_name}\"" if model_class.respond_to?(:to_class_name) && model_class.to_class_name
|
|
201
|
+
|
|
202
|
+
# Node base class (for connection delegation)
|
|
203
|
+
lines << "node_base_class = \"#{model_class._node_base_class.name}\"" if model_class.respond_to?(:node_base_class) && model_class._node_base_class
|
|
204
|
+
|
|
205
|
+
# Attributes
|
|
206
|
+
lines.concat(attribute_lines)
|
|
207
|
+
|
|
208
|
+
# Connection info
|
|
209
|
+
lines.concat(connection_lines)
|
|
210
|
+
|
|
211
|
+
lines
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# ============================================================
|
|
215
|
+
# Shared Annotation Helpers (TOML format)
|
|
216
|
+
# ============================================================
|
|
217
|
+
|
|
218
|
+
def attribute_lines
|
|
219
|
+
lines = []
|
|
220
|
+
|
|
221
|
+
return lines unless model_class.respond_to?(:attribute_types)
|
|
222
|
+
|
|
223
|
+
attrs = model_class.attribute_types.except('internal_id')
|
|
224
|
+
return lines if attrs.empty?
|
|
225
|
+
|
|
226
|
+
lines << ''
|
|
227
|
+
attr_entries = attrs.map do |name, type|
|
|
228
|
+
type_name = type.class.name.demodulize.underscore.sub(/_type$/, '')
|
|
229
|
+
"{ name = \"#{name}\", type = \"#{type_name}\" }"
|
|
230
|
+
end
|
|
231
|
+
lines << "attributes = [#{attr_entries.join(', ')}]"
|
|
232
|
+
|
|
233
|
+
lines
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def association_lines
|
|
237
|
+
lines = []
|
|
238
|
+
reflections = model_class._reflections
|
|
239
|
+
|
|
240
|
+
return lines if reflections.empty?
|
|
241
|
+
|
|
242
|
+
lines << ''
|
|
243
|
+
lines << '[associations]'
|
|
244
|
+
|
|
245
|
+
reflections.each do |name, opts|
|
|
246
|
+
macro = opts[:macro]
|
|
247
|
+
target = opts[:class_name]
|
|
248
|
+
rel_type = opts[:relationship]
|
|
249
|
+
direction = opts[:direction]
|
|
250
|
+
|
|
251
|
+
parts = ["macro = \"#{macro}\""]
|
|
252
|
+
parts << "class = \"#{target}\"" if target
|
|
253
|
+
parts << "rel = \"#{rel_type}\"" if rel_type
|
|
254
|
+
parts << "direction = \"#{direction}\"" if direction && direction != :out
|
|
255
|
+
|
|
256
|
+
if opts[:through]
|
|
257
|
+
parts << "through = \"#{opts[:through]}\""
|
|
258
|
+
parts << "source = \"#{opts[:source]}\"" if opts[:source]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
parts << "relationship_class = \"#{opts[:relationship_class]}\"" if opts[:relationship_class]
|
|
262
|
+
|
|
263
|
+
lines << "#{name} = { #{parts.join(', ')} }"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
lines
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def connection_lines
|
|
270
|
+
lines = []
|
|
271
|
+
|
|
272
|
+
return lines unless model_class.respond_to?(:connects_to_mappings)
|
|
273
|
+
|
|
274
|
+
mappings = model_class.connects_to_mappings
|
|
275
|
+
return lines if mappings.nil? || mappings.empty?
|
|
276
|
+
|
|
277
|
+
lines << ''
|
|
278
|
+
lines << '[connection]'
|
|
279
|
+
|
|
280
|
+
mappings.each do |role, db_key|
|
|
281
|
+
lines << "#{role} = \"#{db_key}\""
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
lines
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# ============================================================
|
|
288
|
+
# Node Notes (Best Practices)
|
|
289
|
+
# ============================================================
|
|
290
|
+
|
|
291
|
+
def node_notes
|
|
292
|
+
notes = []
|
|
293
|
+
|
|
294
|
+
# Check for missing labels
|
|
295
|
+
notes << "[activecypher] #{model_class.name}: No labels defined" if model_class.respond_to?(:labels) && model_class.labels.empty?
|
|
296
|
+
|
|
297
|
+
# Check for models without attributes (besides internal_id)
|
|
298
|
+
if model_class.respond_to?(:attribute_types)
|
|
299
|
+
user_attrs = model_class.attribute_types.except('internal_id')
|
|
300
|
+
notes << "[activecypher] #{model_class.name}: No attributes defined" if user_attrs.empty? && !abstract_class?
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Check for potential N+1 patterns in associations
|
|
304
|
+
if model_class.respond_to?(:_reflections)
|
|
305
|
+
has_many_count = model_class._reflections.count { |_, r| r[:macro] == :has_many }
|
|
306
|
+
notes << "[activecypher] #{model_class.name}: #{has_many_count} has_many associations - consider eager loading" if has_many_count > 3
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
notes
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# ============================================================
|
|
313
|
+
# Relationship Notes (Best Practices)
|
|
314
|
+
# ============================================================
|
|
315
|
+
|
|
316
|
+
def relationship_notes
|
|
317
|
+
notes = []
|
|
318
|
+
|
|
319
|
+
# Check for missing endpoints
|
|
320
|
+
unless abstract_class?
|
|
321
|
+
if !model_class.respond_to?(:from_class_name) || model_class.from_class_name.nil?
|
|
322
|
+
notes << "[activecypher] #{model_class.name}: Missing from_class definition"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
if !model_class.respond_to?(:to_class_name) || model_class.to_class_name.nil?
|
|
326
|
+
notes << "[activecypher] #{model_class.name}: Missing to_class definition"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
if !model_class.respond_to?(:relationship_type) || model_class.relationship_type.nil?
|
|
330
|
+
notes << "[activecypher] #{model_class.name}: Missing relationship type definition"
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
notes
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# ============================================================
|
|
338
|
+
# ERD Additions
|
|
339
|
+
# ============================================================
|
|
340
|
+
|
|
341
|
+
def node_erd_additions
|
|
342
|
+
badges = ['graph-node']
|
|
343
|
+
badges << 'abstract' if abstract_class?
|
|
344
|
+
|
|
345
|
+
relationships = []
|
|
346
|
+
|
|
347
|
+
# Add relationships from associations
|
|
348
|
+
if model_class.respond_to?(:_reflections)
|
|
349
|
+
model_class._reflections.each_value do |opts|
|
|
350
|
+
rel = {
|
|
351
|
+
type: opts[:macro].to_s,
|
|
352
|
+
from: model_class.name,
|
|
353
|
+
to: opts[:class_name],
|
|
354
|
+
label: opts[:relationship],
|
|
355
|
+
style: opts[:macro] == :has_many ? 'solid' : 'dashed',
|
|
356
|
+
direction: opts[:direction]
|
|
357
|
+
}
|
|
358
|
+
relationships << rel
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
{
|
|
363
|
+
relationships: relationships,
|
|
364
|
+
badges: badges,
|
|
365
|
+
attributes: {
|
|
366
|
+
model_type: 'node',
|
|
367
|
+
labels: model_class.respond_to?(:labels) ? model_class.labels : []
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def relationship_erd_additions
|
|
373
|
+
badges = ['graph-relationship']
|
|
374
|
+
badges << 'abstract' if abstract_class?
|
|
375
|
+
|
|
376
|
+
relationships = []
|
|
377
|
+
|
|
378
|
+
# Add the relationship edge
|
|
379
|
+
if model_class.respond_to?(:from_class_name) &&
|
|
380
|
+
model_class.respond_to?(:to_class_name) &&
|
|
381
|
+
model_class.from_class_name &&
|
|
382
|
+
model_class.to_class_name
|
|
383
|
+
|
|
384
|
+
relationships << {
|
|
385
|
+
type: 'edge',
|
|
386
|
+
from: model_class.from_class_name,
|
|
387
|
+
to: model_class.to_class_name,
|
|
388
|
+
label: model_class.relationship_type || 'RELATED',
|
|
389
|
+
style: 'bold',
|
|
390
|
+
model: model_class.name
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
{
|
|
395
|
+
relationships: relationships,
|
|
396
|
+
badges: badges,
|
|
397
|
+
attributes: {
|
|
398
|
+
model_type: 'relationship',
|
|
399
|
+
relationship_type: model_class.respond_to?(:relationship_type) ? model_class.relationship_type : nil
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Register the extension with RailsLens for gem-based auto-discovery
|
|
407
|
+
# RailsLens looks for GemName::RailsLensExtension constant
|
|
408
|
+
RailsLensExtension = RailsLensExt::Extension
|
|
409
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Only define model source if RailsLens is available
|
|
4
|
+
# This file is loaded conditionally via railtie, not via autoload
|
|
5
|
+
return unless defined?(RailsLens::ModelSource)
|
|
6
|
+
|
|
7
|
+
require_relative 'annotator'
|
|
8
|
+
|
|
9
|
+
module ActiveCypher
|
|
10
|
+
module RailsLensExt
|
|
11
|
+
# Model source for ActiveCypher graph models
|
|
12
|
+
# Provides integration with RailsLens annotation system
|
|
13
|
+
class ModelSource < ::RailsLens::ModelSource
|
|
14
|
+
class << self
|
|
15
|
+
def models(options = {})
|
|
16
|
+
Annotator.send(:discover_models, options)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def file_patterns
|
|
20
|
+
['app/graph/**/*.rb', 'app/models/graph/**/*.rb', 'app/graphs/**/*.rb']
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def annotate_model(model, options = {})
|
|
24
|
+
Annotator.annotate_model(model, options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def remove_annotation(model)
|
|
28
|
+
Annotator.remove_annotation(model)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def source_name
|
|
32
|
+
'ActiveCypher Graph'
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Register for auto-discovery by RailsLens (for gems with conventional names)
|
|
39
|
+
RailsLensModelSource = RailsLensExt::ModelSource
|
|
40
|
+
|
|
41
|
+
# Explicitly register with RailsLens (gem name 'activecypher' doesn't match 'ActiveCypher')
|
|
42
|
+
::RailsLens::ModelSourceLoader.register(RailsLensExt::ModelSource)
|
|
43
|
+
end
|
|
@@ -60,6 +60,11 @@ module ActiveCypher
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Load RailsLens integration if RailsLens is available
|
|
64
|
+
initializer 'active_cypher.rails_lens_integration', after: :load_config_initializers do
|
|
65
|
+
require 'active_cypher/rails_lens_ext/model_source' if defined?(::RailsLens::ModelSource)
|
|
66
|
+
end
|
|
67
|
+
|
|
63
68
|
generators do
|
|
64
69
|
require 'active_cypher/generators/install_generator'
|
|
65
70
|
require 'active_cypher/generators/node_generator'
|
|
@@ -70,6 +75,7 @@ module ActiveCypher
|
|
|
70
75
|
rake_tasks do
|
|
71
76
|
load File.expand_path('../tasks/graphdb_migrate.rake', __dir__)
|
|
72
77
|
load File.expand_path('../tasks/graphdb_schema.rake', __dir__)
|
|
78
|
+
# Standalone annotation task no longer needed - RailsLens handles it
|
|
73
79
|
end
|
|
74
80
|
end
|
|
75
81
|
end
|
|
@@ -129,6 +129,10 @@ module ActiveCypher
|
|
|
129
129
|
# Prevent subclasses from overriding node_base_class
|
|
130
130
|
def inherited(subclass)
|
|
131
131
|
super
|
|
132
|
+
# Reset abstract_class for subclasses (mirrors Model::Abstract behavior
|
|
133
|
+
# which gets overridden by this method definition)
|
|
134
|
+
subclass.abstract_class = false
|
|
135
|
+
|
|
132
136
|
return unless _node_base_class
|
|
133
137
|
|
|
134
138
|
subclass._node_base_class = _node_base_class
|
data/lib/activecypher.rb
CHANGED
|
@@ -100,6 +100,7 @@ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
|
|
|
100
100
|
loader.ignore("#{__dir__}/active_cypher/version.rb")
|
|
101
101
|
loader.ignore("#{__dir__}/active_cypher/railtie.rb")
|
|
102
102
|
loader.ignore("#{__dir__}/active_cypher/generators")
|
|
103
|
+
loader.ignore("#{__dir__}/active_cypher/rails_lens_ext")
|
|
103
104
|
loader.ignore("#{__dir__}/activecypher.rb")
|
|
104
105
|
loader.ignore("#{__dir__}/cyrel.rb")
|
|
105
106
|
loader.inflector.inflect(
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activecypher
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -93,6 +93,34 @@ dependencies:
|
|
|
93
93
|
- - "~>"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '0.6'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: async-safe
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: rails_lens
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
type: :development
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '0'
|
|
96
124
|
description: OpenCypher Adapter ala ActiveRecord
|
|
97
125
|
email:
|
|
98
126
|
- seuros@pre-history.com
|
|
@@ -157,6 +185,9 @@ files:
|
|
|
157
185
|
- lib/active_cypher/model/labelling.rb
|
|
158
186
|
- lib/active_cypher/model/persistence.rb
|
|
159
187
|
- lib/active_cypher/model/querying.rb
|
|
188
|
+
- lib/active_cypher/rails_lens_ext/annotator.rb
|
|
189
|
+
- lib/active_cypher/rails_lens_ext/extension.rb
|
|
190
|
+
- lib/active_cypher/rails_lens_ext/model_source.rb
|
|
160
191
|
- lib/active_cypher/railtie.rb
|
|
161
192
|
- lib/active_cypher/redaction.rb
|
|
162
193
|
- lib/active_cypher/relation.rb
|