activecypher 0.7.3 → 0.8.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_cypher/connection_adapters/abstract_bolt_adapter.rb +1 -0
  3. data/lib/active_cypher/connection_adapters/memgraph_adapter.rb +10 -1
  4. data/lib/active_cypher/connection_adapters/neo4j_adapter.rb +1 -1
  5. data/lib/active_cypher/connection_adapters/persistence_methods.rb +31 -14
  6. data/lib/active_cypher/relation.rb +1 -1
  7. data/lib/active_cypher/version.rb +1 -1
  8. data/lib/activecypher.rb +3 -1
  9. data/lib/cyrel/ast/call_node.rb +39 -0
  10. data/lib/cyrel/ast/clause_adapter.rb +38 -0
  11. data/lib/cyrel/ast/clause_node.rb +10 -0
  12. data/lib/cyrel/ast/compiler.rb +609 -0
  13. data/lib/cyrel/ast/create_node.rb +21 -0
  14. data/lib/cyrel/ast/delete_node.rb +22 -0
  15. data/lib/cyrel/ast/expression_node.rb +10 -0
  16. data/lib/cyrel/ast/foreach_node.rb +23 -0
  17. data/lib/cyrel/ast/limit_node.rb +21 -0
  18. data/lib/cyrel/ast/literal_node.rb +39 -0
  19. data/lib/cyrel/ast/load_csv_node.rb +24 -0
  20. data/lib/cyrel/ast/match_node.rb +23 -0
  21. data/lib/cyrel/ast/merge_node.rb +23 -0
  22. data/lib/cyrel/ast/node.rb +36 -0
  23. data/lib/cyrel/ast/optimized_nodes.rb +117 -0
  24. data/lib/cyrel/ast/order_by_node.rb +21 -0
  25. data/lib/cyrel/ast/pattern_node.rb +10 -0
  26. data/lib/cyrel/ast/query_integrated_compiler.rb +27 -0
  27. data/lib/cyrel/ast/remove_node.rb +21 -0
  28. data/lib/cyrel/ast/return_node.rb +21 -0
  29. data/lib/cyrel/ast/set_node.rb +20 -0
  30. data/lib/cyrel/ast/simple_cache.rb +50 -0
  31. data/lib/cyrel/ast/skip_node.rb +19 -0
  32. data/lib/cyrel/ast/union_node.rb +22 -0
  33. data/lib/cyrel/ast/unwind_node.rb +20 -0
  34. data/lib/cyrel/ast/where_node.rb +20 -0
  35. data/lib/cyrel/ast/with_node.rb +23 -0
  36. data/lib/cyrel/clause/unwind.rb +71 -0
  37. data/lib/cyrel/expression/literal.rb +9 -2
  38. data/lib/cyrel/expression/property_access.rb +1 -1
  39. data/lib/cyrel/pattern/node.rb +11 -1
  40. data/lib/cyrel/pattern/relationship.rb +21 -13
  41. data/lib/cyrel/query.rb +405 -91
  42. data/lib/cyrel.rb +132 -2
  43. metadata +29 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bcfe9f62e0fdcf58b7d4f46fef0b7869bac550fc730f2e93a2d057938cad8a4e
4
- data.tar.gz: a835e25b6ff10e6e9e4884c2855091e617086e1635931988cc018d9eac06afd8
3
+ metadata.gz: e620571f10d214866edea1ed9542b49fc0527c97e3a41d26fad6992d5c9dc900
4
+ data.tar.gz: 1c79530d7aebae332ee2acaf12c25657866be8edbf3ccaaf50241308098edeba
5
5
  SHA512:
6
- metadata.gz: 4297ab6127aa159da2800dd2024102e8768cfac6604ce390fa35d71d95b393e3226a6d544e535ccc09c14d1632a23009c48df5067a62b6f158fc344b7e1ff21c
7
- data.tar.gz: b929133db5f0be22b7fbd2e3923e54b0a6b041e748d28dc32bbd6f454ab0547ea48637a945262b480339ccee8e78551b23b64cd6aea8a276c5f3d5e766adbc8d
6
+ metadata.gz: 355a7867dbce5af603a5473b061e874afbd3af77ff8f2ad1d29419e308208ff3391dfb3dedd6542dd9467d0abed5bf4f63e0940bf8d4d958352c77545558747a
7
+ data.tar.gz: 6bd1446f02f78ca88d0da56da74001ffa28c1d0691619df5ba28dff319ab0e78002a41c626cf15da58749105fcd42b2bb2c4fe6b9769fc7e6cb09e57a55484f3
@@ -59,6 +59,7 @@ module ActiveCypher
59
59
 
60
60
  # Connection health check. If this returns false, you're probably in trouble.
61
61
  def active? = @connection&.connected?
