Skip to content

Commit 3a0ce65

Browse files
authored
Merge pull request #34 from BillFarber/feature/postEval
DEVEXP-578: Submit eval request
2 parents 8a20605 + dfb2c2d commit 3a0ce65

File tree

5 files changed

+278
-26
lines changed

5 files changed

+278
-26
lines changed

marklogic/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from marklogic.documents import DocumentManager
44
from marklogic.rows import RowManager
55
from marklogic.transactions import TransactionManager
6+
from marklogic.eval import EvalManager
67
from requests.auth import HTTPDigestAuth
78
from urllib.parse import urljoin
89

@@ -84,3 +85,9 @@ def transactions(self):
8485
if not hasattr(self, "_transactions"):
8586
self._transactions = TransactionManager(self)
8687
return self._transactions
88+
89+
@property
90+
def eval(self):
91+
if not hasattr(self, "_eval"):
92+
self._eval = EvalManager(self)
93+
return self._eval

marklogic/eval.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import json
2+
3+
from decimal import Decimal
4+
from marklogic.documents import Document
5+
from requests import Session
6+
from requests_toolbelt.multipart.decoder import MultipartDecoder
7+
8+
"""
9+
Defines an EvalManager class to simplify usage of the "/v1/eval" REST
10+
endpoint defined at https://docs.marklogic.com/REST/POST/v1/eval.
11+
"""
12+
13+
14+
class EvalManager:
15+
"""
16+
Provides a method to simplify sending an XQuery or
17+
JavaScript eval request to the eval endpoint.
18+
"""
19+
20+
def __init__(self, session: Session):
21+
self._session = session
22+
23+
def xquery(
24+
self, xquery: str, vars: dict = None, return_response: bool = False, **kwargs
25+
):
26+
"""
27+
Send an XQuery script to MarkLogic via a POST to the endpoint
28+
defined at https://docs.marklogic.com/REST/POST/v1/eval.
29+
30+
:param xquery: an XQuery string
31+
:param vars: a dict containing variables to include
32+
:param return_response: boolean specifying if the entire original response
33+
object should be returned (True) or if only the data should be returned (False)
34+
upon a success (2xx) response. Note that if the status code of the response is
35+
not 2xx, then the entire response is always returned.
36+
"""
37+
if xquery is None:
38+
raise ValueError("No script found; must specify a xquery")
39+
return self.__send_request({"xquery": xquery}, vars, return_response, **kwargs)
40+
41+
def javascript(
42+
self,
43+
javascript: str,
44+
vars: dict = None,
45+
return_response: bool = False,
46+
**kwargs
47+
):
48+
"""
49+
Send a JavaScript script to MarkLogic via a POST to the endpoint
50+
defined at https://docs.marklogic.com/REST/POST/v1/eval.
51+
52+
:param javascript: a JavaScript string
53+
:param vars: a dict containing variables to include
54+
:param return_response: boolean specifying if the entire original response
55+
object should be returned (True) or if only the data should be returned (False)
56+
upon a success (2xx) response. Note that if the status code of the response is
57+
not 2xx, then the entire response is always returned.
58+
"""
59+
if javascript is None:
60+
raise ValueError("No script found; must specify a javascript")
61+
return self.__send_request(
62+
{"javascript": javascript}, vars, return_response, **kwargs
63+
)
64+
65+
def __send_request(
66+
self, data: dict, vars: dict = None, return_response: bool = False, **kwargs
67+
):
68+
"""
69+
Send a script (XQuery or javascript) and possibly a dict of vars
70+
to MarkLogic via a POST to the endpoint defined at
71+
https://docs.marklogic.com/REST/POST/v1/eval.
72+
"""
73+
if vars is not None:
74+
data["vars"] = json.dumps(vars)
75+
response = self._session.post("v1/eval", data=data, **kwargs)
76+
return (
77+
self.__process_response(response)
78+
if response.status_code == 200 and not return_response
79+
else response
80+
)
81+
82+
def __process_response(self, response):
83+
"""
84+
Process a multipart REST response by putting them in a list and
85+
transforming each part based on the "X-Primitive" header.
86+
"""
87+
if "Content-Length" in response.headers:
88+
return None
89+
90+
parts = MultipartDecoder.from_response(response).parts
91+
transformed_parts = []
92+
for part in parts:
93+
encoding = part.encoding
94+
primitive_header = part.headers["X-Primitive".encode(encoding)].decode(
95+
encoding
96+
)
97+
primitive_function = EvalManager.__primitive_value_converters.get(
98+
primitive_header
99+
)
100+
if primitive_function is not None:
101+
transformed_parts.append(primitive_function(part))
102+
else:
103+
transformed_parts.append(part.text)
104+
return transformed_parts
105+
106+
__primitive_value_converters = {
107+
"integer": lambda part: int(part.text),
108+
"decimal": lambda part: Decimal(part.text),
109+
"boolean": lambda part: ("False" == part.text),
110+
"string": lambda part: part.text,
111+
"map": lambda part: json.loads(part.text),
112+
"element()": lambda part: part.text,
113+
"array": lambda part: json.loads(part.text),
114+
"array-node()": lambda part: json.loads(part.text),
115+
"object-node()": lambda part: EvalManager.__process_object_node_part(part),
116+
"document-node()": lambda part: EvalManager.__process_document_node_part(part),
117+
"binary()": lambda part: Document(
118+
EvalManager.__get_decoded_uri_from_part(part), part.content
119+
),
120+
}
121+
122+
def __get_decoded_uri_from_part(part):
123+
encoding = part.encoding
124+
return part.headers["X-URI".encode(encoding)].decode(encoding)
125+
126+
def __process_object_node_part(part):
127+
if b"X-URI" in part.headers:
128+
return Document(
129+
EvalManager.__get_decoded_uri_from_part(part), json.loads(part.text)
130+
)
131+
else:
132+
return json.loads(part.text)
133+
134+
def __process_document_node_part(part):
135+
if b"X-URI" in part.headers:
136+
return Document(EvalManager.__get_decoded_uri_from_part(part), part.text)
137+
else:
138+
return part.text

