Class: Cyrel::Query

Inherits:
Object
  • Object
show all
Includes:
Logging, Parameterizable
Defined in:
lib/cyrel/query.rb

Constant Summary

Constants included from Logging

Logging::LOG_TAG

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Logging

#log_debug, #log_error, #log_info, #log_warn, logger, #logger, resolve_log_level

Constructor Details

#initializeQuery

Returns a new instance of Query.



21
22
23
24
25
26
# File 'lib/cyrel/query.rb', line 21

def initialize
  @parameters = {}
  @param_counter = 0
  @clauses = [] # Holds instances of Clause::Base subclasses, because arrays are the new query planner
  @loop_variables = Set.new # Track loop variables for FOREACH context
end

Instance Attribute Details

#clausesObject (readonly)

Expose clauses for merge logic



19
20
21
# File 'lib/cyrel/query.rb', line 19

def clauses
  @clauses
end

#parametersObject (readonly)

Expose clauses for merge logic



19
20
21
# File 'lib/cyrel/query.rb', line 19

def parameters
  @parameters
end

Class Method Details

.union_queries(queries, all: false) ⇒ Cyrel::Query

Combines multiple queries using UNION or UNION ALL

Parameters:

  • queries (Array<Cyrel::Query>)

    The queries to combine

  • all (Boolean) (defaults to: false)

    Whether to use UNION ALL (true) or UNION (false)

Returns:

Raises:

  • (ArgumentError)


561
562
563
564
565
566
567
568
569
# File 'lib/cyrel/query.rb', line 561

def self.union_queries(queries, all: false)
  raise ArgumentError, 'UNION requires at least 2 queries' if queries.size < 2

  # Create a new query that represents the union
  union_query = new
  union_node = AST::UnionNode.new(queries, all: all)
  union_query.add_clause(AST::ClauseAdapter.new(union_node))
  union_query
end

Instance Method Details

#add_clause(clause) ⇒ Object

Adds a clause object to the query. Because what you really wanted was a linked list of existential dread.

Parameters:



50
51
52
53
# File 'lib/cyrel/query.rb', line 50

def add_clause(clause)
  @clauses << clause
  self # Allow chaining
end

#call_procedure(procedure_name, arguments: [], yield_items: nil, where: nil, return_items: nil) ⇒ self

Adds a CALL procedure clause. For when you want to call a procedure and pretend it’s not just another query.

Parameters:

  • procedure_name (String)

    Name of the procedure.

  • arguments (Array) (defaults to: [])

    Arguments for the procedure.

  • yield_items (Array<String>, String, nil) (defaults to: nil)

    Items to YIELD.

  • where (Clause::Where, Hash, Array, nil) (defaults to: nil)

    WHERE condition after YIELD.

  • return_items (Clause::Return, Array, nil) (defaults to: nil)

    RETURN items after WHERE/YIELD.

Returns:

  • (self)


497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/cyrel/query.rb', line 497

def call_procedure(procedure_name, arguments: [], yield_items: nil, where: nil, return_items: nil)
  # Use AST-based implementation for simple CALL
  # Note: WHERE and RETURN after YIELD are not yet supported in AST version
  if where || return_items
    # Fall back to clause-based for complex cases
    add_clause(Clause::Call.new(procedure_name,
                                arguments: arguments,
                                yield_items: yield_items,
                                where: where,
                                return_items: return_items))
  else
    call_node = AST::CallNode.new(procedure_name, arguments: arguments, yield_items: yield_items)
    ast_clause = AST::ClauseAdapter.new(call_node)
    add_clause(ast_clause)
  end
  self
end

#call_subquery {|Cyrel::Query| ... } ⇒ self

Adds a CALL { subquery } clause. Because why write one query when you can write two and glue them together?

Yields:

  • (Cyrel::Query)

    Yields a new query object for building the subquery.

Returns:

  • (self)


519
520
521
522
523
524
525
526
# File 'lib/cyrel/query.rb', line 519

def call_subquery
  subquery = Cyrel::Query.new
  yield subquery
  # Use AST-based implementation
  call_subquery_node = AST::CallSubqueryNode.new(subquery)
  ast_clause = AST::ClauseAdapter.new(call_subquery_node)
  add_clause(ast_clause)
