Skip to content

Commit

Permalink
Add support for JSON types
Browse files Browse the repository at this point in the history
  • Loading branch information
pjwerneck committed Dec 10, 2023
1 parent 9dbf3a8 commit 5875392
Show file tree
Hide file tree
Showing 11 changed files with 477 additions and 83 deletions.
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[run]
omit = tests/*
119 changes: 118 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ sqlalchemy = "^2.0"
isort = "^5.12.0"
black = "^23.11.0"
pytest = "^7.4.3"
pytest-coverage = "^0.0"

[build-system]
requires = ["poetry>=0.12"]
Expand Down
86 changes: 75 additions & 11 deletions rqlalchemy/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.sql import _typing
from sqlalchemy.sql import elements
from sqlalchemy.sql.sqltypes import JSON

ArgsType = List[Any]
BinaryOperator = Callable[[Any, Any], Any]
Expand Down Expand Up @@ -80,9 +81,9 @@ def _rql_select_limit(self):
def _rql_select_offset(self):
return self._offset_clause.value if self._offset_clause is not None else None

def rql(self, query: str = "", limit: Optional[int] = None) -> "RQLSelect":
def rql(self, query: str = "", limit: Optional[int] = None) -> "RQLSelect": # noqa: C901
if len(self._rql_select_entities) > 1:
raise NotImplementedError("Select must have only one entity")
raise self._rql_error_cls("Select must have only one entity")

if not query:
self.rql_parsed = None
Expand Down Expand Up @@ -124,7 +125,9 @@ def rql(self, query: str = "", limit: Optional[int] = None) -> "RQLSelect":

return select_

def execute(self, session: Session) -> Sequence[Union[Union[Row, RowMapping], Any]]:
def execute( # noqa: C901
self, session: Session
) -> Sequence[Union[Union[Row, RowMapping], Any]]: # noqa: C901
"""
Executes the sql expression differently based on which clauses included:
- For single aggregates a scalar is returned
Expand Down Expand Up @@ -256,38 +259,95 @@ def _rql_apply(self, node: Dict[str, Any]) -> Any:

return node

def _rql_attr(self, attr):
model = self._rql_select_entities[0]
def _rql_attr(self, attr, model=None):
model = model or self._rql_select_entities[0]

# if it's just a plain attribute name, return it
if isinstance(attr, str):
try:
return getattr(model, attr)
except AttributeError as e:
raise self._rql_error_cls(f"Invalid query attribute: {attr}") from e

elif isinstance(attr, tuple):
# Every entry in attr but the last should be a relationship name.
for name in attr[:-1]:
if name not in inspect(model).relationships:
raise AttributeError(f'{model} has no relationship "{name}"')
# if it's an one-item tuple resolve it recursively
if len(attr) == 1:
return self._rql_attr(attr[0], model)

# if there are more than one item in the tuple, resolve the first
# item

name = attr[0]
# if it's a relationship, resolve it, add a join, and resolve the
# rest recursively
if name in inspect(model).relationships:
relation = getattr(model, name)
self._rql_joins.append(relation)
model = relation.mapper.class_
return getattr(model, attr[-1])
raise NotImplementedError
return self._rql_attr(attr[1:], model)

# if it's a JSON column, build a path to the value using the
# remaining entries, set the field name as key to be used in RQL
# select clauses, and return the result immediately.
if isinstance(inspect(model).columns[name].type, JSON):
json_column = getattr(model, name)
json_path = reduce(operator.getitem, attr[1:], json_column) # noqa: E203
json_path.key = attr[-1]
return json_path

# if it's neither, something is wrong.
raise self._rql_error_cls(f"Invalid nested query attribute: {name}")

# Parsed RQL attributes are either strings or tuples. We should never
# get here.
raise TypeError(f"Invalid attribute type: {attr}")

def _rql_value(self, value: Any) -> Any:
if isinstance(value, dict):
value = self._rql_apply(value)

return value

def _rql_set_attr_type_for_json_value(self, attr: Any, value: Any) -> Any:
# if it's not a JSON column, return it unchanged
if not isinstance(attr.type, JSON):
return attr

# if value is a list of values, they must all be of the same type
if isinstance(value, list):
if not value:
return attr

value_type = type(value[0])
if not all(isinstance(v, value_type) for v in value):
raise self._rql_error_cls(
"Cannot compare JSON column against multiple values of different types"
)

value = value[0]

# if it's a JSON column, cast the value to the appropriate type
if isinstance(value, str):
return attr.as_string()
if isinstance(value, bool):
return attr.as_boolean()
elif isinstance(value, int):
return attr.as_integer()
elif isinstance(value, float):
return attr.as_float()
else:
# NOTE: we might have to add support for all pyrql types here
raise self._rql_error_cls(
f"Cannot cast to type {type(value)} for comparison with JSON column"
)

def _rql_compare(self, args: ArgsType, op: BinaryOperator) -> elements.BinaryExpression:
attr, value = args
attr = self._rql_attr(attr=attr)
value = self._rql_value(value)

attr = self._rql_set_attr_type_for_json_value(attr, value)

return op(attr, value)

def _rql_and(self, args: ArgsType) -> Optional[elements.BooleanClauseList]:
Expand All @@ -305,13 +365,17 @@ def _rql_in(self, args: ArgsType) -> elements.BinaryExpression:
attr = self._rql_attr(attr=attr)
value = self._rql_value([str(v) for v in value])

attr = self._rql_set_attr_type_for_json_value(attr, value)

return attr.in_(value)

def _rql_out(self, args: ArgsType) -> elements.BinaryExpression:
attr, value = args
attr = self._rql_attr(attr=attr)
value = self._rql_value([str(v) for v in value])

attr = self._rql_set_attr_type_for_json_value(attr, value)

return sql.not_(attr.in_(value))

def _rql_like(self, args: ArgsType) -> elements.BinaryExpression:
Expand Down
Empty file added tests/__init__.py
Empty file.
Loading

0 comments on commit 5875392

Please sign in to comment.