activecypher 0.12.2 → 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29fa2252b85c3c67d4f8a8e72acc69ad1021088561ce02c8e1d205bef28b4069
4
- data.tar.gz: 0665ef0b5e7a68264f50b846ba78036dc5d3c805e874bc475abb12a3bb699069
3
+ metadata.gz: 614049312045dc138ca5e6aa6950333aa16936ed7f271c0dde0939971b8a4d9d
4
+ data.tar.gz: 5fd731b9d85a3b45ceb5a27fb08b86904fd4adebe95aeb928cff7da3a4f556b5
5
5
  SHA512:
6
- metadata.gz: 13b575ead30e4ea3b3476e97993c33e8eebc2dfb788d10e7ad9aa8e02014f389581d3e619f50629611340ea33af2bae967b026cd07110de57586887919ff89f3
7
- data.tar.gz: a9dd762af2674169b558739ca5652e21cfcb60adb8a491c5d0bdf22b64a19b45c1087858db972a71f26af9113ee146e67ef57188e6f273adef959ca369a407d3
6
+ metadata.gz: df907144cb86d4ba2bc10e135a71e38979a91faeaa01b1ed231841626c499f19564dbaf327df5445d7338dd7849b09196fafd42155a20e8abe51397d839c3720
7
+ data.tar.gz: c4879e2edc165ea074510a759878ae99a95d12e6f74b7d4f21a0846bfcf6eedbbfe08c9ba213211ca748dbcde3e7fc2f4d8720857452376edba5c225a90e32c9
@@ -213,8 +213,13 @@ module ActiveCypher
213
213
 
214
214
  def protocol_handler_class = ProtocolHandler
215
215
 
216
+ # Memgraph 3.7+ is expected. Earlier versions are untested and may not work.
217
+ # See: https://memgraph.com/docs for version-specific features.
218
+ MINIMUM_VERSION = '3.7'
219
+
216
220
  def validate_connection
217
- raise ActiveCypher::ConnectionError, "Server at #{config[:uri]} is not Memgraph" unless connection.server_agent.to_s.include?('Memgraph')
221
+ agent = connection.server_agent.to_s
222
+ raise ActiveCypher::ConnectionError, "Server at #{config[:uri]} is not Memgraph" unless agent.include?('Memgraph')
218
223
 
219
224
  true
220
225
  end
@@ -28,10 +28,26 @@ module ActiveCypher
28
28
 
29
29
  # DSL ---------------------------------------------------------------
30
30
 
31
- def create_node_index(label, *props, unique: false, if_not_exists: true, name: nil)
31
+ # Create a node property index.
32
+ # @param label [Symbol, String] Node label
33
+ # @param props [Array<Symbol>] Properties to index
34
+ # @param unique [Boolean] Create unique index (Neo4j only)
35
+ # @param if_not_exists [Boolean] Add IF NOT EXISTS clause (Neo4j only)
36
+ # @param name [String] Index name (Neo4j only)
37
+ # @param composite [Boolean] Create composite index (Memgraph 3.2+). Default true for multiple props.
38
+ def create_node_index(label, *props, unique: false, if_not_exists: true, name: nil, composite: nil)
39
+ # Default composite to true when multiple properties provided
40
+ composite = props.size > 1 if composite.nil?
41
+
32
42
  cypher = if connection.vendor == :memgraph
33
- # Memgraph syntax: CREATE INDEX ON :Label(prop)
34
- props.map { |p| "CREATE INDEX ON :#{label}(#{p})" }
43
+ if composite && props.size > 1
44
+ # Memgraph 3.2+ composite index: CREATE INDEX ON :Label(prop1, prop2)
45
+ props_list = props.join(', ')
46
+ ["CREATE INDEX ON :#{label}(#{props_list})"]
47
+ else
48
+ # Memgraph single property indexes
49
+ props.map { |p| "CREATE INDEX ON :#{label}(#{p})" }
50
+ end
35
51
  else
36
52
  # Neo4j syntax
37
53
  props_clause = props.map { |p| "n.#{p}" }.join(', ')
@@ -46,10 +62,23 @@ module ActiveCypher
46
62
  operations.concat(Array(cypher))
47
63
  end
48
64
 
49
- def create_rel_index(rel_type, *props, if_not_exists: true, name: nil)
65
+ # Create a relationship property index.
66
+ # @param rel_type [Symbol, String] Relationship type
67
+ # @param props [Array<Symbol>] Properties to index
68
+ # @param if_not_exists [Boolean] Add IF NOT EXISTS clause (Neo4j only)
69
+ # @param name [String] Index name (Neo4j only)
70
+ # @param composite [Boolean] Create composite index (Memgraph 3.2+). Default true for multiple props.
71
+ def create_rel_index(rel_type, *props, if_not_exists: true, name: nil, composite: nil)
72
+ composite = props.size > 1 if composite.nil?
73
+
50
74
  cypher = if connection.vendor == :memgraph