end

#check_alias_conflicts!(other_query) ⇒ Object

Detects alias conflicts between queries. Because two nodes with the same name but different labels are the graph equivalent of identity theft.



695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
# File 'lib/cyrel/query.rb', line 695

def check_alias_conflicts!(other_query)
  self_aliases = defined_aliases
  other_aliases = other_query.defined_aliases

  conflicting_aliases = self_aliases.keys & other_aliases.keys

  conflicting_aliases.each do |alias_name|
    self_labels = self_aliases[alias_name]
    other_labels = other_aliases[alias_name]

    # Conflict if labels are defined and different, or if one defines labels and the other doesn't.
    # Allowing merge if both define the *same* labels or neither defines labels.
    is_conflict = !self_labels.empty? && !other_labels.empty? && self_labels != other_labels
    # Consider it a conflict if one defines labels and the other doesn't? Maybe too strict.
    # is_conflict ||= (self_labels.empty? != other_labels.empty?)

    next unless is_conflict

    raise AliasConflictError.new(
      alias_name,
      "labels #{self_labels.to_a.inspect}",
      "labels #{other_labels.to_a.inspect}"
    )
  end
end

#clause_order(clause) ⇒ Object

Provides a sort order for clauses during rendering. Lower numbers come first. Because even your clauses need to know their place in the world.



623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
# File 'lib/cyrel/query.rb', line 623

def clause_order(clause)
  # All clauses should be AST-based now
  return 997 unless clause.is_a?(AST::ClauseAdapter)

  # Clause ordering values - lower numbers come first
  case clause.ast_node
  when AST::LoadCsvNode then 2
  when AST::MatchNode then 5
  when AST::CallNode, AST::CallSubqueryNode then 7
  when AST::WhereNode
    # WHERE can come after different clauses - check what came before
    # This is a simplified approach - a more sophisticated one would
    # track the actual clause relationships
    has_load_csv = @clauses.any? { |c| c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::LoadCsvNode) }
    has_load_csv ? 3 : 11
  when AST::WithNode then 13
  when AST::UnwindNode then 17
  when AST::CreateNode then 23
  when AST::MergeNode then 23
  when AST::SetNode then 29
  when AST::RemoveNode then 29
  when AST::DeleteNode then 29
  when AST::ForeachNode then 31
  when AST::ReturnNode then 37
  when AST::OrderByNode then 41
  when AST::SkipNode then 43
  when AST::LimitNode then 47
  when AST::UnionNode then 53
  else 997
  end
end

#combine_clauses!(other_query) ⇒ Object

Combines clauses from the other query into this one based on type. Because merging queries is just like merging companies: someone always loses.



723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
# File 'lib/cyrel/query.rb', line 723

def combine_clauses!(other_query)
  # Clone other query's clauses to avoid modifying it during iteration
  other_clauses_to_process = other_query.clauses.dup

  # --- Handle Replacing Clauses (OrderBy, Skip, Limit) ---
  [AST::OrderByNode, AST::SkipNode, AST::LimitNode].each do |ast_class|
    # Helper to check if a clause matches the type we're looking for
    clause_matcher = lambda do |c|
      c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(ast_class)
    end

    # Find the last occurrence in the other query's clauses
    other_clause = other_clauses_to_process.reverse.find(&clause_matcher)
    next unless other_clause

    # Find the clause in self, if it exists
    self_clause = @clauses.find(&clause_matcher)

    if self_clause && other_clause
      # Replace the existing clause
      self_clause_index = @clauses.index(self_clause)
      @clauses[self_clause_index] = other_clause
    elsif !self_clause
      # If self doesn't have the clause, add the one from other_query
      add_clause(other_clause)
    end

    # Remove *all* occurrences of this clause type from the list to process further
    other_clauses_to_process.delete_if(&clause_matcher)
  end

  # --- Handle Merging Clauses (Where) ---
  other_wheres = other_query.clauses.select { |c| c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WhereNode) }
  unless other_wheres.empty?
    self_where = @clauses.find { |c| c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WhereNode) }
    if self_where
      # For AST WHERE nodes, we need to merge the conditions
      other_wheres.each do |ow|
        # Extract conditions from both WHERE nodes and create a new merged one
        self_conditions = self_where.ast_node.conditions
        other_conditions = ow.ast_node.conditions
        merged_where_node = AST::WhereNode.new(self_conditions + other_conditions)
        self_where_index = @clauses.index(self_where)
        @clauses[self_where_index] = AST::ClauseAdapter.new(merged_where_node)
      end
    else
      # Add the first other_where
      add_clause(other_wheres.first)
    end
    # Remove processed clauses
    other_clauses_to_process.delete_if { |c| c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WhereNode) }
  end

  # --- Handle Appending Clauses (Match, Create, Set, Remove, Delete, With, Return, Call, etc.) ---
  # Add remaining clauses from other_query
  other_clauses_to_process.each { |clause| add_clause(clause) }
