"""OPTIMADE-filter querying over stored dataclasses: sugar tying the SQL layer to the translator.
:func:`optimade_filter_searcher` builds a :class:`~httk.data.db.searcher.SqlSearcher`
over a storable class directly from an OPTIMADE filter string, deriving the
recognized property names and their types from the class's resolved
:class:`~httk.data.db.schema.TableSchema` — the same derivation
:class:`~httk.data.db.entry_provider.StoreEntryProvider` uses to serve the
class (via the shared :func:`~httk.data.db.entry_provider.served_specs` /
:func:`~httk.data.db.entry_provider.auto_definition` helpers), so a filter
that works against the served API works against the store.
Filter property names are the served names: ``{prefix}{field}`` (default
``_httk_<field>``) for every servable stored field. Related storable classes
declared via ``related_classes`` can be filtered relationally —
``references.id HAS "references-3"`` and depth-1 related-property filters like
``references._httk_doi CONTAINS "10.1"`` — over the class's reference or
child-of-storable fields.
"""
from collections.abc import Callable, Mapping
from typing import Any, cast
from httk.core import EntryTypeDefinition, FilterAst, PropertyDefinition
from httk.data.db.entry_provider import served_specs
from httk.data.db.schema import resolve_schema
from httk.data.db.searcher import SqlColumn, SqlExpression, SqlSearcher, SqlVariable
from httk.data.db.store import SqlStore
from httk.data.optimade_query import (
FilterTranslationError,
filter_searcher,
known_unknown_handler,
set_handler,
simple_property_handlers,
)
from httk.data.query import Searcher, SearchExpression, SearchVariable
__all__ = [
"optimade_filter_searcher",
]
def _fulltype_from_doc(doc: Mapping[str, Any]) -> str:
optimade_type = doc["x-optimade-type"]
if optimade_type == "list":
return "list of " + _fulltype_from_doc(doc["items"])
if optimade_type == "dictionary":
return "dict"
return cast(str, optimade_type)
def _definition_fulltype(definition: PropertyDefinition) -> str:
"""The ``fulltype`` string a property definition describes."""
return _fulltype_from_doc(definition.as_optimade())
def _related_sid(related_type: str, value: Any) -> int:
"""Parse a default-minted ``"<related_type>-<sid>"`` id back to its sid.
Any value not of that exact form (a foreign id format, a different entry
type, a non-string) parses to ``-1``, a sentinel sid that never exists —
such ids simply match nothing, they are not an error.
"""
if isinstance(value, str) and value.startswith(related_type + "-"):
tail = value[len(related_type) + 1 :]
if tail.isdigit():
return int(tail)
return -1
def _related_id_has_handlers(related_type: str, field: str) -> Mapping[str, Callable[..., Any]]:
"""The ``'<related_type>.id'`` HAS handler over a reference or child-of-storable field."""
def has_handler(
entry: str, ops: Any, values: Any, search_variable: SearchVariable, has_type: str
) -> SearchExpression:
# A SqlVariable satisfies the set-handler contract here: both its
# reference fields (via SqlReference's set operations over the foreign
# key) and its child-of-storable fields (a SqlColumn over the child
# element column) accept sid values.
sids = [_related_sid(related_type, value) for value in values]
return set_handler(field, ops, sids, has_type, search_variable)
return {'HAS': has_handler}
def _own_id_handlers(related_type: str) -> Mapping[str, Callable[..., Any]]:
"""Handlers serving the related class's own ``id`` property in a nested sub-search."""
def comparison(entry: str, op: str, value: Any, search_variable: SqlVariable) -> SqlExpression:
if op not in ('=', '!='):
raise FilterTranslationError("Ordering comparisons on relationship ids not implemented.", "not-implemented")
sid_column = search_variable.sid
assert isinstance(sid_column, SqlColumn)
sid = _related_sid(related_type, value)
return sid_column == sid if op == '=' else sid_column != sid
return {'comparison': comparison, 'unknown': known_unknown_handler}
[docs]
def optimade_filter_searcher(
store: SqlStore,
cls: type,
filter_string: str | FilterAst,
*,
prefix: str = "_httk_",
definition: EntryTypeDefinition | None = None,
extra_handlers: Mapping[str, Mapping[str, Callable[..., Any]]] | None = None,
related_classes: Mapping[str, type] | None = None,
) -> Searcher:
"""Build a searcher over the stored rows of ``cls`` from an OPTIMADE filter.
``filter_string`` is an OPTIMADE filter string or an already-parsed
:py:type:`~httk.core.FilterAst`. The returned searcher outputs the matching
stored instances (``item[0][0]`` per match).
**Property names.** Every servable stored field of ``cls`` (per
:func:`~httk.data.db.entry_provider.served_specs`) is filterable as
``{prefix}{field}``, with its schema-derived fulltype driving constant
conversion and handler dispatch (rational fields compare on their
documented-approximate float query column). Unknown names carrying
``prefix`` raise :class:`~httk.data.optimade_query.FilterTranslationError`
(``"unrecognized-property"``); other unknown names match nothing, per the
OPTIMADE specification. Unprefixed property names (beyond ``id``/``type``)
are recognized only when a ``definition`` describes them — and even then
they translate only if ``extra_handlers`` supplies their handlers, since
the store knows no column for them. The OPTIMADE core ``id`` and ``type``
properties are recognized but **not supported** without ``extra_handlers``
entries (a store row has no served id: ids are minted at serving time), so
filtering on them raises ``"not-implemented"``.
**Relationships.** ``related_classes`` maps relationship-type names to
storable classes; each must be the target of exactly one reference or
child-of-storable (``list[Target]``) field of ``cls`` (anything else
raises :class:`ValueError`). For each such ``(rtype, rcls)``:
- ``<rtype>.id HAS "<rtype>-<sid>"`` filters over the field's foreign-key
(or child-element) column against default-minted
``"<entry type>-<sid>"`` ids, as
:class:`~httk.data.db.entry_provider.StoreEntryProvider` mints them;
ids of any other format match nothing. Custom ``id_of`` minting is out
of scope here — with a custom minting scheme, supply your own
``'<rtype>.id'`` entry via ``extra_handlers``.
- Depth-1 related-property filters (``<rtype>._httk_doi CONTAINS "10.1"``,
``<rtype>.id != "..."``, ``<rtype>._httk_year IS KNOWN``, ...) resolve by
a two-phase semi-join: a nested ``optimade_filter_searcher`` over
``rcls`` (with the same ``prefix``; no further relationship nesting)
collects the matching related sids, which are then matched as
``<rtype>.id HAS ANY ...``. Each dotted filter node resolves
independently — see :func:`~httk.data.optimade_query.translate_filter_ast`.
``extra_handlers`` entries are merged over the derived handler table last
(so they can also override derived handlers); extra property names that are
otherwise unknown are recognized with fulltype ``"unknown"`` (filter
constants pass through unconverted).
Raises:
httk.data.optimade_query.FilterTranslationError: When the filter cannot be translated.
httk.core.ParserSyntaxError: When a filter string does not parse.
ValueError: When a ``related_classes`` entry does not match exactly one
reference or child-of-storable field of ``cls``.
"""
schema = resolve_schema(cls)
served = served_specs(schema, prefix)
property_fulltypes: dict[str, str] = {"id": "string", "type": "string"}
property_fulltypes.update({name: fulltype for name, _spec, fulltype in served})
property_keys = {name: spec.field for name, spec, _fulltype in served}
if definition is not None:
for name, prop in definition.properties.items():
if name in ("id", "type"):
continue
property_fulltypes[name] = _definition_fulltype(prop)
handlers = simple_property_handlers(cls.__name__, property_keys, property_fulltypes)
# The default id/type handlers of simple_property_handlers query the
# serving-layer '__id' column and constant entry-type name; neither exists
# on a store row, so drop them (see the docstring: id/type filtering needs
# extra_handlers).
del handlers["id"]
del handlers["type"]
relationship_targets: tuple[str, ...] = ()
resolver = None
if related_classes:
related = dict(related_classes)
for related_type, related_cls in related.items():
matching = [
spec for spec in schema.fields if spec.target is related_cls and spec.role in ("reference", "child")
]
if len(matching) != 1:
raise ValueError(
f"related_classes entry {related_type!r} ({related_cls.__name__}) matches "
f"{'no' if not matching else str(len(matching))} reference or child-of-storable "
f"field{'' if len(matching) == 1 else 's'} of {cls.__name__}; exactly one is required"
)
handlers[f"{related_type}.id"] = _related_id_has_handlers(related_type, matching[0].field)
relationship_targets = tuple(related)
def resolve_related(related_type: str, sub_ast: FilterAst) -> tuple[str, ...]:
nested = optimade_filter_searcher(
store,
related[related_type],
sub_ast,
prefix=prefix,
extra_handlers={"id": _own_id_handlers(related_type)},
)
assert isinstance(nested, SqlSearcher)
sid_column = nested._variables[0].sid
assert isinstance(sid_column, SqlColumn)
nested.output(sid_column, "sid")
# Output 0 is the matched instance (declared by filter_searcher), output 1 the sid.
return tuple(f"{related_type}-{int(values[1])}" for values, _names in nested)
resolver = resolve_related
if extra_handlers:
for name in extra_handlers:
if "." not in name:
property_fulltypes.setdefault(name, "unknown")
handlers.update(extra_handlers)
return filter_searcher(
store,
cls,
filter_string,
entry_type=cls.__name__,
property_fulltypes=property_fulltypes,
handlers=handlers,
recognized_prefixes=(prefix,),
relationship_targets=relationship_targets,
related_property_resolver=resolver,
)