From b24b4057a7f358a2d6665230e10efb9f6a2d302e Mon Sep 17 00:00:00 2001 From: Gareth Lloyd Date: Fri, 5 Jun 2026 10:04:04 +0100 Subject: [PATCH 1/2] feat: Add :param command for query parameters in interactive mode Add cypher-shell-style query parameters to the interactive REPL: :param set a parameter to a server-evaluated value :params list all set parameters :params clear remove all parameters Expressions are evaluated server-side via RETURN, giving full Cypher type support, and may reference previously set parameters. Set parameters are passed to every subsequent query via mg_session_run. The parser (ParseParamCommand) and parameter store (ParamStore) live in a new mgclient-only `params` library and are covered by unit tests. mg_memory is extracted into its own header so the library and its test don't depend on replxx. The REPL wiring (dispatch, server-eval, ExecuteQuery params arg) is thin glue verified manually. --- src/CMakeLists.txt | 9 ++ src/interactive.cpp | 76 +++++++++++- src/parameters.cpp | 99 ++++++++++++++++ src/parameters.hpp | 79 +++++++++++++ src/utils/constants.hpp | 9 +- src/utils/mg_memory.hpp | 68 +++++++++++ src/utils/utils.cpp | 10 +- src/utils/utils.hpp | 47 +------- tests/CMakeLists.txt | 1 + tests/unit/CMakeLists.txt | 20 ++++ tests/unit/check.hpp | 72 ++++++++++++ tests/unit/parameters_test.cpp | 203 +++++++++++++++++++++++++++++++++ 12 files changed, 646 insertions(+), 47 deletions(-) create mode 100644 src/parameters.cpp create mode 100644 src/parameters.hpp create mode 100644 src/utils/mg_memory.hpp create mode 100644 tests/unit/CMakeLists.txt create mode 100644 tests/unit/check.hpp create mode 100644 tests/unit/parameters_test.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c45a642f..5fc08611 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -119,6 +119,14 @@ if(MGCONSOLE_ON_WINDOWS) add_compile_options(-Wno-narrowing) endif() +# Parameter handling (`:param`/`:params`). Kept as its own library with a +# minimal dependency surface (mgclient only) so it can be unit tested. +add_library(params STATIC parameters.cpp) +add_dependencies(params mgclient) +target_compile_definitions(params PUBLIC MGCLIENT_STATIC_DEFINE) +target_include_directories(params PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${MGCLIENT_INCLUDE_DIRS}) +target_link_libraries(params ${MGCLIENT_LIBRARY}) + add_executable(mgconsole main.cpp interactive.cpp serial_import.cpp batch_import.cpp parsing.cpp) target_compile_definitions(mgconsole PRIVATE MGCLIENT_STATIC_DEFINE) target_include_directories(mgconsole @@ -131,6 +139,7 @@ target_link_libraries(mgconsole PRIVATE ${GFLAGS_LIBRARY} utils + params ${MGCLIENT_LIBRARY} ${OPENSSL_LIBRARIES}) if(MGCONSOLE_ON_WINDOWS) diff --git a/src/interactive.cpp b/src/interactive.cpp index ad076b25..c533d09a 100644 --- a/src/interactive.cpp +++ b/src/interactive.cpp @@ -15,16 +15,78 @@ #include "interactive.hpp" +#include #include #include +#include "parameters.hpp" #include "utils/constants.hpp" namespace mode::interactive { using namespace std::string_literals; +namespace { + +namespace params = query::params; + +// Evaluates a Cypher expression server-side and returns a copy of the resulting +// value. Existing parameters are made available to the expression. +mg_memory::MgValuePtr EvaluateParamExpression(mg_session *session, const std::string &expression, + const params::ParamStore &store) { + auto result = query::ExecuteQuery(session, "RETURN " + expression, store.AsMap().get()); + if (result.records.empty() || mg_list_size(result.records.front().get()) == 0) { + throw utils::ClientQueryException("expression did not produce a value"); + } + const mg_value *value = mg_list_at(result.records.front().get(), 0); + return mg_memory::MakeCustomUnique(mg_value_copy(value)); +} + +void ListParams(const params::ParamStore &store) { + if (store.Empty()) { + console::EchoInfo("No parameters set"); + return; + } + for (const auto &name : store.Names()) { + std::ostringstream os; + os << name << ": "; + utils::PrintValue(os, store.Get(name)); + console::EchoInfo(os.str()); + } +} + +// Handles a `:param`/`:params` command line. Query-level failures (e.g. a bad +// expression) are reported without aborting the shell; fatal connection +// failures propagate to the reconnect logic in Run. +void HandleParamCommand(mg_session *session, params::ParamStore &store, const std::string &line) { + const auto parsed = params::ParseParamCommand(line); + if (!parsed.command) { + console::EchoFailure("Invalid parameter command", parsed.error); + return; + } + switch (parsed.command->kind) { + case params::ParamCommand::Kind::kSet: + try { + auto value = EvaluateParamExpression(session, parsed.command->expression, store); + store.Set(parsed.command->name, value.get()); + console::EchoInfo("Set parameter '" + parsed.command->name + "'"); + } catch (const utils::ClientQueryException &e) { + console::EchoFailure("Failed to evaluate parameter expression", e.what()); + } + break; + case params::ParamCommand::Kind::kList: + ListParams(store); + break; + case params::ParamCommand::Kind::kClear: + store.Clear(); + console::EchoInfo("Cleared all parameters"); + break; + } +} + +} // namespace + int Run(utils::bolt::Config &bolt_config, const std::string &history, bool no_history, bool verbose_execution_info, const format::CsvOptions &csv_opts, const format::OutputOptions &output_opts) { Replxx *replxx_instance = InitAndSetupReplxx(); @@ -97,6 +159,9 @@ int Run(utils::bolt::Config &bolt_config, const std::string &history, bool no_hi return 1; } + // Query parameters set via `:param`, passed to every executed query. + params::ParamStore param_store; + console::EchoInfo("mgconsole "s + gflags::VersionString()); console::EchoInfo("Connected to 'memgraph://" + bolt_config.host + ":" + std::to_string(bolt_config.port) + "'"); console::EchoInfo("Type :help for shell usage"); @@ -113,7 +178,16 @@ int Run(utils::bolt::Config &bolt_config, const std::string &history, bool no_hi } try { - auto ret = query::ExecuteQuery(session.get(), query->query); + if (query->is_param_command) { + HandleParamCommand(session.get(), param_store, query->query); + auto history_ret = save_history(); + if (history_ret != 0) { + cleanup_resources(); + return history_ret; + } + continue; + } + auto ret = query::ExecuteQuery(session.get(), query->query, param_store.AsMap().get()); if (ret.records.size() > 0) { Output(ret.header, ret.records, output_opts, csv_opts); } diff --git a/src/parameters.cpp b/src/parameters.cpp new file mode 100644 index 00000000..1d1e738a --- /dev/null +++ b/src/parameters.cpp @@ -0,0 +1,99 @@ +// Copyright (C) 2016-2023 Memgraph Ltd. [https://memgraph.com] +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#include "parameters.hpp" + +namespace query::params { + +namespace { + +constexpr const char *kWhitespace = " \t\r\n\f\v"; + +std::string Trim(const std::string &s) { + const auto begin = s.find_first_not_of(kWhitespace); + if (begin == std::string::npos) return ""; + const auto end = s.find_last_not_of(kWhitespace); + return s.substr(begin, end - begin + 1); +} + +} // namespace + +ParamParse ParseParamCommand(const std::string &line) { + const std::string trimmed = Trim(line); + const auto head_end = trimmed.find_first_of(kWhitespace); + const std::string head = trimmed.substr(0, head_end); + const std::string rest = head_end == std::string::npos ? "" : Trim(trimmed.substr(head_end)); + + if (head == ":params") { + if (rest.empty()) { + ParamCommand command; + command.kind = ParamCommand::Kind::kList; + return {.is_param_command = true, .command = command, .error = ""}; + } + if (rest == "clear") { + ParamCommand command; + command.kind = ParamCommand::Kind::kClear; + return {.is_param_command = true, .command = command, .error = ""}; + } + return {.is_param_command = true, .command = std::nullopt, .error = "expected ':params' or ':params clear'"}; + } + + if (head != ":param") return {}; // not a parameter command + + const auto name_end = rest.find_first_of(kWhitespace); + ParamCommand command; + command.kind = ParamCommand::Kind::kSet; + command.name = rest.substr(0, name_end); + command.expression = name_end == std::string::npos ? "" : Trim(rest.substr(name_end)); + + if (command.name.empty() || command.expression.empty()) { + return {.is_param_command = true, .command = std::nullopt, .error = "expected ':param '"}; + } + + return {.is_param_command = true, .command = command, .error = ""}; +} + +bool ParamStore::Empty() const { return params_.empty(); } + +std::size_t ParamStore::Size() const { return params_.size(); } + +void ParamStore::Set(const std::string &name, const mg_value *value) { + params_.insert_or_assign(name, mg_memory::MakeCustomUnique(mg_value_copy(value))); +} + +const mg_value *ParamStore::Get(const std::string &name) const { + const auto it = params_.find(name); + return it == params_.end() ? nullptr : it->second.get(); +} + +std::vector ParamStore::Names() const { + std::vector names; + names.reserve(params_.size()); + for (const auto &[name, value] : params_) names.push_back(name); + return names; // std::map keeps keys sorted +} + +void ParamStore::Clear() { params_.clear(); } + +mg_memory::MgMapPtr ParamStore::AsMap() const { + auto map = mg_memory::MakeCustomUnique(mg_map_make_empty(static_cast(params_.size()))); + for (const auto &[name, value] : params_) { + // mg_map_insert copies the key and takes ownership of the value copy. + mg_map_insert(map.get(), name.c_str(), mg_value_copy(value.get())); + } + return map; +} + +} // namespace query::params diff --git a/src/parameters.hpp b/src/parameters.hpp new file mode 100644 index 00000000..07fc8a46 --- /dev/null +++ b/src/parameters.hpp @@ -0,0 +1,79 @@ +// Copyright (C) 2016-2023 Memgraph Ltd. [https://memgraph.com] +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#pragma once + +#include +#include +#include +#include +#include + +#include "utils/mg_memory.hpp" + +namespace query::params { + +/// A parsed `:param` / `:params` interactive command. +struct ParamCommand { + enum class Kind { + kSet, ///< `:param ` + kList, ///< `:params` + kClear, ///< `:params clear` + }; + + Kind kind; + std::string name; ///< populated for kSet + std::string expression; ///< populated for kSet +}; + +/// Result of attempting to parse a line as a parameter command. +struct ParamParse { + /// True if the line is a `:param`/`:params` command (well-formed or not). + bool is_param_command{false}; + /// Set iff the command parsed successfully. + std::optional command{std::nullopt}; + /// Human-readable reason, set iff `is_param_command && !command`. + std::string error{}; +}; + +/// Parses a single line as a parameter command. +/// +/// Returns `is_param_command == false` for anything that is not a +/// `:param`/`:params` command, so other command handlers can take over. +ParamParse ParseParamCommand(const std::string &line); + +/// Holds the query parameters set via `:param`, owning a copy of each value, +/// and exposes them as an `mg_map` for `mg_session_run`. +class ParamStore { + public: + bool Empty() const; + std::size_t Size() const; + + /// Stores a copy of `value` under `name`, overwriting any existing entry. + void Set(const std::string &name, const mg_value *value); + /// Returns the value stored under `name`, or nullptr if none. + const mg_value *Get(const std::string &name) const; + /// Returns all parameter names in sorted order. + std::vector Names() const; + /// Removes all parameters. + void Clear(); + /// Builds an `mg_map` copy of all parameters, suitable for `mg_session_run`. + mg_memory::MgMapPtr AsMap() const; + + private: + std::map params_; +}; + +} // namespace query::params diff --git a/src/utils/constants.hpp b/src/utils/constants.hpp index eab7b948..0c1d4b7a 100644 --- a/src/utils/constants.hpp +++ b/src/utils/constants.hpp @@ -19,7 +19,12 @@ constexpr const std::string_view kInteractiveUsage = "are printed out.\n\n" "The following interactive commands are supported:\n\n" "\t:help\t Print out usage for interactive mode\n" - "\t:quit\t Exit the shell\n"; + "\t:quit\t Exit the shell\n" + "\t:param \t Set a query parameter to the value of a " + "Cypher expression (e.g. ':param age 21 * 2'); use it in queries as " + "$\n" + "\t:params\t List all currently set query parameters\n" + "\t:params clear\t Remove all query parameters\n"; constexpr const std::string_view kDocs = "If you are new to Memgraph or the Cypher query language, check out these " @@ -33,6 +38,8 @@ constexpr const std::string_view kDocs = constexpr const std::string_view kCommandQuit = ":quit"; constexpr const std::string_view kCommandHelp = ":help"; constexpr const std::string_view kCommandDocs = ":docs"; +constexpr const std::string_view kCommandParam = ":param"; +constexpr const std::string_view kCommandParams = ":params"; // Supported formats. constexpr const std::string_view kCsvFormat = "csv"; diff --git a/src/utils/mg_memory.hpp b/src/utils/mg_memory.hpp new file mode 100644 index 00000000..3479d743 --- /dev/null +++ b/src/utils/mg_memory.hpp @@ -0,0 +1,68 @@ +// Copyright (C) 2016-2023 Memgraph Ltd. [https://memgraph.com] +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#pragma once + +#include + +#include "mgclient.h" + +namespace mg_memory { +/// Unique pointers with custom deleters for automatic memory management of +/// mg_values. + +template +inline void CustomDelete(T *); + +template <> +inline void CustomDelete(mg_session *session) { + mg_session_destroy(session); +} + +template <> +inline void CustomDelete(mg_session_params *session_params) { + mg_session_params_destroy(session_params); +} + +template <> +inline void CustomDelete(mg_value *value) { + mg_value_destroy(value); +} + +template <> +inline void CustomDelete(mg_list *list) { + mg_list_destroy(list); +} + +template <> +inline void CustomDelete(mg_map *map) { + mg_map_destroy(map); +} + +template +using CustomUniquePtr = std::unique_ptr; + +template +CustomUniquePtr MakeCustomUnique(T *ptr) { + return CustomUniquePtr(ptr, CustomDelete); +} + +using MgSessionPtr = CustomUniquePtr; +using MgSessionParamsPtr = CustomUniquePtr; +using MgValuePtr = CustomUniquePtr; +using MgListPtr = CustomUniquePtr; +using MgMapPtr = CustomUniquePtr; + +} // namespace mg_memory diff --git a/src/utils/utils.cpp b/src/utils/utils.cpp index b35f9c14..819cb804 100644 --- a/src/utils/utils.cpp +++ b/src/utils/utils.cpp @@ -901,6 +901,12 @@ std::optional GetQuery(Replxx *replxx_instance, bool collect_info) { } else if (trimmed_line == constants::kCommandDocs) { console::PrintDocs(); return Query{}; + } else if (trimmed_line == constants::kCommandParams || trimmed_line == constants::kCommandParam || + trimmed_line.rfind(std::string(constants::kCommandParam) + " ", 0) == 0 || + trimmed_line.rfind(std::string(constants::kCommandParams) + " ", 0) == 0) { + // Parameter commands need the session/param store, so hand the raw + // line up to the interactive loop instead of handling it here. + return Query{.query = trimmed_line, .is_param_command = true}; } else { console::EchoFailure("Unsupported command", trimmed_line); console::PrintHelp(); @@ -941,8 +947,8 @@ void PrintQueryInfo(const Query &query) { std::cout << "line: " << query.line_number << " index: " << query.index << " query: " << query.query << std::endl; } -QueryResult ExecuteQuery(mg_session *session, const std::string &query) { - int status = mg_session_run(session, query.c_str(), nullptr, nullptr, nullptr, nullptr); +QueryResult ExecuteQuery(mg_session *session, const std::string &query, const mg_map *params) { + int status = mg_session_run(session, query.c_str(), params, nullptr, nullptr, nullptr); auto start = std::chrono::system_clock::now(); if (status != 0) { if (mg_session_status(session) == MG_SESSION_BAD) { diff --git a/src/utils/utils.hpp b/src/utils/utils.hpp index a824f5fe..62faaf17 100644 --- a/src/utils/utils.hpp +++ b/src/utils/utils.hpp @@ -31,52 +31,11 @@ #include "mgclient.h" #include "replxx.h" +#include "mg_memory.hpp" #include "query_type.hpp" namespace fs = std::filesystem; -namespace mg_memory { -/// Unique pointers with custom deleters for automatic memory management of -/// mg_values. - -template -inline void CustomDelete(T *); - -template <> -inline void CustomDelete(mg_session *session) { - mg_session_destroy(session); -} - -template <> -inline void CustomDelete(mg_session_params *session_params) { - mg_session_params_destroy(session_params); -} - -template <> -inline void CustomDelete(mg_list *list) { - mg_list_destroy(list); -} - -template <> -inline void CustomDelete(mg_map *map) { - mg_map_destroy(map); -} - -template -using CustomUniquePtr = std::unique_ptr; - -template -CustomUniquePtr MakeCustomUnique(T *ptr) { - return CustomUniquePtr(ptr, CustomDelete); -} - -using MgSessionPtr = CustomUniquePtr; -using MgSessionParamsPtr = CustomUniquePtr; -using MgListPtr = CustomUniquePtr; -using MgMapPtr = CustomUniquePtr; - -} // namespace mg_memory - namespace utils { class ClientFatalException : public std::exception { @@ -283,6 +242,8 @@ struct Query { int64_t index{0}; std::string query{""}; std::optional info{std::nullopt}; + /// True if `query` is a `:param`/`:params` command rather than Cypher. + bool is_param_command{false}; }; void PrintQueryInfo(const Query &); @@ -321,7 +282,7 @@ struct BatchResult { // The extra part is preserved for the next GetQuery call std::optional GetQuery(Replxx *replxx_instance, bool collect_info = false); -QueryResult ExecuteQuery(mg_session *session, const std::string &query); +QueryResult ExecuteQuery(mg_session *session, const std::string &query, const mg_map *params = nullptr); BatchResult ExecuteBatch(mg_session *session, const Batch &batch); } // namespace query diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ef06a167..9629daf9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,3 +15,4 @@ # along with this program. If not, see . add_subdirectory(input_output) +add_subdirectory(unit) diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt new file mode 100644 index 00000000..b5d6691e --- /dev/null +++ b/tests/unit/CMakeLists.txt @@ -0,0 +1,20 @@ +# mgconsole - console client for Memgraph database +# Copyright (C) 2016-2023 Memgraph Ltd. [https://memgraph.com] +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +add_executable(parameters_test parameters_test.cpp) +target_link_libraries(parameters_test PRIVATE params) +target_include_directories(parameters_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +add_test(NAME parameters-unit-test COMMAND parameters_test) diff --git a/tests/unit/check.hpp b/tests/unit/check.hpp new file mode 100644 index 00000000..287cea43 --- /dev/null +++ b/tests/unit/check.hpp @@ -0,0 +1,72 @@ +// Copyright (C) 2016-2023 Memgraph Ltd. [https://memgraph.com] +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#pragma once + +// Minimal dependency-free check harness for mgconsole unit tests. +// +// Usage: +// #include "check.hpp" +// void my_test() { CHECK(1 + 1 == 2); CHECK_EQ(answer, 42); } +// int main() { RUN(my_test); return check::summary(); } + +#include +#include + +namespace check { + +inline int &failures() { + static int n = 0; + return n; +} + +inline const char *¤t_test() { + static const char *name = ""; + return name; +} + +inline void fail(const char *file, int line, const std::string &expr) { + std::printf(" FAIL [%s] %s:%d: %s\n", current_test(), file, line, expr.c_str()); + ++failures(); +} + +inline int summary() { + if (failures() == 0) { + std::printf("All checks passed.\n"); + return 0; + } + std::printf("%d check(s) failed.\n", failures()); + return 1; +} + +} // namespace check + +#define CHECK(cond) \ + do { \ + if (!(cond)) ::check::fail(__FILE__, __LINE__, #cond); \ + } while (0) + +#define CHECK_EQ(a, b) \ + do { \ + auto &&_a = (a); \ + auto &&_b = (b); \ + if (!(_a == _b)) ::check::fail(__FILE__, __LINE__, #a " == " #b); \ + } while (0) + +#define RUN(test_fn) \ + do { \ + ::check::current_test() = #test_fn; \ + test_fn(); \ + } while (0) diff --git a/tests/unit/parameters_test.cpp b/tests/unit/parameters_test.cpp new file mode 100644 index 00000000..81d7b901 --- /dev/null +++ b/tests/unit/parameters_test.cpp @@ -0,0 +1,203 @@ +// Copyright (C) 2016-2023 Memgraph Ltd. [https://memgraph.com] +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#include "parameters.hpp" + +#include "check.hpp" + +using query::params::ParamCommand; +using query::params::ParamStore; +using query::params::ParseParamCommand; + +// `:param ` is parsed into a Set command carrying the +// parameter name and the (verbatim) Cypher expression. +void set_command_carries_name_and_expression() { + auto result = ParseParamCommand(":param x 1 + 2"); + CHECK(result.is_param_command); + CHECK(result.command.has_value()); + CHECK(result.error.empty()); + if (result.command) { + CHECK(result.command->kind == ParamCommand::Kind::kSet); + CHECK_EQ(result.command->name, std::string{"x"}); + CHECK_EQ(result.command->expression, std::string{"1 + 2"}); + } +} + +// `:params` lists all currently-set parameters. +void params_lists_all() { + auto result = ParseParamCommand(":params"); + CHECK(result.is_param_command); + CHECK(result.command.has_value()); + CHECK(result.error.empty()); + if (result.command) { + CHECK(result.command->kind == ParamCommand::Kind::kList); + } +} + +// `:params clear` removes all parameters. +void params_clear_clears_all() { + auto result = ParseParamCommand(":params clear"); + CHECK(result.is_param_command); + CHECK(result.command.has_value()); + CHECK(result.error.empty()); + if (result.command) { + CHECK(result.command->kind == ParamCommand::Kind::kClear); + } +} + +// A `:param` with no name, or a name but no expression, is a +// recognised-but-malformed command. +void set_without_name_or_expression_is_an_error() { + auto no_name = ParseParamCommand(":param"); + CHECK(no_name.is_param_command); + CHECK(!no_name.command.has_value()); + CHECK(!no_name.error.empty()); + + auto no_expression = ParseParamCommand(":param x"); + CHECK(no_expression.is_param_command); + CHECK(!no_expression.command.has_value()); + CHECK(!no_expression.error.empty()); +} + +// An unknown `:params` argument is a recognised-but-malformed command. +void params_with_unknown_argument_is_an_error() { + auto result = ParseParamCommand(":params bogus"); + CHECK(result.is_param_command); + CHECK(!result.command.has_value()); + CHECK(!result.error.empty()); +} + +// Lines that are not parameter commands are left for other handlers, including +// look-alikes such as `:paramfoo` that share the `:param` prefix. +void non_param_lines_are_ignored() { + for (const char *line : {":help", ":quit", "MATCH (n) RETURN n;", ":paramfoo", ":paramsfoo", ""}) { + auto result = ParseParamCommand(line); + CHECK(!result.is_param_command); + CHECK(!result.command.has_value()); + } +} + +// Surrounding whitespace is trimmed from the name and expression, while +// whitespace inside the expression is preserved verbatim. +void whitespace_around_name_and_expression_is_trimmed() { + auto result = ParseParamCommand(" :param x 1 + 2 "); + CHECK(result.command.has_value()); + if (result.command) { + CHECK_EQ(result.command->name, std::string{"x"}); + CHECK_EQ(result.command->expression, std::string{"1 + 2"}); + } +} + +// A freshly constructed store holds no parameters. +void new_store_is_empty() { + ParamStore store; + CHECK(store.Empty()); + CHECK_EQ(store.Size(), std::size_t{0}); +} + +namespace { +mg_memory::MgValuePtr IntValue(int64_t n) { + return mg_memory::MakeCustomUnique(mg_value_make_integer(n)); +} +} // namespace + +// Setting a parameter stores a value retrievable by name. +void set_then_get_returns_equal_value() { + ParamStore store; + auto value = IntValue(42); + store.Set("x", value.get()); + CHECK(!store.Empty()); + CHECK_EQ(store.Size(), std::size_t{1}); + const mg_value *got = store.Get("x"); + CHECK(got != nullptr); + if (got) { + CHECK(mg_value_get_type(got) == MG_VALUE_TYPE_INTEGER); + CHECK_EQ(mg_value_integer(got), int64_t{42}); + } +} + +// Setting an existing name replaces its value rather than adding a duplicate. +void set_overwrites_existing_value() { + ParamStore store; + store.Set("x", IntValue(1).get()); + store.Set("x", IntValue(2).get()); + CHECK_EQ(store.Size(), std::size_t{1}); + CHECK(store.Get("x") != nullptr); + if (store.Get("x")) { + CHECK_EQ(mg_value_integer(store.Get("x")), int64_t{2}); + } +} + +// Parameter names are listed in sorted order (for stable `:params` output). +void names_are_returned_sorted() { + ParamStore store; + store.Set("b", IntValue(1).get()); + store.Set("a", IntValue(2).get()); + store.Set("c", IntValue(3).get()); + CHECK(store.Names() == (std::vector{"a", "b", "c"})); +} + +// Clear removes every parameter. +void clear_empties_store() { + ParamStore store; + store.Set("x", IntValue(1).get()); + store.Set("y", IntValue(2).get()); + store.Clear(); + CHECK(store.Empty()); + CHECK_EQ(store.Size(), std::size_t{0}); +} + +// AsMap builds an mg_map carrying every stored parameter, keyed by name. +void as_map_contains_all_parameters() { + ParamStore store; + store.Set("x", IntValue(42).get()); + store.Set("y", IntValue(7).get()); + + auto map = store.AsMap(); + CHECK(map != nullptr); + if (map) { + CHECK_EQ(mg_map_size(map.get()), uint32_t{2}); + const mg_value *x = mg_map_at(map.get(), "x"); + const mg_value *y = mg_map_at(map.get(), "y"); + CHECK(x != nullptr && mg_value_integer(x) == 42); + CHECK(y != nullptr && mg_value_integer(y) == 7); + } +} + +// An empty store still produces a usable (empty) mg_map. +void as_map_of_empty_store_is_empty() { + ParamStore store; + auto map = store.AsMap(); + CHECK(map != nullptr); + if (map) CHECK_EQ(mg_map_size(map.get()), uint32_t{0}); +} + +int main() { + RUN(set_command_carries_name_and_expression); + RUN(params_lists_all); + RUN(params_clear_clears_all); + RUN(set_without_name_or_expression_is_an_error); + RUN(params_with_unknown_argument_is_an_error); + RUN(non_param_lines_are_ignored); + RUN(whitespace_around_name_and_expression_is_trimmed); + RUN(new_store_is_empty); + RUN(set_then_get_returns_equal_value); + RUN(set_overwrites_existing_value); + RUN(names_are_returned_sorted); + RUN(clear_empties_store); + RUN(as_map_contains_all_parameters); + RUN(as_map_of_empty_store_is_empty); + return check::summary(); +} From d405abd6007192b90b7039854ca6e99422cea6fd Mon Sep 17 00:00:00 2001 From: Gareth Lloyd Date: Fri, 5 Jun 2026 11:10:26 +0100 Subject: [PATCH 2/2] fix: Stop replxx aborting when navigating multi-line buffers mgconsole assembles multi-line queries itself via the continuation prompt and does not use replxx's in-buffer multiline editing, which is buggy in the pinned release-0.0.4. history_previous() calls prev_newline_position(_pos - 1) without the `_pos > 0` guard its sibling history_next() has; with a newline at buffer position 0 (navigating up through a recalled multi-line history entry) it passes -1 and trips an assertion that aborts the process. Separately, in-buffer multiline redraws clear to end of screen and erase already-printed output. Patch replxx (replxx-patches/, applied via the replxx-proj PATCH_COMMAND) to add the missing bounds guard to history_previous. This fixes the abort for any newline-bearing buffer, including multi-line entries decoded from the history file. Rebind Ctrl-J (line feed, 0x0A) to commit_line so a bare newline submits the current line like Enter instead of feeding replxx's NEW_LINE action. A multi-line paste then submits one physical line at a time, which is what GetQuery expects, and keeps pasted or typed newlines out of the buffer so the redraw corruption stays unreachable for the common paste case. It does not cover newlines that arrive from history, which is why the bounds guard is the actual crash fix. --- .gitattributes | 3 +++ src/utils/CMakeLists.txt | 14 +++++++++++ .../0001-history_previous-bounds-check.patch | 24 +++++++++++++++++++ src/utils/utils.cpp | 12 ++++++++++ 4 files changed, 53 insertions(+) create mode 100644 .gitattributes create mode 100644 src/utils/replxx-patches/0001-history_previous-bounds-check.patch diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1dbcb90f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# git apply needs LF context lines to match an LF-checked-out source tree; +# Windows autocrlf would otherwise rewrite these to CRLF and break the patch. +*.patch text eol=lf diff --git a/src/utils/CMakeLists.txt b/src/utils/CMakeLists.txt index 3a0fd788..59648e77 100644 --- a/src/utils/CMakeLists.txt +++ b/src/utils/CMakeLists.txt @@ -9,10 +9,24 @@ else() endif() get_filename_component(REPLXX_LIB_PATH "${REPLXX_PREFIX}/${MG_INSTALL_LIB_DIR}/libreplxx${REPLXX_LIB_POSTFIX}.a" ABSOLUTE) + +# Local fixes applied on top of the pinned replxx tag. +find_package(Git REQUIRED) ExternalProject_Add(replxx-proj PREFIX ${REPLXX_PREFIX} GIT_REPOSITORY https://github.com/AmokHuginnsson/replxx.git GIT_TAG release-0.0.4 + # Force an LF checkout so the LF patch below applies. Windows/MSYS2 git + # defaults to core.autocrlf=true, which rewrites the sources with CRLF + # endings and makes the patch fail with "patch does not apply" (the + # trailing-CR context lines no longer match). --config persists into the + # cloned repo, so the update-step checkout stays LF too. + GIT_CONFIG core.autocrlf=false + # --3way makes this idempotent: the patch step chains off the git + # update step and can re-run, and --3way no-ops cleanly when the fix is + # already present (plain `git apply` would error "patch does not apply"). + PATCH_COMMAND ${GIT_EXECUTABLE} apply --3way + "${CMAKE_CURRENT_SOURCE_DIR}/replxx-patches/0001-history_previous-bounds-check.patch" CMAKE_ARGS "-DCMAKE_INSTALL_PREFIX=" "-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}" "-DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER}" diff --git a/src/utils/replxx-patches/0001-history_previous-bounds-check.patch b/src/utils/replxx-patches/0001-history_previous-bounds-check.patch new file mode 100644 index 00000000..d6b115ab --- /dev/null +++ b/src/utils/replxx-patches/0001-history_previous-bounds-check.patch @@ -0,0 +1,24 @@ +Add the missing bounds guard to ReplxxImpl::history_previous. + +When the edit buffer contains newlines and the cursor sits on a newline at +position 0, history_previous indexed prev_newline_position(_pos - 1), that is +prev_newline_position(-1), tripping the `pos_ >= 0` assertion and aborting the +process. history_next already guards the symmetric case with +`_pos > 0 ? ... : -1`; apply the same guard so navigating up through a +multi-line buffer falls through to history_move instead of aborting. + +Not fixed in the pinned upstream tag. + +diff --git a/src/replxx_impl.cxx b/src/replxx_impl.cxx +index 22ee748..24a63b8 100644 +--- a/src/replxx_impl.cxx ++++ b/src/replxx_impl.cxx +@@ -1831,7 +1831,7 @@ Replxx::ACTION_RESULT Replxx::ReplxxImpl::history_previous( char32_t ) { + } + int prevNewlinePosition( prev_newline_position( _pos ) ); + if ( prevNewlinePosition == _pos ) { +- prevNewlinePosition = prev_newline_position( _pos - 1 ); ++ prevNewlinePosition = _pos > 0 ? prev_newline_position( _pos - 1 ) : -1; + } + if ( prevNewlinePosition < 0 ) { + break; diff --git a/src/utils/utils.cpp b/src/utils/utils.cpp index 819cb804..71f398a8 100644 --- a/src/utils/utils.cpp +++ b/src/utils/utils.cpp @@ -1324,6 +1324,18 @@ Replxx *InitAndSetupReplxx() { replxx_set_unique_history(replxx_instance, 1); replxx_set_completion_callback(replxx_instance, CompletionHook, nullptr); + // Treat a bare line feed (Ctrl-J / 0x0A) like Enter: commit the current line + // instead of feeding replxx's NEW_LINE action. mgconsole assembles multi-line + // queries itself via the continuation prompt, so a multi-line paste should + // submit one physical line at a time rather than accumulate in replxx's edit + // buffer, whose in-buffer multiline redraw clears to end of screen and erases + // already-printed output. + // + // This only keeps typed and pasted newlines out of the buffer. A recalled + // multi-line history entry still contains them, so replxx's multiline cursor + // navigation has to stay correct independently of this bind. + replxx_bind_key_internal(replxx_instance, REPLXX_KEY_CONTROL('J'), "commit_line"); + // ToDo(the-joksim): // - syntax highlighting disabled for now - figure out a smarter way of // picking the right colors depending on the user's terminal settings