end

#create(pattern) ⇒ self

Adds a CREATE clause. Because sometimes you want to make things, not just break them.

Parameters:

Returns:

  • (self)


197
198
199
200
201
202
# File 'lib/cyrel/query.rb', line 197

def create(pattern)
  # Use AST-based implementation
  create_node = AST::CreateNode.new(pattern)
  ast_clause = AST::ClauseAdapter.new(create_node)
  add_clause(ast_clause)
end

#defined_aliasesHash{Symbol => Set<String>}

Extracts defined aliases and their labels from the query’s clauses. Because even your variables want to be unique snowflakes.

Returns:

  • (Hash{Symbol => Set<String>})

    { alias_name => Set[label1, label2] }



658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
# File 'lib/cyrel/query.rb', line 658

def defined_aliases
  aliases = {}
  @clauses.each do |clause|
    # Look for AST clauses that define patterns (Match, Create, Merge)
    next unless clause.is_a?(AST::ClauseAdapter)

    pattern = case clause.ast_node
              when AST::MatchNode, AST::CreateNode, AST::MergeNode
                clause.ast_node.pattern
              end

    next unless pattern

    elements_to_check = []
    case pattern
    when Pattern::Path
      elements_to_check.concat(pattern.elements)
    when Pattern::Node, Pattern::Relationship
      elements_to_check << pattern
    end

    elements_to_check.each do |element|
      next unless element.respond_to?(:alias_name) && element.alias_name

      alias_name = element.alias_name
      labels = Set.new
      labels.merge(element.labels) if element.is_a?(Pattern::Node) && element.respond_to?(:labels)

      aliases[alias_name] ||= Set.new
      aliases[alias_name].merge(labels) unless labels.empty?
    end
  end
  aliases
end

#delete_(*variables) ⇒ self

Adds a DELETE clause. Use ‘detach_delete` for DETACH DELETE. Underscore to avoid keyword clash Because sometimes you just want to watch the world burn, one node at a time.

Parameters:

  • variables (Array<Symbol, String>)

    Variables to delete.

Returns:

  • (self)


299
300
301
302
303
304
# File 'lib/cyrel/query.rb', line 299

def delete_(*variables)
  # Use AST-based implementation
  delete_node = AST::DeleteNode.new(variables, detach: false)
  ast_clause = AST::ClauseAdapter.new(delete_node)
  add_clause(ast_clause)
end

#detach_delete(*variables) ⇒ self

Adds a DETACH DELETE clause. For when you want to delete with extreme prejudice.

Parameters:

  • variables (Array<Symbol, String>)

    Variables to delete.

Returns:

  • (self)


310
311
312
313
314
315
# File 'lib/cyrel/query.rb', line 310

def detach_delete(*variables)
  # Use AST-based implementation
  delete_node = AST::DeleteNode.new(variables, detach: true)
  ast_clause = AST::ClauseAdapter.new(delete_node)
  add_clause(ast_clause)
end

#foreach(variable, expression) {|sub_query| ... } ⇒ self

Adds a FOREACH clause for iterating over a list with update operations

Parameters:

  • variable (Symbol)

    The iteration variable

  • expression (Expression, Array)

    The list to iterate over

  • update_clauses (Array<Clause>)

    The update clauses to execute for each element

Yields:

  • (sub_query)

Returns:

  • (self)

Raises:

  • (ArgumentError)


576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
# File 'lib/cyrel/query.rb', line 576

def foreach(variable, expression)
  # If a block is given, create a sub-query context for update clauses
  raise ArgumentError, 'FOREACH requires a block with update clauses' unless block_given?

  sub_query = self.class.new
  # Pass loop variable context to sub-query
  sub_query.instance_variable_set(:@loop_variables, @loop_variables.dup)
  sub_query.instance_variable_get(:@loop_variables).add(variable.to_sym)

  yield sub_query
  update_clauses = sub_query.clauses

  foreach_node = AST::ForeachNode.new(variable, expression, update_clauses)
  add_clause(AST::ClauseAdapter.new(foreach_node))
end

#freeze!Object



806
807
808
809
810
# File 'lib/cyrel/query.rb', line 806

def freeze!
  @parameters.freeze
  @clauses.each(&:freeze)
  freeze
end

#infer_aliasObject

Helper needed for ‘where` DSL method with hash conditions Tries to guess the primary alias. Like Sherlock Holmes, but with fewer clues and more yelling.