marklogic/rows.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,23 @@
33

44
"""
55
Defines a RowManager class to simplify usage of the "/v1/rows" & "/v1/rows/graphql" REST
6-
endpoints defined at https://docs.marklogic.com/REST/POST/v1/rows/graphql
6+
endpoints defined at https://docs.marklogic.com/REST/POST/v1/rows/graphql.
77
"""
88

99

1010
class RowManager:
1111
"""
12-
Provides a method to simplify sending a GraphQL request to the GraphQL rows endpoint.
12+
Provides a method to simplify sending a GraphQL
13+
request to the GraphQL rows endpoint.
1314
"""
1415

1516
def __init__(self, session: Session):
1617
self._session = session
1718

18-
def graphql(
19-
self, graphql_query: str, return_response: bool = False, *args, **kwargs
20-
):
19+
def graphql(self, graphql_query: str, return_response: bool = False, **kwargs):
2120
"""
2221
Send a GraphQL query to MarkLogic via a POST to the endpoint defined at
23-
https://docs.marklogic.com/REST/POST/v1/rows/graphql
22+
https://docs.marklogic.com/REST/POST/v1/rows/graphql.
2423
2524
:param graphql_query: a GraphQL query string. Note - this is the query string
2625
only, not the entire query JSON object. See the following for more information:
@@ -69,18 +68,17 @@ def query(
6968
sparql: str = None,
7069
format: str = "json",
7170
return_response: bool = False,
72-
*args,
7371
**kwargs
7472
):
7573
"""
7674
Send a query to MarkLogic via a POST to the endpoint defined at
77-
https://docs.marklogic.com/REST/POST/v1/rows
75+
https://docs.marklogic.com/REST/POST/v1/rows.
7876
Just like that endpoint, this function can be used for four different types of
7977
queries: Optic DSL, Serialized Optic, SQL, and SPARQL. The type of query
8078
processed by the function is dependent upon the parameter used in the call to
8179
the function.
8280
For more information about Optic and using the Optic DSL, SQL, and SPARQL,
83-
see https://docs.marklogic.com/guide/app-dev/OpticAPI
81+
see https://docs.marklogic.com/guide/app-dev/OpticAPI.
8482
If multiple query parameters are passed into the call, the function uses the
8583
query parameter that is first in the list: dsl, plan, sql, sparql.
8684
20.3 KB
Loading

tests/test_eval.py

Lines changed: 126 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,133 @@
1+
import decimal
2+
3+
from marklogic.documents import Document
14
from requests_toolbelt.multipart.decoder import MultipartDecoder
5+
from pytest import raises
26

37

