Add GraphQL transpilation compatibility (#1018)

* Add callable mappings feature
* Implement mgps.validate (void procedure)
* Make '_' a valid variable name
This commit is contained in:
gvolfing 2023-07-31 14:48:12 +02:00 committed by GitHub
parent 57fe3463f2
commit 210bea83d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 971 additions and 17 deletions

4
init
View File

@ -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 ..

View File

@ -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::Session<memgraph
if (user_) {
username = &user_->username();
}
#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};

View File

@ -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

View File

@ -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<CallProcedure>();
@ -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;
}

View File

@ -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.");

View File

@ -407,4 +407,5 @@ cypherKeyword : ALL
symbolicName : UnescapedSymbolicName
| EscapedSymbolicName
| cypherKeyword
| UNDERSCORE
;

View File

@ -179,6 +179,7 @@ EscapedSymbolicName : ( '`' ~[`]* '`' )+ ;
*/
IdentifierStart : ID_Start | Pc ;
IdentifierPart : ID_Continue | Sc ;
UNDERSCORE : '_' ;
/* Hack for case-insensitive reserved words */
fragment A : 'A' | 'a' ;

View File

@ -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 ;

View File

@ -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<Or<Null, List, String, Map, Path>>("size", args, nargs);
const auto &value = args[0];
@ -1258,6 +1263,7 @@ std::function<TypedValue(const TypedValue *, int64_t, const FunctionContext &ctx
if (function_name == kId) return Id;
if (function_name == "LAST") return Last;
if (function_name == "PROPERTIES") return Properties;
if (function_name == "RANDOMUUID") return RandomUuid;
if (function_name == "SIZE") return Size;
if (function_name == "STARTNODE") return StartNode;
if (function_name == "TIMESTAMP") return Timestamp;

View File

@ -12,6 +12,7 @@
#include "query/plan/operator.hpp"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <limits>
#include <optional>
@ -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<LogicalOperator> input, std::string name, std::vector<Expression *> args,
std::vector<std::string> fields, std::vector<Symbol> 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<Once>()),
procedure_name_(name),
arguments_(args),
@ -4461,7 +4463,8 @@ CallProcedure::CallProcedure(std::shared_ptr<LogicalOperator> 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<decltype(message.ValueString())>;
using TElement = std::remove_cvref_t<decltype(message_args.ValueList()[0])>;
utils::JStringFormatter<TString, TElement> 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<CallValidateProcedureCursor>(mem, this, mem);
}
return MakeUniqueCursorPtr<CallProcedureCursor>(mem, this, mem);
}

View File

@ -2178,7 +2178,7 @@ class CallProcedure : public memgraph::query::plan::LogicalOperator {
CallProcedure() = default;
CallProcedure(std::shared_ptr<LogicalOperator> input, std::string name, std::vector<Expression *> arguments,
std::vector<std::string> fields, std::vector<Symbol> 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;
}

View File

@ -221,7 +221,8 @@ class RuleBasedPlanner {
// storage::View::NEW.
input_op = std::make_unique<plan::CallProcedure>(
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<query::LoadCsv>(clause)) {
const auto &row_sym = context.symbol_table->at(*load_csv->row_var_);
context.bound_symbols.insert(row_sym);

View File

@ -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 <algorithm>
#include <array>
#include <filesystem>
#include <fstream>
#include <spdlog/spdlog.h>
#include <json/json.hpp>
#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<std::unordered_map<std::string, std::string>>();
} 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<std::string_view> CallableAliasMapper::FindAlias(const std::string &name) const noexcept {
if (!mapping_.contains(name)) {
return std::nullopt;
}
return mapping_.at(name);
}
} // namespace memgraph::query::procedure

View File

@ -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 <array>
#include <cstring>
#include <filesystem>
#include <functional>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
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<std::string_view> FindAlias(const std::string &) const noexcept;
private:
std::unordered_map<std::string, std::string> mapping_;
};
/// Single, global alias mapper.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
extern CallableAliasMapper gCallableAliasMapper;
} // namespace memgraph::query::procedure

View File

@ -22,6 +22,7 @@ extern "C" {
#include <unistd.h>
#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<std::pair<ModulePtr, const T *>> 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;

View File

@ -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 <string>
#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 <class... Args>
explicit JStringFormatException(fmt::format_string<Args...> fmt, Args &&...args) noexcept
: JStringFormatException(fmt::format(fmt, std::forward<Args>(args)...)) {}
};
template <typename T>
concept TTypedValueLike = requires(T t) {
{ t.ValueInt() } -> std::convertible_to<int>;
{ t.ValueDouble() } -> std::convertible_to<double>;
{ t.ValueString() } -> std::convertible_to<pmr::string>;
};
template <typename TString, typename TTypedValueLike>
class JStringFormatter final {
public:
[[nodiscard]] TString FormatString(TString str, const pmr::vector<TTypedValueLike> &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

View File

@ -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)

View File

@ -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.",
),
}

4
tests/e2e/graphql/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
# Because the parent folder ignores *.json
!callable_alias_mapping.json
!package.json

View File

@ -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)

View File

@ -0,0 +1,4 @@
{
"dbms.components": "mgps.components",
"apoc.util.validate": "mgps.validate"
}

View File

@ -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"]))

View File

@ -0,0 +1 @@
copy_graphql_e2e_python_files(crud.js)

View File

@ -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}`);
});
})

View File

@ -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)

View File

@ -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"
}
}

9
tests/e2e/graphql/setup.sh Executable file
View File

@ -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

View File

@ -0,0 +1 @@
copy_graphql_e2e_python_files(mgps.py)

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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 <iostream>
#include <string_view>
#include <variant>
#include <vector>
#include <gtest/gtest.h>
#include <utils/java_string_formatter.hpp>
#include <utils/pmr/string.hpp>
using TString = memgraph::utils::pmr::string;
namespace {
class DummyTypedValue {
public:
template <typename T>
DummyTypedValue(T &&val) : data_(std::forward<decltype(val)>(val)) {}
int ValueInt() const noexcept {
auto *value = std::get_if<int>(&data_);
MG_ASSERT(value);
return *value;
}
double ValueDouble() const noexcept {
auto *value = std::get_if<double>(&data_);
MG_ASSERT(value);
return *value;
}
TString ValueString() const noexcept {
auto *value = std::get_if<TString>(&data_);
MG_ASSERT(value);
return *value;
}
private:
std::variant<int, double, TString> data_;
};
auto GetVector() { return memgraph::utils::pmr::vector<DummyTypedValue>(memgraph::utils::NewDeleteResource()); }
auto GetString() { return TString(memgraph::utils::NewDeleteResource()); }
memgraph::utils::JStringFormatter<TString, DummyTypedValue> 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");
}

28
tests/util.sh Normal file
View File

@ -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
}