788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
# File 'lib/cyrel/query.rb', line 788

def infer_alias
  # Find first Node alias defined in MATCH/CREATE/MERGE clauses
  @clauses.each do |clause|
    next unless clause.is_a?(AST::ClauseAdapter)

    pattern = case clause.ast_node
              when AST::MatchNode, AST::CreateNode, AST::MergeNode
                clause.ast_node.pattern
              end

    next unless pattern

    element = pattern.is_a?(Pattern::Path) ? pattern.elements.first : pattern
    return element.alias_name if element.is_a?(Pattern::Node) && element.alias_name
  end
  raise 'Cannot infer alias for WHERE hash conditions. Define a node alias in MATCH/CREATE first.'
end

#limit(amount) ⇒ self

Adds or replaces the LIMIT clause. Because sometimes you want boundaries, even in your queries.

Parameters:

  • amount (Integer, Expression)

    Maximum number of results.

Returns:

  • (self)


472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
# File 'lib/cyrel/query.rb', line 472

def limit(amount)
  # Use AST-based implementation
  limit_node = AST::LimitNode.new(amount)
  ast_clause = AST::ClauseAdapter.new(limit_node)

  # Find and replace existing limit or add new one
  existing_limit_index = @clauses.find_index { |c| c.is_a?(Clause::Limit) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::LimitNode)) }

  if existing_limit_index
    @clauses[existing_limit_index] = ast_clause
  else
    add_clause(ast_clause)
  end

  self
end

#load_csv(from:, as:, with_headers: false, fieldterminator: nil) ⇒ self

Adds a LOAD CSV clause for importing CSV data

Parameters:

  • url (String)

    The URL or file path to load CSV from

  • variable (Symbol)

    The variable to bind each row to

  • with_headers (Boolean) (defaults to: false)

    Whether the CSV has headers

  • fieldterminator (String) (defaults to: nil)

    The field delimiter (default is comma)

Returns:

  • (self)


598
599
600
601
# File 'lib/cyrel/query.rb', line 598

def load_csv(from:, as:, with_headers: false, fieldterminator: nil)
  load_csv_node = AST::LoadCsvNode.new(from, as, with_headers: with_headers, fieldterminator: fieldterminator)
  add_clause(AST::ClauseAdapter.new(load_csv_node))
end

#match(pattern, path_variable: nil) ⇒ self

Adds a MATCH clause. Because nothing says “find me” like a declarative pattern and a prayer.

Parameters:

  • pattern (Cyrel::Pattern::Path, Node, Relationship, Hash, Array)

    Pattern definition.

    • Can pass Pattern objects directly.

    • Can pass Hashes/Arrays to construct simple Node/Relationship patterns implicitly? (TBD)

  • path_variable (Symbol, String, nil) (defaults to: nil)

    Optional variable for the path.

Returns:

  • (self)


102
103
104
105
106
107
# File 'lib/cyrel/query.rb', line 102

def match(pattern, path_variable: nil)
  # Use AST-based implementation
  match_node = AST::MatchNode.new(pattern, optional: false, path_variable: path_variable)
  ast_clause = AST::ClauseAdapter.new(match_node)
  add_clause(ast_clause)
end

#merge(pattern, on_create: nil, on_match: nil) ⇒ self

Adds a MERGE clause. For when you want to find-or-create, but with more existential angst.

