activecypher 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_cypher/associations/collection_proxy.rb +144 -0
  3. data/lib/active_cypher/associations.rb +537 -0
  4. data/lib/active_cypher/base.rb +47 -0
  5. data/lib/active_cypher/bolt/connection.rb +525 -0
  6. data/lib/active_cypher/bolt/driver.rb +144 -0
  7. data/lib/active_cypher/bolt/handlers.rb +10 -0
  8. data/lib/active_cypher/bolt/message_reader.rb +100 -0
  9. data/lib/active_cypher/bolt/message_writer.rb +53 -0
  10. data/lib/active_cypher/bolt/messaging.rb +307 -0
  11. data/lib/active_cypher/bolt/packstream.rb +319 -0
  12. data/lib/active_cypher/bolt/result.rb +82 -0
  13. data/lib/active_cypher/bolt/session.rb +201 -0
  14. data/lib/active_cypher/bolt/transaction.rb +211 -0
  15. data/lib/active_cypher/bolt/version_encoding.rb +41 -0
  16. data/lib/active_cypher/bolt.rb +7 -0
  17. data/lib/active_cypher/connection_adapters/abstract_adapter.rb +75 -0
  18. data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +178 -0
  19. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
  20. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +58 -0
  21. data/lib/active_cypher/connection_factory.rb +130 -0
  22. data/lib/active_cypher/connection_handler.rb +9 -0
  23. data/lib/active_cypher/connection_pool.rb +123 -0
  24. data/lib/active_cypher/connection_url_resolver.rb +137 -0
  25. data/lib/active_cypher/cypher_config.rb +50 -0
  26. data/lib/active_cypher/generators/install_generator.rb +23 -0
  27. data/lib/active_cypher/generators/node_generator.rb +32 -0
  28. data/lib/active_cypher/generators/relationship_generator.rb +33 -0
  29. data/lib/active_cypher/generators/templates/application_graph_node.rb +5 -0
  30. data/lib/active_cypher/generators/templates/application_graph_relationship.rb +5 -0
  31. data/lib/active_cypher/generators/templates/cypher_databases.yml +28 -0
  32. data/lib/active_cypher/generators/templates/node.rb.erb +10 -0
  33. data/lib/active_cypher/generators/templates/relationship.rb.erb +11 -0
  34. data/lib/active_cypher/logging.rb +44 -0
  35. data/lib/active_cypher/model/abstract.rb +87 -0
  36. data/lib/active_cypher/model/attributes.rb +24 -0
  37. data/lib/active_cypher/model/callbacks.rb +44 -0
  38. data/lib/active_cypher/model/connection_handling.rb +76 -0
  39. data/lib/active_cypher/model/connection_owner.rb +50 -0
  40. data/lib/active_cypher/model/core.rb +45 -0
  41. data/lib/active_cypher/model/countable.rb +30 -0
  42. data/lib/active_cypher/model/destruction.rb +49 -0
  43. data/lib/active_cypher/model/inspectable.rb +28 -0
  44. data/lib/active_cypher/model/persistence.rb +182 -0
  45. data/lib/active_cypher/model/querying.rb +67 -0
  46. data/lib/active_cypher/railtie.rb +34 -0
  47. data/lib/active_cypher/relation.rb +190 -0
  48. data/lib/active_cypher/relationship.rb +233 -0
  49. data/lib/active_cypher/runtime_registry.rb +8 -0
  50. data/lib/active_cypher/scoping.rb +97 -0
  51. data/lib/active_cypher/utils/logger.rb +100 -0
  52. data/lib/active_cypher/version.rb +5 -0
  53. data/lib/activecypher.rb +108 -0
  54. data/lib/cyrel/call_procedure.rb +29 -0
  55. data/lib/cyrel/clause/call.rb +46 -0
  56. data/lib/cyrel/clause/call_subquery.rb +40 -0
  57. data/lib/cyrel/clause/create.rb +33 -0
  58. data/lib/cyrel/clause/delete.rb +41 -0
  59. data/lib/cyrel/clause/limit.rb +33 -0
  60. data/lib/cyrel/clause/match.rb +40 -0
  61. data/lib/cyrel/clause/merge.rb +34 -0
  62. data/lib/cyrel/clause/order_by.rb +78 -0
  63. data/lib/cyrel/clause/remove.rb +75 -0
  64. data/lib/cyrel/clause/return.rb +90 -0
  65. data/lib/cyrel/clause/set.rb +97 -0
  66. data/lib/cyrel/clause/skip.rb +34 -0
  67. data/lib/cyrel/clause/where.rb +42 -0
  68. data/lib/cyrel/clause/with.rb +94 -0
  69. data/lib/cyrel/clause.rb +25 -0
  70. data/lib/cyrel/direction.rb +18 -0
  71. data/lib/cyrel/expression/alias.rb +27 -0
  72. data/lib/cyrel/expression/base.rb +101 -0
  73. data/lib/cyrel/expression/case.rb +45 -0
  74. data/lib/cyrel/expression/comparison.rb +60 -0
  75. data/lib/cyrel/expression/exists.rb +42 -0
  76. data/lib/cyrel/expression/function_call.rb +57 -0
  77. data/lib/cyrel/expression/literal.rb +33 -0
  78. data/lib/cyrel/expression/logical.rb +38 -0
  79. data/lib/cyrel/expression/operator.rb +27 -0
  80. data/lib/cyrel/expression/pattern_comprehension.rb +44 -0
  81. data/lib/cyrel/expression/property_access.rb +25 -0
  82. data/lib/cyrel/expression.rb +56 -0
  83. data/lib/cyrel/functions.rb +116 -0
  84. data/lib/cyrel/node.rb +397 -0
  85. data/lib/cyrel/parameterizable.rb +20 -0
  86. data/lib/cyrel/pattern/node.rb +66 -0
  87. data/lib/cyrel/pattern/path.rb +41 -0
  88. data/lib/cyrel/pattern/relationship.rb +74 -0
  89. data/lib/cyrel/pattern.rb +8 -0
  90. data/lib/cyrel/query.rb +497 -0
  91. data/lib/cyrel/return_only.rb +26 -0
  92. data/lib/cyrel/types/hash_type.rb +22 -0
  93. data/lib/cyrel/types/symbol_type.rb +13 -0
  94. data/lib/cyrel.rb +72 -0
  95. data/lib/tasks/active_cypher_tasks.rake +6 -0
  96. data/sig/activecypher.rbs +4 -0
  97. metadata +173 -10