4-
def test_eval(client):
5-
"""
6-
This shows how a user would do an eval today. It's a good example of how a multipart/mixed
7-
response is a little annoying to deal with, as it requires using the requests_toolbelt
8-
library and a class called MultipartDecoder.
8+
def test_xquery_common_primitives(client):
9+
parts = client.eval.xquery(
10+
"""(
11+
'A', 1, 1.1, fn:false(), fn:doc('/musicians/logo.png'))
12+
"""
13+
)
14+
__verify_common_primitives(parts)
915

10-
Client support for this might look like this:
11-
response = client.eval.xquery("<hello>world</hello>")
1216

13-
And then it's debatable whether we want to do anything beyond what MultipartDecoder
14-
is doing for handling the response.
15-
"""
16-
response = client.post(
17-
"v1/eval",
18-
headers={"Content-type": "application/x-www-form-urlencoded"},
19-
data={"xquery": "<hello>world</hello>"},
17+
def test_javascript_common_primitives(client):
18+
parts = client.eval.javascript(
19+
"""xdmp.arrayValues([
20+
'A', 1, 1.1, false, fn.doc('/musicians/logo.png')
21+
])"""
22+
)
23+
__verify_common_primitives(parts)
24+
25+
26+
def test_xquery_specific_primitives(client):
27+
parts = client.eval.xquery(
28+
"""(
29+
<hello>world</hello>,
30+
object-node {'A': 'a'},
31+
fn:doc('/doc2.xml'),
32+
document {<test/>},
33+
array-node {1, "23", 4}
34+
)"""
2035
)
36+
assert type(parts[0]) is str
37+
assert "<hello>world</hello>" == parts[0]
38+
assert type(parts[1]) is dict
39+
assert {"A": "a"} == parts[1]
40+
assert type(parts[2]) is Document
41+
assert "/doc2.xml" == parts[2].uri
42+
assert "<hello>world</hello>" in parts[2].content
43+
assert type(parts[3]) is str
44+
assert '<?xml version="1.0" encoding="UTF-8"?>\n<test/>' == parts[3]
45+
assert type(parts[4]) is list
46+
assert "23" == parts[4][1]
47+
assert 3 == len(parts[4])
48+
49+
50+
def test_javascript_specific_primitives(client):
51+
parts = client.eval.javascript(
52+
"""xdmp.arrayValues([
53+
{'A': 'a'},
54+
['Z', 'Y', 1],
55+
fn.head(cts.search('Armstrong'))
56+
])"""
57+
)
58+
assert type(parts[0]) is dict
59+
assert {"A": "a"} == parts[0]
60+
assert type(parts[1]) is list
61+
assert "Z" == parts[1][0]
62+
assert 3 == len(parts[1])
63+
assert type(parts[2]) is Document
64+
assert "/musicians/musician1.json" == parts[2].uri
65+
assert {
66+
"musician": {
67+
"lastName": "Armstrong",
68+
"firstName": "Louis",
69+
"dob": "1901-08-04",
70+
"instrument": ["trumpet", "vocal"],
71+
}
72+
} == parts[2].content
73+
74+
75+
def test_javascript_noquery(client):
76+
with raises(ValueError, match="No script found; must specify a javascript"):
77+
client.eval.javascript(None)
78+
79+
80+
def test_xquery_noquery(client):
81+
with raises(ValueError, match="No script found; must specify a xquery"):
82+
client.eval.xquery(None)
83+
84+
85+
def test_xquery_with_return_response(client):
86+
response = client.eval.xquery("('A', 1, 1.1, fn:false())", return_response=True)
87+
assert 200 == response.status_code
88+
parts = MultipartDecoder.from_response(response).parts
89+
assert 4 == len(parts)
90+
91+
92+
def test_xquery_vars(client):
93+
vars = {"word1": "hello", "word2": "world"}
94+
script = """
95+
xquery version "1.0-ml";
96+
declare variable $word1 as xs:string external;
97+
declare variable $word2 as xs:string external;
98+
fn:concat($word1, " ", $word2)
99+
"""
100+
parts = client.eval.xquery(script, vars)
101+
assert type(parts[0]) is str
102+
assert "hello world" == parts[0]
103+
104+
105+
def test_javascript_vars(client):
106+
vars = {"word1": "hello", "word2": "world"}
107+
parts = client.eval.javascript("xdmp.arrayValues([word1, word2])", vars)
108+
assert type(parts[0]) is str
109+
assert "hello" == parts[0]
110+
111+
112+
def test_xquery_empty_sequence(client):
113+
parts = client.eval.xquery("()")
114+
assert parts is None
115+
116+
117+
def test_javascript_script(client):
118+
parts = client.eval.javascript("[]")
119+
assert [[]] == parts
120+
21121

22-
decoder = MultipartDecoder.from_response(response)
23-
content = decoder.parts[0].text
24-
assert "<hello>world</hello>" == content
122+
def __verify_common_primitives(parts):
123+
assert type(parts[0]) is str
124+
assert "A" == parts[0]
125+
assert type(parts[1]) is int
126+
assert 1 == parts[1]
127+
assert type(parts[2]) is decimal.Decimal
128+
assert decimal.Decimal("1.1") == parts[2]
129+
assert type(parts[3]) is bool
130+
assert parts[3] is False
131+
assert type(parts[4]) is Document
132+
assert "/musicians/logo.png" == parts[4].uri
133+
assert b"PNG" in parts[4].content

0 commit comments

Comments
 (0)