From 210bea83d4a457adfc8885ea327ee43742e53f71 Mon Sep 17 00:00:00 2001 From: gvolfing <107616712+gvolfing@users.noreply.github.com> Date: Mon, 31 Jul 2023 14:48:12 +0200 Subject: [PATCH] Add GraphQL transpilation compatibility (#1018) * Add callable mappings feature * Implement mgps.validate (void procedure) * Make '_' a valid variable name --- init | 4 + src/memgraph.cpp | 9 + src/query/CMakeLists.txt | 1 + src/query/frontend/ast/ast.hpp | 2 + .../frontend/ast/cypher_main_visitor.cpp | 18 +- .../frontend/opencypher/grammar/Cypher.g4 | 1 + .../opencypher/grammar/CypherLexer.g4 | 1 + .../opencypher/grammar/MemgraphCypherLexer.g4 | 2 - .../interpret/awesome_memgraph_functions.cpp | 6 + src/query/plan/operator.cpp | 65 +++++- src/query/plan/operator.hpp | 4 +- src/query/plan/rule_based_planner.hpp | 3 +- src/query/procedure/callable_alias_mapper.cpp | 62 +++++ src/query/procedure/callable_alias_mapper.hpp | 45 ++++ src/query/procedure/module.cpp | 20 +- src/utils/java_string_formatter.hpp | 101 ++++++++ tests/e2e/CMakeLists.txt | 1 + tests/e2e/configuration/default_config.py | 5 + tests/e2e/graphql/.gitignore | 4 + tests/e2e/graphql/CMakeLists.txt | 10 + tests/e2e/graphql/callable_alias_mapping.json | 4 + tests/e2e/graphql/graphql_crud.py | 105 +++++++++ .../graphql_library_config/CMakeLists.txt | 1 + .../graphql/graphql_library_config/crud.js | 34 +++ tests/e2e/graphql/graphql_server.py | 149 ++++++++++++ tests/e2e/graphql/package.json | 9 + tests/e2e/graphql/setup.sh | 9 + .../temporary_procedures/CMakeLists.txt | 1 + .../e2e/graphql/temporary_procedures/mgps.py | 8 + tests/e2e/graphql/workloads.yaml | 35 +++ tests/e2e/run.sh | 5 + tests/setup.sh | 12 +- tests/unit/CMakeLists.txt | 3 + tests/unit/utils_java_string_formatter.cpp | 221 ++++++++++++++++++ tests/util.sh | 28 +++ 35 files changed, 971 insertions(+), 17 deletions(-) create mode 100644 src/query/procedure/callable_alias_mapper.cpp create mode 100644 src/query/procedure/callable_alias_mapper.hpp create mode 100644 src/utils/java_string_formatter.hpp create mode 100644 tests/e2e/graphql/.gitignore create mode 100644 tests/e2e/graphql/CMakeLists.txt create mode 100644 tests/e2e/graphql/callable_alias_mapping.json create mode 100644 tests/e2e/graphql/graphql_crud.py create mode 100644 tests/e2e/graphql/graphql_library_config/CMakeLists.txt create mode 100644 tests/e2e/graphql/graphql_library_config/crud.js create mode 100644 tests/e2e/graphql/graphql_server.py create mode 100644 tests/e2e/graphql/package.json create mode 100755 tests/e2e/graphql/setup.sh create mode 100644 tests/e2e/graphql/temporary_procedures/CMakeLists.txt create mode 100644 tests/e2e/graphql/temporary_procedures/mgps.py create mode 100644 tests/e2e/graphql/workloads.yaml create mode 100644 tests/unit/utils_java_string_formatter.cpp create mode 100644 tests/util.sh diff --git a/init b/init index 036e7ee20..9187ee5aa 100755 --- a/init +++ b/init @@ -93,6 +93,10 @@ setup_virtualenv tests/stress setup_virtualenv tests/integration/ldap # Setup tests dependencies. +# NOTE: This is commented out because of the build order (at the time of +# execution mgclient is not built yet) which makes this setup to fail. mgclient +# is built during the make phase. The tests/setup.sh is called under GHA CI +# jobs. # cd tests # ./setup.sh # cd .. diff --git a/src/memgraph.cpp b/src/memgraph.cpp index 33899832d..1c8750553 100644 --- a/src/memgraph.cpp +++ b/src/memgraph.cpp @@ -53,6 +53,7 @@ #include "query/frontend/ast/ast.hpp" #include "query/interpreter.hpp" #include "query/plan/operator.hpp" +#include "query/procedure/callable_alias_mapper.hpp" #include "query/procedure/module.hpp" #include "query/procedure/py_module.hpp" #include "requests/requests.hpp" @@ -355,6 +356,12 @@ DEFINE_VALIDATED_string(query_modules_directory, "", return true; }); +// NOLINTNEXTLINE (cppcoreguidelines-avoid-non-const-global-variables) +DEFINE_string(query_callable_mappings_path, "", + "The path to mappings that describes aliases to callables in cypher queries in the form of key-value " + "pairs in a json file. With this option query module procedures that do not exist in memgraph can be " + "mapped to ones that exist."); + // Logging flags // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) DEFINE_HIDDEN_bool(also_log_to_stderr, false, "Log messages go to stderr in addition to logfiles"); @@ -569,6 +576,7 @@ class BoltSession final : public memgraph::communication::bolt::Sessionusername(); } + #ifdef MG_ENTERPRISE if (memgraph::license::global_license_checker.IsEnterpriseValidFast()) { audit_log_->Record(endpoint_.address().to_string(), user_ ? *username : "", query, @@ -954,6 +962,7 @@ int main(int argc, char **argv) { memgraph::query::procedure::gModuleRegistry.SetModulesDirectory(query_modules_directories, FLAGS_data_directory); memgraph::query::procedure::gModuleRegistry.UnloadAndLoadModulesFromDirectories(); + memgraph::query::procedure::gCallableAliasMapper.LoadMapping(FLAGS_query_callable_mappings_path); memgraph::glue::AuthQueryHandler auth_handler(&auth, FLAGS_auth_user_or_role_name_regex); memgraph::glue::AuthChecker auth_checker{&auth}; diff --git a/src/query/CMakeLists.txt b/src/query/CMakeLists.txt index 63eedf5d2..b78c5b74d 100644 --- a/src/query/CMakeLists.txt +++ b/src/query/CMakeLists.txt @@ -27,6 +27,7 @@ set(mg_query_sources procedure/mg_procedure_helpers.cpp procedure/module.cpp procedure/py_module.cpp + procedure/callable_alias_mapper.cpp serialization/property_value.cpp stream/streams.cpp stream/sources.cpp diff --git a/src/query/frontend/ast/ast.hpp b/src/query/frontend/ast/ast.hpp index e30ffdb82..90a787005 100644 --- a/src/query/frontend/ast/ast.hpp +++ b/src/query/frontend/ast/ast.hpp @@ -2253,6 +2253,7 @@ class CallProcedure : public memgraph::query::Clause { memgraph::query::Expression *memory_limit_{nullptr}; size_t memory_scale_{1024U}; bool is_write_; + bool void_procedure_{false}; CallProcedure *Clone(AstStorage *storage) const override { CallProcedure *object = storage->Create(); @@ -2269,6 +2270,7 @@ class CallProcedure : public memgraph::query::Clause { object->memory_limit_ = memory_limit_ ? memory_limit_->Clone(storage) : nullptr; object->memory_scale_ = memory_scale_; object->is_write_ = is_write_; + object->void_procedure_ = void_procedure_; return object; } diff --git a/src/query/frontend/ast/cypher_main_visitor.cpp b/src/query/frontend/ast/cypher_main_visitor.cpp index ef0a0aad9..33647b0fa 100644 --- a/src/query/frontend/ast/cypher_main_visitor.cpp +++ b/src/query/frontend/ast/cypher_main_visitor.cpp @@ -35,6 +35,7 @@ #include "query/frontend/ast/ast_visitor.hpp" #include "query/frontend/parsing.hpp" #include "query/interpret/awesome_memgraph_functions.hpp" +#include "query/procedure/callable_alias_mapper.hpp" #include "query/procedure/module.hpp" #include "query/stream/common.hpp" #include "utils/exceptions.hpp" @@ -1188,13 +1189,26 @@ antlrcpp::Any CypherMainVisitor::visitCallProcedure(MemgraphCypher::CallProcedur const auto &maybe_found = procedure::FindProcedure(procedure::gModuleRegistry, call_proc->procedure_name_, utils::NewDeleteResource()); if (!maybe_found) { - throw SemanticException("There is no procedure named '{}'.", call_proc->procedure_name_); + // TODO remove this once void procedures are supported, + // this will not be needed anymore. + const auto mg_specific_name = procedure::gCallableAliasMapper.FindAlias(call_proc->procedure_name_); + const bool void_procedure_required = (mg_specific_name && *mg_specific_name == "mgps.validate"); + if (void_procedure_required) { + // This is a special case. Since void procedures currently are not supported, + // we have to make sure that the non-memgraph native, void procedures that are + // possibly used against a memgraph instance are handled correctly. As of now + // this is the only known such case. This should be more generic, but the most + // generic solution would be to implement void procedures. + call_proc->void_procedure_ = true; + } else { + throw SemanticException("There is no procedure named '{}'.", call_proc->procedure_name_); + } } call_proc->is_write_ = maybe_found->second->info.is_write; auto *yield_ctx = ctx->yieldProcedureResults(); if (!yield_ctx) { - if (!maybe_found->second->results.empty()) { + if (!maybe_found->second->results.empty() && !call_proc->void_procedure_) { throw SemanticException( "CALL without YIELD may only be used on procedures which do not " "return any result fields."); diff --git a/src/query/frontend/opencypher/grammar/Cypher.g4 b/src/query/frontend/opencypher/grammar/Cypher.g4 index 04dcc1499..ea9a9f5f7 100644 --- a/src/query/frontend/opencypher/grammar/Cypher.g4 +++ b/src/query/frontend/opencypher/grammar/Cypher.g4 @@ -407,4 +407,5 @@ cypherKeyword : ALL symbolicName : UnescapedSymbolicName | EscapedSymbolicName | cypherKeyword + | UNDERSCORE ; diff --git a/src/query/frontend/opencypher/grammar/CypherLexer.g4 b/src/query/frontend/opencypher/grammar/CypherLexer.g4 index abf9aee13..3428a2191 100644 --- a/src/query/frontend/opencypher/grammar/CypherLexer.g4 +++ b/src/query/frontend/opencypher/grammar/CypherLexer.g4 @@ -179,6 +179,7 @@ EscapedSymbolicName : ( '`' ~[`]* '`' )+ ; */ IdentifierStart : ID_Start | Pc ; IdentifierPart : ID_Continue | Sc ; +UNDERSCORE : '_' ; /* Hack for case-insensitive reserved words */ fragment A : 'A' | 'a' ; diff --git a/src/query/frontend/opencypher/grammar/MemgraphCypherLexer.g4 b/src/query/frontend/opencypher/grammar/MemgraphCypherLexer.g4 index 149e637b1..1f07e74f0 100644 --- a/src/query/frontend/opencypher/grammar/MemgraphCypherLexer.g4 +++ b/src/query/frontend/opencypher/grammar/MemgraphCypherLexer.g4 @@ -23,8 +23,6 @@ lexer grammar MemgraphCypherLexer ; import CypherLexer ; -UNDERSCORE : '_' ; - AFTER : A F T E R ; ALTER : A L T E R ; ANALYZE : A N A L Y Z E ; diff --git a/src/query/interpret/awesome_memgraph_functions.cpp b/src/query/interpret/awesome_memgraph_functions.cpp index 4640e0d8e..a6d886983 100644 --- a/src/query/interpret/awesome_memgraph_functions.cpp +++ b/src/query/interpret/awesome_memgraph_functions.cpp @@ -28,6 +28,7 @@ #include "query/typed_value.hpp" #include "utils/string.hpp" #include "utils/temporal.hpp" +#include "utils/uuid.hpp" namespace memgraph::query { namespace { @@ -419,6 +420,10 @@ TypedValue Properties(const TypedValue *args, int64_t nargs, const FunctionConte } } +TypedValue RandomUuid(const TypedValue * /*args*/, int64_t /*nargs*/, const FunctionContext &ctx) { + return TypedValue(utils::GenerateUUID(), ctx.memory); +} + TypedValue Size(const TypedValue *args, int64_t nargs, const FunctionContext &ctx) { FType>("size", args, nargs); const auto &value = args[0]; @@ -1258,6 +1263,7 @@ std::function +#include #include #include #include @@ -51,6 +52,7 @@ #include "utils/event_counter.hpp" #include "utils/exceptions.hpp" #include "utils/fnv.hpp" +#include "utils/java_string_formatter.hpp" #include "utils/likely.hpp" #include "utils/logging.hpp" #include "utils/memory.hpp" @@ -4453,7 +4455,7 @@ UniqueCursorPtr OutputTableStream::MakeCursor(utils::MemoryResource *mem) const CallProcedure::CallProcedure(std::shared_ptr input, std::string name, std::vector args, std::vector fields, std::vector symbols, Expression *memory_limit, - size_t memory_scale, bool is_write) + size_t memory_scale, bool is_write, bool void_procedure) : input_(input ? input : std::make_shared()), procedure_name_(name), arguments_(args), @@ -4461,7 +4463,8 @@ CallProcedure::CallProcedure(std::shared_ptr input, std::string result_symbols_(symbols), memory_limit_(memory_limit), memory_scale_(memory_scale), - is_write_(is_write) {} + is_write_(is_write), + void_procedure_(void_procedure) {} ACCEPT_WITH_INPUT(CallProcedure); @@ -4697,10 +4700,68 @@ class CallProcedureCursor : public Cursor { } }; +class CallValidateProcedureCursor : public Cursor { + const CallProcedure *self_; + UniqueCursorPtr input_cursor_; + + public: + CallValidateProcedureCursor(const CallProcedure *self, utils::MemoryResource *mem) + : self_(self), input_cursor_(self_->input_->MakeCursor(mem)) {} + + bool Pull(Frame &frame, ExecutionContext &context) override { + SCOPED_PROFILE_OP("CallValidateProcedureCursor"); + + AbortCheck(context); + if (!input_cursor_->Pull(frame, context)) { + return false; + } + + ExpressionEvaluator evaluator(&frame, context.symbol_table, context.evaluation_context, context.db_accessor, + storage::View::NEW); + + const auto args = self_->arguments_; + MG_ASSERT(args.size() == 3U); + + const auto predicate = args[0]->Accept(evaluator); + const bool predicate_val = predicate.ValueBool(); + + if (predicate_val) [[unlikely]] { + const auto &message = args[1]->Accept(evaluator); + const auto &message_args = args[2]->Accept(evaluator); + + using TString = std::remove_cvref_t; + using TElement = std::remove_cvref_t; + + utils::JStringFormatter formatter; + + try { + const auto &msg = formatter.FormatString(message.ValueString(), message_args.ValueList()); + throw QueryRuntimeException(msg); + } catch (const utils::JStringFormatException &e) { + throw QueryRuntimeException(e.what()); + } + } + + return true; + } + + void Reset() override { input_cursor_->Reset(); } + + void Shutdown() override {} +}; + UniqueCursorPtr CallProcedure::MakeCursor(utils::MemoryResource *mem) const { memgraph::metrics::IncrementCounter(memgraph::metrics::CallProcedureOperator); CallProcedure::IncrementCounter(procedure_name_); + if (void_procedure_) { + // Currently we do not support Call procedures that do not return + // anything. This cursor is way too specific, but it provides a workaround + // to ensure GraphQL compatibility until we start supporting truly void + // procedures. + return MakeUniqueCursorPtr(mem, this, mem); + } + return MakeUniqueCursorPtr(mem, this, mem); } diff --git a/src/query/plan/operator.hpp b/src/query/plan/operator.hpp index 79df74ffb..1ca3028c8 100644 --- a/src/query/plan/operator.hpp +++ b/src/query/plan/operator.hpp @@ -2178,7 +2178,7 @@ class CallProcedure : public memgraph::query::plan::LogicalOperator { CallProcedure() = default; CallProcedure(std::shared_ptr input, std::string name, std::vector arguments, std::vector fields, std::vector symbols, Expression *memory_limit, - size_t memory_scale, bool is_write); + size_t memory_scale, bool is_write, bool void_procedure = false); bool Accept(HierarchicalLogicalOperatorVisitor &visitor) override; UniqueCursorPtr MakeCursor(utils::MemoryResource *) const override; @@ -2200,6 +2200,7 @@ class CallProcedure : public memgraph::query::plan::LogicalOperator { Expression *memory_limit_{nullptr}; size_t memory_scale_{1024U}; bool is_write_; + bool void_procedure_; mutable utils::MonotonicBufferResource monotonic_memory{1024UL * 1024UL}; utils::MemoryResource *memory_resource = &monotonic_memory; @@ -2216,6 +2217,7 @@ class CallProcedure : public memgraph::query::plan::LogicalOperator { object->memory_limit_ = memory_limit_ ? memory_limit_->Clone(storage) : nullptr; object->memory_scale_ = memory_scale_; object->is_write_ = is_write_; + object->void_procedure_ = void_procedure_; return object; } diff --git a/src/query/plan/rule_based_planner.hpp b/src/query/plan/rule_based_planner.hpp index 09a53cf29..055d47d2c 100644 --- a/src/query/plan/rule_based_planner.hpp +++ b/src/query/plan/rule_based_planner.hpp @@ -221,7 +221,8 @@ class RuleBasedPlanner { // storage::View::NEW. input_op = std::make_unique( std::move(input_op), call_proc->procedure_name_, call_proc->arguments_, call_proc->result_fields_, - result_symbols, call_proc->memory_limit_, call_proc->memory_scale_, call_proc->is_write_); + result_symbols, call_proc->memory_limit_, call_proc->memory_scale_, call_proc->is_write_, + call_proc->void_procedure_); } else if (auto *load_csv = utils::Downcast(clause)) { const auto &row_sym = context.symbol_table->at(*load_csv->row_var_); context.bound_symbols.insert(row_sym); diff --git a/src/query/procedure/callable_alias_mapper.cpp b/src/query/procedure/callable_alias_mapper.cpp new file mode 100644 index 000000000..0440f0ea5 --- /dev/null +++ b/src/query/procedure/callable_alias_mapper.cpp @@ -0,0 +1,62 @@ +// Copyright 2023 Memgraph Ltd. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt; by using this file, you agree to be bound by the terms of the Business Source +// License, and you may not use this file except in compliance with the Business Source License. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +#include "callable_alias_mapper.hpp" + +#include +#include +#include +#include + +#include +#include + +#include "utils/logging.hpp" + +namespace memgraph::query::procedure { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +CallableAliasMapper gCallableAliasMapper; + +void CallableAliasMapper::LoadMapping(const std::filesystem::path &path) { + using json = nlohmann::json; + if (path.empty()) { + spdlog::info("Path to callable mappings was not set."); + return; + } + + if (std::filesystem::exists(path)) { + const bool is_regular_file = std::filesystem::is_regular_file(path); + const bool has_json_extension = (path.extension() == ".json"); + if (is_regular_file && has_json_extension) { + std::ifstream mapping_file(path); + try { + json mapping_data = json::parse(mapping_file); + mapping_ = mapping_data.get>(); + } catch (...) { + MG_ASSERT(false, "Parsing callable mapping was unsuccesful. Make sure it is in correct json format."); + } + } else { + MG_ASSERT(false, "Path to callable mappings is not a regular file or does not have .json extension."); + } + } else { + MG_ASSERT(false, "Path to callable mappings was set, but the path does not exist."); + } +} + +std::optional CallableAliasMapper::FindAlias(const std::string &name) const noexcept { + if (!mapping_.contains(name)) { + return std::nullopt; + } + return mapping_.at(name); +} + +} // namespace memgraph::query::procedure diff --git a/src/query/procedure/callable_alias_mapper.hpp b/src/query/procedure/callable_alias_mapper.hpp new file mode 100644 index 000000000..83ee02729 --- /dev/null +++ b/src/query/procedure/callable_alias_mapper.hpp @@ -0,0 +1,45 @@ +// Copyright 2023 Memgraph Ltd. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt; by using this file, you agree to be bound by the terms of the Business Source +// License, and you may not use this file except in compliance with the Business Source License. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace memgraph::query::procedure { + +class CallableAliasMapper final { + public: + CallableAliasMapper() = default; + CallableAliasMapper(const CallableAliasMapper &) = delete; + CallableAliasMapper &operator=(const CallableAliasMapper &) = delete; + CallableAliasMapper(CallableAliasMapper &&) = delete; + CallableAliasMapper &operator=(CallableAliasMapper &&) = delete; + ~CallableAliasMapper() = default; + + void LoadMapping(const std::filesystem::path &); + [[nodiscard]] std::optional FindAlias(const std::string &) const noexcept; + + private: + std::unordered_map mapping_; +}; + +/// Single, global alias mapper. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern CallableAliasMapper gCallableAliasMapper; + +} // namespace memgraph::query::procedure diff --git a/src/query/procedure/module.cpp b/src/query/procedure/module.cpp index b7d0cb55c..895afad79 100644 --- a/src/query/procedure/module.cpp +++ b/src/query/procedure/module.cpp @@ -22,6 +22,7 @@ extern "C" { #include #include "py/py.hpp" +#include "query/procedure/callable_alias_mapper.hpp" #include "query/procedure/mg_procedure_helpers.hpp" #include "query/procedure/py_module.hpp" #include "utils/file.hpp" @@ -1326,10 +1327,25 @@ std::optional> MakePairIfPropFound(const ModuleR } }; auto result = FindModuleNameAndProp(module_registry, fully_qualified_name, memory); - if (!result) return std::nullopt; + if (!result) { + return std::nullopt; + } auto [module_name, prop_name] = *result; auto module = module_registry.GetModuleNamed(module_name); - if (!module) return std::nullopt; + if (!module) { + // Check for possible callable aliases. + const auto maybe_valid_alias = gCallableAliasMapper.FindAlias(std::string(fully_qualified_name)); + if (maybe_valid_alias) { + result = FindModuleNameAndProp(module_registry, *maybe_valid_alias, memory); + auto [module_name, prop_name] = *result; + module = module_registry.GetModuleNamed(module_name); + if (!module) { + return std::nullopt; + } + } else { + return std::nullopt; + } + } auto *prop = prop_fun(module); const auto &prop_it = prop->find(prop_name); if (prop_it == prop->end()) return std::nullopt; diff --git a/src/utils/java_string_formatter.hpp b/src/utils/java_string_formatter.hpp new file mode 100644 index 000000000..2b929715b --- /dev/null +++ b/src/utils/java_string_formatter.hpp @@ -0,0 +1,101 @@ +// Copyright 2023 Memgraph Ltd. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt; by using this file, you agree to be bound by the terms of the Business Source +// License, and you may not use this file except in compliance with the Business Source License. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +#pragma once + +#include + +#include "utils/exceptions.hpp" +#include "utils/pmr/string.hpp" +#include "utils/pmr/vector.hpp" + +namespace memgraph::utils { + +class JStringFormatException final : public BasicException { + public: + explicit JStringFormatException(const std::string &what) noexcept : BasicException(what) {} + + template + explicit JStringFormatException(fmt::format_string fmt, Args &&...args) noexcept + : JStringFormatException(fmt::format(fmt, std::forward(args)...)) {} +}; + +template +concept TTypedValueLike = requires(T t) { + { t.ValueInt() } -> std::convertible_to; + { t.ValueDouble() } -> std::convertible_to; + { t.ValueString() } -> std::convertible_to; +}; + +template +class JStringFormatter final { + public: + [[nodiscard]] TString FormatString(TString str, const pmr::vector &format_args) const { + std::size_t found{0U}; + std::size_t arg_index{0U}; + + while (true) { + found = str.find('%', found); + if (found == std::string::npos) { + break; + } + + const bool ends_with_percentile = (found == str.size() - 1U); + if (ends_with_percentile) { + break; + } + + const auto format_specifier = str.at(found + 1U); + if (!std::isalpha(format_specifier)) { + ++found; + continue; + } + const bool does_argument_list_overflow = (format_args.size() < arg_index + 1U) && (arg_index > 0U); + if (does_argument_list_overflow) { + throw JStringFormatException( + "There are more format specifiers in the CALL procedure error message, then arguments provided."); + } + const bool arg_count_exceeds_format_spec_count = (arg_index > format_args.size() - 1U); + if (arg_count_exceeds_format_spec_count) { + break; + } + + ReplaceFormatSpecifier(str, found, format_specifier, format_args.at(arg_index)); + ++arg_index; + ++found; + } + + str.shrink_to_fit(); + return str; + } + + private: + void ReplaceFormatSpecifier(TString &str, std::size_t pos, char format_specifier, TTypedValueLike current_arg) const { + std::string replacement_str; + switch (format_specifier) { + case 'd': + replacement_str = std::to_string(current_arg.ValueInt()); + break; + case 'f': + replacement_str = std::to_string(current_arg.ValueDouble()); + break; + case 's': + replacement_str = current_arg.ValueString(); + break; + default: + throw JStringFormatException("Format specifier %'{}', in CALL procedure is not supported.", format_specifier); + } + + str.replace(pos, 2U, replacement_str); + } +}; + +} // namespace memgraph::utils diff --git a/tests/e2e/CMakeLists.txt b/tests/e2e/CMakeLists.txt index 6f729c968..188515b32 100644 --- a/tests/e2e/CMakeLists.txt +++ b/tests/e2e/CMakeLists.txt @@ -55,6 +55,7 @@ add_subdirectory(python_query_modules_reloading) add_subdirectory(analyze_graph) add_subdirectory(transaction_queue) add_subdirectory(mock_api) +add_subdirectory(graphql) add_subdirectory(disk_storage) add_subdirectory(load_csv) add_subdirectory(init_file_flags) diff --git a/tests/e2e/configuration/default_config.py b/tests/e2e/configuration/default_config.py index d2c55ae1d..a0a7153fb 100644 --- a/tests/e2e/configuration/default_config.py +++ b/tests/e2e/configuration/default_config.py @@ -192,4 +192,9 @@ startup_config_dict = { "false", "Restore replication state on startup, e.g. recover replica", ), + "query_callable_mappings_path": ( + "", + "", + "The path to mappings that describes aliases to callables in cypher queries in the form of key-value pairs in a json file. With this option query module procedures that do not exist in memgraph can be mapped to ones that exist.", + ), } diff --git a/tests/e2e/graphql/.gitignore b/tests/e2e/graphql/.gitignore new file mode 100644 index 000000000..da564214b --- /dev/null +++ b/tests/e2e/graphql/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +# Because the parent folder ignores *.json +!callable_alias_mapping.json +!package.json diff --git a/tests/e2e/graphql/CMakeLists.txt b/tests/e2e/graphql/CMakeLists.txt new file mode 100644 index 000000000..7ad1624b6 --- /dev/null +++ b/tests/e2e/graphql/CMakeLists.txt @@ -0,0 +1,10 @@ +function(copy_graphql_e2e_python_files FILE_NAME) + copy_e2e_python_files(graphql ${FILE_NAME}) +endfunction() + +copy_graphql_e2e_python_files(graphql_crud.py) +copy_graphql_e2e_python_files(graphql_server.py) +copy_graphql_e2e_python_files(callable_alias_mapping.json) + +add_subdirectory(graphql_library_config) +add_subdirectory(temporary_procedures) diff --git a/tests/e2e/graphql/callable_alias_mapping.json b/tests/e2e/graphql/callable_alias_mapping.json new file mode 100644 index 000000000..cd86f8d7b --- /dev/null +++ b/tests/e2e/graphql/callable_alias_mapping.json @@ -0,0 +1,4 @@ +{ + "dbms.components": "mgps.components", + "apoc.util.validate": "mgps.validate" +} diff --git a/tests/e2e/graphql/graphql_crud.py b/tests/e2e/graphql/graphql_crud.py new file mode 100644 index 000000000..fe9f28d76 --- /dev/null +++ b/tests/e2e/graphql/graphql_crud.py @@ -0,0 +1,105 @@ +import sys + +import pytest +from graphql_server import * + + +def test_create_query(query_server): + query = 'mutation{createUsers(input:[{name:"John Doe"}]){users{id name}}}' + gotten = query_server.send_query(query) + expected_result = ( + '{"data":{"createUsers":{"users":[{"id":"e2d65187-d522-47bf-9791-6c66dd8fd672","name":"John Doe"}]}}}' + ) + assert server_returned_expected(expected_result, gotten) + + +def test_nested_create_query(query_server): + query = """ + mutation { + createUsers(input: [ + { + name: "John Doe" + posts: { + create: [ + { + node: { + content: "Hi, my name is John!" + } + } + ] + } + } + ]) { + users { + id + name + posts { + id + content + } + } + } + } + """ + + expected_result = '{"data":{"createUsers":{"users":[{"id": "361004b7-f92d-4df0-9f96-5b43602c0f25","name": "John Doe","posts":[{"id":"e8d2033f-c15e-4529-a4f8-ca2ae09a066b", "content": "Hi, my name is John!"}]}]}}}' + gotten_response = query_server.send_query(query) + assert server_returned_expected(expected_result, gotten_response) + + +def test_delete_node_query(query_server): + created_node_uuid = create_node_query(query_server) + + delete_query = 'mutation{deleteUsers(where:{id:"' + created_node_uuid + '"}){nodesDeleted relationshipsDeleted}}' + expected_delete_response = '{"data":{"deleteUsers":{"nodesDeleted":1,"relationshipsDeleted":0}}}\n' + + gotten = query_server.send_query(delete_query) + assert expected_delete_response == str(gotten.text) + + +def test_nested_delete_node_query(query_server): + node_uuids = create_related_nodes_query(query_server) + created_user_uuid = node_uuids[0] + + delete_query = ( + 'mutation {deleteUsers(where: {id: "' + + created_user_uuid + + '"},delete: {posts: {where: {}}}) {nodesDeleted relationshipsDeleted}}' + ) + expected_delete_response = '{"data":{"deleteUsers":{"nodesDeleted":2,"relationshipsDeleted":1}}}\n' + + gotten = query_server.send_query(delete_query) + assert expected_delete_response == str(gotten.text) + + +def test_update_node(query_server): + node_uuids = create_related_nodes_query(query_server) + created_post_uuid = node_uuids[1] + + update_query = ( + 'mutation {updatePosts(where: {id: "' + + created_post_uuid + + '"}update: {content: "Some new content for this Post!"}) {posts {content}}}' + ) + expected_update_response = '{"data":{"updatePosts":{"posts":[{"content":"Some new content for this Post!"}]}}}\n' + + gotten = query_server.send_query(update_query) + assert expected_update_response == str(gotten.text) + + +def test_connect_or_create(query_server): + created_user_uuid = create_node_query(query_server) + + connect_or_create_query = ( + 'mutation {updateUsers(update: {posts: {connectOrCreate: {where: { node: { id: "1234" } }onCreate: { node: { content: "Some content" } }}}},where: { id: "' + + created_user_uuid + + '" }) {info {nodesCreated}}}' + ) + expected_response = '{"data":{"updateUsers":{"info":{"nodesCreated":1}}}}\n' + + gotten = query_server.send_query(connect_or_create_query) + assert expected_response == str(gotten.text) + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-rA"])) diff --git a/tests/e2e/graphql/graphql_library_config/CMakeLists.txt b/tests/e2e/graphql/graphql_library_config/CMakeLists.txt new file mode 100644 index 000000000..860c4f240 --- /dev/null +++ b/tests/e2e/graphql/graphql_library_config/CMakeLists.txt @@ -0,0 +1 @@ +copy_graphql_e2e_python_files(crud.js) diff --git a/tests/e2e/graphql/graphql_library_config/crud.js b/tests/e2e/graphql/graphql_library_config/crud.js new file mode 100644 index 000000000..65c65c79f --- /dev/null +++ b/tests/e2e/graphql/graphql_library_config/crud.js @@ -0,0 +1,34 @@ +const { Neo4jGraphQL } = require("@neo4j/graphql"); +const { ApolloServer, gql } = require("apollo-server"); +const neo4j = require("neo4j-driver"); + +const typeDefs = gql` +type Post { + id: ID! @id + content: String! + creator: User! @relationship(type: "HAS_POST", direction: IN) +} + +type User { + id: ID! @id + name: String + posts: [Post!]! @relationship(type: "HAS_POST", direction: OUT) +} +`; + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("", "") +); + +const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + +neoSchema.getSchema().then((schema) => { + const server = new ApolloServer({ + schema, + }); + + server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); + }); +}) diff --git a/tests/e2e/graphql/graphql_server.py b/tests/e2e/graphql/graphql_server.py new file mode 100644 index 000000000..74a5e197a --- /dev/null +++ b/tests/e2e/graphql/graphql_server.py @@ -0,0 +1,149 @@ +import atexit +import collections.abc +import json +import os.path +import socket +import subprocess +import time +from uuid import UUID + +import pytest + +import requests + + +class GraphQLServer: + def __init__(self, config_file_path: str): + self.url = "http://127.0.0.1:4000" + + self.graphql_lib = subprocess.Popen(["node", config_file_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + self.__wait_process_to_init(7687) + self.__wait_process_to_init(4000) + atexit.register(self.__shut_down) + + def send_query(self, query: str, timeout=5.0) -> requests.Response: + try: + response = requests.post(self.url, json={"query": query}, timeout=timeout) + except requests.exceptions.Timeout as err: + print("Request to GraphQL server has timed out. Details:", err) + else: + return response + + def __wait_process_to_init(self, port): + host = "127.0.0.1" + try: + while True: + # Create a socket object + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(5) + result = s.connect_ex((host, port)) + if result == 0: + break + + except socket.error as e: + print(f"Error occurred while checking port {port}: {e}") + return False + + def __shut_down(self): + self.graphql_lib.kill() + ls = subprocess.Popen(("lsof", "-t", "-i:4000"), stdout=subprocess.PIPE) + subprocess.check_output(("xargs", "-r", "kill"), stdin=ls.stdout) + ls.wait() + + +def _ordered(obj: any) -> any: + if isinstance(obj, dict): + return sorted((k, _ordered(v)) for k, v in obj.items()) + if isinstance(obj, list): + return sorted(_ordered(x) for x in obj) + else: + return obj + + +def _flatten(x: any) -> list: + result = [] + for el in x: + if isinstance(x, collections.abc.Iterable) and not isinstance(el, str): + result.extend(_flatten(el)) + else: + result.append(el) + return result + + +def _valid_uuid(uuid_to_test: any, version: int = 4) -> any: + try: + uuid_obj = UUID(uuid_to_test, version=version) + except ValueError: + return False + return str(uuid_obj) == uuid_to_test + + +def server_returned_expected(expected_string: str, server_response: requests.Response) -> bool: + expected_json = json.loads(expected_string) + server_response_json = json.loads(server_response.text) + + expected = _flatten(_ordered(expected_json)) + actual = _flatten(_ordered(server_response_json)) + + for expected_item, actual_item in zip(expected, actual): + if expected_item != actual_item and not (_valid_uuid(expected_item)): + return False + + return True + + +def get_uuid_from_response(response: requests.Response) -> list: + response_json = json.loads(response.text) + flattened_response = _flatten(_ordered(response_json)) + uuids = [] + for item in flattened_response: + if _valid_uuid(item): + uuids.append(str(item)) + return uuids + + +def create_node_query(server: GraphQLServer): + query = 'mutation{createUsers(input:[{name:"John Doe"}]){users{id name}}}' + gotten = server.send_query(query) + uuids = get_uuid_from_response(gotten) + return uuids[0] + + +def create_related_nodes_query(server: GraphQLServer): + query = """ + mutation { + createUsers(input: [ + { + name: "John Doe" + posts: { + create: [ + { + node: { + content: "Hi, my name is John!" + } + } + ] + } + } + ]) { + users { + id + name + posts { + id + content + } + } + } + } + """ + + gotten_response = server.send_query(query) + return get_uuid_from_response(gotten_response) + + +@pytest.fixture +def query_server() -> GraphQLServer: + path = os.path.join("graphql/graphql_library_config/crud.js") + return GraphQLServer(path) diff --git a/tests/e2e/graphql/package.json b/tests/e2e/graphql/package.json new file mode 100644 index 000000000..175e9f4a2 --- /dev/null +++ b/tests/e2e/graphql/package.json @@ -0,0 +1,9 @@ +{ + "dependencies": { + "@apollo/server": "^4.8.1", + "@neo4j/graphql": "^3.24.0", + "apollo-server": "^3.12.0", + "graphql": "^16.7.1", + "neo4j-driver": "^5.10.0" + } +} diff --git a/tests/e2e/graphql/setup.sh b/tests/e2e/graphql/setup.sh new file mode 100755 index 000000000..87d57e64c --- /dev/null +++ b/tests/e2e/graphql/setup.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -Eeuo pipefail +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +source "$SCRIPT_DIR/../../util.sh" +setup_node + +npm i diff --git a/tests/e2e/graphql/temporary_procedures/CMakeLists.txt b/tests/e2e/graphql/temporary_procedures/CMakeLists.txt new file mode 100644 index 000000000..1be99754e --- /dev/null +++ b/tests/e2e/graphql/temporary_procedures/CMakeLists.txt @@ -0,0 +1 @@ +copy_graphql_e2e_python_files(mgps.py) diff --git a/tests/e2e/graphql/temporary_procedures/mgps.py b/tests/e2e/graphql/temporary_procedures/mgps.py new file mode 100644 index 000000000..5732b46e7 --- /dev/null +++ b/tests/e2e/graphql/temporary_procedures/mgps.py @@ -0,0 +1,8 @@ +import typing + +import mgp + + +@mgp.read_proc +def components(context: mgp.ProcCtx) -> mgp.Record(versions=list, edition=str): + return mgp.Record(versions=["4.3"], edition="4.3.2") diff --git a/tests/e2e/graphql/workloads.yaml b/tests/e2e/graphql/workloads.yaml new file mode 100644 index 000000000..2122e2195 --- /dev/null +++ b/tests/e2e/graphql/workloads.yaml @@ -0,0 +1,35 @@ +args: &args + - "--bolt-port" + - "7687" + - "--log-level" + - "TRACE" + - "--query-callable-mappings-path" + - "graphql/callable_alias_mapping.json" + +in_memory_cluster: &in_memory_cluster + cluster: + main: + args: *args + log_file: "graphql-e2e.log" + setup_queries: [] + validation_queries: [] + +disk_cluster: &disk_cluster + cluster: + main: + args: *args + log_file: "graphql-e2e.log" + setup_queries: ["STORAGE MODE ON_DISK_TRANSACTIONAL"] + validation_queries: [] + +workloads: + - name: "GraphQL crud" + binary: "tests/e2e/pytest_runner.sh" + proc: "tests/e2e/graphql/temporary_procedures/" + args: ["graphql/graphql_crud.py"] + <<: *in_memory_cluster + - name: "Disk GraphQL crud" + binary: "tests/e2e/pytest_runner.sh" + proc: "tests/e2e/graphql/temporary_procedures/" + args: ["graphql/graphql_crud.py"] + <<: *disk_cluster diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index c3611b2bf..e6a2ec1dc 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -1,4 +1,6 @@ #!/bin/bash +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" # TODO(gitbuda): Setup mgclient and pymgclient properly. export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:../../libs/mgclient/lib @@ -17,6 +19,9 @@ check_license() { fi } +source "$SCRIPT_DIR/../util.sh" +setup_node + if [ "$#" -eq 0 ]; then check_license # NOTE: If you want to run all tests under specific folder/section just diff --git a/tests/setup.sh b/tests/setup.sh index 1630a6454..9c1e7d20c 100755 --- a/tests/setup.sh +++ b/tests/setup.sh @@ -1,10 +1,10 @@ #!/bin/bash - # shellcheck disable=1091 set -Eeuo pipefail - DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$DIR" + PIP_DEPS=( "behave==1.2.6" "ldap3==2.6" @@ -19,14 +19,11 @@ PIP_DEPS=( "networkx==2.4" "gqlalchemy==1.3.3" ) -cd "$DIR" -# Remove old virtualenv. +# Remove old and create a new virtualenv. if [ -d ve3 ]; then rm -rf ve3 fi - -# Create new virtualenv. virtualenv -p python3 ve3 set +u source "ve3/bin/activate" @@ -37,7 +34,6 @@ PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info[:][1])') # install pulsar-client pip --timeout 1000 install "pulsar-client==3.1.0" - for pkg in "${PIP_DEPS[@]}"; do pip --timeout 1000 install "$pkg" done @@ -51,3 +47,5 @@ CFLAGS="-std=c99" python3 setup.py install popd > /dev/null deactivate + +"$DIR"/e2e/graphql/setup.sh diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 1c69d2606..da6987260 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -287,6 +287,9 @@ target_link_libraries(${test_prefix}utils_settings mg-utils mg-settings) add_unit_test(utils_temporal utils_temporal.cpp) target_link_libraries(${test_prefix}utils_temporal mg-utils) +add_unit_test(utils_java_string_formatter.cpp) +target_link_libraries(${test_prefix}utils_java_string_formatter mg-utils) + # Test mg-storage-v2 add_unit_test(commit_log_v2.cpp) target_link_libraries(${test_prefix}commit_log_v2 gflags mg-utils mg-storage-v2) diff --git a/tests/unit/utils_java_string_formatter.cpp b/tests/unit/utils_java_string_formatter.cpp new file mode 100644 index 000000000..94c0392ef --- /dev/null +++ b/tests/unit/utils_java_string_formatter.cpp @@ -0,0 +1,221 @@ +// Copyright 2023 Memgraph Ltd. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt; by using this file, you agree to be bound by the terms of the Business Source +// License, and you may not use this file except in compliance with the Business Source License. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +#include +#include +#include +#include + +#include + +#include +#include + +using TString = memgraph::utils::pmr::string; + +namespace { + +class DummyTypedValue { + public: + template + DummyTypedValue(T &&val) : data_(std::forward(val)) {} + + int ValueInt() const noexcept { + auto *value = std::get_if(&data_); + MG_ASSERT(value); + return *value; + } + double ValueDouble() const noexcept { + auto *value = std::get_if(&data_); + MG_ASSERT(value); + return *value; + } + TString ValueString() const noexcept { + auto *value = std::get_if(&data_); + MG_ASSERT(value); + return *value; + } + + private: + std::variant data_; +}; + +auto GetVector() { return memgraph::utils::pmr::vector(memgraph::utils::NewDeleteResource()); } +auto GetString() { return TString(memgraph::utils::NewDeleteResource()); } + +memgraph::utils::JStringFormatter gFormatter; + +} // namespace + +TEST(JavaStringFormatter, FormatesOneIntegerCharacter) { + auto args = GetVector(); + auto str = GetString(); + args.emplace_back(1); + str = "\%d"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "1"); +} + +TEST(JavaStringFormatter, FormatesOneDoubleCharacter) { + auto args = GetVector(); + auto str = GetString(); + args.emplace_back(1.0); + str = "\%f"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "1.000000"); +} + +TEST(JavaStringFormatter, FormatesOneSimpleStringCharacter) { + auto args = GetVector(); + auto str = GetString(); + auto replacement_str = GetString(); + replacement_str = "x"; + args.emplace_back(replacement_str); + str = "\%s"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "x"); +} + +TEST(JavaStringFormatter, FormatesOneComplexStringCharacter) { + auto args = GetVector(); + auto str = GetString(); + auto replacement_str = GetString(); + replacement_str = "moja najdraža boja je zelena"; + args.emplace_back(replacement_str); + str = "\%s"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "moja najdraža boja je zelena"); +} + +TEST(JavaStringFormatter, FormatesMoreCharacters) { + auto args = GetVector(); + auto str = GetString(); + args.emplace_back(1); + args.emplace_back(2); + args.emplace_back(3); + str = "\%d\%d\%d"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "123"); +} + +TEST(JavaStringFormatter, FormatesMoreCharactersInSentence) { + auto args = GetVector(); + auto str = GetString(); + args.emplace_back(1); + args.emplace_back(10); + args.emplace_back(10.0); + str = "The chances of picking \%d out of \%d matches is \%f%"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "The chances of picking 1 out of 10 matches is 10.000000%"); +} + +TEST(JavaStringFormatter, FormateSinglePecrentile) { + auto args = GetVector(); + auto str = GetString(); + str = "%"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "%"); +} + +TEST(JavaStringFormatter, FormatPercineleBeforeFormatString) { + auto args = GetVector(); + auto str = GetString(); + args.emplace_back(1); + str = "%\%d"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "\%1"); +} + +TEST(JavaStringFormatter, FormatPercineleAfterFormatString) { + auto args = GetVector(); + auto str = GetString(); + args.emplace_back(1); + str = "\%d%"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "1\%"); +} + +TEST(JavaStringFormatter, FormatPercineleInBetweenFormatStrings) { + auto args = GetVector(); + auto str = GetString(); + args.emplace_back(1); + str = "%\%d%"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "\%1\%"); +} + +TEST(JavaStringFormatter, FormatManyPercentilesOneFormatString) { + auto args = GetVector(); + auto str = GetString(); + args.emplace_back(1); + str = "Some% random% strings here% and there wit% h \%da bunch of percent% iles and one format%-string."; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "Some% random% strings here% and there wit% h 1a bunch of percent% iles and one format%-string."); +} + +TEST(JavaStringFormatter, ThrowOnNonExistentFormatSpecifier) { + auto args = GetVector(); + auto str = GetString(); + args.emplace_back(1); + str = "\%x"; + + ASSERT_THROW(gFormatter.FormatString(str, args), memgraph::utils::JStringFormatException); +} + +TEST(JavaStringFormatter, ThrowOnLessFormatSpecifiersThanSlots) { + auto args = GetVector(); + auto str = GetString(); + auto replacement_str = GetString(); + replacement_str = "format specifiers"; + args.emplace_back(3); + args.emplace_back(replacement_str); + str = "There is \%d \%s in this sentence but only \%d specified in the argument list"; + + ASSERT_THROW(gFormatter.FormatString(str, args), memgraph::utils::JStringFormatException); +} + +TEST(JavaStringFormatter, DoNotThrowOnMoreFormatSpecifiersThanSlots) { + auto args = GetVector(); + auto str = GetString(); + auto replacement_str = GetString(); + replacement_str = "format specifiers"; + args.emplace_back(3); + args.emplace_back(replacement_str); + args.emplace_back(123); + args.emplace_back(123); + str = "There is \%d \%s in this sentence but only \%d specified in the argument list"; + + auto result = gFormatter.FormatString(str, args); + + ASSERT_EQ(result, "There is 3 format specifiers in this sentence but only 123 specified in the argument list"); +} diff --git a/tests/util.sh b/tests/util.sh new file mode 100644 index 000000000..450ae9046 --- /dev/null +++ b/tests/util.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +setup_node() { + if [ -f "$HOME/.nvm/nvm.sh" ]; then + . "$HOME/.nvm/nvm.sh" + nvm install 14 + nvm use 14 + fi + + if ! command -v node >/dev/null; then + echo "Could NOT node. Make sure node is installed." + exit 1 + fi + if ! command -v npm >/dev/null; then + echo "Could NOT npm. Make sure npm is installed." + exit 1 + fi + node_version=$(node --version) + npm_version=$(npm --version) + echo "NODE VERSION: $node_version" + echo "NPM VERSION: $npm_version" + node_major_version=${node_version##v} + node_major_version=${node_major_version%%.*} + if [ ! "$node_major_version" -ge 14 ]; then + echo "ERROR: It's required to have node >= 14." + exit 1 + fi +}