51
- # Memgraph syntax: CREATE EDGE INDEX ON :REL_TYPE(prop)
52
- props.map { |p| "CREATE EDGE INDEX ON :#{rel_type}(#{p})" }
75
+ if composite && props.size > 1
76
+ # Memgraph 3.2+ composite edge index
77
+ props_list = props.join(', ')
78
+ ["CREATE EDGE INDEX ON :#{rel_type}(#{props_list})"]
79
+ else
80
+ props.map { |p| "CREATE EDGE INDEX ON :#{rel_type}(#{p})" }
81
+ end
53
82
  else
54
83
  # Neo4j syntax
55
84
  props_clause = props.map { |p| "r.#{p}" }.join(', ')
@@ -99,6 +128,94 @@ module ActiveCypher
99
128
  operations.concat(Array(cypher))
100
129
  end
101
130
 
131
+ # Create a vector index (Memgraph 3.4+, Neo4j 5.0+).
132
+ # @param name [String] Index name
133
+ # @param label [Symbol, String] Node label
134
+ # @param property [Symbol] Property containing vector embeddings
135
+ # @param dimension [Integer] Vector dimension (required)
136
+ # @param metric [Symbol] Distance metric: :cosine, :euclidean, :dot_product (default: :cosine)
137
+ # @param quantization [Symbol] Quantization type for memory reduction (Memgraph 3.4+): :scalar, nil
138
+ def create_vector_index(name, label, property, dimension:, metric: :cosine, quantization: nil)
139
+ cypher = if connection.vendor == :memgraph
140
+ config = { dimension: dimension, metric: metric.to_s }
141
+ config[:scalar_kind] = 'f32' if quantization == :scalar
142
+ config_str = config.map { |k, v| "#{k}: #{v.is_a?(String) ? "'#{v}'" : v}" }.join(', ')
143
+ "CREATE VECTOR INDEX #{name} ON :#{label}(#{property}) WITH CONFIG { #{config_str} }"
144
+ else
145
+ # Neo4j syntax
146
+ options = { indexConfig: { 'vector.dimensions' => dimension, 'vector.similarity_function' => metric.to_s.upcase } }
147
+ opts_str = options.to_json.gsub('"', "'")
148
+ "CREATE VECTOR INDEX #{name} IF NOT EXISTS FOR (n:#{label}) ON (n.#{property}) OPTIONS #{opts_str}"
149
+ end
150
+ operations << cypher
151
+ end
152
+
153
+ # Create a vector index on relationships (Memgraph 3.4+, Neo4j 2025+).
154
+ # @param name [String] Index name
155
+ # @param rel_type [Symbol, String] Relationship type
156
+ # @param property [Symbol] Property containing vector embeddings
157
+ # @param dimension [Integer] Vector dimension (required)
158
+ # @param metric [Symbol] Distance metric: :cosine, :euclidean, :dot_product (default: :cosine)
159
+ def create_vector_rel_index(name, rel_type, property, dimension:, metric: :cosine)
160
+ cypher = if connection.vendor == :memgraph
161
+ config_str = "dimension: #{dimension}, metric: '#{metric}'"
162
+ "CREATE VECTOR EDGE INDEX #{name} ON :#{rel_type}(#{property}) WITH CONFIG { #{config_str} }"
163
+ else
164
+ # Neo4j 2025+ syntax
165
+ "CREATE VECTOR INDEX #{name} IF NOT EXISTS FOR ()-[r:#{rel_type}]-() ON (r.#{property}) " \
166
+ "OPTIONS { indexConfig: { `vector.dimensions`: #{dimension}, `vector.similarity_function`: '#{metric}' } }"
167
+ end
168
+ operations << cypher
169
+ end
170
+
171
+ # Alias for backwards compatibility
172
+ alias create_vector_edge_index create_vector_rel_index
173
+
174
+ # Create a text index on edges (Memgraph 3.6+ only).
175
+ # Neo4j fulltext indexes on relationships use different syntax via create_fulltext_rel_index.
176
+ # @param name [String] Index name
177
+ # @param rel_type [Symbol, String] Relationship type
178
+ # @param props [Array<Symbol>] Properties to index
179
+ def create_text_edge_index(name, rel_type, *props)
180
+ raise NotImplementedError, 'Text edge indexes only supported on Memgraph 3.6+' unless connection.vendor == :memgraph
181
+
182
+ props.each do |p|
183
+ index_name = props.size > 1 ? "#{name}_#{p}" : name.to_s
184
+ operations << "CREATE TEXT EDGE INDEX #{index_name} ON :#{rel_type}(#{p})"
185
+ end
186
+ end
187
+
188
+ # Create a fulltext index on relationships (Neo4j only).
189
+ # @param name [String] Index name
190
+ # @param rel_type [Symbol, String] Relationship type
191
+ # @param props [Array<Symbol>] Properties to index
192
+ # @param if_not_exists [Boolean] Add IF NOT EXISTS clause
193
+ def create_fulltext_rel_index(name, rel_type, *props, if_not_exists: true)
194
+ raise NotImplementedError, 'Fulltext relationship indexes only supported on Neo4j' unless connection.vendor == :neo4j
195
+
196
+ props_clause = props.map { |p| "r.#{p}" }.join(', ')
197
+ c = +"CREATE FULLTEXT INDEX #{name}"
198
+ c << ' IF NOT EXISTS' if if_not_exists
199
+ c << " FOR ()-[r:#{rel_type}]-() ON EACH [#{props_clause}]"
200
+ operations << c
201
+ end
202
+
203
+ # Drop all indexes (Memgraph 3.6+ only).
204
+ # Neo4j requires dropping indexes individually.
205
+ def drop_all_indexes
206
+ raise NotImplementedError, 'drop_all_indexes only supported on Memgraph 3.6+' unless connection.vendor == :memgraph
207
+
208
+ operations << 'DROP ALL INDEXES'
209
+ end
210
+
211
+ # Drop all constraints (Memgraph 3.6+ only).
212
+ # Neo4j requires dropping constraints individually.
213
+ def drop_all_constraints
214
+ raise NotImplementedError, 'drop_all_constraints only supported on Memgraph 3.6+' unless connection.vendor == :memgraph
215
+
216
+ operations << 'DROP ALL CONSTRAINTS'
217
+ end
218
+
102
219
  def execute(cypher_string)
