activecypher 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +10 -1
  3. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +1 -1
  4. data/lib/active_cypher/connection_adapters/persistence_methods.rb +31 -14
  5. data/lib/active_cypher/relation.rb +1 -1
  6. data/lib/active_cypher/version.rb +1 -1
  7. data/lib/activecypher.rb +3 -1
  8. data/lib/cyrel/ast/call_node.rb +39 -0
  9. data/lib/cyrel/ast/clause_adapter.rb +38 -0
  10. data/lib/cyrel/ast/clause_node.rb +10 -0
  11. data/lib/cyrel/ast/compiler.rb +609 -0
  12. data/lib/cyrel/ast/create_node.rb +21 -0
  13. data/lib/cyrel/ast/delete_node.rb +22 -0
  14. data/lib/cyrel/ast/expression_node.rb +10 -0
  15. data/lib/cyrel/ast/foreach_node.rb +23 -0
  16. data/lib/cyrel/ast/limit_node.rb +21 -0
  17. data/lib/cyrel/ast/literal_node.rb +39 -0
  18. data/lib/cyrel/ast/load_csv_node.rb +24 -0
  19. data/lib/cyrel/ast/match_node.rb +23 -0
  20. data/lib/cyrel/ast/merge_node.rb +23 -0
  21. data/lib/cyrel/ast/node.rb +36 -0
  22. data/lib/cyrel/ast/optimized_nodes.rb +117 -0
  23. data/lib/cyrel/ast/order_by_node.rb +21 -0
  24. data/lib/cyrel/ast/pattern_node.rb +10 -0
  25. data/lib/cyrel/ast/query_integrated_compiler.rb +27 -0
  26. data/lib/cyrel/ast/remove_node.rb +21 -0
  27. data/lib/cyrel/ast/return_node.rb +21 -0
  28. data/lib/cyrel/ast/set_node.rb +20 -0
  29. data/lib/cyrel/ast/simple_cache.rb +50 -0
  30. data/lib/cyrel/ast/skip_node.rb +19 -0
  31. data/lib/cyrel/ast/union_node.rb +22 -0
  32. data/lib/cyrel/ast/unwind_node.rb +20 -0
  33. data/lib/cyrel/ast/where_node.rb +20 -0
  34. data/lib/cyrel/ast/with_node.rb +23 -0
  35. data/lib/cyrel/clause/unwind.rb +71 -0
  36. data/lib/cyrel/expression/literal.rb +9 -2
  37. data/lib/cyrel/expression/property_access.rb +1 -1
  38. data/lib/cyrel/pattern/node.rb +11 -1
  39. data/lib/cyrel/pattern/relationship.rb +21 -13
  40. data/lib/cyrel/query.rb +405 -91
  41. data/lib/cyrel.rb +132 -2
  42. metadata +29 -1
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # Immutable AST node for literal values using Ruby's Data class
6
+ # Because sometimes you just want to say what you mean, immutably
7
+ LiteralNode = Data.define(:value) do
8
+ def accept(visitor)
9
+ visitor.visit_literal_node(self)
10
+ end
11
+
12
+ def to_ast
13
+ self
14
+ end
15
+
16
+ # Pattern matching support
17
+ def deconstruct
18
+ [value]
19
+ end
20
+
21
+ def deconstruct_keys(_keys)
22
+ { value: value }
23
+ end
24
+
25
+ # Type inference
26
+ def inferred_type
27
+ case value
28
+ when String then :string
29
+ when Symbol then :parameter
30
+ when Integer then :integer
31
+ when Float then :float
32
+ when TrueClass, FalseClass then :boolean
33
+ when NilClass then :null
34
+ else :unknown
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # AST node for LOAD CSV clause (extension)
6
+ # For when you need to import data from the ancient format of CSV
7
+ class LoadCsvNode < ClauseNode
8
+ attr_reader :url, :variable, :with_headers, :fieldterminator
9
+
10
+ def initialize(url, variable, with_headers: false, fieldterminator: nil)
11
+ @url = url
12
+ @variable = variable
13
+ @with_headers = with_headers
14
+ @fieldterminator = fieldterminator
15
+ end
16
+
17
+ protected
18
+
19
+ def state
20
+ [@url, @variable, @with_headers, @fieldterminator]
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # AST node for MATCH and OPTIONAL MATCH clauses
6
+ # Because finding things in graphs is what we're all about
7
+ class MatchNode < ClauseNode
8
+ attr_reader :pattern, :optional, :path_variable
9
+
10
+ def initialize(pattern, optional: false, path_variable: nil)
11
+ @pattern = pattern
12
+ @optional = optional
13
+ @path_variable = path_variable
14
+ end
15
+
16
+ protected
17
+
18
+ def state
19
+ [@pattern, @optional, @path_variable]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # AST node for MERGE clauses
6
+ # For when you want to find or create, the Schrödinger's cat of graph operations
7
+ class MergeNode < ClauseNode
8
+ attr_reader :pattern, :on_create, :on_match
9
+
10
+ def initialize(pattern, on_create: nil, on_match: nil)
11
+ @pattern = pattern
12
+ @on_create = on_create
13
+ @on_match = on_match
14
+ end
15
+
16
+ protected
17
+
18
+ def state
19
+ [@pattern, @on_create, @on_match]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # Base class for all AST nodes
6
+ # Because every tree needs roots, even if they're just pretending to be organized
7
+ class Node
8
+ # Accept a visitor for the visitor pattern
9
+ # It's like accepting guests, but these guests judge your entire structure
10
+ def accept(visitor)
11
+ method_name = "visit_#{self.class.name.demodulize.underscore}"
12
+ if visitor.respond_to?(method_name)
13
+ visitor.send(method_name, self)
14
+ else
15
+ raise NotImplementedError,
16
+ "Visitor #{visitor.class} doesn't know how to visit #{self.class}. " \
17
+ "Did you forget to implement #{method_name}?"
18
+ end
19
+ end
20
+
21
+ # Equality for testing and debugging
22
+ # Because sometimes you need to know if two trees fell in the same forest
23
+ def ==(other)
24
+ self.class == other.class && state == other.state
25
+ end
26
+
27
+ protected
28
+
29
+ # Override in subclasses to define equality state
30
+ # The essence of what makes this node unique, like a fingerprint but less criminal
31
+ def state
32
+ instance_variables.map { |var| instance_variable_get(var) }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # Optimized nodes using Data for frequently used, simple nodes
6
+ # These benefit from Data's automatic hash/== and immutability
7
+
8
+ # For simple value nodes, Data is perfect
9
+ module OptimizedNodes
10
+ # Literal values - these are created frequently and benefit from Data
11
+ LiteralData = Data.define(:value) do
12
+ def accept(visitor)
13
+ visitor.visit_literal_data(self)
14
+ end
15
+ end
16
+
17
+ # Simple expression nodes that benefit from fast equality
18
+ PropertyAccessData = Data.define(:variable, :property_name) do
19
+ def accept(visitor)
20
+ visitor.visit_property_access_data(self)
21
+ end
22
+ end
23
+
24
+ # Skip and Limit are perfect for Data - simple, immutable
25
+ SkipData = Data.define(:expression) do
26
+ def accept(visitor)
27
+ visitor.visit_skip_data(self)
28
+ end
29
+ end
30
+
31
+ LimitData = Data.define(:expression) do
32
+ def accept(visitor)
33
+ visitor.visit_limit_data(self)
34
+ end
35
+ end
36
+ end
37
+
38
+ # Optimized cache that takes advantage of Data's hash/==
39
+ class OptimizedCache
40
+ include Singleton
41
+
42
+ def initialize
43
+ @cache = {}
44
+ @max_size = 1000
45
+ @mutex = Mutex.new
46
+ end
47
+
48
+ def fetch(node)
49
+ # Data objects have reliable hash/== so we can use them directly as keys
50
+ @mutex.synchronize do
51
+ if @cache.key?(node)
52
+ @cache[node]
53
+ else
54
+ value = yield
55
+ store(node, value)
56
+ value
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def store(node, value)
64
+ if @cache.size >= @max_size
65
+ # LRU eviction
66
+ @cache.shift
67
+ end
68
+ @cache[node] = value
69
+ end
70
+ end
71
+
72
+ # Example of how to use Data nodes effectively
73
+ class HybridApproach
74
+ # Use Data for simple, frequently created nodes
75
+ # Use Classes for complex nodes with behavior
76
+
77
+ def self.create_literal(value)
78
+ # Literals are perfect for Data - immutable, simple
79
+ OptimizedNodes::LiteralData.new(value)
80
+ end
81
+
82
+ def self.create_match(pattern, optional: false)
83
+ # Complex nodes with optional parameters stay as classes
84
+ MatchNode.new(pattern, optional: optional)
85
+ end
86
+
87
+ def self.benchmark_hybrid
88
+ require 'benchmark'
89
+
90
+ n = 10_000
91
+ puts "\nHybrid Approach Benchmark:"
92
+ puts '-' * 40
93
+
94
+ Benchmark.bm(35) do |x|
95
+ x.report('Create literals (Class)') do
96
+ n.times { |i| LiteralNode.new(i) }
97
+ end
98
+
99
+ x.report('Create literals (Data)') do
100
+ n.times { |i| OptimizedNodes::LiteralData.new(i) }
101
+ end
102
+
103
+ # Cache performance with Data nodes
104
+ cache = OptimizedCache.instance
105
+ literals = 100.times.map { |i| OptimizedNodes::LiteralData.new(i) }
106
+
107
+ x.report('Cache lookups (Data as key)') do
108
+ n.times do |i|
109
+ node = literals[i % 100]
110
+ cache.fetch(node) { "value_#{i}" }
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ class OrderByNode < ClauseNode
6
+ attr_reader :items
7
+
8
+ def initialize(items)
9
+ # items is an array of [expression, direction] pairs
10
+ # e.g., [[expr1, :asc], [expr2, :desc]]
11
+ @items = items
12
+ end
13
+
14
+ protected
15
+
16
+ def state
17
+ [@items]
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # Base class for pattern nodes (nodes, relationships, paths)
6
+ # Because graphs need patterns like developers need coffee
7
+ class PatternNode < Node
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # Compiler that integrates with the Query's parameter system
6
+ # Like a diplomat that speaks both AST and Query fluently
7
+ class QueryIntegratedCompiler < Compiler
8
+ attr_reader :query
9
+
10
+ def initialize(query)
11
+ # Store current loop_variables before calling super
12
+ old_loop_variables = @loop_variables
13
+ super()
14
+ @query = query
15
+ # Restore loop_variables if they were set before initialization
16
+ @loop_variables = old_loop_variables if old_loop_variables
17
+ end
18
+
19
+ protected
20
+
21
+ # Override to use the query's parameter registration
22
+ def register_parameter(value)
23
+ @query.register_parameter(value)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # AST node for REMOVE clauses
6
+ # For when you need to Marie Kondo your graph properties and labels
7
+ class RemoveNode < ClauseNode
8
+ attr_reader :items
9
+
10
+ def initialize(items)
11
+ @items = items
12
+ end
13
+
14
+ protected
15
+
16
+ def state
17
+ [@items]
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ class ReturnNode < ClauseNode
6
+ attr_reader :items, :distinct
7
+
8
+ def initialize(items, distinct: false)
9
+ # items is an array of expressions/identifiers to return
10
+ @items = items
11
+ @distinct = distinct
12
+ end
13
+
14
+ protected
15
+
16
+ def state
17
+ [@items, @distinct]
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ class SetNode < ClauseNode
6
+ attr_reader :assignments
7
+
8
+ def initialize(assignments)
9
+ # assignments is an array of processed assignment tuples
10
+ @assignments = assignments
11
+ end
12
+
13
+ protected
14
+
15
+ def state
16
+ [@assignments]
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module Cyrel
6
+ module AST
7
+ # Simple thread-safe compilation cache
8
+ # Because compiling the same query twice is like watching reruns
9
+ class SimpleCache
10
+ include Singleton
11
+
12
+ def initialize
13
+ @cache = {}
14
+ @mutex = Mutex.new
15
+ @max_size = 1000
16
+ end
17
+
18
+ def fetch(key)
19
+ @mutex.synchronize do
20
+ if @cache.key?(key)
21
+ @cache[key]
22
+ elsif block_given?
23
+ value = yield
24
+ store(key, value)
25
+ value
26
+ end
27
+ end
28
+ end
29
+
30
+ def clear!
31
+ @mutex.synchronize { @cache.clear }
32
+ end
33
+
34
+ def size
35
+ @mutex.synchronize { @cache.size }
36
+ end
37
+
38
+ private
39
+
40
+ def store(key, value)
41
+ # Simple LRU: remove oldest entries when cache is full
42
+ if @cache.size >= @max_size
43
+ # Remove half of the oldest entries
44
+ (@max_size / 2).times { @cache.shift }
45
+ end
46
+ @cache[key] = value
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ class SkipNode < ClauseNode
6
+ attr_reader :amount
7
+
8
+ def initialize(amount)
9
+ @amount = amount
10
+ end
11
+
12
+ protected
13
+
14
+ def state
15
+ [@amount]
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # AST node for UNION and UNION ALL clauses
6
+ # Because sometimes you need to combine queries like a database DJ
7
+ class UnionNode < ClauseNode
8
+ attr_reader :queries, :all
9
+
10
+ def initialize(queries, all: false)
11
+ @queries = queries
12
+ @all = all
13
+ end
14
+
15
+ protected
16
+
17
+ def state
18
+ [@queries, @all]
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ class UnwindNode < ClauseNode
6
+ attr_reader :expression, :alias_name
7
+
8
+ def initialize(expression, alias_name)
9
+ @expression = expression
10
+ @alias_name = alias_name
11
+ end
12
+
13
+ protected
14
+
15
+ def state
16
+ [@expression, @alias_name]
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ class WhereNode < ClauseNode
6
+ attr_reader :conditions
7
+
8
+ def initialize(conditions)
9
+ # conditions is an array of expression objects
10
+ @conditions = conditions
11
+ end
12
+
13
+ protected
14
+
15
+ def state
16
+ [@conditions]
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ class WithNode < ClauseNode
6
+ attr_reader :items, :distinct, :where_conditions
7
+
8
+ def initialize(items, distinct: false, where_conditions: nil)
9
+ # items is an array of expressions/identifiers to project
10
+ @items = items
11
+ @distinct = distinct
12
+ # where_conditions can be nil or an array of expressions
13
+ @where_conditions = where_conditions
14
+ end
15
+
16
+ protected
17
+
18
+ def state
19
+ [@items, @distinct, @where_conditions]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module Clause
5
+ # UNWIND clause for expanding lists into rows
6
+ # Like unpacking a suitcase, but for data and with less wrinkled clothes
7
+ class Unwind < Base
8
+ attr_reader :expression, :variable
9
+
10
+ def initialize(expression, variable)
11
+ @expression = expression
12
+ @variable = variable.to_sym
13
+ end
14
+
15
+ def render(query)
16
+ cypher_parts = ['UNWIND']
17
+
18
+ # Handle the expression
19
+ expr_str = case @expression
20
+ when Symbol
21
+ # It's a parameter
22
+ param_key = query.register_parameter(@expression)
23
+ "$#{param_key}"
24
+ when Array
25
+ # Literal array
26
+ "[#{@expression.map { |item| render_array_item(item, query) }.join(', ')}]"
27
+ when Expression::Base
28
+ # It's already an expression
29
+ @expression.render(query)
30
+ else
31
+ # Try to coerce it
32
+ Expression.coerce(@expression).render(query)
33
+ end
34
+
35
+ cypher_parts << expr_str
36
+ cypher_parts << 'AS'
37
+ cypher_parts << @variable.to_s
38
+
39
+ cypher_parts.join(' ')
40
+ end
41
+
42
+ private
43
+
44
+ def render_array_item(item, query)
45
+ case item
46
+ when Array
47
+ # Nested array
48
+ "[#{item.map { |v| render_value(v, query) }.join(', ')}]"
49
+ else
50
+ render_value(item, query)
51
+ end
52
+ end
53
+
54
+ def render_value(value, query)
55
+ case value
56
+ when String
57
+ "'#{value.gsub("'", "\\\\'")}'"
58
+ when Symbol
59
+ # Symbols in arrays should be rendered as strings
60
+ "'#{value}'"
61
+ when Numeric, TrueClass, FalseClass, NilClass
62
+ value.inspect
63
+ else
64
+ # For more complex values, treat as parameter
65
+ param_key = query.register_parameter(value)
66
+ "$#{param_key}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -18,11 +18,18 @@ module Cyrel
18
18
  # @param query [Cyrel::Query] The query object for parameter registration.