62
+ alias connected? active?
62
63
 
63
64
  # Clean disconnection. Resets the internal state.
64
65
  def disconnect
@@ -136,7 +136,16 @@ module ActiveCypher
136
136
  labels = run('MATCH (n) RETURN DISTINCT labels(n) AS lbl').flat_map { |r| r['lbl'] }
137
137
 
138
138
  nodes = labels.map do |lbl|
139
- props = run("MATCH (n:`#{lbl}`) WITH n LIMIT 100 UNWIND keys(n) AS k RETURN DISTINCT k").map { |r| r['k'] }
139
+ # Use Cyrel for secure query building with user-provided label
140
+ query = Cyrel::Query.new
141
+ .match(Cyrel.node(:n, lbl))
142
+ .with(:n)
143
+ .limit(100)
144
+ .unwind(Cyrel.function(:keys, :n), :k)
145
+ .return_('DISTINCT k')
146
+
147
+ cypher, params = query.to_cypher
148
+ props = run(cypher, params).map { |r| r['k'] }
140
149
  Schema::NodeTypeDef.new(lbl, props, nil)
141
150
  end
142
151
 
@@ -94,7 +94,7 @@ module ActiveCypher
94
94
  connect
95
95
  # Replace adapter-aware placeholder with Neo4j's elementId function
96
96
  cypher = cypher.gsub('__NODE_ID__', 'elementId')
97
-
97
+
98
98
  session = connection.session # thin wrapper around Bolt::Session
99
99
  result = session.write_transaction do |tx|
100
100
  logger.debug { "[#{ctx}] #{cypher} #{params.inspect}" }
@@ -14,10 +14,18 @@ module ActiveCypher
14
14
  else
15
15
  [model.class.label_name.to_s]
16
16
  end
17
- label_string = labels.map { |l| ":#{l}" }.join
18
17
 
19
18
  adapter = model.connection.id_handler
20
- cypher = "CREATE (n#{label_string} $props) RETURN #{adapter.return_node_id('n')}"
19
+
20
+ # OPTIMIZED: Use string template instead of Cyrel for known-safe CREATE pattern
21
+ # Labels come from model class (safe), props are parameterized (safe)
22
+ label_string = labels.map { |l| ":#{l}" }.join
23
+ cypher = if adapter.id_function == 'elementId'
24
+ "CREATE (n#{label_string} $props) RETURN elementId(n) AS internal_id"
25
+ else
26
+ "CREATE (n#{label_string} $props) RETURN id(n) AS internal_id"
27
+ end
28
+
21
29
  data = model.connection.execute_cypher(cypher, { props: props }, 'Create')
22
30
 
23
31
  return false if data.blank? || !data.first.key?(:internal_id)
@@ -41,16 +49,23 @@ module ActiveCypher
41
49
  [model.class.label_name.to_s]
42
50
  end
43
51
 
44
- label_string = labels.map { |l| ":#{l}" }.join
45
- set_clauses = changes.keys.map { |property| "n.#{property} = $#{property}" }.join(', ')
46
-
47
52
  adapter = model.connection.id_handler
48
53
  # Convert internal_id to its preferred existential format
49
54
  # Neo4j wants strings because it's complicated, Memgraph wants integers because it's not
50
55
  node_id_param = adapter.id_function == 'elementId' ? model.internal_id.to_s : model.internal_id.to_i
51
- params = changes.merge(node_id: node_id_param)
52
56
 
53
- cypher = "MATCH (n#{label_string}) WHERE #{adapter.node_id_where('n', 'node_id')} SET #{set_clauses} RETURN n"
57
+ # OPTIMIZED: Use string template for known-safe UPDATE pattern
58
+ # Labels come from model class (safe), property names from model attributes (safe)
59
+ label_string = labels.map { |l| ":#{l}" }.join
60
+ set_clauses = changes.keys.map { |property| "n.#{property} = $#{property}" }.join(', ')
61
+
62
+ cypher = if adapter.id_function == 'elementId'
63
+ "MATCH (n#{label_string}) WHERE elementId(n) = $node_id SET #{set_clauses} RETURN n"
64
+ else
65
+ "MATCH (n#{label_string}) WHERE id(n) = $node_id SET #{set_clauses} RETURN n"
66
+ end
67
+
68
+ params = changes.merge(node_id: node_id_param)
54
69
  model.connection.execute_cypher(cypher, params, 'Update')
55
70
 
56
71
  model.send(:changes_applied)
@@ -66,19 +81,21 @@ module ActiveCypher
66
81
  else
67
82
  [model.class.label_name]
68
83
  end
69
- label_string = labels.map { |l| ":#{l}" }.join
70
84
 
71
85
  adapter = model.connection.id_handler
