activecypher 0.0.0 → 0.3.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 +61 -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 +16 -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/sig/activecypher.rbs +4 -0
- metadata +172 -10
data/lib/cyrel/query.rb
ADDED
@@ -0,0 +1,497 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Require necessary clause and pattern types
|
4
|
+
|
5
|
+
# Require all clause types for DSL methods
|
6
|
+
|
7
|
+
module Cyrel
|
8
|
+
# Error raised when merging queries with conflicting alias definitions.
|
9
|
+
# Because even in graphs, two things can't have the same name without drama.
|
10
|
+
class AliasConflictError < StandardError
|
11
|
+
def initialize(alias_name, query1_details, query2_details)
|
12
|
+
super("Alias conflict for ':#{alias_name}'. Query 1 defines it as #{query1_details}, Query 2 defines it as #{query2_details}.")
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
# @!parse
|
17
|
+
# # The Cyrel Query class: where all your hopes, dreams, and clauses go to be awkwardly merged.
|
18
|
+
# # Manages clauses, parameters, and final query generation, because string interpolation is for amateurs.
|
19
|
+
class Query
|
20
|
+
include Parameterizable
|
21
|
+
attr_reader :parameters, :clauses # Expose clauses for merge logic
|
22
|
+
|
23
|
+
def initialize
|
24
|
+
@parameters = {}
|
25
|
+
@param_counter = 0
|
26
|
+
@clauses = [] # Holds instances of Clause::Base subclasses, because arrays are the new query planner
|
27
|
+
end
|
28
|
+
|
29
|
+
# Registers a value and returns a parameter key.
|
30
|
+
# Think of it as variable adoption but with less paperwork and more risk.
|
31
|
+
# @param value [Object] The value to parameterize.
|
32
|
+
# @return [Symbol] The parameter key (e.g., :p1, :p2).
|
33
|
+
# Because nothing says "safe query" like a parade of anonymous parameters.
|
34
|
+
def register_parameter(value)
|
35
|
+
existing_key = @parameters.key(value)
|
36
|
+
return existing_key if existing_key
|
37
|
+
|
38
|
+
key = next_param_key
|
39
|
+
@parameters[key] = value
|
40
|
+
key
|
41
|
+
end
|
42
|
+
|
43
|
+
# Adds a clause object to the query.
|
44
|
+
# @param clause [Cyrel::Clause::Base] The clause instance to add.
|
45
|
+
# Because what you really wanted was a linked list of existential dread.
|
46
|
+
def add_clause(clause)
|
47
|
+
@clauses << clause
|
48
|
+
self # Allow chaining
|
49
|
+
end
|
50
|
+
|
51
|
+
# Generates the final Cypher query string and parameters hash.
|
52
|
+
# @return [Array(String, Hash)] The Cypher string and parameters.
|
53
|
+
# This is where all your careful planning gets flattened into a string.
|
54
|
+
def to_cypher
|
55
|
+
ActiveSupport::Notifications.instrument('cyrel.render', query: self) do
|
56
|
+
cypher_string = @clauses
|
57
|
+
.map { it.render(self) }
|
58
|
+
.reject(&:blank?)
|
59
|
+
.join("\n")
|
60
|
+
|
61
|
+
[cypher_string, @parameters]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Merges two Cyrel::Query objects together.
|
66
|
+
# Think Cypher polyamory: full of unexpected alias drama and parameter custody battles.
|
67
|
+
# @param other_query [Cyrel::Query] The query to merge in.
|
68
|
+
# @return [self]
|
69
|
+
# If you like surprises, you'll love this method.
|
70
|
+
def merge!(other_query)
|
71
|
+
raise ArgumentError, 'Can only merge another Cyrel::Query' unless other_query.is_a?(Cyrel::Query)
|
72
|
+
return self if other_query.clauses.empty? # Nothing to merge
|
73
|
+
|
74
|
+
# 1. Alias Conflict Detection
|
75
|
+
check_alias_conflicts!(other_query)
|
76
|
+
|
77
|
+
# 2. Parameter Merging
|
78
|
+
merge_parameters!(other_query)
|
79
|
+
|
80
|
+
# 3. Clause Combination
|
81
|
+
combine_clauses!(other_query)
|
82
|
+
|
83
|
+
self
|
84
|
+
end
|
85
|
+
# --- DSL Methods ---
|
86
|
+
|
87
|
+
# Adds a MATCH clause.
|
88
|
+
# @param pattern [Cyrel::Pattern::Path, Node, Relationship, Hash, Array] Pattern definition.
|
89
|
+
# - Can pass Pattern objects directly.
|
90
|
+
# - Can pass Hashes/Arrays to construct simple Node/Relationship patterns implicitly? (TBD)
|
91
|
+
# @param path_variable [Symbol, String, nil] Optional variable for the path.
|
92
|
+
# @return [self]
|
93
|
+
# Because nothing says "find me" like a declarative pattern and a prayer.
|
94
|
+
def match(pattern, path_variable: nil)
|
95
|
+
# TODO: Add implicit pattern construction from Hash/Array if desired.
|
96
|
+
add_clause(Clause::Match.new(pattern, optional: false, path_variable: path_variable))
|
97
|
+
end
|
98
|
+
|
99
|
+
# Adds an OPTIONAL MATCH clause.
|
100
|
+
# @param pattern [Cyrel::Pattern::Path, Node, Relationship, Hash, Array] Pattern definition.
|
101
|
+
# @param path_variable [Symbol, String, nil] Optional variable for the path.
|
102
|
+
# @return [self]
|
103
|
+
# For when you want to be non-committal, even in your queries.
|
104
|
+
def optional_match(pattern, path_variable: nil)
|
105
|
+
add_clause(Clause::Match.new(pattern, optional: true, path_variable: path_variable))
|
106
|
+
end
|
107
|
+
|
108
|
+
# Adds a WHERE clause (merging with an existing one if present).
|
109
|
+
#
|
110
|
+
# @example
|
111
|
+
# query.where(name: 'Alice').where(age: 30)
|
112
|
+
# # ⇒ WHERE ((n.name = $p1) AND (n.age = $p2))
|
113
|
+
#
|
114
|
+
# Accepts:
|
115
|
+
# • Hash – coerced into equality comparisons
|
116
|
+
# • Cyrel::Expression instances (or anything Expression.coerce understands)
|
117
|
+
#
|
118
|
+
# @return [self]
|
119
|
+
# Because sometimes you want to filter, and sometimes you just want to judge.
|
120
|
+
def where(*conditions)
|
121
|
+
# ------------------------------------------------------------------
|
122
|
+
# 1. Coerce incoming objects into Cyrel::Expression instances
|
123
|
+
# ------------------------------------------------------------------
|
124
|
+
processed_conditions = conditions.flat_map do |cond|
|
125
|
+
if cond.is_a?(Hash)
|
126
|
+
cond.map do |key, value|
|
127
|
+
Expression::Comparison.new(
|
128
|
+
Expression::PropertyAccess.new(@current_alias || infer_alias, key),
|
129
|
+
:'=',
|
130
|
+
value
|
131
|
+
)
|
132
|
+
end
|
133
|
+
else
|
134
|
+
cond # already an expression (or coercible)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
new_where = Clause::Where.new(*processed_conditions)
|
139
|
+
|
140
|
+
# ------------------------------------------------------------------
|
141
|
+
# 2. Merge with an existing WHERE (if any)
|
142
|
+
# ------------------------------------------------------------------
|
143
|
+
existing_where = @clauses.find { |c| c.is_a?(Clause::Where) }
|
144
|
+
if existing_where
|
145
|
+
existing_where.merge!(new_where)
|
146
|
+
return self
|
147
|
+
end
|
148
|
+
|
149
|
+
# ------------------------------------------------------------------
|
150
|
+
# 3. Determine correct insertion point
|
151
|
+
# ------------------------------------------------------------------
|
152
|
+
insertion_index = @clauses.index do |c|
|
153
|
+
c.is_a?(Clause::Return) ||
|
154
|
+
c.is_a?(Clause::OrderBy) ||
|
155
|
+
c.is_a?(Clause::Skip) ||
|
156
|
+
c.is_a?(Clause::Limit)
|
157
|
+
end
|
158
|
+
|
159
|
+
if insertion_index
|
160
|
+
@clauses.insert(insertion_index, new_where)
|
161
|
+
else
|
162
|
+
@clauses << new_where
|
163
|
+
end
|
164
|
+
|
165
|
+
self
|
166
|
+
end
|
167
|
+
|
168
|
+
# Adds a CREATE clause.
|
169
|
+
# @param pattern [Cyrel::Pattern::Path, Node, Relationship, Hash, Array] Pattern definition.
|
170
|
+
# @return [self]
|
171
|
+
# Because sometimes you want to make things, not just break them.
|
172
|
+
def create(pattern)
|
173
|
+
# TODO: Add implicit pattern construction
|
174
|
+
add_clause(Clause::Create.new(pattern))
|
175
|
+
end
|
176
|
+
|
177
|
+
# Adds a MERGE clause.
|
178
|
+
# @param pattern [Cyrel::Pattern::Path, Node, Relationship, Hash, Array] Pattern definition.
|
179
|
+
# @return [self]
|
180
|
+
# For when you want to find-or-create, but with more existential angst.
|
181
|
+
def merge(pattern)
|
182
|
+
# TODO: Add implicit pattern construction
|
183
|
+
# TODO: Add ON CREATE SET / ON MATCH SET options
|
184
|
+
add_clause(Clause::Merge.new(pattern))
|
185
|
+
end
|
186
|
+
|
187
|
+
# Adds a SET clause.
|
188
|
+
# @param assignments [Hash, Array] See Clause::Set#initialize.
|
189
|
+
# @return [self]
|
190
|
+
# Because sometimes you just want to change everything and pretend it was always that way.
|
191
|
+
def set(assignments)
|
192
|
+
# TODO: Consider merging SET clauses intelligently if needed.
|
193
|
+
add_clause(Clause::Set.new(assignments))
|
194
|
+
end
|
195
|
+
|
196
|
+
# Adds a REMOVE clause.
|
197
|
+
# @param items [Array<Cyrel::Expression::PropertyAccess, Array>] See Clause::Remove#initialize.
|
198
|
+
# @return [self]
|
199
|
+
# For when you want to Marie Kondo your graph.
|
200
|
+
def remove(*items)
|
201
|
+
# TODO: Consider merging REMOVE clauses.
|
202
|
+
add_clause(Clause::Remove.new(items)) # Pass array directly
|
203
|
+
end
|
204
|
+
|
205
|
+
# Adds a DELETE clause. Use `detach_delete` for DETACH DELETE.
|
206
|
+
# @param variables [Array<Symbol, String>] Variables to delete.
|
207
|
+
# @return [self]
|
208
|
+
# Underscore to avoid keyword clash
|
209
|
+
# Because sometimes you just want to watch the world burn, one node at a time.
|
210
|
+
def delete_(*variables)
|
211
|
+
add_clause(Clause::Delete.new(*variables, detach: false))
|
212
|
+
end
|
213
|
+
|
214
|
+
# Adds a DETACH DELETE clause.
|
215
|
+
# @param variables [Array<Symbol, String>] Variables to delete.
|
216
|
+
# @return [self]
|
217
|
+
# For when you want to delete with extreme prejudice.
|
218
|
+
def detach_delete(*variables)
|
219
|
+
add_clause(Clause::Delete.new(*variables, detach: true))
|
220
|
+
end
|
221
|
+
|
222
|
+
# Adds a WITH clause.
|
223
|
+
# @param items [Array] Items to project. See Clause::With#initialize.
|
224
|
+
# @param distinct [Boolean] Use DISTINCT?
|
225
|
+
# @param where [Cyrel::Clause::Where, Hash, Array] Optional WHERE condition(s) after WITH.
|
226
|
+
# @return [self]
|
227
|
+
# Because sometimes you want to pass things along, and sometimes you just want to pass the buck.
|
228
|
+
def with(*items, distinct: false, where: nil)
|
229
|
+
where_clause = case where
|
230
|
+
when Clause::Where then where
|
231
|
+
when nil then nil
|
232
|
+
else Clause::Where.new(*Array(where)) # Coerce Hash/Array/Expression
|
233
|
+
end
|
234
|
+
add_clause(Clause::With.new(*items, distinct: distinct, where: where_clause))
|
235
|
+
end
|
236
|
+
|
237
|
+
# Adds a RETURN clause.
|
238
|
+
# @param items [Array] Items to return. See Clause::Return#initialize.
|
239
|
+
# @param distinct [Boolean] Use DISTINCT?
|
240
|
+
# @return [self]
|
241
|
+
# Underscore to avoid keyword clash
|
242
|
+
# Because what you really want is your data, but what you'll get is a hash.
|
243
|
+
def return_(*items, distinct: false)
|
244
|
+
# TODO: Consider merging RETURN clauses?
|
245
|
+
add_clause(Clause::Return.new(*items, distinct: distinct))
|
246
|
+
end
|
247
|
+
|
248
|
+
# Adds or replaces the ORDER BY clause.
|
249
|
+
# @param order_items [Array<Array>, Hash] Ordering specifications.
|
250
|
+
# - Array: [[expr, :asc], [expr, :desc], ...]
|
251
|
+
# - Hash: { expr => :asc, expr => :desc, ... }
|
252
|
+
# @return [self]
|
253
|
+
# Because sometimes you want order, and sometimes you just want chaos.
|
254
|
+
def order_by(*order_items)
|
255
|
+
items_array = order_items.first.is_a?(Hash) ? order_items.first.to_a : order_items
|
256
|
+
|
257
|
+
existing_order = @clauses.find { |c| c.is_a?(Clause::OrderBy) }
|
258
|
+
new_order = Clause::OrderBy.new(*items_array)
|
259
|
+
if existing_order
|
260
|
+
existing_order.replace!(new_order)
|
261
|
+
else
|
262
|
+
add_clause(new_order)
|
263
|
+
end
|
264
|
+
self
|
265
|
+
end
|
266
|
+
|
267
|
+
# Adds or replaces the SKIP clause.
|
268
|
+
# @param amount [Integer, Expression] Number of results to skip.
|
269
|
+
# @return [self]
|
270
|
+
# For when you want to ignore the first N results, just like your unread emails.
|
271
|
+
def skip(amount)
|
272
|
+
existing_skip = @clauses.find { |c| c.is_a?(Clause::Skip) }
|
273
|
+
new_skip = Clause::Skip.new(amount)
|
274
|
+
if existing_skip
|
275
|
+
existing_skip.replace!(new_skip)
|
276
|
+
else
|
277
|
+
add_clause(new_skip)
|
278
|
+
end
|
279
|
+
self
|
280
|
+
end
|
281
|
+
|
282
|
+
# Adds or replaces the LIMIT clause.
|
283
|
+
# @param amount [Integer, Expression] Maximum number of results.
|
284
|
+
# @return [self]
|
285
|
+
# Because sometimes you want boundaries, even in your queries.
|
286
|
+
def limit(amount)
|
287
|
+
existing_limit = @clauses.find { |c| c.is_a?(Clause::Limit) }
|
288
|
+
new_limit = Clause::Limit.new(amount)
|
289
|
+
if existing_limit
|
290
|
+
existing_limit.replace!(new_limit)
|
291
|
+
else
|
292
|
+
add_clause(new_limit)
|
293
|
+
end
|
294
|
+
self
|
295
|
+
end
|
296
|
+
|
297
|
+
# Adds a CALL procedure clause.
|
298
|
+
# @param procedure_name [String] Name of the procedure.
|
299
|
+
# @param arguments [Array] Arguments for the procedure.
|
300
|
+
# @param yield_items [Array<String>, String, nil] Items to YIELD.
|
301
|
+
# @param where [Clause::Where, Hash, Array, nil] WHERE condition after YIELD.
|
302
|
+
# @param return_items [Clause::Return, Array, nil] RETURN items after WHERE/YIELD.
|
303
|
+
# @return [self]
|
304
|
+
# For when you want to call a procedure and pretend it's not just another query.
|
305
|
+
def call_procedure(procedure_name, arguments: [], yield_items: nil, where: nil, return_items: nil)
|
306
|
+
add_clause(Clause::Call.new(procedure_name,
|
307
|
+
arguments: arguments,
|
308
|
+
yield_items: yield_items,
|
309
|
+
where: where,
|
310
|
+
return_items: return_items))
|
311
|
+
end
|
312
|
+
|
313
|
+
# Adds a CALL { subquery } clause.
|
314
|
+
# @yield [Cyrel::Query] Yields a new query object for building the subquery.
|
315
|
+
# @return [self]
|
316
|
+
# Because why write one query when you can write two and glue them together?
|
317
|
+
def call_subquery
|
318
|
+
subquery = Cyrel::Query.new
|
319
|
+
yield subquery
|
320
|
+
# Important: Parameters defined within the subquery block are currently
|
321
|
+
# NOT automatically merged into the outer query's parameters by this DSL method.
|
322
|
+
# This needs to be handled either by manually merging parameters after the block
|
323
|
+
# or by enhancing the rendering/parameter registration logic.
|
324
|
+
add_clause(Clause::CallSubquery.new(subquery))
|
325
|
+
# Consider adding: merge_parameters!(subquery) here, but it might re-register params.
|
326
|
+
end
|
327
|
+
|
328
|
+
# No longer private, needed by merge!
|
329
|
+
# private
|
330
|
+
|
331
|
+
# Merges parameters from another query, ensuring keys are unique.
|
332
|
+
# Because parameter collisions are the only collisions you want in production.
|
333
|
+
def merge_parameters!(other_query)
|
334
|
+
# Ensure our counter is beyond the other query's potential keys
|
335
|
+
max_other_param_num = other_query.parameters.keys
|
336
|
+
.map { |k| k.to_s.sub(/^p/, '').to_i }
|
337
|
+
.max || 0
|
338
|
+
@param_counter = [@param_counter, max_other_param_num].max
|
339
|
+
|
340
|
+
# Re-register each parameter from the other query
|
341
|
+
other_query.parameters.each_value do |value|
|
342
|
+
register_parameter(value)
|
343
|
+
# NOTE: This doesn't update references within the other_query's original clauses.
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
# Provides a sort order for clauses during rendering. Lower numbers come first.
|
348
|
+
# Because even your clauses need to know their place in the world.
|
349
|
+
def clause_order(clause)
|
350
|
+
case clause
|
351
|
+
when Clause::Match, Clause::Create, Clause::Merge then 10 # Reading/Writing/Merging clauses
|
352
|
+
when Clause::Call, Clause::CallSubquery then 15 # CALL often follows MATCH/CREATE
|
353
|
+
when Clause::With then 20
|
354
|
+
when Clause::Where then 30 # Filtering clauses
|
355
|
+
when Clause::Set, Clause::Remove, Clause::Delete then 40 # Modifying clauses
|
356
|
+
when Clause::Return then 50 # Projection
|
357
|
+
when Clause::OrderBy then 60 # Ordering/Paging
|
358
|
+
when Clause::Skip then 70
|
359
|
+
when Clause::Limit then 80
|
360
|
+
else 99 # Unknown clauses go last
|
361
|
+
end
|
362
|
+
end
|
363
|
+
|
364
|
+
# Extracts defined aliases and their labels from the query's clauses.
|
365
|
+
# @return [Hash{Symbol => Set<String>}] { alias_name => Set[label1, label2] }
|
366
|
+
# Because even your variables want to be unique snowflakes.
|
367
|
+
def defined_aliases
|
368
|
+
aliases = {}
|
369
|
+
@clauses.each do |clause|
|
370
|
+
# Look for clauses that define patterns (Match, Create, Merge)
|
371
|
+
next unless clause.respond_to?(:pattern) && clause.pattern
|
372
|
+
|
373
|
+
elements_to_check = []
|
374
|
+
case clause.pattern
|
375
|
+
when Pattern::Path
|
376
|
+
elements_to_check.concat(clause.pattern.elements)
|
377
|
+
when Pattern::Node, Pattern::Relationship
|
378
|
+
elements_to_check << clause.pattern
|
379
|
+
end
|
380
|
+
|
381
|
+
elements_to_check.each do |element|
|
382
|
+
next unless element.respond_to?(:alias_name) && element.alias_name
|
383
|
+
|
384
|
+
alias_name = element.alias_name
|
385
|
+
labels = Set.new
|
386
|
+
labels.merge(element.labels) if element.is_a?(Pattern::Node) && element.respond_to?(:labels)
|
387
|
+
|
388
|
+
aliases[alias_name] ||= Set.new
|
389
|
+
aliases[alias_name].merge(labels) unless labels.empty?
|
390
|
+
end
|
391
|
+
end
|
392
|
+
aliases
|
393
|
+
end
|
394
|
+
|
395
|
+
# Detects alias conflicts between queries.
|
396
|
+
# Because two nodes with the same name but different labels are the graph equivalent of identity theft.
|
397
|
+
def check_alias_conflicts!(other_query)
|
398
|
+
self_aliases = defined_aliases
|
399
|
+
other_aliases = other_query.defined_aliases
|
400
|
+
|
401
|
+
conflicting_aliases = self_aliases.keys & other_aliases.keys
|
402
|
+
|
403
|
+
conflicting_aliases.each do |alias_name|
|
404
|
+
self_labels = self_aliases[alias_name]
|
405
|
+
other_labels = other_aliases[alias_name]
|
406
|
+
|
407
|
+
# Conflict if labels are defined and different, or if one defines labels and the other doesn't.
|
408
|
+
# Allowing merge if both define the *same* labels or neither defines labels.
|
409
|
+
is_conflict = !self_labels.empty? && !other_labels.empty? && self_labels != other_labels
|
410
|
+
# Consider it a conflict if one defines labels and the other doesn't? Maybe too strict.
|
411
|
+
# is_conflict ||= (self_labels.empty? != other_labels.empty?)
|
412
|
+
|
413
|
+
next unless is_conflict
|
414
|
+
|
415
|
+
raise AliasConflictError.new(
|
416
|
+
alias_name,
|
417
|
+
"labels #{self_labels.to_a.inspect}",
|
418
|
+
"labels #{other_labels.to_a.inspect}"
|
419
|
+
)
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
# Combines clauses from the other query into this one based on type.
|
424
|
+
# Because merging queries is just like merging companies: someone always loses.
|
425
|
+
def combine_clauses!(other_query)
|
426
|
+
# Clone other query's clauses to avoid modifying it during iteration
|
427
|
+
other_clauses_to_process = other_query.clauses.dup
|
428
|
+
|
429
|
+
# --- Handle Replacing Clauses (OrderBy, Skip, Limit) ---
|
430
|
+
[Clause::OrderBy, Clause::Skip, Clause::Limit].each do |clause_class|
|
431
|
+
# Find the last occurrence in the other query's clauses
|
432
|
+
other_clause = other_clauses_to_process.reverse.find { |c| c.is_a?(clause_class) }
|
433
|
+
next unless other_clause
|
434
|
+
|
435
|
+
# Find the clause in self, if it exists
|
436
|
+
self_clause = @clauses.find { |c| c.is_a?(clause_class) }
|
437
|
+
|
438
|
+
if self_clause.respond_to?(:replace!)
|
439
|
+
# If self has the clause and it supports replace!, replace it
|
440
|
+
self_clause.replace!(other_clause)
|
441
|
+
elsif !self_clause
|
442
|
+
# If self doesn't have the clause, add the one from other_query
|
443
|
+
add_clause(other_clause)
|
444
|
+
# Else: self has the clause but doesn't support replace! - do nothing (keep self's)
|
445
|
+
end
|
446
|
+
|
447
|
+
# Remove *all* occurrences of this clause type from the list to process further
|
448
|
+
other_clauses_to_process.delete_if { |c| c.is_a?(clause_class) }
|
449
|
+
end
|
450
|
+
|
451
|
+
# --- Handle Merging Clauses (Where) ---
|
452
|
+
other_wheres = other_query.clauses.select { |c| c.is_a?(Clause::Where) }
|
453
|
+
unless other_wheres.empty?
|
454
|
+
self_where = @clauses.find { |c| c.is_a?(Clause::Where) }
|
455
|
+
if self_where
|
456
|
+
other_wheres.each { |ow| self_where.merge!(ow) }
|
457
|
+
else
|
458
|
+
# Add the first other_where and merge the rest into it
|
459
|
+
first_other_where = other_wheres.shift
|
460
|
+
add_clause(first_other_where)
|
461
|
+
other_wheres.each { |ow| first_other_where.merge!(ow) }
|
462
|
+
end
|
463
|
+
# Remove processed clauses
|
464
|
+
other_clauses_to_process.delete_if { |c| c.is_a?(Clause::Where) }
|
465
|
+
end
|
466
|
+
|
467
|
+
# --- Handle Appending Clauses (Match, Create, Set, Remove, Delete, With, Return, Call, etc.) ---
|
468
|
+
# Add remaining clauses from other_query
|
469
|
+
other_clauses_to_process.each { |clause| add_clause(clause) }
|
470
|
+
end
|
471
|
+
|
472
|
+
# Helper to map instance variable names used in combine_clauses! to class types
|
473
|
+
# This helper is no longer needed with the refactored combine_clauses!
|
474
|
+
# def clause_class_for_ivar(ivar_name) ... end
|
475
|
+
|
476
|
+
# Helper needed for `where` DSL method with hash conditions
|
477
|
+
# Tries to guess the primary alias.
|
478
|
+
# Like Sherlock Holmes, but with fewer clues and more yelling.
|
479
|
+
def infer_alias
|
480
|
+
# Find first Node alias defined in MATCH/CREATE/MERGE clauses
|
481
|
+
@clauses.each do |clause|
|
482
|
+
next unless clause.respond_to?(:pattern) && clause.pattern
|
483
|
+
|
484
|
+
pattern = clause.pattern
|
485
|
+
element = pattern.is_a?(Pattern::Path) ? pattern.elements.first : pattern
|
486
|
+
return element.alias_name if element.is_a?(Pattern::Node) && element.alias_name
|
487
|
+
end
|
488
|
+
raise 'Cannot infer alias for WHERE hash conditions. Define a node alias in MATCH/CREATE first.'
|
489
|
+
end
|
490
|
+
|
491
|
+
def freeze!
|
492
|
+
@parameters.freeze
|
493
|
+
@clauses.each(&:freeze)
|
494
|
+
freeze
|
495
|
+
end
|
496
|
+
end
|
497
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
# Class for standalone RETURN statements
|
5
|
+
class ReturnOnly
|
6
|
+
def initialize(return_values)
|
7
|
+
@return_values = return_values
|
8
|
+
end
|
9
|
+
|
10
|
+
def to_cypher
|
11
|
+
formatted_values = @return_values.map do |alias_name, value|
|
12
|
+
formatted = case value
|
13
|
+
when Array
|
14
|
+
"[#{value.join(', ')}]"
|
15
|
+
when Hash
|
16
|
+
"{#{value.map { |k, v| "#{k}: #{v.is_a?(String) ? "\"#{v}\"" : v}" }.join(', ')}}"
|
17
|
+
else
|
18
|
+
value.to_s
|
19
|
+
end
|
20
|
+
"#{formatted} AS #{alias_name}"
|
21
|
+
end
|
22
|
+
|
23
|
+
"RETURN #{formatted_values.join(', ')}"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/hash_with_indifferent_access'
|
4
|
+
require 'active_model/type/value'
|
5
|
+
|
6
|
+
module Cyrel
|
7
|
+
module Types
|
8
|
+
class HashType < ActiveModel::Type::Value
|
9
|
+
def cast(value)
|
10
|
+
case value
|
11
|
+
when nil then ActiveSupport::HashWithIndifferentAccess.new
|
12
|
+
when Hash then value.with_indifferent_access
|
13
|
+
else
|
14
|
+
value.respond_to?(:to_h) ? value.to_h.with_indifferent_access : {}
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Serialize as a plain Hash (e.g., for JSON output); symbols are fine
|
19
|
+
def serialize(value) = cast(value)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cyrel
|
4
|
+
module Types
|
5
|
+
# inherits low‑overhead base
|
6
|
+
class SymbolType < ActiveModel::Type::Value
|
7
|
+
# String/ Symbol → Symbol / nil
|
8
|
+
def cast(value) = value&.to_sym
|
9
|
+
# Symbol → String (for JSON, etc.)
|
10
|
+
def serialize(value) = value.to_s
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
data/lib/cyrel.rb
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'irb' # Required for binding.irb
|
4
|
+
|
5
|
+
module Cyrel
|
6
|
+
module_function
|
7
|
+
|
8
|
+
# Define all top-level helpers as instance methods first
|
9
|
+
def call(procedure)
|
10
|
+
CallProcedure.new(procedure)
|
11
|
+
end
|
12
|
+
|
13
|
+
def return(**return_values)
|
14
|
+
ReturnOnly.new(return_values)
|
15
|
+
end
|
16
|
+
# Now make all defined instance methods module functions
|
17
|
+
|
18
|
+
# --- Pattern Helpers ---
|
19
|
+
def node(alias_name, labels: [], properties: {})
|
20
|
+
Pattern::Node.new(alias_name, labels: labels, properties: properties)
|
21
|
+
end
|
22
|
+
# Add helpers for Relationship, Path if needed
|
23
|
+
|
24
|
+
# --- Query Building Starters ---
|
25
|
+
def create(pattern)
|
26
|
+
Query.new.create(pattern) # Start a new query and call create
|
27
|
+
end
|
28
|
+
|
29
|
+
def match(pattern, path_variable: nil)
|
30
|
+
Query.new.match(pattern, path_variable: path_variable) # Start a new query and call match
|
31
|
+
end
|
32
|
+
# Add helpers for merge etc. if desired as query starters
|
33
|
+
|
34
|
+
# --- Function Helpers (Delegated) ---
|
35
|
+
# Keep id for now for compatibility? Or remove entirely? Let's keep it but delegate element_id too.
|
36
|
+
# Delegate to the correct module function
|
37
|
+
def id(...) = Functions.element_id(...)
|
38
|
+
def element_id(...) = Functions.element_id(...)
|
39
|
+
def count(...) = Functions.count(...)
|
40
|
+
def labels(...) = Functions.labels(...)
|
41
|
+
def type(...) = Functions.type(...)
|
42
|
+
def properties(...) = Functions.properties(...)
|
43
|
+
def coalesce(...) = Functions.coalesce(...)
|
44
|
+
def timestamp(...) = Functions.timestamp(...)
|
45
|
+
def to_string(...) = Functions.to_string(...)
|
46
|
+
def to_integer(...) = Functions.to_integer(...)
|
47
|
+
def to_float(...) = Functions.to_float(...)
|
48
|
+
def to_boolean(...) = Functions.to_boolean(...)
|
49
|
+
def sum(...) = Functions.sum(...)
|
50
|
+
def avg(...) = Functions.avg(...)
|
51
|
+
def min(...) = Functions.min(...)
|
52
|
+
def max(...) = Functions.max(...)
|
53
|
+
def collect(...) = Functions.collect(...)
|
54
|
+
def size(...) = Functions.size(...)
|
55
|
+
|
56
|
+
# --- Expression Helpers (Delegated) ---
|
57
|
+
|
58
|
+
# Helper for creating PropertyAccess expressions.
|
59
|
+
def prop(variable, property_name)
|
60
|
+
Expression.prop(variable, property_name)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Helper for creating Exists expressions.
|
64
|
+
def exists(pattern)
|
65
|
+
Expression.exists(pattern)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Helper for creating Logical NOT expressions.
|
69
|
+
def not(expression)
|
70
|
+
Expression.not(expression)
|
71
|
+
end
|
72
|
+
end
|