-
Notifications
You must be signed in to change notification settings - Fork 16
Run Cypher queries from the rdx console #883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
paracycle
wants to merge
1
commit into
main
Choose a base branch
from
uk_rdx_console_query_mode
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # The console is built on IRB's command and helper-method registration APIs, both introduced in IRB | ||
| # 1.13. Requiring an older or absent IRB raises `LoadError` (`Gem::LoadError` is one), which | ||
| # `exe/rdx` turns into a friendly message rather than a backtrace. | ||
| gem "irb", ">= 1.13" | ||
| require "irb" | ||
| require "irb/command" | ||
| require "irb/helper_method" | ||
|
|
||
| module Rubydex | ||
| # Interactive console backing `rdx console`. | ||
| # | ||
| # On top of a normal IRB session (with `graph` in scope so you can call Ruby directly, e.g. | ||
| # `graph["Foo"]`), it offers two complementary ways to run Cypher against the same graph: | ||
| # | ||
| # # `run("...")` — a method that returns rich Ruby objects for programmatic use: an Array of | ||
| # # Hashes keyed by RETURN column, where node columns are live Declaration/Definition/Document | ||
| # # handles. Because it's a plain method, the result can be assigned and navigated. | ||
| # rubydex(main):001> result = run("MATCH (c:Class)-[:HAS_PARENT]->(p) RETURN c, p") | ||
| # rubydex(main):002> result.first["c"].name | ||
| # | ||
| # # `query <CYPHER>` — a command that takes the rest of the line verbatim (no quotes / valid | ||
| # # Ruby needed) and prints a formatted table for a quick look. | ||
| # rubydex(main):003> query MATCH (n:Class|Module) RETURN n.name ORDER BY n.name | ||
| # | ||
| # # `schema` — prints the queryable schema. | ||
| # rubydex(main):004> schema | ||
| # | ||
| # So `run` is the programmatic entry point and `query` is the interactive quick-look; they are | ||
| # deliberately separate rather than one delegating to the other. This is the clean, idiomatic | ||
| # "query mode": rather than swapping the REPL's evaluator, Cypher lines are simply prefixed with | ||
| # `query`. | ||
| # | ||
| # The `run` helper and the `query`/`schema` commands are registered through IRB's own extension | ||
| # APIs, so the session itself is a stock IRB workspace (see {.start}). | ||
| module Console | ||
| class << self | ||
| # The graph that `run` and the `query`/`schema` commands operate on. Set by {.start}. | ||
| attr_accessor :graph | ||
|
|
||
| # Starts an interactive session. `graph` is exposed at the prompt, and `run` plus the | ||
| # `query`/`schema` commands operate on it via {.graph}. | ||
| def start(graph) | ||
| self.graph = graph | ||
|
|
||
| IRB.setup(nil) | ||
| IRB.conf[:IRB_NAME] = "rubydex" | ||
| IRB::Irb.new(workspace_for(graph)).run(IRB.conf) | ||
| end | ||
|
|
||
| # Runs a Cypher query against {.graph} and returns the rows as rich Ruby objects: an Array of | ||
| # Hashes keyed by RETURN column name. Scalars become String/Integer/true/false/nil, lists | ||
| # become Arrays, map projections become Hashes, and node columns become live | ||
| # `Declaration`/`Definition`/`Document` handles. Raises `ArgumentError` on a syntax or | ||
| # execution error. Callable in the console as `run("MATCH ...")`. | ||
| def run(cypher) | ||
| Rubydex::Query.parse(cypher.to_s).run(graph) | ||
| end | ||
|
|
||
| # Runs a Cypher query against {.graph} and returns the formatted output as a String (`:table` | ||
| # or `:json`) — the quick-look counterpart of {.run}, used by the `query` command. Raises | ||
| # `ArgumentError` on a syntax or execution error. | ||
| def render(cypher, format: :table) | ||
| Rubydex::Query.parse(cypher.to_s).render(graph, format) | ||
| end | ||
|
|
||
| # Returns the queryable Cypher schema description as a formatted String. | ||
| def describe_schema(format: :table) | ||
| Rubydex::Query.schema(format) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # The session workspace. This is deliberately IRB's own default workspace rather than a | ||
| # hand-rolled binding, because `IRB::WorkSpace.new` derives its binding from a copy of | ||
| # `IRB::TOPLEVEL_BINDING`, which buys three things a custom binding gets wrong: | ||
| # | ||
| # * `self` is `main` and the cref is `Object`, so `class Foo; end` at the prompt defines | ||
| # `::Foo`. A `binding` captured inside `class << self` would define | ||
| # `#<Class:Rubydex::Console>::Foo` instead. | ||
| # * no caller locals leak in. Deriving from the CLI's `TOPLEVEL_BINDING` would inherit every | ||
| # top-level local of `exe/rdx` — including `query` and `schema`, and a local shadows the | ||
| # IRB command of the same name (IRB resolves locals before dispatching commands). | ||
| # * `_`, `help` and the rest of IRB's own conveniences behave as they do in stock `irb`. | ||
| # | ||
| # Only `graph` is injected; `run` reaches the prompt as a registered IRB helper method. | ||
| def workspace_for(graph) | ||
| workspace = IRB::WorkSpace.new | ||
| workspace.local_variable_set(:graph, graph) | ||
| workspace | ||
| end | ||
| end | ||
|
|
||
| # `run(<CYPHER>)` — an IRB helper method, so it is callable bare at the prompt while still | ||
| # returning a value that can be assigned and navigated. | ||
| class RunHelper < IRB::HelperMethod::Base | ||
| description "Run a Cypher query and return the rows as Ruby objects: run(<CYPHER>)" | ||
|
|
||
| def execute(cypher) | ||
| Console.run(cypher) | ||
| end | ||
| end | ||
|
|
||
| # `query <CYPHER>` — runs a Cypher query against the console graph and prints a formatted table | ||
| # for a quick look. For a result you can assign and navigate, use `run("...")` (see {Console.run}). | ||
| class QueryCommand < IRB::Command::Base | ||
| category "Rubydex" | ||
| description "Run a Cypher query against the graph: query <CYPHER>" | ||
|
|
||
| def execute(arg) | ||
| cypher = arg.to_s.strip | ||
| if cypher.empty? | ||
| warn("Usage: query <CYPHER>") | ||
| return | ||
| end | ||
|
|
||
| puts(Console.render(cypher)) | ||
| nil | ||
| rescue ArgumentError => e | ||
| warn(e.message) | ||
| nil | ||
| end | ||
| end | ||
|
|
||
| # `schema` — prints the queryable Cypher schema (labels, relationships, properties). | ||
| class SchemaCommand < IRB::Command::Base | ||
| category "Rubydex" | ||
| description "Describe the queryable Cypher schema" | ||
|
|
||
| def execute(_arg) | ||
| puts(Console.describe_schema) | ||
| nil | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| IRB::HelperMethod.register(:run, Rubydex::Console::RunHelper) | ||
| IRB::Command.register(:query, Rubydex::Console::QueryCommand) | ||
| IRB::Command.register(:schema, Rubydex::Console::SchemaCommand) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "test_helper" | ||
| require "helpers/context" | ||
| require "json" | ||
| require "rubydex/console" | ||
|
|
||
| class ConsoleTest < Minitest::Test | ||
| include Test::Helpers::WithContext | ||
|
|
||
| def teardown | ||
| Rubydex::Console.graph = nil | ||
| end | ||
|
|
||
| def test_run_returns_rich_objects_against_the_console_graph | ||
| with_graph("class Animal; end\nclass Dog < Animal; end\n") do | ||
| rows = Rubydex::Console.run("MATCH (c:Class)-[:HAS_PARENT]->(p) WHERE c.name = 'Dog' RETURN c, p") | ||
|
|
||
| assert_kind_of(Array, rows) | ||
| assert_equal(1, rows.length) | ||
| # Node columns come back as live handles that can be navigated/assigned. | ||
| assert_kind_of(Rubydex::Declaration, rows.first["c"]) | ||
| assert_equal("Dog", rows.first["c"].name) | ||
| assert_equal("Animal", rows.first["p"].name) | ||
| end | ||
| end | ||
|
|
||
| def test_run_raises_on_invalid_query | ||
| with_graph("class Dog; end\n") do | ||
| error = assert_raises(ArgumentError) { Rubydex::Console.run("MATCH (c RETURN c") } | ||
| assert_match(/Cypher syntax error/, error.message) | ||
| end | ||
| end | ||
|
|
||
| def test_render_returns_formatted_output_for_the_query_command | ||
| with_graph("class Animal; end\nclass Dog < Animal; end\n") do | ||
| output = Rubydex::Console.render("MATCH (c:Class)-[:HAS_PARENT]->(p) WHERE c.name = 'Dog' RETURN p.name") | ||
|
|
||
| assert_match(/p\.name/, output) | ||
| assert_match(/Animal/, output) | ||
| end | ||
| end | ||
|
|
||
| def test_render_supports_json_format | ||
| with_graph("class Dog; end\n") do | ||
| output = Rubydex::Console.render("MATCH (c:Class {name: 'Dog'}) RETURN c.name", format: :json) | ||
|
|
||
| assert_equal("[{\"c.name\":\"Dog\"}]", output) | ||
| end | ||
| end | ||
|
|
||
| def test_describe_schema_lists_relationships | ||
| output = Rubydex::Console.describe_schema(format: :json) | ||
| parsed = JSON.parse(output) | ||
|
|
||
| assert(parsed["relationships"].any? { |r| r["type"] == "HAS_PARENT" }) | ||
| end | ||
|
|
||
| def test_commands_and_helpers_are_registered | ||
| assert_includes(IRB::Command.commands.keys, :query) | ||
| assert_includes(IRB::Command.commands.keys, :schema) | ||
|
|
||
| # `run` is a helper method rather than a command, so it stays an ordinary expression whose | ||
| # value can be assigned and navigated at the prompt. | ||
| assert_includes(IRB::HelperMethod.helper_methods.keys, :run) | ||
| end | ||
|
|
||
| def test_run_helper_delegates_to_the_console_graph | ||
| with_graph("class Dog; end\n") do | ||
| helper = Rubydex::Console::RunHelper.instance | ||
|
|
||
| assert_equal([{ "c.name" => "Dog" }], helper.execute("MATCH (c:Class {name: 'Dog'}) RETURN c.name")) | ||
| end | ||
| end | ||
|
|
||
| def test_session_workspace_evaluates_at_top_level | ||
| # `IRB::WorkSpace` reads IRB.conf[:CONTEXT_MODE] to build its binding; `Console.start` calls | ||
| # `IRB.setup` before it does. | ||
| IRB.setup(nil) unless IRB.conf[:CONTEXT_MODE] | ||
| workspace = Rubydex::Console.send(:workspace_for, :sentinel) | ||
|
|
||
| # `self` must be `main`, as in a stock IRB session — not `Rubydex::Console`. | ||
| assert_same(TOPLEVEL_BINDING.receiver, workspace.binding.receiver) | ||
|
|
||
| # `graph` is exposed, and nothing else is inherited from the caller's scope. A local named | ||
| # `query` or `schema` would shadow the IRB command of the same name. | ||
| assert_same(:sentinel, workspace.binding.eval("graph")) | ||
| refute_includes(workspace.binding.local_variables, :query) | ||
| refute_includes(workspace.binding.local_variables, :schema) | ||
|
|
||
| # A class defined at the prompt must land at top level, not under the singleton class of | ||
| # Rubydex::Console (which would name it "#<Class:Rubydex::Console>::ConsoleWorkspaceProbe"). | ||
| workspace.binding.eval("class ConsoleWorkspaceProbe; end") | ||
|
|
||
| assert(Object.const_defined?(:ConsoleWorkspaceProbe, false)) | ||
| assert_equal("ConsoleWorkspaceProbe", workspace.binding.eval("ConsoleWorkspaceProbe").name) | ||
| ensure | ||
| Object.send(:remove_const, :ConsoleWorkspaceProbe) if Object.const_defined?(:ConsoleWorkspaceProbe, false) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def with_graph(source) | ||
| with_context do |context| | ||
| context.write!("zoo.rb", source) | ||
| graph = Rubydex::Graph.new | ||
| graph.index_all(context.glob("**/*.rb")) | ||
| graph.resolve | ||
| Rubydex::Console.graph = graph | ||
| yield | ||
| end | ||
| end | ||
| end |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You may need to
require "fiddle"to fix the Windows build?