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
35 changed files with 971 additions and 17 deletions

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