activecypher 0.12.2 → 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/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 +18 -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
|
|
@@ -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
|
|
@@ -107,6 +107,20 @@ dependencies:
|
|
|
107
107
|
- - ">="
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
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'
|
|
110
124
|
description: OpenCypher Adapter ala ActiveRecord
|
|
111
125
|
email:
|
|
112
126
|
- seuros@pre-history.com
|
|
@@ -171,6 +185,9 @@ files:
|
|
|
171
185
|
- lib/active_cypher/model/labelling.rb
|
|
172
186
|
- lib/active_cypher/model/persistence.rb
|
|
173
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
|
|
174
191
|
- lib/active_cypher/railtie.rb
|
|
175
192
|
- lib/active_cypher/redaction.rb
|
|
176
193
|
- lib/active_cypher/relation.rb
|