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
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.4.0-alpha-14
current_version = 0.4.0-alpha-15
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-(?P<release>.*)-(?P<build>\d+))?
serialize =
{major}.{minor}.{patch}-{release}-{build}
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.6.6"
version: "0.11.29"
enable-cache: true
python-version: '3.12'
python-version: '3.14'

- uses: actions/setup-python@v5
with:
python-version: '3.12'
python-version: '3.14'

- run: uv build
- run: uv publish
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ jobs:
strategy:
matrix:
python-version:
- '3.9'
- '3.10'
- '3.11'
- '3.12'
- '3.13'
- '3.14'
runs-on: ubuntu-latest
services:
postgres:
Expand All @@ -29,9 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.6.6"
version: "0.11.29"
enable-cache: true
python-version: ${{ matrix.python-version }}

Expand Down
12 changes: 9 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ authors = [
{name = "Brian Downing", email = "bdowning@lavos.net"},
]
license = {text = "MIT"}
requires-python = "<4.0,>=3.9"
requires-python = "<4.0,>=3.10"
dependencies = [
"typing-extensions",
]
name = "sql-athame"
version = "0.4.0-alpha-14"
version = "0.4.0-alpha-15"
description = "Python tool for slicing and dicing SQL"
readme = "README.md"

Expand All @@ -28,18 +28,20 @@ dev = [
"bump2version",
"ipython",
"mypy",
"pdbpp>=0.12.1",
"pytest",
"pytest-asyncio",
"pytest-cov",
"ruff",
"xdoctest>=1.3.2",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.ruff]
target-version = "py39"
target-version = "py310"