Parameters:

  • pattern (Cyrel::Pattern::Path, Node, Relationship, Hash, Array)

    Pattern definition.

  • on_create (Array, Hash) (defaults to: nil)

    Optional ON CREATE SET assignments

  • on_match (Array, Hash) (defaults to: nil)

    Optional ON MATCH SET assignments

Returns:

  • (self)


210
211
212
213
214
215
# File 'lib/cyrel/query.rb', line 210

def merge(pattern, on_create: nil, on_match: nil)
  # Use AST-based implementation
  merge_node = AST::MergeNode.new(pattern, on_create: on_create, on_match: on_match)
  ast_clause = AST::ClauseAdapter.new(merge_node)
  add_clause(ast_clause)
end

#merge!(other_query) ⇒ self

Merges two Cyrel::Query objects together. Think Cypher polyamory: full of unexpected alias drama and parameter custody battles. If you like surprises, you’ll love this method.

Parameters:

Returns:

  • (self)

Raises:

  • (ArgumentError)


78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/cyrel/query.rb', line 78

def merge!(other_query)
  raise ArgumentError, 'Can only merge another Cyrel::Query' unless other_query.is_a?(Cyrel::Query)
  return self if other_query.clauses.empty? # Nothing to merge

  # 1. Alias Conflict Detection
  check_alias_conflicts!(other_query)

  # 2. Parameter Merging
  merge_parameters!(other_query)

  # 3. Clause Combination
  combine_clauses!(other_query)

  self
end

#merge_parameters!(other_query) ⇒ Object

Merges parameters from another query, ensuring keys are unique. Because parameter collisions are the only collisions you want in production.



607
608
609
610
611
612
613
614
615
616
617
618
619
# File 'lib/cyrel/query.rb', line 607

def merge_parameters!(other_query)
  # Ensure our counter is beyond the other query's potential keys
  max_other_param_num = other_query.parameters.keys
                                   .map { |k| k.to_s.sub(/^p/, '').to_i }
                                   .max || 0
  @param_counter = [@param_counter, max_other_param_num].max

  # Re-register each parameter from the other query
  other_query.parameters.each_value do |value|
    register_parameter(value)
    # NOTE: This doesn't update references within the other_query's original clauses.
  end
end

#optional_match(pattern, path_variable: nil) ⇒ self

Adds an OPTIONAL MATCH clause. For when you want to be non-committal, even in your queries.

Parameters:

  • pattern (Cyrel::Pattern::Path, Node, Relationship, Hash, Array)

    Pattern definition.

  • path_variable (Symbol, String, nil) (defaults to: nil)

    Optional variable for the path.

Returns:

  • (self)


114
115
116
117
118
119
# File 'lib/cyrel/query.rb', line 114

def optional_match(pattern, path_variable: nil)
  # Use AST-based implementation
  match_node = AST::MatchNode.new(pattern, optional: true, path_variable: path_variable)
  ast_clause = AST::ClauseAdapter.new(match_node)
  add_clause(ast_clause)
end

#order_by(*order_items) ⇒ self

Adds or replaces the ORDER BY clause. Because sometimes you want order, and sometimes you just want chaos.

Parameters:

  • order_items (Array<Array>, Hash)

    Ordering specifications.

    • Array: [[expr, :asc], [expr, :desc], …]

    • Hash: { expr => :asc, expr => :desc, … }

Returns:

  • (self)


430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'lib/cyrel/query.rb', line 430

def order_by(*order_items)
  items_array = order_items.first.is_a?(Hash) ? order_items.first.to_a : order_items

  # Use AST-based implementation
  order_by_node = AST::OrderByNode.new(items_array)
  ast_clause = AST::ClauseAdapter.new(order_by_node)

  # Find and replace existing order by or add new one
  existing_order_index = @clauses.find_index { |c| c.is_a?(Clause::OrderBy) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::OrderByNode)) }

  if existing_order_index
    @clauses[existing_order_index] = ast_clause
  else
    add_clause(ast_clause)
  end
  self
end

#register_parameter(value) ⇒ Symbol

Registers a value and returns a parameter key. Think of it as variable adoption but with less paperwork and more risk. Because nothing says “safe query” like a parade of anonymous parameters.

Parameters:

  • value (Object)

    The value to parameterize.

