diff --git a/aiosql/query_loader.py b/aiosql/query_loader.py index 89354614..4ec63b41 100644 --- a/aiosql/query_loader.py +++ b/aiosql/query_loader.py @@ -14,7 +14,7 @@ # extract a valid query name followed by an optional operation spec # FIXME this accepts "1st" but seems to reject "é" -_NAME_OP = re.compile(r"^(?P\w+)(?P(|\^|\$|!|\w+)(|\((?P(\s*|\s*\w+\s*(,\s*\w+\s*)*))\))(?P(|\^|\$|!| :u__a, **after** signature generation sql, attributes = _preprocess_object_attributes(self.attribute, sql) @@ -132,13 +132,18 @@ def _make_query_datum( sql = self.driver_adapter.process_sql(query_fqn, qop, sql) return QueryDatum(query_fqn, doc, qop, sql, record_class, signature, floc, attributes) - def _get_name_op(self, text: str) -> Tuple[str, SQLOperationType]: + def _get_name_op(self, text: str) -> Tuple[str, SQLOperationType, List[str]|None]: qname_spec = text.replace("-", "_") matched = _NAME_OP.match(qname_spec) if not matched or _BAD_PREFIX.match(qname_spec): raise SQLParseException(f'invalid query name and operation spec: "{qname_spec}"') nameop = matched.groupdict() - return nameop["name"], _OP_TYPES[nameop["op"]] + params, rawparams = None, nameop["params"] + if rawparams is not None: + params = [p.strip() for p in rawparams.split(",")] + if params == ['']: # handle "( )" + params = [] + return nameop["name"], _OP_TYPES[nameop["op"]], params def _get_record_class(self, text: str) -> Optional[Type]: rc_match = _RECORD_DEF.match(text) @@ -157,7 +162,7 @@ def _get_sql_doc(self, lines: Sequence[str]) -> Tuple[str, str]: return sql.strip(), doc.rstrip() - def _build_signature(self, sql: str) -> inspect.Signature: + def _build_signature(self, sql: str, qname: str, sig: List[str]|None) -> inspect.Signature: params = [inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)] names = set() for match in VAR_REF.finditer(sql): @@ -167,6 +172,9 @@ def _build_signature(self, sql: str) -> inspect.Signature: name = gd["var_name"] if name.isdigit() or name in names: continue + if sig is not None: # optional parameter declarations + if name not in sig: + raise SQLParseException(f"undeclared parameter name in query {qname}: {name}") names.add(name) params.append( inspect.Parameter( @@ -174,6 +182,9 @@ def _build_signature(self, sql: str) -> inspect.Signature: kind=inspect.Parameter.KEYWORD_ONLY, ) ) + if sig is not None and len(sig) != len(names): + unused = sorted(n for n in sig if n not in names) + raise SQLParseException(f"unused declared parameter in query {qname}: {unused}") return inspect.Signature(parameters=params) def load_query_data_from_sql( diff --git a/docs/source/defining-sql-queries.rst b/docs/source/defining-sql-queries.rst index 570074db..99efc257 100644 --- a/docs/source/defining-sql-queries.rst +++ b/docs/source/defining-sql-queries.rst @@ -16,6 +16,17 @@ into underlines (``_``). This query will be available in aiosql under the python method name ``.get_all_blogs(conn)`` +Query Parameters +---------------- + +Query parameters may be declared in parentheses just after the method name. + +.. literalinclude:: ../../tests/blogdb/sql/blogs/blogs.sql + :language: sql + :lines: 55,56 + +When declared they are checked, raising errors when parameters are unused or undeclared. + Query Comments -------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index c0d8f37b..72733d76 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -74,7 +74,7 @@ Badges .. NOTE all tests # MIST - loading: 15 + loading: 16 patterns: 5 # SYNC sqlite3: 17 @@ -92,7 +92,7 @@ Badges # ASYNC aiosqlite: 13 asyncpg: 18 -.. image:: https://img.shields.io/badge/tests-245%20✓-success +.. image:: https://img.shields.io/badge/tests-246%20✓-success :alt: Tests :target: https://github.com/nackjicholson/aiosql/actions/ .. image:: https://img.shields.io/github/issues/nackjicholson/aiosql?style=flat @@ -148,13 +148,13 @@ eg this *greetings.sql* file: .. code:: sql - -- name: get_all_greetings + -- name: get_all_greetings() -- Get all the greetings in the database select greeting_id, greeting from greetings order by 1; - -- name: get_user_by_username^ + -- name: get_user_by_username(username)^ -- Get a user from the database using a named parameter select user_id, username, name from users @@ -164,6 +164,8 @@ This example has an imaginary SQLite database with greetings and users. It prints greetings in various languages to the user and showcases the basic feature of being able to load queries from a SQL file and call them by name in python code. +Query parameter declarations (eg ``(username)``) are optional, but enforced +when provided. You can use ``aiosql`` to load the queries in this file for use in your Python application: diff --git a/docs/source/versions.rst b/docs/source/versions.rst index 47ea441e..a551df34 100644 --- a/docs/source/versions.rst +++ b/docs/source/versions.rst @@ -12,21 +12,16 @@ TODO - rethink record classes? we just really want a row conversion function? - add documentation about docker runs. - allow tagging queries, eg whether it can be cached -- add ability to _declare_ named query parameters for readability and reliability, - allowing to check for unused or undeclared parameters - - ```sql - -- name: get_foo_by_id(id)^ - SELECT * FROM Foo WHERE fooid = :id: - ``` ? on ? ------ -- improve Makefile. +- add optional parameter declarations to queries, and check them when provided. - warn on probable mission operation. - add *psycopg2* to CI. - improve documentation. +- improve Makefile. +- silent some test warnings. 12.2 on 2024-10-02 ------------------ diff --git a/tests/blogdb/sql/blogs/blogs.sql b/tests/blogdb/sql/blogs/blogs.sql index 273f397c..435318cc 100644 --- a/tests/blogdb/sql/blogs/blogs.sql +++ b/tests/blogdb/sql/blogs/blogs.sql @@ -33,7 +33,7 @@ values ( -- Remove a blog from the database delete from blogs where blogid = :blogid; --- name: get-user-blogs +-- name: get-user-blogs(userid) -- record_class: UserBlogSummary -- Get blogs authored by a user. select title AS title, @@ -43,7 +43,7 @@ delete from blogs where blogid = :blogid; order by published desc; --- name: get-latest-user-blog^ +-- name: get-latest-user-blog(userid)^ -- record_class: UserBlogSummary -- Get latest blog by user. select title AS title, published AS published @@ -52,8 +52,8 @@ where userid = :userid order by published desc limit 1; --- name: search -select title from blogs where title = :title and published = :published; +-- name: search(title, published) +select title from blogs where title LIKE :title and published = :published; -- name: blog_title^ select blogid, title from blogs where blogid = :blogid; diff --git a/tests/test_loading.py b/tests/test_loading.py index a97eb73c..3458f0b1 100644 --- a/tests/test_loading.py +++ b/tests/test_loading.py @@ -224,3 +224,28 @@ def test_kwargs(): pytest.fail("must raise an exception") # pragma: no cover except ValueError as e: assert "mix" in str(e) + +def test_parameter_declarations(): + # ok + import sqlite3 + conn = sqlite3.connect(":memory:") + q = aiosql.from_str( + "-- name: xlii()$\nSELECT 42;\n" + "-- name: next(n)$\nSELECT :n+1;\n" + "-- name: add(n, m)$\nSELECT :n+:m;\n", + "sqlite3") + assert q.xlii(conn) == 42 + assert q.next(conn, n=41) == 42 + assert q.add(conn, n=20, m=22) == 42 + conn.close() + # errors + try: + aiosql.from_str("-- name: foo()\nSELECT :N + 1;\n", "sqlite3") + pytest.fail("must raise an exception") + except SQLParseException as e: + assert "undeclared" in str(e) and "N" in str(e) + try: + aiosql.from_str("-- name: foo(N, M)\nSELECT :N + 1;\n", "sqlite3") + pytest.fail("must raise an exception") + except SQLParseException as e: + assert "unused" in str(e) and "M" in str(e)