Add GraphQL transpilation compatibility (#1018)
* Add callable mappings feature * Implement mgps.validate (void procedure) * Make '_' a valid variable name
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
4
tests/e2e/graphql/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
# Because the parent folder ignores *.json
|
||||
!callable_alias_mapping.json
|
||||
!package.json
|
||||
10
tests/e2e/graphql/CMakeLists.txt
Normal file
10
tests/e2e/graphql/CMakeLists.txt
Normal 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)
|
||||
4
tests/e2e/graphql/callable_alias_mapping.json
Normal file
4
tests/e2e/graphql/callable_alias_mapping.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"dbms.components": "mgps.components",
|
||||
"apoc.util.validate": "mgps.validate"
|
||||
}
|
||||
105
tests/e2e/graphql/graphql_crud.py
Normal file
105
tests/e2e/graphql/graphql_crud.py
Normal 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"]))
|
||||
1
tests/e2e/graphql/graphql_library_config/CMakeLists.txt
Normal file
1
tests/e2e/graphql/graphql_library_config/CMakeLists.txt
Normal file
@@ -0,0 +1 @@
|
||||
copy_graphql_e2e_python_files(crud.js)
|
||||
34
tests/e2e/graphql/graphql_library_config/crud.js
Normal file
34
tests/e2e/graphql/graphql_library_config/crud.js
Normal 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}`);
|
||||
});
|
||||
})
|
||||
149
tests/e2e/graphql/graphql_server.py
Normal file
149
tests/e2e/graphql/graphql_server.py
Normal 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)
|
||||
9
tests/e2e/graphql/package.json
Normal file
9
tests/e2e/graphql/package.json
Normal 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
9
tests/e2e/graphql/setup.sh
Executable 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
|
||||
1
tests/e2e/graphql/temporary_procedures/CMakeLists.txt
Normal file
1
tests/e2e/graphql/temporary_procedures/CMakeLists.txt
Normal file
@@ -0,0 +1 @@
|
||||
copy_graphql_e2e_python_files(mgps.py)
|
||||
8
tests/e2e/graphql/temporary_procedures/mgps.py
Normal file
8
tests/e2e/graphql/temporary_procedures/mgps.py
Normal 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")
|
||||
35
tests/e2e/graphql/workloads.yaml
Normal file
35
tests/e2e/graphql/workloads.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user