Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions marklogic/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from marklogic.documents import DocumentManager
from marklogic.rows import RowManager
from marklogic.transactions import TransactionManager
from marklogic.eval import EvalManager
from requests.auth import HTTPDigestAuth
from urllib.parse import urljoin

Expand Down Expand Up @@ -84,3 +85,9 @@ def transactions(self):
if not hasattr(self, "_transactions"):
self._transactions = TransactionManager(self)
return self._transactions

@property
def eval(self):
if not hasattr(self, "_eval"):
self._eval = EvalManager(self)
return self._eval
138 changes: 138 additions & 0 deletions marklogic/eval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import json

from decimal import Decimal
from marklogic.documents import Document
from requests import Session
from requests_toolbelt.multipart.decoder import MultipartDecoder

"""
Defines an EvalManager class to simplify usage of the "/v1/eval" REST
endpoint defined at https://docs.marklogic.com/REST/POST/v1/eval.
"""


class EvalManager:
"""
Provides a method to simplify sending an XQuery or
JavaScript eval request to the eval endpoint.
"""

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

def xquery(
self, xquery: str, vars: dict = None, return_response: bool = False, **kwargs
):
"""
Send an XQuery script to MarkLogic via a POST to the endpoint
defined at https://docs.marklogic.com/REST/POST/v1/eval.

:param xquery: an XQuery string
:param vars: a dict containing variables to include
:param return_response: boolean specifying if the entire original response
object should be returned (True) or if only the data should be returned (False)
upon a success (2xx) response. Note that if the status code of the response is
not 2xx, then the entire response is always returned.
"""
if xquery is None:
raise ValueError("No script found; must specify a xquery")
return self.__send_request({"xquery": xquery}, vars, return_response, **kwargs)

def javascript(
self,
javascript: str,
vars: dict = None,
return_response: bool = False,
**kwargs
):
"""
Send a JavaScript script to MarkLogic via a POST to the endpoint
defined at https://docs.marklogic.com/REST/POST/v1/eval.

:param javascript: a JavaScript string
:param vars: a dict containing variables to include
:param return_response: boolean specifying if the entire original response
object should be returned (True) or if only the data should be returned (False)
upon a success (2xx) response. Note that if the status code of the response is
not 2xx, then the entire response is always returned.
"""
if javascript is None:
raise ValueError("No script found; must specify a javascript")
return self.__send_request(
{"javascript": javascript}, vars, return_response, **kwargs
)

def __send_request(
self, data: dict, vars: dict = None, return_response: bool = False, **kwargs
):
"""
Send a script (XQuery or javascript) and possibly a dict of vars
to MarkLogic via a POST to the endpoint defined at
https://docs.marklogic.com/REST/POST/v1/eval.
"""
if vars is not None:
data["vars"] = json.dumps(vars)
response = self._session.post("v1/eval", data=data, **kwargs)
return (
self.__process_response(response)
if response.status_code == 200 and not return_response
else response
)

def __process_response(self, response):
"""
Process a multipart REST response by putting them in a list and
transforming each part based on the "X-Primitive" header.
"""
if "Content-Length" in response.headers:
return None

parts = MultipartDecoder.from_response(response).parts
transformed_parts = []
for part in parts:
encoding = part.encoding
primitive_header = part.headers["X-Primitive".encode(encoding)].decode(
encoding
)
primitive_function = EvalManager.__primitive_value_converters.get(
primitive_header
)
if primitive_function is not None:
transformed_parts.append(primitive_function(part))
else:
transformed_parts.append(part.text)
return transformed_parts

__primitive_value_converters = {
"integer": lambda part: int(part.text),
"decimal": lambda part: Decimal(part.text),
"boolean": lambda part: ("False" == part.text),
"string": lambda part: part.text,
"map": lambda part: json.loads(part.text),
"element()": lambda part: part.text,
"array": lambda part: json.loads(part.text),
"array-node()": lambda part: json.loads(part.text),
"object-node()": lambda part: EvalManager.__process_object_node_part(part),
"document-node()": lambda part: EvalManager.__process_document_node_part(part),
"binary()": lambda part: Document(
EvalManager.__get_decoded_uri_from_part(part), part.content
),
}

def __get_decoded_uri_from_part(part):
encoding = part.encoding
return part.headers["X-URI".encode(encoding)].decode(encoding)

def __process_object_node_part(part):
if b"X-URI" in part.headers:
return Document(
EvalManager.__get_decoded_uri_from_part(part), json.loads(part.text)
)
else:
return json.loads(part.text)

def __process_document_node_part(part):
if b"X-URI" in part.headers:
return Document(EvalManager.__get_decoded_uri_from_part(part), part.text)
else:
return part.text
16 changes: 7 additions & 9 deletions marklogic/rows.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,23 @@

"""
Defines a RowManager class to simplify usage of the "/v1/rows" & "/v1/rows/graphql" REST
endpoints defined at https://docs.marklogic.com/REST/POST/v1/rows/graphql
endpoints defined at https://docs.marklogic.com/REST/POST/v1/rows/graphql.
"""


class RowManager:
"""
Provides a method to simplify sending a GraphQL request to the GraphQL rows endpoint.
Provides a method to simplify sending a GraphQL
request to the GraphQL rows endpoint.
"""

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

