activecypher 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/active_cypher/associations/collection_proxy.rb +144 -0
- data/lib/active_cypher/associations.rb +537 -0
- data/lib/active_cypher/base.rb +47 -0
- data/lib/active_cypher/bolt/connection.rb +525 -0
- data/lib/active_cypher/bolt/driver.rb +144 -0
- data/lib/active_cypher/bolt/handlers.rb +10 -0
- data/lib/active_cypher/bolt/message_reader.rb +100 -0
- data/lib/active_cypher/bolt/message_writer.rb +53 -0
- data/lib/active_cypher/bolt/messaging.rb +307 -0
- data/lib/active_cypher/bolt/packstream.rb +319 -0
- data/lib/active_cypher/bolt/result.rb +82 -0
- data/lib/active_cypher/bolt/session.rb +201 -0
- data/lib/active_cypher/bolt/transaction.rb +211 -0
- data/lib/active_cypher/bolt/version_encoding.rb +41 -0
- data/lib/active_cypher/bolt.rb +7 -0
- data/lib/active_cypher/connection_adapters/abstract_adapter.rb +75 -0
- data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +178 -0
- data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +44 -0
- data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +58 -0
- data/lib/active_cypher/connection_factory.rb +130 -0
- data/lib/active_cypher/connection_handler.rb +9 -0
- data/lib/active_cypher/connection_pool.rb +123 -0
- data/lib/active_cypher/connection_url_resolver.rb +137 -0
- data/lib/active_cypher/cypher_config.rb +50 -0
- data/lib/active_cypher/generators/install_generator.rb +23 -0
- data/lib/active_cypher/generators/node_generator.rb +32 -0
- data/lib/active_cypher/generators/relationship_generator.rb +33 -0
- data/lib/active_cypher/generators/templates/application_graph_node.rb +5 -0
- data/lib/active_cypher/generators/templates/application_graph_relationship.rb +5 -0
- data/lib/active_cypher/generators/templates/cypher_databases.yml +28 -0
- data/lib/active_cypher/generators/templates/node.rb.erb +10 -0
- data/lib/active_cypher/generators/templates/relationship.rb.erb +11 -0
- data/lib/active_cypher/logging.rb +44 -0
- data/lib/active_cypher/model/abstract.rb +87 -0
- data/lib/active_cypher/model/attributes.rb +24 -0
- data/lib/active_cypher/model/callbacks.rb +44 -0
- data/lib/active_cypher/model/connection_handling.rb +76 -0
- data/lib/active_cypher/model/connection_owner.rb +50 -0
- data/lib/active_cypher/model/core.rb +45 -0
- data/lib/active_cypher/model/countable.rb +30 -0
- data/lib/active_cypher/model/destruction.rb +49 -0
- data/lib/active_cypher/model/inspectable.rb +28 -0
- data/lib/active_cypher/model/persistence.rb +182 -0
- data/lib/active_cypher/model/querying.rb +67 -0
- data/lib/active_cypher/railtie.rb +34 -0
- data/lib/active_cypher/relation.rb +190 -0
- data/lib/active_cypher/relationship.rb +233 -0
- data/lib/active_cypher/runtime_registry.rb +8 -0
- data/lib/active_cypher/scoping.rb +97 -0
- data/lib/active_cypher/utils/logger.rb +100 -0
- data/lib/active_cypher/version.rb +5 -0
- data/lib/activecypher.rb +108 -0
- data/lib/cyrel/call_procedure.rb +29 -0
- data/lib/cyrel/clause/call.rb +46 -0
- data/lib/cyrel/clause/call_subquery.rb +40 -0
- data/lib/cyrel/clause/create.rb +33 -0
- data/lib/cyrel/clause/delete.rb +41 -0
- data/lib/cyrel/clause/limit.rb +33 -0
- data/lib/cyrel/clause/match.rb +40 -0
- data/lib/cyrel/clause/merge.rb +34 -0
- data/lib/cyrel/clause/order_by.rb +78 -0
- data/lib/cyrel/clause/remove.rb +75 -0
- data/lib/cyrel/clause/return.rb +90 -0
- data/lib/cyrel/clause/set.rb +97 -0
- data/lib/cyrel/clause/skip.rb +34 -0
- data/lib/cyrel/clause/where.rb +42 -0
- data/lib/cyrel/clause/with.rb +94 -0
- data/lib/cyrel/clause.rb +25 -0
- data/lib/cyrel/direction.rb +18 -0
- data/lib/cyrel/expression/alias.rb +27 -0
- data/lib/cyrel/expression/base.rb +101 -0
- data/lib/cyrel/expression/case.rb +45 -0
- data/lib/cyrel/expression/comparison.rb +60 -0
- data/lib/cyrel/expression/exists.rb +42 -0
- data/lib/cyrel/expression/function_call.rb +57 -0
- data/lib/cyrel/expression/literal.rb +33 -0
- data/lib/cyrel/expression/logical.rb +38 -0
- data/lib/cyrel/expression/operator.rb +27 -0
- data/lib/cyrel/expression/pattern_comprehension.rb +44 -0
- data/lib/cyrel/expression/property_access.rb +25 -0
- data/lib/cyrel/expression.rb +56 -0
- data/lib/cyrel/functions.rb +116 -0
- data/lib/cyrel/node.rb +397 -0
- data/lib/cyrel/parameterizable.rb +20 -0
- data/lib/cyrel/pattern/node.rb +66 -0
- data/lib/cyrel/pattern/path.rb +41 -0
- data/lib/cyrel/pattern/relationship.rb +74 -0
- data/lib/cyrel/pattern.rb +8 -0
- data/lib/cyrel/query.rb +497 -0
- data/lib/cyrel/return_only.rb +26 -0
- data/lib/cyrel/types/hash_type.rb +22 -0
- data/lib/cyrel/types/symbol_type.rb +13 -0
- data/lib/cyrel.rb +72 -0
- data/lib/tasks/active_cypher_tasks.rake +6 -0
- data/sig/activecypher.rbs +4 -0
- metadata +173 -10
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
|