data/lib/cyrel/node.rb ADDED
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string/inflections'
4
+
5
+ module Cyrel
6
+ # The base class for building Cypher queries.
7
+ class Node
8
+ # @param label [String] The label of the node.
9
+ # @param as [Symbol, nil] An optional alias for the node.
10
+ def initialize(label, as: nil)
11
+ @label = label
12
+ @alias = as || label.to_s.underscore.to_sym
13
+ @conditions = {}
14
+ @raw_conditions = []
15
+ @related_node_conditions = {}
16
+ @optional_match = false
17
+ @return_fields = []
18
+ @order_clauses = []
19
+ @skip_value = nil
20
+ @limit_value = nil
21
+ @with_clause = nil
22
+ @where_after_with = nil
23
+ @remove_props = []
24
+ @detach_delete = false
25
+ @path_variable = nil
26
+ end
27
+
28
+ # Adds conditions to the query.
29
+ # @param conditions [Hash, String] A hash of conditions or a raw condition string to add to the query.
30
+ def where(conditions)
31
+ if conditions.is_a?(String)
32
+ # Process string-based conditions
33
+ if @with_clause && !@where_after_with.nil?
34
+ @where_after_with = "#{@where_after_with} AND #{conditions}"
35
+ elsif @with_clause
36
+ @where_after_with = conditions
37
+ else
38
+ @raw_conditions << conditions
39
+ end
40
+ else
41
+ conditions = conditions.transform_keys(&:to_s)
42
+ if @outgoing_relationship && @related_node_label
43
+ @related_node_conditions.merge!(conditions)
44
+ else
45
+ @conditions.merge!(conditions)
46
+ end
47
+ end
48
+ self
49
+ end
50
+
51
+ # Match all nodes of this type
52
+ def all
53
+ self
54
+ end
55
+
56
+ # Specifies what to return in the query.
57
+ # Also raises an exception if you try to be too clever—because your cleverness is not welcome here.
58
+ # @param fields [Array<Symbol, String>] The fields to return.
59
+ def return(*fields)
60
+ fields.each do |field|
61
+ # Skip validation for path variables and variables from subqueries
62
+ next if field.is_a?(String) && (field == @path_variable.to_s || (field.match(/^\w+$/) && defined_in_query?(field)))
63
+
64
+ if field.is_a?(Symbol) || (field.is_a?(String) && !field.include?('.') && !field.include?(' as ') &&
65
+ !field.include?('(') && !field.match(/\[.+\]/))
66
+ raise StandardError, 'Ambiguous name. Please use a string with alias.'
67
+ end
68
+ end
69
+ @return_fields = fields
70
+ self
71
+ end
72
+
73
+ # Defines the pattern to MATCH.
74
+ # Not to be confused with your desperate search for compatibility on dating apps.
75
+ def match(pattern)
76
+ @match_pattern = pattern
77
+ self
78
+ end
79
+
80
+ def set(properties)
81
+ @set_properties = properties
82
+ self
83
+ end
84
+
85
+ def create(properties)
86
+ @create_properties = properties
87
+ self
88
+ end
89
+
90
+ def merge(properties)
91
+ @merge_properties = properties
92
+ self
93
+ end
94
+
95
+ def remove(*properties)
96
+ @remove_props.concat(properties)
97
+ self
98
+ end
99
+
100
+ # Schedules the node for DEATH, WITH DETACHMENT.
101
+ # Cold, clean, and emotionally unavailable. Just like your ex.
102
+ def detach_delete
103
+ @detach_delete = true
104
+ self
105
+ end
106
+
107
+ def order_by(field, direction = :asc)
108
+ @order_clauses << { field: field, direction: direction }
109
+ self
110
+ end
111
+
112
+ def skip(amount)
113
+ @skip_value = amount
114
+ self
115
+ end
116
+
117
+ def limit(amount)
118
+ @limit_value = amount
119
+ self
120
+ end
121
+
122
+ def with(clause)
123
+ @with_clause = clause
124
+ self
125
+ end
126
+
127
+ def as_path(path_variable)
128
+ @path_variable = path_variable
129
+ self
130
+ end
131
+
132
+ # Builds a WHERE EXISTS subquery.
133
+ # A fancy way to say, “Does this thing even exist?”—the same question your self-esteem asks daily.
134
+ def where_exists(&)
135
+ subquery = self.class.new(@label, as: @alias)
136
+ subquery.instance_eval(&)
137
+ pattern = "(#{@alias})"
138
+
139
+ if subquery.instance_variable_get(:@outgoing_relationship)
140
+ rel = subquery.instance_variable_get(:@outgoing_relationship)
141
+ rel_node_label = subquery.instance_variable_get(:@related_node_label)
142
+ pattern += "-[:#{rel}]->(:#{rel_node_label})"
143
+ end
144
+
145
+ @raw_conditions << "EXISTS(#{pattern})"
146
+ self
147
+ end
148
+
149
+ # Invokes a subquery block. Like a Matryoshka of complexity.
150
+ # Because why write one query when you can write *two* for double the confusion?
151
+ def call(&)
152
+ if block_given?
153
+ subquery = self.class.new(@label, as: @alias)
154
+ subquery.instance_eval(&)
155
+ @call_subquery = subquery
156
+ else
157
+ # This is for standalone call
158
+ end
159
+ self
160
+ end
161
+
162
+ # Specifies an outgoing relationship.
163
+ # @param relationship [Symbol] The type of relationship.
164
+ def outgoing(relationship)
165
+ @outgoing_relationship = relationship
166
+ self
167
+ end
168
+
169
+ # Specifies an optional outgoing relationship.
170
+ # It’s not ghosting, it’s just... optional commitment.
171
+ def optional_outgoing(relationship)
172
+ @outgoing_relationship = relationship
173
+ @optional_match = true
174
+ self
175
+ end
176
+
177
+ # Specifies a related node.
178
+ # @param label [String] The label of the related node.
179
+ # @param as [Symbol, nil] An optional alias for the related node.
180
+ def node(label, as: nil)
181
+ @related_node_label = label
182
+ @related_node_alias = as || label.to_s.underscore.to_sym
183
+ self
184
+ end
185
+
186
+ # This method assembles the final Cypher query like Dr. Frankenstein assembling a monster:
187
+ # a bit of this, a stitch of that, and screaming at lightning until it runs.
188
+ # If this explodes, blame the architecture, not the architect.
189
+ # @return [String] The Cypher query string.
190
+ def to_cypher
191
+ parts = []
192
+
193
+ # CREATE or MERGE clauses
194
+ if @create_properties
195
+ formatted_props = format_properties(@create_properties)
196
+ parts << "CREATE (#{@alias}:#{@label} {#{formatted_props}})"
197
+ elsif @merge_properties
198
+ formatted_props = format_properties(@merge_properties)
199
+ parts << "MERGE (#{@alias}:#{@label} {#{formatted_props}})"
200
+ else
201
+ # Build MATCH clause
202
+ match_clause = build_match_clause
203
+ parts << match_clause if match_clause
204
+ end
205
+
206
+ # Add CALL subquery if present
207
+ if @call_subquery
208
+ subquery_cypher = @call_subquery.to_cypher
209
+ # Format for subquery in CALL
210
+ subquery_cypher = subquery_cypher.gsub(/^MATCH /, '')
211
+ parts << "CALL { MATCH #{subquery_cypher} }"
212
+ end
213
+
214
+ # WITH clause
215
+ parts << "WITH #{@with_clause}" if @with_clause
216
+ parts << "WHERE #{@where_after_with}" if @where_after_with && @with_clause
217
+
218
+ # SET clause for property updates
219
+ if @set_properties
220
+ set_parts = @set_properties.map { |k, v| "#{@alias}.#{k} = #{format_value(v)}" }
221
+ parts << "SET #{set_parts.join(', ')}"
222
+ end
223
+
224
+ # REMOVE clause
225
+ if @remove_props.any?
226
+ remove_parts = @remove_props.map { |prop| "#{@alias}.#{prop}" }
227
+ parts << "REMOVE #{remove_parts.join(', ')}"
228
+ end
229
+
230
+ # DETACH DELETE clause
231
+ parts << "DETACH DELETE #{@alias}" if @detach_delete
232
+
233
+ # RETURN clause
234
+ if @return_fields.any?
235
+ return_parts = @return_fields.map do |field|
236
+ if field.is_a?(Symbol)
237
+ "#{@alias}.#{field}"
238
+ elsif field.is_a?(String)
239
+ # Check if it's a path variable
240
+ if @path_variable && field == @path_variable.to_s
241
+ field
242
+ # Check if it might be a variable from a subquery
243
+ elsif field.match(/^\w+$/) && defined_in_query?(field)
244
+ field
245
+ # Handle function calls (prevent node alias prefix on CASE/function keywords)
246
+ elsif field.start_with?('CASE ') || field.match(/^\w+\s*\(/)
247
+ field.gsub(' as ', ' AS ')
248
+ # Handle pattern comprehensions
249
+ elsif field.match(/\[.+\]/)
250
+ field.gsub(' as ', ' AS ')
251
+ # Handle field with alias syntax
252
+ elsif field.include?(' as ')
253
+ modified_field = field.gsub(' as ', ' AS ')
254
+ if modified_field.include?('.')
255
+ modified_field
256
+ else
257
+ "#{@alias}.#{modified_field}"
258
+ end
259
+ # Handle field without dot notation
260
+ elsif !field.include?('.')
261
+ "#{@alias}.#{field}"
262
+ else
263
+ field
264
+ end
265
+ else
266
+ field
267
+ end
268
+ end
269
+ parts << "RETURN #{return_parts.join(', ')}"
270
+ end
271
+
272
+ # ORDER BY clause
273
+ if @order_clauses.any?
274
+ order_parts = @order_clauses.map do |clause|
275
+ "#{@alias}.#{clause[:field]} #{clause[:direction].to_s.upcase}"
276
+ end
277
+ parts << "ORDER BY #{order_parts.join(', ')}"
278
+ end
279
+
280
+ # SKIP and LIMIT
281
+ parts << "SKIP #{@skip_value}" if @skip_value
282
+ parts << "LIMIT #{@limit_value}" if @limit_value
283
+
284
+ parts.join(' ')
285
+ end
286
+
287
+ private
288
+
289
+ def defined_in_query?(field)
290
+ # Check if this is from a subquery's return
291
+ if @call_subquery
292
+ subquery_return = @call_subquery.instance_variable_get(:@return_fields)
293
+ return true if subquery_return.any? do |f|
294
+ f.is_a?(String) && (f.include?(" as #{field}") || f.include?(" AS #{field}"))
295
+ end
296
+ end
297
+
298
+ # Check if it's from a WITH clause
299
+ return true if @with_clause && (@with_clause.include?(" as #{field}") || @with_clause.include?(" AS #{field}"))
300
+
301
+ false
302
+ end
303
+
304
+ def build_match_clause
305
+ return nil if @create_properties || @merge_properties
306
+
307
+ # Start building the initial match clause
308
+ path_prefix = @path_variable ? "#{@path_variable} = " : ''
309
+ initial_match = "MATCH #{path_prefix}(#{@alias}:#{@label}"
310
+
311
+ # Add property conditions
312
+ if @conditions.any?
313
+ formatted_conditions = format_properties(@conditions)
314
+ initial_match += " {#{formatted_conditions}}"
315
+ end
316
+ initial_match += ')'
317
+
318
+ # Handle relationship clauses
319
+ if @outgoing_relationship
320
+ if @optional_match
321
+ # For optional matches, create a separate OPTIONAL MATCH clause
322
+ relationship_match = "OPTIONAL MATCH (#{@alias})-[:#{@outgoing_relationship}]->"
323
+ node_text = "(#{@related_node_alias}:#{@related_node_label}"
324
+ if @related_node_conditions.any?
325
+ formatted_conditions = format_properties(@related_node_conditions)
326
+ node_text += " {#{formatted_conditions}}"
327
+ end
328
+ node_text += ')'
329
+ match_clause = "#{initial_match} #{relationship_match}#{node_text}"
330
+ else
331
+ # For regular matches, include the relationship in the initial MATCH
332
+ relationship_text = "-[:#{@outgoing_relationship}]->"
333
+ node_text = "(#{@related_node_alias}:#{@related_node_label}"
334
+ if @related_node_conditions.any?
335
+ formatted_conditions = format_properties(@related_node_conditions)
336
+ node_text += " {#{formatted_conditions}}"
337
+ end
338
+ node_text += ')'
339
+ match_clause = initial_match + relationship_text + node_text
340
+ end
341
+ else
342
+ match_clause = initial_match
343
+ end
344
+
345
+ # Add any additional pattern matching
346
+ match_clause += " #{@match_pattern}" if @match_pattern
347
+
348
+ # Add raw WHERE conditions if any
349
+ if @raw_conditions.any?
350
+ where_conditions = @raw_conditions.map do |condition|
351
+ # Process property references in raw conditions
352
+ processed_condition = condition
353
+
354
+ if condition.match(/\w+\s+(CONTAINS|STARTS WITH|ENDS WITH)\s+/)
355
+ # Special handling for string operations
356
+ property = condition.match(/(\w+)\s+(CONTAINS|STARTS WITH|ENDS WITH)/)[1]
357
+ rest = condition.sub(/^\w+\s+/, '')
358
+ processed_condition = "#{@alias}.#{property} #{rest}"
359
+ elsif !condition.include?('(') && !condition.include?('[') && !condition.match(/\s(AND|OR|NOT|XOR|IN|IS|NULL|TRUE|FALSE)\s/)
360
+ # Add node alias to simple property references
361
+ processed_condition = "#{@alias}.#{condition}"
362
+ end
363
+
364
+ processed_condition
365
+ end
366
+ match_clause += " WHERE #{where_conditions.join(' AND ')}"
367
+ end
368
+
369
+ match_clause
370
+ end
371
+
372
+ # Formats properties into Cypher-compatible key-value pairs.
373
+ # It's like JSON, but with commitment issues and worse syntax.
374
+ def format_properties(props)
375
+ props.map do |k, v|
376
+ if v.is_a?(Array)
377
+ "#{k} IN #{format_value(v)}"
378
+ else
379
+ "#{k}: #{format_value(v)}"
380
+ end
381
+ end.join(', ')
382
+ end
383
+
384
+ def format_value(value)
385
+ case value
386
+ when String
387
+ "\"#{value}\""
388
+ when Array
389
+ "[#{value.map { |v| format_value(v) }.join(', ')}]"
390
+ when Hash
391
+ "{#{format_properties(value)}}"
392
+ else
393
+ value.to_s
394
+ end
395
+ end
396
+ end
397
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'concurrent'
5
+
6
+ module Cyrel
7
+ module Parameterizable
8
+ extend ActiveSupport::Concern
9
+
10
+ private
11
+
12
+ # Generates the next parameter key.
13
+ # p1, p2, p3... it’s like a sad carnival of increasingly desperate guesses.
14
+ def next_param_key
15
+ @param_counter ||= 0
16
+ @param_counter += 1
17
+ :"p#{@param_counter}"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model' # ships with Rails 8
4
+ require 'active_model/attributes'
5
+
6
+ module Cyrel
7
+ module Pattern
8
+ class Node
9
+ include ActiveModel::Model
10
+ include ActiveModel::Attributes # :contentReference[oaicite:3]{index=3}
11
+ include Cyrel::Parameterizable
12
+
13
+ attribute :alias_name, Cyrel::Types::SymbolType.new
14
+ attribute :labels, array: :string, default: []
15
+ attribute :properties, Cyrel::Types::HashType.new, default: -> { {} }
16
+
17
+ validates :alias_name, presence: true
18
+
19
+ def initialize(alias_name, labels: nil, properties: {}, **kw)
20
+ super(
21
+ { alias_name: alias_name,
22
+ labels: Array(labels).compact.flatten,
23
+ properties: properties }.merge(kw)
24
+ )
25
+ end
26
+
27
+ # ------------------------------------------------------------------
28
+ # Public: return a *copy* of this Node with a different alias.
29
+ #
30
+ # Cyrel.node('Person').as(:p) # (:p:Person)
31
+ #
32
+ # We dup so the original immutable instance (often reused by the DSL)
33
+ # isn’t mutated.
34
+ # ------------------------------------------------------------------
35
+ def as(new_alias)
36
+ dup_with(alias_name: new_alias.to_sym)
37
+ end
38
+
39
+ def render(query)
40
+ base = +"(#{alias_name}"
41
+ base << ':' << labels.join(':') unless labels.empty?
42
+ unless properties.empty?
43
+ params = properties.with_indifferent_access
44
+ formatted = params.map { |k, v| "#{k}: $#{query.register_parameter(v)}" }.join(', ')
45
+ base << " {#{formatted}}"
46
+ end
47
+ base << ')'
48
+ end
49
+
50
+ def freeze
51
+ super
52
+ labels.freeze
53
+ properties.freeze
54
+ end
55
+
56
+ private
57
+
58
+ # Utility used by Node & Relationship to make modified copies
59
+ def dup_with(**attrs)
60
+ copy = dup
61
+ attrs.each { |k, v| copy.public_send("#{k}=", v) }
62
+ copy
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module Pattern
5
+ # Represents a linear path pattern in a Cypher query,
6
+ # consisting of an alternating sequence of Node and Relationship objects.
7
+ class Path
8
+ attr_reader :elements
9
+
10
+ # @param elements [Array<Cyrel::Pattern::Node, Cyrel::Pattern::Relationship>]
11
+ # An array starting with a Node, followed by alternating Relationship and Node objects.
12
+ def initialize(elements)
13
+ validate_elements(elements)
14
+ @elements = elements
15
+ end
16
+
17
+ # Renders the path pattern part of the Cypher query.
18
+ # @param query [Cyrel::Query] The query object, used for parameter registration.
19
+ # @return [String] The Cypher string fragment for the path pattern.
20
+ def render(query)
21
+ @elements.map { |element| element.render(query) }.join
22
+ end
23
+
24
+ private
25
+
26
+ # Validates the structure of the elements array.
27
+ def validate_elements(elements)
28
+ raise ArgumentError, 'Path elements must be a non-empty array.' unless elements.is_a?(Array) && elements.any?
29
+ raise ArgumentError, 'Path must start with a Node.' unless elements.first.is_a?(Cyrel::Pattern::Node)
30
+
31
+ elements.each_with_index do |element, index|
32
+ expected_class = (index.even? ? Cyrel::Pattern::Node : Cyrel::Pattern::Relationship)
33
+ unless element.is_a?(expected_class)
34
+ raise ArgumentError,
35
+ "Invalid element sequence at index #{index}. Expected #{expected_class}, got #{element.class}."
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../direction'
4
+
5
+ module Cyrel
6
+ module Pattern
7
+ class Relationship
8
+ include ActiveModel::Model
9
+ include ActiveModel::Attributes
10
+ include Cyrel::Parameterizable
11
+
12
+ attribute :alias_name, Cyrel::Types::SymbolType.new, default: nil
13
+ attribute :types, array: :string, default: []
14
+ attribute :properties, Cyrel::Types::HashType.new, default: -> { {} }
15
+ attribute :direction, default: Cyrel::Direction::BOTH
16
+ attribute :length
17
+
18
+ def initialize(types:, direction: Cyrel::Direction::BOTH, **kw)
19
+ # Accept string or array for :types just like the old API
20
+ super({ types: Array(types) }.merge(kw).merge(direction: direction))
21
+ end
22
+
23
+ def render(query)
24
+ arrow =
25
+ case direction # Ruby 3.4 pattern match
26
+ in Direction::OUT then '->'
27
+ in Direction::IN then '<-'
28
+ else '-'
29
+ end
30
+
31
+ core = +'['
32
+ core << "#{alias_name} " if alias_name
33
+ core << ':' << Array(types).join('|') unless types.empty?
34
+ core << length_spec
35
+ core << " #{prop_string(query)}" unless properties.empty?
36
+ core << ']'
37
+
38
+ "#{arrow.start_with?('<') ? arrow : '-'}#{core}#{arrow.end_with?('>') ? arrow : '-'}"
39
+ end
40
+
41
+ private
42
+
43
+ # Builds the Cypher length fragment after the relationship type.
44
+ #
45
+ # * ➜ variable length (any)
46
+ # *3 ➜ exact length 3
47
+ # *1..5 ➜ range 1 – 5
48
+ # *1.. ➜ open range start (1 or more)
49
+ # *..5 ➜ open range end (up to 5)
50
+ def length_spec
51
+ return '' if length.nil?
52
+
53
+ case length
54
+ when Integer
55
+ "*#{length}"
56
+ when Range
57
+ start = length.begin # nil for ..N ranges
58
+ finish = length.end # nil for N.. ranges
59
+
60
+ "*#{start if start}..#{finish if finish}"
61
+ else
62
+ length # already a string like "*" – keep as‑is
63
+ end
64
+ end
65
+
66
+ def prop_string(query)
67
+ formatted = properties.to_h.with_indifferent_access.map do |k, v|
68
+ "#{k}: $#{query.register_parameter(v)}"
69
+ end.join(', ')
70
+ "{#{formatted}}"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ # Namespace for classes representing structural components of Cypher patterns
5
+ # (nodes, relationships, paths).
6
+ module Pattern
7
+ end
8
+ end