diff --git a/docs/dev_manual_todo_examples.rb b/docs/dev_manual_todo_examples.rb index 511a96a6..2dd4ef6c 100644 --- a/docs/dev_manual_todo_examples.rb +++ b/docs/dev_manual_todo_examples.rb @@ -4,8 +4,8 @@ result = driver.execute_query( 'MATCH (p:Person {age: $age}) RETURN p.name AS name', - nil, # auth_token not specified - nil may be omitted - { database: 'neo4j' }, # default value may be omitted + nil, # auth_token - positional argument can't be omitted when config is provided + { database: 'neo4j' }, # config - default value may be omitted age: 42 ) @@ -26,7 +26,7 @@ # Create two nodes and a relationship result = driver.execute_query( 'CREATE (a:Person {name: $name}) - CREATE (b:Person {friend: $name}) + CREATE (b:Person {name: $friend}) CREATE (a)-[:KNOWS]->(b)', name: 'Alice', friend: 'David' @@ -104,6 +104,7 @@ # Database selection driver.execute_query( 'MATCH (p:Person) RETURN p.name', + nil, { database: 'neo4j' }, age: 42 ) @@ -157,7 +158,7 @@ puts notifications # Notifications with GQL status codes -result = execute_query( +result = driver.execute_query( "MATCH p=shortestPath((:Person {name: $start})-[*]->(:Person {name: $end})) RETURN p", start: 'Alice', diff --git a/jruby/neo4j/driver/ext/internal_driver.rb b/jruby/neo4j/driver/ext/internal_driver.rb index eadcfdf7..b3f55016 100644 --- a/jruby/neo4j/driver/ext/internal_driver.rb +++ b/jruby/neo4j/driver/ext/internal_driver.rb @@ -11,6 +11,16 @@ module InternalDriver auto_closable :session + def execute_query(query, auth_token = nil, config = {}, **parameters) + check do + executable_query(query) + .with_auth_token(auth_token) + .with_config(to_java_config(Neo4j::Driver::QueryConfig, **config)) + .with_parameters(to_neo(parameters)) + .execute + end + end + def session(**session_config) java_method(:session, [org.neo4j.driver.SessionConfig]) .call(to_java_config(Neo4j::Driver::SessionConfig, **session_config)) diff --git a/ruby/neo4j/driver/internal/internal_driver.rb b/ruby/neo4j/driver/internal/internal_driver.rb index 6a211388..1daf604a 100644 --- a/ruby/neo4j/driver/internal/internal_driver.rb +++ b/ruby/neo4j/driver/internal/internal_driver.rb @@ -25,6 +25,22 @@ def async_session(**session_config) InternalAsyncSession.new(new_session(**session_config)) end + def execute_query(query, auth_token = nil, config = {}, **parameters) + session_config_keys = %i[bookmarks database impersonated_user default_access_mode fetch_size] + tx_config_keys = %i[timeout metadata] + + session_config = config.slice(*session_config_keys) + session_config[:auth] = auth_token if auth_token + tx_config = config.slice(*tx_config_keys) + + session(**session_config) do |session| + result = session.run(query, parameters, tx_config) + records = result.to_a + summary = result.consume + InternalEagerResult.new(records, summary) + end + end + def encrypted? assert_open! @security_plan.requires_encryption? diff --git a/ruby/neo4j/driver/internal/internal_eager_result.rb b/ruby/neo4j/driver/internal/internal_eager_result.rb new file mode 100644 index 00000000..dc40cb43 --- /dev/null +++ b/ruby/neo4j/driver/internal/internal_eager_result.rb @@ -0,0 +1,12 @@ +module Neo4j::Driver + module Internal + class InternalEagerResult + attr_reader :records, :summary + + def initialize(records, summary) + @records = records + @summary = summary + end + end + end +end diff --git a/spec/neo4j/driver/execute_query_spec.rb b/spec/neo4j/driver/execute_query_spec.rb new file mode 100644 index 00000000..ea6b30fb --- /dev/null +++ b/spec/neo4j/driver/execute_query_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +RSpec.describe Neo4j::Driver do + + describe '#execute_query' do + context 'when querying the database' do + it 'accepts query with default auth_token, config hash and parameters' do + expect { + driver.execute_query( + 'MATCH (p:Person {age: $age}) RETURN p.name AS name', + nil, + { database: 'neo4j' }, + age: 42 + ) + }.not_to raise_error + end + end + + context 'when writing to the database' do + it 'accepts query with only keyword parameters' do + expect { + driver.execute_query( + 'CREATE (a:Person {name: $name}) CREATE (b:Person {name: $friend}) CREATE (a)-[:KNOWS]->(b)', + name: 'Alice', + friend: 'David' + ) + }.not_to raise_error + end + end + + context 'when reading from the database' do + it 'accepts query with no additional parameters' do + expect { + driver.execute_query('MATCH (p:Person)-[:KNOWS]->(:Person) RETURN p.name AS name') + }.not_to raise_error + end + end + + context 'when updating the database' do + it 'accepts update query with keyword parameters' do + expect { + driver.execute_query( + 'MATCH (p:Person {name: $name}) SET p.age = $age', + name: 'Alice', + age: 42 + ) + }.not_to raise_error + end + + it 'accepts relationship creation with keyword parameters' do + expect { + driver.execute_query( + 'MATCH (alice:Person {name: $name}) MATCH (bob:Person {name: $friend}) CREATE (alice)-[:KNOWS]->(bob)', + name: 'Alice', + friend: 'Bob' + ) + }.not_to raise_error + end + end + + context 'when deleting from the database' do + it 'accepts delete query with keyword parameters' do + expect { + driver.execute_query( + 'MATCH (p:Person {name: $name}) DETACH DELETE p', + name: 'Alice' + ) + }.not_to raise_error + end + end + + context 'when using query configuration' do + it 'accepts nil auth_token with config hash and parameters' do + expect { + driver.execute_query( + 'MATCH (p:Person) RETURN p.name', + nil, + { database: 'neo4j' }, + age: 42 + ) + }.not_to raise_error + end + + it 'accepts auth_token with keyword parameters' do + auth_token = Neo4j::Driver::AuthTokens.basic(neo4j_user, neo4j_password) + + expect { + driver.execute_query( + 'MATCH (p:Person) RETURN p.name', + auth_token, + age: 42 + ) + }.not_to raise_error + end + end + + context 'when working with query summaries' do + it 'accepts UNWIND query without parameters' do + expect { + driver.execute_query("UNWIND ['Alice', 'Bob'] AS name MERGE (p:Person {name: name})") + }.not_to raise_error + end + + it 'accepts MERGE query with keyword parameters' do + expect { + driver.execute_query( + "MERGE (p:Person {name: $name}) MERGE (p)-[:KNOWS]->(:Person {name: $friend})", + name: 'Mark', + friend: 'Bob' + ) + }.not_to raise_error + end + + it 'accepts EXPLAIN query with keyword parameters' do + expect { + driver.execute_query( + 'EXPLAIN MATCH (p {name: $name}) RETURN p', + name: 'Alice' + ) + }.not_to raise_error + end + + it 'accepts shortestPath query with keyword parameters' do + expect { + driver.execute_query( + "MATCH p=shortestPath((:Person {name: $start})-[*]->(:Person {name: $end})) RETURN p", + start: 'Alice', + end: 'Bob' + ) + }.not_to raise_error + end + end + + context 'parameter handling' do + it 'handles mixed positional and keyword arguments correctly' do + expect { + driver.execute_query( + 'MATCH (p:Person {age: $age, name: $name}) RETURN p', + nil, + { database: 'neo4j' }, + age: 42, + name: 'Alice' + ) + }.not_to raise_error + end + + it 'handles only keyword arguments' do + expect { + driver.execute_query( + 'MATCH (p:Person {name: $name}) RETURN p', + name: 'Alice' + ) + }.not_to raise_error + end + end + end +end diff --git a/testkit-backend/lib/testkit/backend/messages/requests/execute_query.rb b/testkit-backend/lib/testkit/backend/messages/requests/execute_query.rb new file mode 100644 index 00000000..a1e65588 --- /dev/null +++ b/testkit-backend/lib/testkit/backend/messages/requests/execute_query.rb @@ -0,0 +1,9 @@ +module Testkit::Backend::Messages + module Requests + class ExecuteQuery < Request + def response + Responses::EagerResult.new(fetch(driver_id).execute_query(cypher, auth_token, config, **decode(params))) + end + end + end +end diff --git a/testkit-backend/lib/testkit/backend/messages/requests/get_features.rb b/testkit-backend/lib/testkit/backend/messages/requests/get_features.rb index 88a4fae4..edc1613e 100644 --- a/testkit-backend/lib/testkit/backend/messages/requests/get_features.rb +++ b/testkit-backend/lib/testkit/backend/messages/requests/get_features.rb @@ -8,7 +8,7 @@ class GetFeatures < Request { 'Feature:API:BookmarkManager' => 'a', 'Feature:API:ConnectionAcquisitionTimeout' => 'ja', - 'Feature:API:Driver.ExecuteQuery' => 'a', + 'Feature:API:Driver.ExecuteQuery' => 'ja', 'Feature:API:Driver:GetServerInfo' => '', 'Feature:API:Driver.IsEncrypted' => 'jar', 'Feature:API:Driver:NotificationsConfig' => 'ja', diff --git a/testkit-backend/lib/testkit/backend/messages/requests/result_consume.rb b/testkit-backend/lib/testkit/backend/messages/requests/result_consume.rb index fbad9060..5d350a89 100644 --- a/testkit-backend/lib/testkit/backend/messages/requests/result_consume.rb +++ b/testkit-backend/lib/testkit/backend/messages/requests/result_consume.rb @@ -1,50 +1,10 @@ module Testkit::Backend::Messages module Requests class ResultConsume < Request - def process - named_entity('Summary', - **{ - serverInfo: to_map(summary.server, :protocol_version, :address, :agent), - counters: to_map(summary.counters, *%w[constraints_added constraints_removed contains_system_updates? contains_updates? indexes_added - indexes_removed labels_added labels_removed nodes_created nodes_deleted properties_set relationships_created - relationships_deleted system_updates]), - query: { text: summary.query.text, parameters: summary.query.parameters.transform_values(&method(:to_testkit)) }, - database: summary.database.name, - queryType: summary.query_type, - notifications: summary.notifications&.then(&method(:notifications)), - plan: (plan_to_h(summary.plan) if summary.has_plan?), - profile: summary.has_profile? ? summary.profile.then { |p| { db_hits: p.db_hits } } : nil, - }.merge!(to_map(summary, *%w[result_available_after result_consumed_after]))) - end - - private - - def summary - @object ||= fetch(result_id).consume - end - - def to_map(o, *methods) - methods.map { |name| [key(name), o.send(name).then { |obj| block_given? ? yield(obj) : obj }] }.to_h - end + include SummaryHelper - def key(name) - name.to_s.gsub('?', '').camelize(:lower).to_sym - end - - def map_entry(n, method, *methods) - n.send(method)&.then { |o| { key(method) => to_map(o, *methods) } } || {} - end - - def notifications(ns) - ns.map do |n| - to_map(n, *%w[code title description raw_category severity raw_severity_level]) - .merge(to_map(n, *%w[category severity_level]) { |o| o&.name || 'UNKNOWN' }) - .merge(map_entry(n, :position, :column, :line, :offset)) - end - end - - def plan_to_h(plan) - plan.to_h.transform_keys(&method(:key)).tap {|hash| hash[:children]&.map!(&method(:plan_to_h))} + def process + summary_to_testkit(fetch(result_id).consume) end end end diff --git a/testkit-backend/lib/testkit/backend/messages/responses/eager_result.rb b/testkit-backend/lib/testkit/backend/messages/responses/eager_result.rb new file mode 100644 index 00000000..bdf24bf4 --- /dev/null +++ b/testkit-backend/lib/testkit/backend/messages/responses/eager_result.rb @@ -0,0 +1,34 @@ +module Testkit + module Backend + module Messages + module Responses + class EagerResult < Response + alias_method :response_to_testkit, :to_testkit + + include Conversion + alias_method :value_to_testkit, :to_testkit + + include SummaryHelper + + def to_testkit(*args) + if args.empty? + response_to_testkit + else + value_to_testkit(*args) + end + end + + def data + { + keys: @object.records.first&.keys || [], + records: @object.records.map do |record| + { values: record.values.map(&method(:to_testkit)) } + end, + summary: summary_to_testkit(@object.summary)[:data] + } + end + end + end + end + end +end diff --git a/testkit-backend/lib/testkit/backend/messages/summary_helper.rb b/testkit-backend/lib/testkit/backend/messages/summary_helper.rb new file mode 100644 index 00000000..40c5904d --- /dev/null +++ b/testkit-backend/lib/testkit/backend/messages/summary_helper.rb @@ -0,0 +1,50 @@ +module Testkit + module Backend + module Messages + module SummaryHelper + def summary_to_testkit(summary) + named_entity('Summary', + **{ + serverInfo: to_map(summary.server, :protocol_version, :address, :agent), + counters: to_map(summary.counters, *%w[constraints_added constraints_removed contains_system_updates? contains_updates? indexes_added indexes_removed labels_added labels_removed nodes_created nodes_deleted properties_set relationships_created relationships_deleted system_updates]), + query: { + text: summary.query.text, + parameters: summary.query.parameters.transform_values(&method(:to_testkit)) + }, + database: summary.database.name, + queryType: summary.query_type, + notifications: summary.notifications&.then(&method(:notifications)), + plan: (plan_to_h(summary.plan) if summary.has_plan?), + profile: summary.has_profile? ? summary.profile.then { |p| { db_hits: p.db_hits } } : nil + }.merge!(to_map(summary, *%w[result_available_after result_consumed_after]))) + end + + private + + def to_map(o, *methods) + methods.map { |name| [key(name), o.send(name).then { |obj| block_given? ? yield(obj) : obj }] }.to_h + end + + def key(name) + name.to_s.gsub('?', '').camelize(:lower).to_sym + end + + def map_entry(n, method, *methods) + n.send(method)&.then { |o| { key(method) => to_map(o, *methods) } } || {} + end + + def notifications(ns) + ns.map do |n| + to_map(n, *%w[code title description raw_category severity raw_severity_level]) + .merge(to_map(n, *%w[category severity_level]) { |o| o&.name || 'UNKNOWN' }) + .merge(map_entry(n, :position, :column, :line, :offset)) + end + end + + def plan_to_h(plan) + plan.to_h.transform_keys(&method(:key)).tap { |hash| hash[:children]&.map!(&method(:plan_to_h)) } + end + end + end + end +end