19
19
  # @return [String] The parameter placeholder string (e.g., "$p1").
20
20
  def render(query)
21
- # Special handling for NULL as it doesn't use a parameter
21
+ # nil is special - render as NULL literal, not a parameter
22
22
  return 'NULL' if @value.nil?
23
23
 
24
24
  param_key = query.register_parameter(@value)
25
- "$#{param_key}"
25
+
26
+ # If the param_key is the same as the value (for loop variables),
27
+ # don't add the $ prefix - just render as identifier
28
+ if param_key == @value && @value.is_a?(Symbol)
29
+ param_key.to_s
30
+ else
31
+ "$#{param_key}"
32
+ end
26
33
  end
27
34
 
28
35
  # Override comparison methods for direct literal comparison if needed,
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cyrel/expression/base'
3
+ require_relative 'base'
4
4
 
5
5
  module Cyrel
6
6
  module Expression
@@ -41,7 +41,17 @@ module Cyrel
41
41
  base << ':' << labels.join(':') unless labels.empty?
42
42
  unless properties.empty?
43
43
  params = properties.with_indifferent_access
44
- formatted = params.map { |k, v| "#{k}: $#{query.register_parameter(v)}" }.join(', ')
44
+ formatted = params.map do |k, v|
45
+ # Let register_parameter handle loop variable detection
46
+ param_key = query.register_parameter(v)
47
+ if param_key.is_a?(Symbol) && param_key == v
48
+ # Loop variable returned as-is, don't parameterize
49
+ "#{k}: #{v}"
50
+ else
51
+ # Normal parameter key returned
52
+ "#{k}: $#{param_key}"
53
+ end
54
+ end.join(', ')
45
55
  base << " {#{formatted}}"
46
56
  end
47
57
  base << ')'