Returns:

  • (Symbol)

    The parameter key (e.g., :p1, :p2).



33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/cyrel/query.rb', line 33

def register_parameter(value)
  # Don't parameterize loop variables in FOREACH context
  if value.is_a?(Symbol) && @loop_variables.include?(value)
    return value # Return the symbol itself, not a parameter key
  end

  existing_key = @parameters.key(value)
  return existing_key if existing_key

  key = next_param_key
  @parameters[key] = value
  key
end

#remove(*items) ⇒ self

Adds a REMOVE clause. For when you want to Marie Kondo your graph.

Parameters:

Returns:

  • (self)


287
288
289
290
291
292
# File 'lib/cyrel/query.rb', line 287

def remove(*items)
  # Use AST-based implementation
  remove_node = AST::RemoveNode.new(items)
  ast_clause = AST::ClauseAdapter.new(remove_node)
  add_clause(ast_clause)
end

#return_(*items, distinct: false) ⇒ self

Adds a RETURN clause. Note: Method is named ‘return_` with an underscore suffix because `return` is a reserved keyword in Ruby. We’re not crazy - we just want to provide a clean DSL while respecting Ruby’s language constraints.

Parameters:

  • items (Array)

    Items to return. See Clause::Return#initialize.

  • distinct (Boolean) (defaults to: false)

    Use DISTINCT?

Returns:

  • (self)


386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'lib/cyrel/query.rb', line 386

def return_(*items, distinct: false)
  # Process items similar to existing Return clause
  processed_items = items.flatten.map do |item|
    case item
    when Expression::Base
      item
    when Symbol
      # Create a RawIdentifier for variable names
      Clause::Return::RawIdentifier.new(item.to_s)
    when String
      # Check if string looks like property access (e.g. "person.name")
      # If so, treat as raw identifier, otherwise parameterize
      if item.match?(/\A\w+\.\w+\z/)
        Clause::Return::RawIdentifier.new(item)
      else
        # String literals should be coerced to expressions (parameterized)
        Expression.coerce(item)
      end
    else
      Expression.coerce(item)
    end
  end

  # Use AST-based implementation
  return_node = AST::ReturnNode.new(processed_items, distinct: distinct)
  ast_clause = AST::ClauseAdapter.new(return_node)

  # Find and replace existing return or add new one
  existing_return_index = @clauses.find_index { |c| c.is_a?(Clause::Return) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::ReturnNode)) }

  if existing_return_index
    @clauses[existing_return_index] = ast_clause
  else
    add_clause(ast_clause)
  end
  self
end

#set(assignments) ⇒ self

Adds a SET clause. Because sometimes you just want to change everything and pretend it was always that way.

Parameters:

  • assignments (Hash, Array)

    See Clause::Set#initialize.

Returns:

  • (self)


221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/cyrel/query.rb', line 221

def set(assignments)
  # Process assignments similar to existing Set clause
  processed_assignments = case assignments
                          when Hash
                            assignments.flat_map do |key, value|
                              case key
                              when Expression::PropertyAccess
                                # SET n.prop = value
                                [[:property, key, Expression.coerce(value)]]
                              when Symbol, String
                                # SET n = properties
                                raise ArgumentError, 'Value for variable assignment must be a Hash' unless value.is_a?(Hash)

                                [[:variable_properties, key.to_sym, Expression.coerce(value), :assign]]
                              when Cyrel::Plus
                                # SET n += properties
                                raise ArgumentError, 'Value for variable assignment must be a Hash' unless value.is_a?(Hash)

                                [[:variable_properties, key.variable.to_sym, Expression.coerce(value), :merge]]
                              else
                                raise ArgumentError, "Invalid key type in SET assignments: #{key.class}"
                              end
                            end
                          when Array
                            assignments.map do |item|
                              unless item.is_a?(Array) && item.length == 2
                                raise ArgumentError, "Invalid label assignment format. Expected [[:variable, 'Label'], ...], got #{item.inspect}"
                              end

                              # SET n:Label
                              [:label, item[0].to_sym, item[1]]
                            end
                          else
                            raise ArgumentError, "Invalid assignments type: #{assignments.class}"
                          end

  set_node = AST::SetNode.new(processed_assignments)
  ast_clause = AST::ClauseAdapter.new(set_node)

  # Check for existing SET clause to merge with
  existing_set_index = @clauses.find_index { |c| c.is_a?(Clause::Set) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::SetNode)) }

  if existing_set_index
    existing_clause = @clauses[existing_set_index]
    if existing_clause.is_a?(AST::ClauseAdapter) && existing_clause.ast_node.is_a?(AST::SetNode)
      # Merge with existing AST SET node by creating a new one with combined assignments
      combined_assignments = existing_clause.ast_node.assignments + processed_assignments
      merged_set_node = AST::SetNode.new(combined_assignments)
    else
      # Replace old clause-based SET with merged AST version
      combined_assignments = existing_clause.assignments + set_node.assignments
      merged_set_node = AST::SetNode.new({})
      merged_set_node.instance_variable_set(:@assignments, combined_assignments)
    end
    @clauses[existing_set_index] = AST::ClauseAdapter.new(merged_set_node)
  else
    add_clause(ast_clause)
  end

  self
end

#skip(amount) ⇒ self

Adds or replaces the SKIP clause. For when you want to ignore the first N results, just like your unread emails.

Parameters:

  • amount (Integer, Expression)

    Number of results to skip.

Returns:

  • (self)


452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
# File 'lib/cyrel/query.rb', line 452

def skip(amount)
  # Use AST-based implementation
  skip_node = AST::SkipNode.new(amount)
  ast_clause = AST::ClauseAdapter.new(skip_node)

  # Find and replace existing skip or add new one
  existing_skip_index = @clauses.find_index { |c| c.is_a?(Clause::Skip) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::SkipNode)) }

  if existing_skip_index
    @clauses[existing_skip_index] = ast_clause
  else
    add_clause(ast_clause)
  end
  self
end

#to_cypherArray(String, Hash)

Generates the final Cypher query string and parameters hash. This is where all your careful planning gets flattened into a string.

Returns:

  • (Array(String, Hash))

    The Cypher string and parameters.



58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/cyrel/query.rb', line 58

def to_cypher
  ActiveSupport::Notifications.instrument('cyrel.render', query: self) do
    cypher_string = @clauses
                    .sort_by { |clause| clause_order(clause) }
                    .map { it.render(self) }
                    .reject(&:blank?)
                    .join("\n")

    log_debug("QUERY: #{cypher_string}")
    log_debug("PARAMS: #{@parameters.inspect}") unless @parameters.empty?

    [cypher_string, @parameters]
  end
end

#union(other_query) ⇒ Cyrel::Query

No longer private, needed by merge! Combines this query with another using UNION

Parameters:

Returns:



546
547
548
# File 'lib/cyrel/query.rb', line 546

def union(other_query)
  self.class.union_queries([self, other_query], all: false)
end

#union_all(other_query) ⇒ Cyrel::Query

Combines this query with another using UNION ALL

Parameters:

Returns:



553
554
555
# File 'lib/cyrel/query.rb', line 553

def union_all(other_query)
  self.class.union_queries([self, other_query], all: true)
end

#unwind(expression, variable) ⇒ self

Adds an UNWIND clause. For when you want to turn one row with a list into many rows with values, like unpacking a suitcase but for data Example: query.unwind(, :x).return_(:x)

query.unwind(:names, :name).create(...)

Parameters:

  • expression (Array, Symbol, Object)

    The list expression to unwind

  • variable (Symbol, String)

    The variable name to bind each element to

Returns:

  • (self)


536
537
538
539
540
# File 'lib/cyrel/query.rb', line 536

def unwind(expression, variable)
  # Create an AST UnwindNode wrapped in a ClauseAdapter
  ast_node = AST::UnwindNode.new(expression, variable)
  add_clause(AST::ClauseAdapter.new(ast_node))
end

#where(*conditions) ⇒ self

Adds a WHERE clause (merging with an existing one if present).

Accepts:

• Hash  – coerced into equality comparisons
• Cyrel::Expression instances (or anything Expression.coerce understands)

Because sometimes you want to filter, and sometimes you just want to judge.

Examples:

query.where(name: 'Alice').where(age: 30)
# ⇒ WHERE ((n.name = $p1) AND (n.age = $p2))

Returns:

  • (self)


133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/cyrel/query.rb', line 133

def where(*conditions)
  # ------------------------------------------------------------------
  # 1. Coerce incoming objects into Cyrel::Expression instances
  # ------------------------------------------------------------------
  processed_conditions = conditions.flat_map do |cond|
    if cond.is_a?(Hash)
      cond.map do |key, value|
        Expression::Comparison.new(
          Expression::PropertyAccess.new(@current_alias || infer_alias, key),
          :'=',
          value
        )
      end
    else
      cond # already an expression (or coercible)
    end
  end

  # Use AST-based implementation
  where_node = AST::WhereNode.new(processed_conditions)
  ast_clause = AST::ClauseAdapter.new(where_node)

  # ------------------------------------------------------------------
  # 2. Merge with an existing WHERE (if any)
  # ------------------------------------------------------------------
  existing_where_index = @clauses.find_index { |c| c.is_a?(Clause::Where) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WhereNode)) }

  if existing_where_index
    existing_clause = @clauses[existing_where_index]
    if existing_clause.is_a?(AST::ClauseAdapter) && existing_clause.ast_node.is_a?(AST::WhereNode)
      # Merge conditions by creating a new WHERE node with combined conditions
      combined_conditions = existing_clause.ast_node.conditions + processed_conditions
      merged_where_node = AST::WhereNode.new(combined_conditions)
      @clauses[existing_where_index] = AST::ClauseAdapter.new(merged_where_node)
    else
      # Replace old-style WHERE with AST WHERE
      @clauses[existing_where_index] = ast_clause
    end
    return self
  end

  # ------------------------------------------------------------------
  # 3. Determine correct insertion point
  # ------------------------------------------------------------------
  insertion_index = @clauses.index do |c|
    c.is_a?(Clause::Return) ||
      c.is_a?(Clause::OrderBy) ||
      c.is_a?(Clause::Skip)    ||
      c.is_a?(Clause::Limit)
  end

  if insertion_index
    @clauses.insert(insertion_index, ast_clause)
  else
    @clauses << ast_clause
  end

  self
end

#with(*items, distinct: false, where: nil) ⇒ self

Adds a WITH clause. Because sometimes you want to pass things along, and sometimes you just want to pass the buck.

Parameters:

  • items (Array)

    Items to project. See Clause::With#initialize.

  • distinct (Boolean) (defaults to: false)

    Use DISTINCT?

  • where (Cyrel::Clause::Where, Hash, Array) (defaults to: nil)

    Optional WHERE condition(s) after WITH.

Returns:

  • (self)


323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/cyrel/query.rb', line 323

def with(*items, distinct: false, where: nil)
  # Process items similar to existing Return clause
  processed_items = items.flatten.map do |item|
    case item
    when Expression::Base
      item
    when Symbol
      # Create a RawIdentifier for variable names
      Clause::Return::RawIdentifier.new(item.to_s)
    when String
      # Check if string looks like property access (e.g. "person.name")
      # If so, treat as raw identifier, otherwise parameterize
      if item.match?(/\A\w+\.\w+\z/)
        Clause::Return::RawIdentifier.new(item)
      else
        # String literals should be coerced to expressions (parameterized)
        Expression.coerce(item)
      end
    else
      Expression.coerce(item)
    end
  end

  # Process WHERE conditions if provided
  where_conditions = case where
                     when nil then []
                     when Hash
                       # Convert hash to equality comparisons
                       where.map do |key, value|
                         Expression::Comparison.new(
                           Expression::PropertyAccess.new(@current_alias || infer_alias, key),
                           :'=',
                           value
                         )
                       end
                     when Array then where
                     else [where] # Single condition
                     end

  # Use AST-based implementation
  with_node = AST::WithNode.new(processed_items, distinct: distinct, where_conditions: where_conditions)
  ast_clause = AST::ClauseAdapter.new(with_node)

  # Find and replace existing with or add new one
  existing_with_index = @clauses.find_index { |c| c.is_a?(Clause::With) || (c.is_a?(AST::ClauseAdapter) && c.ast_node.is_a?(AST::WithNode)) }

  if existing_with_index
    @clauses[existing_with_index] = ast_clause
  else
    add_clause(ast_clause)
  end

  self
end