72
86
  # Convert internal_id to whatever format makes the database feel validated
73
87
  # It's like therapy, but for graph databases
74
88
  node_id_param = adapter.id_function == 'elementId' ? model.internal_id.to_s : model.internal_id.to_i
75
89
 
76
- cypher = <<~CYPHER
77
- MATCH (n#{label_string})
78
- WHERE #{adapter.node_id_where('n', 'node_id')}
79
- DETACH DELETE n
80
- RETURN count(*) AS deleted
81
- CYPHER
90
+ # OPTIMIZED: Use string template for known-safe DELETE pattern
91
+ # Labels come from model class (safe)
92
+ label_string = labels.map { |l| ":#{l}" }.join
93
+
94
+ cypher = if adapter.id_function == 'elementId'
95
+ "MATCH (n#{label_string}) WHERE elementId(n) = $node_id DETACH DELETE n RETURN count(*) AS deleted"
96
+ else
97
+ "MATCH (n#{label_string}) WHERE id(n) = $node_id DETACH DELETE n RETURN count(*) AS deleted"
98
+ end
82
99
 
83
100
  result = model.connection.execute_cypher(cypher, { node_id: node_id_param }, 'Destroy')
84
101
  result.present? && result.first[:deleted].to_i.positive?
@@ -141,7 +141,7 @@ module ActiveCypher
141
141
  end
142
142
 
143
143
  Cyrel
144
- .match(Cyrel.node(node_alias, labels: labels))
144
+ .match(Cyrel.node(node_alias, *labels))
145
145
  .return_(node_alias, Cyrel.node_id(node_alias).as(:internal_id))
146
146
  end
147
147
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveCypher
4
- VERSION = '0.7.3'
4
+ VERSION = '0.8.1'
5
5
 
6
6
  def self.gem_version
7
7
  Gem::Version.new VERSION
data/lib/activecypher.rb CHANGED
@@ -104,8 +104,10 @@ loader.ignore("#{__dir__}/activecypher.rb")
104
104
  loader.ignore("#{__dir__}/cyrel.rb")
105
105
  loader.inflector.inflect(
106
106
  'activecypher' => 'ActiveCypher',
107
- 'dsl_context' => 'DSLContext'
107
+ 'dsl_context' => 'DSLContext',
108
+ 'ast' => 'AST'
108
109
  )
110
+
109
111
  loader.push_dir("#{__dir__}/cyrel", namespace: Cyrel)
110
112
  loader.setup
111
113
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # AST node for CALL clauses (procedures)
6
+ # For when you need to call upon the powers that be
7
+ class CallNode < Node
8
+ attr_reader :procedure_name, :arguments, :yield_items
9
+
10
+ def initialize(procedure_name, arguments: [], yield_items: nil)
11
+ @procedure_name = procedure_name
12
+ @arguments = arguments
13
+ @yield_items = yield_items
14
+ end
15
+
16
+ protected
17
+
18
+ def state
19
+ [@procedure_name, @arguments, @yield_items]
20
+ end
21
+ end
22
+
23
+ # AST node for CALL subqueries
24
+ # For when you need a query within a query (queryception)
25
+ class CallSubqueryNode < Node
26
+ attr_reader :subquery
27
+
28
+ def initialize(subquery)
29
+ @subquery = subquery
30
+ end
31
+
32
+ protected
33
+
34
+ def state
35
+ [@subquery]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # Adapter that allows AST nodes to work with the existing clause-based system
6
+ # Now with simple caching for performance
7
+ class ClauseAdapter < Clause::Base
8
+ attr_reader :ast_node
9
+
10
+ def initialize(ast_node)
11
+ @ast_node = ast_node
12
+ @ast_node_hash = ast_node.hash
13
+ super()
14
+ end
15
+
16
+ def render(query)
17
+ # Use a simple cache key based on AST node structure
18
+ cache_key = [@ast_node_hash, @ast_node.class.name].join(':')
19
+
20
+ SimpleCache.instance.fetch(cache_key) do
21
+ # Create a compiler that delegates parameter registration to the query
22
+ compiler = QueryIntegratedCompiler.new(query)
23
+ compiler.compile(ast_node)
24
+ compiler.output.string
25
+ end
26
+ end
27
+
28
+ # Ruby 3.0+ pattern matching support
29
+ def deconstruct
30
+ [ast_node]
31
+ end
32
+
33
+ def deconstruct_keys(_keys)
34
+ { ast_node: ast_node, type: ast_node.class }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cyrel
4
+ module AST
5
+ # Base class for clause nodes (MATCH, WHERE, RETURN, etc.)
6
+ # The building blocks of your query, like LEGO but with more existential dread
7
+ class ClauseNode < Node
8
+ end
9
+ end
10
+ end