[tool.ruff.lint]
select = [
Expand Down Expand Up @@ -68,6 +70,7 @@ ignore = [
"RET505", # Unnecessary `else` after `return` statement
"RET506", # Unnecessary `else` after `raise` statement
]
pyupgrade.keep-runtime-typing = true

[tool.ruff.lint.per-file-ignores]
"__init__.py" = [
Expand All @@ -77,6 +80,8 @@ ignore = [
[tool.pytest.ini_options]
addopts = [
"-v",
"--xdoctest",
"--xdoctest-global-exec=from sql_athame._doctest import *",
"--cov",
"--cov-report", "xml:results/pytest/coverage.xml",
"--cov-report", "html:results/pytest/cov_html",
Expand All @@ -97,6 +102,7 @@ report.precision = 2
[tool.mypy]
disallow_incomplete_defs = true
check_untyped_defs = true
warn_unused_ignores = true

[[tool.mypy.overrides]]
module = [
Expand Down
35 changes: 35 additions & 0 deletions sql_athame/_doctest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import uuid
from dataclasses import dataclass
from typing import Any

from .dataclasses import ModelBase

USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER_ID_2 = uuid.UUID("00000000-0000-0000-0000-000000000002")
USER_ID_3 = uuid.UUID("00000000-0000-0000-0000-000000000003")


@dataclass
class User(ModelBase, table_name="users", primary_key="id"):
id: uuid.UUID
name: str
email: str | None


@dataclass
class InsertUser(ModelBase, table_name="users"):
name: str
email: str | None = None


def user_row(
*,
user_id: uuid.UUID = USER_ID,
name: str = "Alice",
email: str | None = None,
) -> dict[str, Any]:
return {
"id": user_id,
"name": name,
"email": email,
}
62 changes: 44 additions & 18 deletions sql_athame/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,19 @@
import json
import re
import string
from collections.abc import Iterable, Iterator, Sequence
from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Sequence
from typing import (
Any,
Callable,
Optional,
Literal,
Union,
cast,
overload,
)

from typing_extensions import Literal

from .engines import async_cursor, async_execute, async_fetch
from .escape import escape
from .sqlalchemy import sqlalchemy_text_from_fragment
from .types import FlatPart, Part, Placeholder, Slot
from .types import AnyConnection, AnyFetchable, FlatPart, Part, Placeholder, Row, Slot

newline_whitespace_re = re.compile(r"\s*\n\s*")
auto_numbered_re = re.compile(r"[A-Za-z0-9_]")
Expand Down Expand Up @@ -193,7 +191,7 @@ def fill(self, **kwargs: Any) -> "Fragment":
@overload
def prep_query(
self, allow_slots: Literal[True]
) -> tuple[str, list[Union[Placeholder, Slot]]]: ... # pragma: no cover
) -> tuple[str, list[Placeholder | Slot]]: ... # pragma: no cover

@overload
def prep_query(
Expand All @@ -217,7 +215,7 @@ def prep_query(self, allow_slots: bool = False) -> tuple[str, list[Any]]:
"""
parts: list[FlatPart] = []
self.flatten_into(parts)
args: list[Union[Placeholder, Slot]] = []
args: list[Placeholder | Slot] = []
placeholder_ids: dict[Placeholder, int] = {}
slot_ids: dict[Slot, int] = {}
out_parts: list[str] = []
Expand Down Expand Up @@ -311,7 +309,7 @@ def prepare(self) -> tuple[str, Callable[..., list[Any]]]:
func.append(f" value_{i},")
func += [" ]"]
exec("\n".join(func), env)
return query, env["generate_args"] # type: ignore
return query, env["generate_args"]

def __iter__(self) -> Iterator[Any]:
"""Make Fragment iterable for use with asyncpg and similar drivers.
Expand All @@ -327,7 +325,11 @@ def __iter__(self) -> Iterator[Any]:
>>> list(frag)
['SELECT * FROM users WHERE id = $1 AND name = $2', 42, 'Alice']
>>> # Can be used directly with asyncpg
>>> await conn.fetch(*frag)
>>> class Conn:
... async def fetch(self, query, *args):
... return [query, *args]
>>> await Conn().fetch(*frag)
['SELECT * FROM users WHERE id = $1 AND name = $2', 42, 'Alice']
"""
sql, args = self.query()
return iter((sql, *args))
Expand Down Expand Up @@ -357,6 +359,29 @@ def join(self, parts: Iterable["Fragment"]) -> "Fragment":
"""
return Fragment(list(join_parts(parts, infix=self)))

async def execute(self, conn: AnyFetchable) -> str:
return await async_execute(conn, self)

async def fetch(self, conn: AnyFetchable) -> list[Row]:
return await async_fetch(conn, self)

async def fetchrow(self, conn: AnyFetchable) -> Row | None:
rows = await self.fetch(conn)
if rows:
return rows[0]
return None

async def fetchval(self, conn: AnyFetchable) -> Any:
row = await self.fetchrow(conn)
if row:
return row[0]
return None

def cursor(
self, conn: AnyConnection, *, prefetch: int = 1000
) -> AsyncIterator[Row]:
return async_cursor(conn, self, prefetch=prefetch)


class SQLFormatter:
"""Main SQL formatting class providing the sql() function and utility methods.
Expand Down Expand Up @@ -394,14 +419,15 @@ def __call__(

Example:
>>> sql("SELECT * FROM users WHERE id = {}", 42)
Fragment(['SELECT * FROM users WHERE id = ', Placeholder('0', 42)])
Fragment(parts=['SELECT * FROM users WHERE id = ', Placeholder(name='0', value=42)])

>>> sql("SELECT * FROM users WHERE id = {id} AND name = {name}", id=42, name="Alice")
Fragment(['SELECT * FROM users WHERE id = ', Placeholder('id', 42), ' AND name = ', Placeholder('name', 'Alice')])
Fragment(parts=['SELECT * FROM users WHERE id = ', Placeholder(name='id', value=42), ' AND name = ', Placeholder(name='name', value='Alice')])

>>> # Fragments can be embedded
>>> where_clause = sql("active = {}", True)
>>> sql("SELECT * FROM users WHERE {}", where_clause)
Fragment(parts=['SELECT * FROM users WHERE ', Fragment(parts=['active = ', Placeholder(name='0', value=True)])])
"""
if not preserve_formatting:
fmt = newline_whitespace_re.sub(" ", fmt)
Expand Down Expand Up @@ -443,7 +469,7 @@ def value(value: Any) -> Fragment:

Example:
>>> sql.value(42)
Fragment([Placeholder('value', 42)])
Fragment(parts=[Placeholder(name='value', value=42)])
"""
placeholder = Placeholder("value", value)
return Fragment([placeholder])
Expand Down Expand Up @@ -511,12 +537,12 @@ def literal(text: str) -> Fragment:

Example:
>>> sql.literal("ORDER BY created_at DESC")
Fragment(['ORDER BY created_at DESC'])
Fragment(parts=['ORDER BY created_at DESC'])
"""
return Fragment([text])

@staticmethod
def identifier(name: str, prefix: Optional[str] = None) -> Fragment:
def identifier(name: str, prefix: str | None = None) -> Fragment:
"""Create a Fragment with a quoted SQL identifier.

Creates a properly quoted identifier name, optionally with a dotted prefix
Expand Down Expand Up @@ -648,7 +674,7 @@ def unnest(self, data: Iterable[Sequence[Any]], types: Iterable[str]) -> Fragmen
>>> insert_query = sql("INSERT INTO users (name, age) SELECT * FROM {}",
... sql.unnest(users_data, ["text", "integer"]))
"""
nested = [nest_for_type(x, t) for x, t in zip(zip(*data), types)]
nested = [nest_for_type(x, t) for x, t in zip(zip(*data), types)] # noqa: B905
if not nested:
nested = [nest_for_type([], t) for t in types]
return Fragment(["UNNEST(", self.list(nested), ")"])
Expand Down Expand Up @@ -751,8 +777,8 @@ def any_all(frags: list[Fragment], op: str, base_case: str) -> Fragment:
def join_parts(
parts: Iterable[Part],
infix: Part,
prefix: Optional[Part] = None,
suffix: Optional[Part] = None,
prefix: Part | None = None,
suffix: Part | None = None,
) -> Iterator[Part]:
"""Join parts with a separator, optionally adding prefix and suffix.

Expand Down
Loading
Loading