103
220
  operations << cypher_string.strip
104
221
  end
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveCypher
4
- VERSION = '0.12.2'
4
+ VERSION = '0.14.0'
5
5
 
6
6
  def self.gem_version
7
7
  Gem::Version.new VERSION
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(
@@ -35,5 +35,38 @@ module Cyrel
35
35
  "EXISTS(#{rendered_pattern})"
36
36
  end
37
37
  end
38
+
39
+ # Represents an EXISTS { MATCH ... WHERE ... } subquery predicate (Memgraph 3.5+).
40
+ # Allows full subquery syntax inside EXISTS block.
41
+ #
42
+ # @example
43
+ # Cyrel.exists_block { match(Cyrel.node(:a) > Cyrel.rel(:r) > Cyrel.node(:b)); where(Cyrel.prop(:b, :active) == true) }
44
+ # # => EXISTS { MATCH (a)-[r]->(b) WHERE b.active = $p1 }
45
+ class ExistsBlock < Base
46
+ attr_reader :subquery
47
+
48
+ # @param subquery [Cyrel::Query] A query object representing the subquery.
49
+ def initialize(subquery)
50
+ raise ArgumentError, 'ExistsBlock requires a Cyrel::Query' unless subquery.is_a?(Cyrel::Query)
51
+
52
+ @subquery = subquery
53
+ end
54
+
55
+ # Renders the EXISTS { ... } expression.
56
+ # @param query [Cyrel::Query] The parent query for parameter merging.
57
+ # @return [String] The Cypher string fragment.
58
+ def render(query)
59
+ inner_cypher, inner_params = @subquery.to_cypher
60
+
61
+ # Merge subquery parameters into the parent query
62
+ # The inner query uses its own parameter keys, we need to register values
63
+ # which will get new keys in the parent query
64
+ inner_params.each_value do |value|
65
+ query.register_parameter(value)
66
+ end
67
+
68
+ "EXISTS { #{inner_cypher} }"
69
+ end
70
+ end
38
71
  end
39
72
  end
@@ -12,14 +12,16 @@ module Cyrel
12
12
 
13
13
  attribute :alias_name, Cyrel::Types::SymbolType.new
14
14
  attribute :labels, array: :string, default: []
15
+ attribute :or_labels, array: :string, default: [] # Memgraph 3.2+: (n:Label1|Label2)
15
16
  attribute :properties, Cyrel::Types::HashType.new, default: -> { {} }
16
17
 
17
18
  validates :alias_name, presence: true
18
19
 
19
- def initialize(alias_name, labels: nil, properties: {}, **kw)
20
+ def initialize(alias_name, labels: nil, or_labels: nil, properties: {}, **kw)
20
21
  super(
21
22
  { alias_name: alias_name,
22
23
  labels: Array(labels).compact.flatten,
24
+ or_labels: Array(or_labels).compact.flatten,
23
25
  properties: properties }.merge(kw)
24
26
  )
25
27
  end
@@ -38,7 +40,14 @@ module Cyrel
38
40
 
39
41
  def render(query)
40
42
  base = +"(#{alias_name}"
41
- base << ':' << labels.join(':') unless labels.empty?
43
+
44
+ # OR labels take precedence (Memgraph 3.2+: n:Label1|Label2)
45
+ if or_labels.any?
46
+ base << ':' << or_labels.join('|')
47
+ elsif labels.any?
48
+ base << ':' << labels.join(':')
49
+ end
50
+
42
51
  unless properties.empty?
43
52
  params = properties.with_indifferent_access
44
53
  formatted = params.map do |k, v|
@@ -60,6 +69,7 @@ module Cyrel
60
69
  def freeze
61
70
  super
62
71
  labels.freeze
72
+ or_labels.freeze
63
73
  properties.freeze
64
74
  end
65
75
 
data/lib/cyrel.rb CHANGED
@@ -11,8 +11,9 @@ module Cyrel
11
11
 
12
12
  # Cyrel DSL helper: alias for node creation.
13
13
  # Example: Cyrel.n(:person, :Person, name: 'Alice')
14
- def n(alias_name = nil, *labels, **properties)
15
- Pattern::Node.new(alias_name, labels: labels, properties: properties)
14
+ # Example: Cyrel.n(:n, or_labels: [:Person, :Organization]) # Memgraph 3.2+
15
+ def n(alias_name = nil, *labels, or_labels: nil, **properties)
16
+ Pattern::Node.new(alias_name, labels: labels, or_labels: or_labels, properties: properties)
16
17
  end
17
18
 
18
19
  # Cyrel DSL helper: creates a CALL clause for a procedure.
@@ -29,8 +30,9 @@ module Cyrel
29
30
 
30
31
  # Cyrel DSL helper: creates a node pattern.
31
32
  # Example: Cyrel.node(:n, :Person, name: 'Alice')
32
- def node(alias_name = nil, *labels, **properties)
33
- Pattern::Node.new(alias_name, labels: labels, properties: properties)
33
+ # Example: Cyrel.node(:n, or_labels: [:Person, :Organization]) # Memgraph 3.2+
34
+ def node(alias_name = nil, *labels, or_labels: nil, **properties)
35
+ Pattern::Node.new(alias_name, labels: labels, or_labels: or_labels, properties: properties)
34
36
  end
35
37
 
36
38
  # Cyrel DSL helper: creates a relationship pattern.
@@ -57,14 +59,14 @@ module Cyrel
57
59
  @pending_direction = nil
58
60
  end
59
61
 
60
- def node(alias_name = nil, *labels, **properties)
62
+ def node(alias_name = nil, *labels, or_labels: nil, **properties)
61
63
  # If there's a pending direction, we need to add a relationship first
62
64
  if @pending_direction && @elements.any? && @elements.last.is_a?(Cyrel::Pattern::Node)
63
65
  @elements << Cyrel::Pattern::Relationship.new(types: [], direction: @pending_direction)
64
66
  @pending_direction = nil
65
67
  end
66
68
 
67
- n = Cyrel::Pattern::Node.new(alias_name, labels: labels, properties: properties)
69
+ n = Cyrel::Pattern::Node.new(alias_name, labels: labels, or_labels: or_labels, properties: properties)
68
70
  @elements << n
69
71
  self
70
72
  end
@@ -250,6 +252,16 @@ module Cyrel
250
252
  Expression.exists(pattern)
251
253
  end
252
254
 
255
+ # Cyrel DSL helper: creates an EXISTS block with full subquery (Memgraph 3.5+).
256
+ # Example:
257
+ # Cyrel.exists_block { match(Cyrel.node(:a) > Cyrel.rel(:r) > Cyrel.node(:b, :Admin)) }
258
+ # # => EXISTS { MATCH (a)-[r]->(b:Admin) }
259
+ def exists_block(&block)
260
+ subquery = Query.new
261
+ subquery.instance_eval(&block)
262
+ Expression::ExistsBlock.new(subquery)
263
+ end
264
+
253
265
  # Cyrel DSL helper: creates a Logical NOT expression.
254
266
  # Example: Cyrel.not(expression)
255
267
  def not(expression)
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.12.2
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: 0.11.0
46
+ version: 0.11.1
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: 0.11.0
53
+ version: 0.11.1
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: io-endpoint
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -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