Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dev #214

Merged
merged 14 commits into from
Nov 10, 2024
Merged

dev #214

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
8 changes: 4 additions & 4 deletions aiosql/aiosql.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def from_str(
sql: str,
driver_adapter: Union[str, Callable[..., DriverAdapterProtocol]],
record_classes: Optional[Dict] = None,
kwargs_only: bool = False,
kwargs_only: bool = True,
attribute: Optional[str] = "__",
args: List[Any] = [],
kwargs: Dict[str, Any] = {},
Expand All @@ -87,7 +87,7 @@ def from_str(
- **driver_adapter** - Either a string to designate one of the aiosql built-in database driver
adapters. One of many available for SQLite, Postgres and MySQL. If you have defined your
own adapter class, you can pass it's constructor.
- **kwargs_only** - *(optional)* whether to only use named parameters on query execution, default is *False*.
- **kwargs_only** - *(optional)* whether to only use named parameters on query execution, default is *True*.
- **attribute** - *(optional)* ``.`` attribute access substitution, defaults to ``"__"``, *None* disables
the feature.
- **args** - *(optional)* adapter creation args (list), forwarded to cursor creation by default.
Expand Down Expand Up @@ -133,7 +133,7 @@ def from_path(
sql_path: Union[str, Path],
driver_adapter: Union[str, Callable[..., DriverAdapterProtocol]],
record_classes: Optional[Dict] = None,
kwargs_only: bool = False,
kwargs_only: bool = True,
attribute: Optional[str] = "__",
args: List[Any] = [],
kwargs: Dict[str, Any] = {},
Expand All @@ -151,7 +151,7 @@ def from_path(
adapters. One of many available for SQLite, Postgres and MySQL. If you have defined your own
adapter class, you may pass its constructor.
- **record_classes** - *(optional)* **DEPRECATED** Mapping of strings used in "record_class"
- **kwargs_only** - *(optional)* Whether to only use named parameters on query execution, default is *False*.
- **kwargs_only** - *(optional)* Whether to only use named parameters on query execution, default is *True*.
- **attribute** - *(optional)* ``.`` attribute access substitution, defaults to ``"__""``, *None* disables
the feature.
- **args** - *(optional)* adapter creation args (list), forwarded to cursor creation by default.
Expand Down
84 changes: 48 additions & 36 deletions aiosql/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import Any, Callable, List, Optional, Set, Tuple, Union, Dict, cast

from .types import DriverAdapterProtocol, QueryDatum, QueryDataTree, QueryFn, SQLOperationType
from .utils import SQLLoadException, log
from .utils import SQLLoadException, SQLParseException, log


class Queries:
Expand All @@ -18,19 +18,19 @@ class Queries:

Much of the needed pre-processing is performed in ``QueryLoader``.

**Parameters:**
Parameters:

- **driver_adapter**: Either a string to designate one of the aiosql built-in database driver
- :param driver_adapter: Either a string to designate one of the aiosql built-in database driver
adapters (e.g. "sqlite3", "psycopg").
If you have defined your own adapter class, you can pass its constructor.
- **kwargs_only**: whether to reject positional parameters, defaults to false.
- :param kwargs_only: whether to reject positional parameters, defaults to true.
"""

def __init__(
self,
driver_adapter: DriverAdapterProtocol,
kwargs_only: bool = False,
):
self,
driver_adapter: DriverAdapterProtocol,
kwargs_only: bool = True,
):
self.driver_adapter: DriverAdapterProtocol = driver_adapter
self.is_aio: bool = getattr(driver_adapter, "is_aio_driver", False)
self._kwargs_only = kwargs_only
Expand All @@ -40,8 +40,12 @@ def __init__(
# INTERNAL UTILS
#
def _params(
self, attributes, args: Union[List[Any], Tuple[Any]], kwargs: Dict[str, Any]
) -> Union[List[Any], Tuple[Any], Dict[str, Any]]:
self,
attributes: Optional[Dict[str, Dict[str, str]]],
params: Optional[List[str]],
args: Union[List[Any], Tuple[Any]],
kwargs: Dict[str, Any],
) -> Union[List[Any], Tuple[Any], Dict[str, Any]]:
"""Handle query parameters.

- update attribute references ``:u.a`` to ``:u__a``.
Expand All @@ -66,38 +70,43 @@ def _params(
raise ValueError("cannot use positional parameters under kwargs_only, use named parameters (name=value, …)")
return kwargs
elif kwargs:
# FIXME is this true?
if args:
raise ValueError("cannot mix positional and named parameters in query")
return kwargs
else:
else: # args
if args and params is not None:
raise ValueError("cannot use positional parameters with declared named parameters")
return args

def _look_like_a_select(self, sql: str) -> bool:
# skipped: VALUES, SHOW
return re.search(r"(?i)\b(SELECT|RETURNING|TABLE|EXECUTE)\b", sql) is not None
"""Tell whether sql may return a relation."""
# skipped: VALUES, SHOW, TABLE, EXECUTE
return re.search(r"(?i)\b(SELECT|RETURNING)\b", sql) is not None

def _query_fn(
self,
fn: Callable[..., Any],
name: str,
doc: Optional[str],
sql: str,
operation: SQLOperationType,
signature: Optional[inspect.Signature],
floc: Tuple[Union[Path, str], int] = ("<unknown>", 0),
attributes: Optional[Dict[str, Dict[str, str]]] = None,
) -> QueryFn:
self,
fn: Callable[..., Any],
name: str,
doc: Optional[str],
sql: str,
operation: SQLOperationType,
signature: Optional[inspect.Signature],
floc: Tuple[Union[Path, str], int] = ("<unknown>", 0),
attributes: Optional[Dict[str, Dict[str, str]]] = None,
params: Optional[List[str]] = None,
) -> QueryFn:
"""Add custom-made metadata to a dynamically generated function."""
fname, lineno = floc
fn.__code__ = fn.__code__.replace(co_filename=str(fname), co_firstlineno=lineno) # type: ignore
qfn = cast(QueryFn, fn)
qfn.__name__ = name
qfn.__doc__ = doc
qfn.__signature__ = signature
# query details
qfn.sql = sql
qfn.operation = operation
qfn.attributes = attributes
qfn.parameters = params
# sanity check in passing…
if operation == SQLOperationType.SELECT and not self._look_like_a_select(sql):
log.warning(f"query {fname} may not be a select, consider adding an operator, eg '!'")
Expand All @@ -109,22 +118,22 @@ def _query_fn(
def _make_sync_fn(self, query_datum: QueryDatum) -> QueryFn:
"""Build a dynamic method from a parsed query."""

query_name, doc_comments, operation_type, sql, record_class, signature, floc, attributes = (
query_name, doc_comments, operation_type, sql, record_class, signature, floc, attributes, params = (
query_datum
)

if operation_type == SQLOperationType.INSERT_RETURNING:

def fn(self, conn, *args, **kwargs): # pragma: no cover
return self.driver_adapter.insert_returning(
conn, query_name, sql, self._params(attributes, args, kwargs)
conn, query_name, sql, self._params(attributes, params, args, kwargs)
)

elif operation_type == SQLOperationType.INSERT_UPDATE_DELETE:

def fn(self, conn, *args, **kwargs): # type: ignore # pragma: no cover
return self.driver_adapter.insert_update_delete(
conn, query_name, sql, self._params(attributes, args, kwargs)
conn, query_name, sql, self._params(attributes, params, args, kwargs)
)

elif operation_type == SQLOperationType.INSERT_UPDATE_DELETE_MANY:
Expand All @@ -135,36 +144,40 @@ def fn(self, conn, *args, **kwargs): # type: ignore # pragma: no cover

elif operation_type == SQLOperationType.SCRIPT:

if params: # pragma: no cover
# NOTE this is caught earlier
raise SQLParseException(f"cannot use named parameters in SQL script: {query_name}")

def fn(self, conn, *args, **kwargs): # type: ignore # pragma: no cover
# FIXME parameters are ignored?
assert not args and not kwargs, f"cannot use parameters in SQL script: {query_name}"
return self.driver_adapter.execute_script(conn, sql)

elif operation_type == SQLOperationType.SELECT:

def fn(self, conn, *args, **kwargs): # type: ignore # pragma: no cover
return self.driver_adapter.select(
conn, query_name, sql, self._params(attributes, args, kwargs), record_class
conn, query_name, sql, self._params(attributes, params, args, kwargs), record_class
)

elif operation_type == SQLOperationType.SELECT_ONE:

def fn(self, conn, *args, **kwargs): # pragma: no cover
return self.driver_adapter.select_one(
conn, query_name, sql, self._params(attributes, args, kwargs), record_class
conn, query_name, sql, self._params(attributes, params, args, kwargs), record_class
)

elif operation_type == SQLOperationType.SELECT_VALUE:

def fn(self, conn, *args, **kwargs): # pragma: no cover
return self.driver_adapter.select_value(
conn, query_name, sql, self._params(attributes, args, kwargs)
conn, query_name, sql, self._params(attributes, params, args, kwargs)
)

else:
raise ValueError(f"Unknown operation_type: {operation_type}")

return self._query_fn(
fn, query_name, doc_comments, sql, operation_type, signature, floc, attributes
fn, query_name, doc_comments, sql, operation_type, signature, floc, attributes, params
)

# NOTE does this make sense?
Expand All @@ -181,7 +194,7 @@ def _make_ctx_mgr(self, fn: QueryFn) -> QueryFn:

def ctx_mgr(self, conn, *args, **kwargs): # pragma: no cover
return self.driver_adapter.select_cursor(
conn, fn.__name__, fn.sql, self._params(fn.attributes, args, kwargs)
conn, fn.__name__, fn.sql, self._params(fn.attributes, fn.parameters, args, kwargs)
)

return self._query_fn(
Expand All @@ -194,9 +207,8 @@ def _create_methods(self, query_datum: QueryDatum, is_aio: bool) -> List[QueryFn
if is_aio:
fn = self._make_async_fn(fn)

ctx_mgr = self._make_ctx_mgr(fn)

if query_datum.operation_type == SQLOperationType.SELECT:
ctx_mgr = self._make_ctx_mgr(fn)
return [fn, ctx_mgr]
else:
return [fn]
Expand Down Expand Up @@ -260,7 +272,7 @@ def load_from_tree(self, query_data_tree: QueryDataTree):
"""Load Queries from a `QueryDataTree`"""
for key, value in query_data_tree.items():
if isinstance(value, dict):
self.add_child_queries(key, Queries(self.driver_adapter).load_from_tree(value))
self.add_child_queries(key, Queries(self.driver_adapter, self._kwargs_only).load_from_tree(value))
else:
self.add_queries(self._create_methods(value, self.is_aio))
return self
31 changes: 24 additions & 7 deletions aiosql/query_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# extract a valid query name followed by an optional operation spec
# FIXME this accepts "1st" but seems to reject "é"
_NAME_OP = re.compile(
# name
# query name
r"^(?P<name>\w+)"
# optional list of parameters (foo, bla) or ()
r"(|\((?P<params>(\s*|\s*\w+\s*(,\s*\w+\s*)*))\))"
Expand Down Expand Up @@ -102,6 +102,10 @@ class QueryLoader:

This class holds the various utilities to read SQL files and build
QueryDatum, which will be transformed as functions in Queries.

- :param driver_adapter: driver name or class.
- :param record_classes: nothing of dict.
- :param attribute: string to insert in place of ``.``.
"""

def __init__(
Expand All @@ -120,10 +124,12 @@ def _make_query_datum(
ns_parts: List[str],
floc: Tuple[Union[Path, str], int],
) -> QueryDatum:
# Build a query datum
# - query: the spec and name ("query-name!\n-- comments\nSQL;\n")
# - ns_parts: name space parts, i.e. subdirectories of loaded files
# - floc: file name and lineno the query was extracted from
"""Build a query datum.

- :param query: the spec and name (``query-name!\n-- comments\nSQL;\n``)
- :param ns_parts: name space parts, i.e. subdirectories of loaded files
- :param floc: file name and lineno the query was extracted from
"""
lines = [line.strip() for line in query.strip().splitlines()]
qname, qop, qsig = self._get_name_op(lines[0])
if re.search(r"[^A-Za-z0-9_]", qname):
Expand All @@ -137,9 +143,10 @@ def _make_query_datum(
else: # pragma: no cover
attributes = None
sql = self.driver_adapter.process_sql(query_fqn, qop, sql)
return QueryDatum(query_fqn, doc, qop, sql, record_class, signature, floc, attributes)
return QueryDatum(query_fqn, doc, qop, sql, record_class, signature, floc, attributes, qsig)

def _get_name_op(self, text: str) -> Tuple[str, SQLOperationType, Optional[List[str]]]:
"""Extract name, parameters and operation from spec."""
qname_spec = text.replace("-", "_")
matched = _NAME_OP.match(qname_spec)
if not matched or _BAD_PREFIX.match(qname_spec):
Expand All @@ -150,15 +157,20 @@ def _get_name_op(self, text: str) -> Tuple[str, SQLOperationType, Optional[List[
params = [p.strip() for p in rawparams.split(",")]
if params == ['']: # handle "( )"
params = []
return nameop["name"], _OP_TYPES[nameop["op"]], params
operation = _OP_TYPES[nameop["op"]]
if params and operation == "#": # pragma: no cover # FIXME it is covered?
raise SQLParseException(f'cannot use named parameters in SQL script: "{qname_spec}"')
return nameop["name"], operation, params

def _get_record_class(self, text: str) -> Optional[Type]:
"""Extract record class from spec."""
rc_match = _RECORD_DEF.match(text)
rc_name = rc_match.group(1) if rc_match else None
# TODO: Probably will want this to be a class, marshal in, and marshal out
return self.record_classes.get(rc_name) if isinstance(rc_name, str) else None

def _get_sql_doc(self, lines: Sequence[str]) -> Tuple[str, str]:
"""Separate SQL-comment documentation and SQL code."""
doc, sql = "", ""
for line in lines:
doc_match = _SQL_COMMENT.match(line)
Expand All @@ -170,6 +182,8 @@ def _get_sql_doc(self, lines: Sequence[str]) -> Tuple[str, str]:
return sql.strip(), doc.rstrip()

def _build_signature(self, sql: str, qname: str, sig: Optional[List[str]]) -> inspect.Signature:
"""Return signature object for generated dynamic function."""
# FIXME what about the connection?!
params = [inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)]
names = set()
for match in VAR_REF.finditer(sql):
Expand Down Expand Up @@ -197,6 +211,7 @@ def _build_signature(self, sql: str, qname: str, sig: Optional[List[str]]) -> in
def load_query_data_from_sql(
self, sql: str, ns_parts: List[str], fname: Union[Path, str] = "<unknown>"
) -> List[QueryDatum]:
"""Load queries from a string."""
usql = _remove_ml_comments(sql)
qdefs = _QUERY_DEF.split(usql)
# FIXME lineno is from the uncommented file
Expand All @@ -211,11 +226,13 @@ def load_query_data_from_sql(
def load_query_data_from_file(
self, path: Path, ns_parts: List[str] = [], encoding=None
) -> List[QueryDatum]:
"""Load queries from a file."""
return self.load_query_data_from_sql(path.read_text(encoding=encoding), ns_parts, path)

def load_query_data_from_dir_path(
self, dir_path, ext=(".sql",), encoding=None
) -> QueryDataTree:
"""Load queries from a directory."""
if not dir_path.is_dir():
raise ValueError(f"The path {dir_path} must be a directory")

Expand Down
2 changes: 2 additions & 0 deletions aiosql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class QueryDatum(NamedTuple):
signature: Optional[inspect.Signature]
floc: Tuple[Union[Path, str], int]
attributes: Optional[Dict[str, Dict[str, str]]]
parameters: Optional[List[str]]


class QueryFn(Protocol):
Expand All @@ -49,6 +50,7 @@ class QueryFn(Protocol):
sql: str
operation: SQLOperationType
attributes: Optional[Dict[str, Dict[str, str]]]
parameters: Optional[List[str]]

def __call__(self, *args: Any, **kwargs: Any) -> Any: ... # pragma: no cover

Expand Down
2 changes: 1 addition & 1 deletion docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ It may seem inconvenient to provide a connection on each call.
You may have a look at the `AnoDB <https://github.com/zx80/anodb>`__ `DB`
class which wraps both a database connection *and* queries in one
connection-like extended object, including performing automatic reconnection
when needed.
when needed. The wrapper also allows to cache query results.

Why you might want to use this
------------------------------
Expand Down
Loading