def graphql(
self, graphql_query: str, return_response: bool = False, *args, **kwargs
):
def graphql(self, graphql_query: str, return_response: bool = False, **kwargs):
"""
Send a GraphQL query to MarkLogic via a POST to the endpoint defined at
https://docs.marklogic.com/REST/POST/v1/rows/graphql
https://docs.marklogic.com/REST/POST/v1/rows/graphql.

:param graphql_query: a GraphQL query string. Note - this is the query string
only, not the entire query JSON object. See the following for more information:
Expand Down Expand Up @@ -69,18 +68,17 @@ def query(
sparql: str = None,
format: str = "json",
return_response: bool = False,
*args,
**kwargs
):
"""
Send a query to MarkLogic via a POST to the endpoint defined at
https://docs.marklogic.com/REST/POST/v1/rows
https://docs.marklogic.com/REST/POST/v1/rows.
Just like that endpoint, this function can be used for four different types of
queries: Optic DSL, Serialized Optic, SQL, and SPARQL. The type of query
processed by the function is dependent upon the parameter used in the call to
the function.
For more information about Optic and using the Optic DSL, SQL, and SPARQL,
see https://docs.marklogic.com/guide/app-dev/OpticAPI
see https://docs.marklogic.com/guide/app-dev/OpticAPI.
If multiple query parameters are passed into the call, the function uses the
query parameter that is first in the list: dsl, plan, sql, sparql.

Expand Down
Binary file added test-app/src/main/ml-data/musicians/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
143 changes: 126 additions & 17 deletions tests/test_eval.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,133 @@
import decimal

from marklogic.documents import Document
from requests_toolbelt.multipart.decoder import MultipartDecoder
from pytest import raises


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

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

And then it's debatable whether we want to do anything beyond what MultipartDecoder
is doing for handling the response.
"""
response = client.post(
"v1/eval",
headers={"Content-type": "application/x-www-form-urlencoded"},
data={"xquery": "<hello>world</hello>"},
def test_javascript_common_primitives(client):
parts = client.eval.javascript(
"""xdmp.arrayValues([
'A', 1, 1.1, false, fn.doc('/musicians/logo.png')
])"""
)
__verify_common_primitives(parts)


def test_xquery_specific_primitives(client):
parts = client.eval.xquery(
"""(
<hello>world</hello>,
object-node {'A': 'a'},
fn:doc('/doc2.xml'),
document {<test/>},
array-node {1, "23", 4}
)"""
)
assert type(parts[0]) is str
assert "<hello>world</hello>" == parts[0]
assert type(parts[1]) is dict
assert {"A": "a"} == parts[1]
assert type(parts[2]) is Document
assert "/doc2.xml" == parts[2].uri
assert "<hello>world</hello>" in parts[2].content
assert type(parts[3]) is str
assert '<?xml version="1.0" encoding="UTF-8"?>\n<test/>' == parts[3]
assert type(parts[4]) is list
assert "23" == parts[4][1]
assert 3 == len(parts[4])


def test_javascript_specific_primitives(client):
parts = client.eval.javascript(
"""xdmp.arrayValues([
{'A': 'a'},
['Z', 'Y', 1],
fn.head(cts.search('Armstrong'))
])"""
)
assert type(parts[0]) is dict
assert {"A": "a"} == parts[0]
assert type(parts[1]) is list
assert "Z" == parts[1][0]
assert 3 == len(parts[1])
assert type(parts[2]) is Document
assert "/musicians/musician1.json" == parts[2].uri
assert {
"musician": {
"lastName": "Armstrong",
"firstName": "Louis",
"dob": "1901-08-04",
"instrument": ["trumpet", "vocal"],
}
} == parts[2].content


def test_javascript_noquery(client):
with raises(ValueError, match="No script found; must specify a javascript"):
client.eval.javascript(None)


def test_xquery_noquery(client):
with raises(ValueError, match="No script found; must specify a xquery"):
client.eval.xquery(None)


def test_xquery_with_return_response(client):
response = client.eval.xquery("('A', 1, 1.1, fn:false())", return_response=True)
assert 200 == response.status_code
parts = MultipartDecoder.from_response(response).parts
assert 4 == len(parts)


def test_xquery_vars(client):
vars = {"word1": "hello", "word2": "world"}
script = """
xquery version "1.0-ml";
declare variable $word1 as xs:string external;
declare variable $word2 as xs:string external;
fn:concat($word1, " ", $word2)
"""
parts = client.eval.xquery(script, vars)
assert type(parts[0]) is str
assert "hello world" == parts[0]


def test_javascript_vars(client):
vars = {"word1": "hello", "word2": "world"}
parts = client.eval.javascript("xdmp.arrayValues([word1, word2])", vars)
assert type(parts[0]) is str
assert "hello" == parts[0]


def test_xquery_empty_sequence(client):
parts = client.eval.xquery("()")
assert parts is None


def test_javascript_script(client):
parts = client.eval.javascript("[]")
assert [[]] == parts


decoder = MultipartDecoder.from_response(response)
content = decoder.parts[0].text
assert "<hello>world</hello>" == content
def __verify_common_primitives(parts):
assert type(parts[0]) is str
assert "A" == parts[0]
assert type(parts[1]) is int
assert 1 == parts[1]
assert type(parts[2]) is decimal.Decimal
assert decimal.Decimal("1.1") == parts[2]
assert type(parts[3]) is bool
assert parts[3] is False
assert type(parts[4]) is Document
assert "/musicians/logo.png" == parts[4].uri
assert b"PNG" in parts[4].content