rowquery 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {rowquery-0.1.0 → rowquery-0.1.2}/CHANGELOG.md +5 -1
- {rowquery-0.1.0 → rowquery-0.1.2}/CLAUDE.md +5 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/PKG-INFO +1 -1
- rowquery-0.1.2/llms-full.txt +883 -0
- rowquery-0.1.2/llms.txt +21 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/pyproject.toml +1 -1
- {rowquery-0.1.0 → rowquery-0.1.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/.github/dependabot.yml +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/.github/workflows/quality.yml +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/.github/workflows/release.yml +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/.github/workflows/test.yml +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/.gitignore +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/.pre-commit-config.yaml +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/CODE_OF_CONDUCT.md +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/CONTRIBUTING.md +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/LICENSE +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/README.md +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/examples/01_basic_query.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/examples/02_model_mapping.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/examples/03_aggregate.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/examples/04_transactions.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/examples/05_async.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/examples/06_migrations.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/examples/07_repository.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/examples/README.md +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/__init__.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/__init__.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/mysql.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/oracle.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/postgresql.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/protocol.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/sqlite.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/__init__.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/connection.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/engine.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/enums.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/exceptions.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/migration.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/params.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/registry.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/sanitizer.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/transaction.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/__init__.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/aggregate.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/builder.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/model.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/plan.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/protocol.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/repository/__init__.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/row_query/repository/base.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/__init__.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/conftest.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/contract/__init__.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/contract/test_adapter_protocol.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/fixtures/sql/billing/invoice/list.sql +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/fixtures/sql/user/count.sql +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/fixtures/sql/user/get_by_id.sql +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/fixtures/sql/user/insert.sql +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/fixtures/sql/user/list.sql +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/integration/__init__.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/integration/test_sqlite.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/__init__.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_aggregate_mapper.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_builder.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_engine.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_migration.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_model_mapper.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_params.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_registry.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_repository.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_sanitizer.py +0 -0
- {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_transaction.py +0 -0
|
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.2] - 2026-02-22
|
|
11
|
+
|
|
10
12
|
### Added
|
|
11
13
|
- **Inline SQL support**: All engine and transaction methods (`fetch_one`, `fetch_all`, `fetch_scalar`, `execute`) now accept raw SQL strings in addition to registry keys. A string containing whitespace is treated as inline SQL; a dot-separated identifier like `user.get_by_id` is resolved from the registry.
|
|
12
14
|
- **Flexible parameter binding**: `params` argument now accepts `dict` (named), `tuple`/`list` (positional), or a single scalar value (automatically wrapped in a tuple). Previously only `dict | None` was accepted.
|
|
@@ -40,5 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
40
42
|
- Support for Python 3.10, 3.11, 3.12, and 3.13
|
|
41
43
|
- 125 unit, contract, and integration tests
|
|
42
44
|
|
|
43
|
-
[Unreleased]: https://github.com/maksim-shevtsov/RowQuery/compare/v0.1.
|
|
45
|
+
[Unreleased]: https://github.com/maksim-shevtsov/RowQuery/compare/v0.1.2...HEAD
|
|
46
|
+
[0.1.2]: https://github.com/maksim-shevtsov/RowQuery/releases/tag/v0.1.2
|
|
47
|
+
[0.1.1]: https://github.com/maksim-shevtsov/RowQuery/releases/tag/v0.1.1
|
|
44
48
|
[0.1.0]: https://github.com/maksim-shevtsov/RowQuery/releases/tag/v0.1.0
|
|
@@ -66,3 +66,8 @@ uv run mypy row_query/ # Type check (strict mode)
|
|
|
66
66
|
## Active Technologies
|
|
67
67
|
- Python >=3.10 + pydantic >=2.0.0 (core); psycopg >=3.1.0, aiosqlite, aiomysql, oracledb (optional per-backend)
|
|
68
68
|
- SQLite, PostgreSQL, MySQL, Oracle via adapter pattern
|
|
69
|
+
- Python >=3.10 (documented library) + N/A (documentation-only feature; no new code dependencies) (001-llm-docs)
|
|
70
|
+
- N/A (static markdown/text files in repository) (001-llm-docs)
|
|
71
|
+
|
|
72
|
+
## Recent Changes
|
|
73
|
+
- 001-llm-docs: Added Python >=3.10 (documented library) + N/A (documentation-only feature; no new code dependencies)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rowquery
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: A SQL-first Python library for querying and mapping data across multiple database backends.
|
|
5
5
|
Project-URL: Homepage, https://github.com/maksim-shevtsov/RowQuery
|
|
6
6
|
Project-URL: Documentation, https://github.com/maksim-shevtsov/RowQuery#readme
|
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
# RowQuery Documentation
|
|
2
|
+
|
|
3
|
+
> SQL-first query execution and projection engine for Python. Supports SQLite, PostgreSQL, MySQL, and Oracle via adapter pattern. Requires Python >=3.10.
|
|
4
|
+
|
|
5
|
+
## What Is RowQuery
|
|
6
|
+
|
|
7
|
+
RowQuery is a Python library for executing raw SQL queries against multiple database backends with structured result mapping. It is currently in alpha (v0.1.2) and requires Python >=3.10.
|
|
8
|
+
|
|
9
|
+
Key features:
|
|
10
|
+
|
|
11
|
+
- **SQL-first**: You write real SQL. RowQuery executes it and maps results to Python objects.
|
|
12
|
+
- **Multi-backend**: SQLite, PostgreSQL, MySQL, and Oracle are supported through an adapter pattern.
|
|
13
|
+
- **Sync and async**: Every operation has both synchronous and asynchronous variants.
|
|
14
|
+
- **Adapter pattern**: Each database backend is implemented as a separate adapter conforming to a runtime-checkable protocol.
|
|
15
|
+
- **Result mapping**: Map flat query rows to dataclasses, Pydantic models, or complex aggregate object graphs.
|
|
16
|
+
- **SQL file registry**: Organize SQL queries in `.sql` files and reference them by dot-separated namespace keys.
|
|
17
|
+
|
|
18
|
+
RowQuery's core dependency is Pydantic >=2.0.0. Database-specific drivers are optional extras installed per backend.
|
|
19
|
+
|
|
20
|
+
## What RowQuery Is Not
|
|
21
|
+
|
|
22
|
+
- **Not an ORM.** RowQuery does not generate SQL from Python model definitions. You write all SQL yourself.
|
|
23
|
+
- **Not a schema generator.** RowQuery does not create database tables from class definitions. Use migrations or external tools for DDL.
|
|
24
|
+
- **Not a migration-only tool.** While RowQuery includes a `MigrationManager`, it is a convenience feature, not the library's primary purpose.
|
|
25
|
+
- **Not a connection pooling library.** RowQuery delegates pooling to the underlying database drivers (psycopg, aiomysql, oracledb). It manages connection lifecycle but does not implement its own pool.
|
|
26
|
+
- **Not a query builder with DSL syntax.** There is no fluent API for constructing SQL. You pass SQL strings directly or load them from `.sql` files via `SQLRegistry`.
|
|
27
|
+
- **Not a data validation framework.** RowQuery maps query results to objects but does not validate business rules or enforce constraints beyond what the database itself provides.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
### Core Installation
|
|
32
|
+
|
|
33
|
+
Install RowQuery with no database-specific drivers:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# Using uv (recommended)
|
|
37
|
+
# uv add row-query
|
|
38
|
+
|
|
39
|
+
# Using pip
|
|
40
|
+
# pip install row-query
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The core installation includes only the `pydantic` dependency and the SQLite sync adapter (which uses Python's built-in `sqlite3` module).
|
|
44
|
+
|
|
45
|
+
### Optional Database Backends
|
|
46
|
+
|
|
47
|
+
Install extras for specific database backends:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
# PostgreSQL (psycopg v3 — handles both sync and async)
|
|
51
|
+
# uv sync --extra postgres
|
|
52
|
+
|
|
53
|
+
# MySQL (mysql-connector-python for sync, aiomysql for async)
|
|
54
|
+
# uv sync --extra mysql
|
|
55
|
+
|
|
56
|
+
# Oracle (oracledb — handles both sync and async)
|
|
57
|
+
# uv sync --extra oracle
|
|
58
|
+
|
|
59
|
+
# SQLite async support (aiosqlite)
|
|
60
|
+
# uv sync --extra sqlite
|
|
61
|
+
|
|
62
|
+
# All backends at once
|
|
63
|
+
# uv sync --extra all
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Quick Start
|
|
67
|
+
|
|
68
|
+
### Sync Example
|
|
69
|
+
|
|
70
|
+
A complete example using Engine with SQLite in-memory database:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from row_query import ConnectionConfig, Engine, SQLRegistry
|
|
74
|
+
|
|
75
|
+
# 1. Configure connection
|
|
76
|
+
config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
77
|
+
|
|
78
|
+
# 2. Create a SQL registry (optional — you can also use inline SQL)
|
|
79
|
+
# For this example, we use inline SQL directly.
|
|
80
|
+
import tempfile, os
|
|
81
|
+
sql_dir = tempfile.mkdtemp()
|
|
82
|
+
# Write a SQL file: sql_dir/users/list.sql
|
|
83
|
+
os.makedirs(os.path.join(sql_dir, "users"))
|
|
84
|
+
with open(os.path.join(sql_dir, "users", "list.sql"), "w") as f:
|
|
85
|
+
f.write("SELECT id, name FROM users")
|
|
86
|
+
|
|
87
|
+
registry = SQLRegistry(sql_dir)
|
|
88
|
+
|
|
89
|
+
# 3. Create engine
|
|
90
|
+
engine = Engine.from_config(config, registry)
|
|
91
|
+
|
|
92
|
+
# 4. Set up a table and insert data using inline SQL
|
|
93
|
+
engine.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
94
|
+
engine.execute("INSERT INTO users (id, name) VALUES (:id, :name)", {"id": 1, "name": "Alice"})
|
|
95
|
+
engine.execute("INSERT INTO users (id, name) VALUES (:id, :name)", {"id": 2, "name": "Bob"})
|
|
96
|
+
|
|
97
|
+
# 5. Query using a registry key
|
|
98
|
+
all_users = engine.fetch_all("users.list")
|
|
99
|
+
print(all_users)
|
|
100
|
+
# [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]
|
|
101
|
+
|
|
102
|
+
# 6. Query using inline SQL
|
|
103
|
+
one_user = engine.fetch_one("SELECT id, name FROM users WHERE id = :id", {"id": 1})
|
|
104
|
+
print(one_user)
|
|
105
|
+
# {'id': 1, 'name': 'Alice'}
|
|
106
|
+
|
|
107
|
+
# 7. Scalar query
|
|
108
|
+
count = engine.fetch_scalar("SELECT COUNT(*) FROM users")
|
|
109
|
+
print(count)
|
|
110
|
+
# 2
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Async Example
|
|
114
|
+
|
|
115
|
+
A complete async example using AsyncEngine with SQLite and aiosqlite:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import asyncio
|
|
119
|
+
from row_query import ConnectionConfig, AsyncEngine, SQLRegistry
|
|
120
|
+
|
|
121
|
+
async def main():
|
|
122
|
+
config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
123
|
+
|
|
124
|
+
import tempfile
|
|
125
|
+
sql_dir = tempfile.mkdtemp()
|
|
126
|
+
registry = SQLRegistry(sql_dir)
|
|
127
|
+
|
|
128
|
+
engine = AsyncEngine.from_config(config, registry)
|
|
129
|
+
|
|
130
|
+
await engine.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
131
|
+
await engine.execute(
|
|
132
|
+
"INSERT INTO users (id, name) VALUES (:id, :name)",
|
|
133
|
+
{"id": 1, "name": "Alice"},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
users = await engine.fetch_all("SELECT id, name FROM users")
|
|
137
|
+
print(users)
|
|
138
|
+
# [{'id': 1, 'name': 'Alice'}]
|
|
139
|
+
|
|
140
|
+
user = await engine.fetch_one("SELECT id, name FROM users WHERE id = :id", {"id": 1})
|
|
141
|
+
print(user)
|
|
142
|
+
# {'id': 1, 'name': 'Alice'}
|
|
143
|
+
|
|
144
|
+
count = await engine.fetch_scalar("SELECT COUNT(*) FROM users")
|
|
145
|
+
print(count)
|
|
146
|
+
# 1
|
|
147
|
+
|
|
148
|
+
asyncio.run(main())
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Async SQLite requires the `aiosqlite` extra: `uv sync --extra sqlite`.
|
|
152
|
+
|
|
153
|
+
## Core Concepts
|
|
154
|
+
|
|
155
|
+
### Engine and AsyncEngine
|
|
156
|
+
|
|
157
|
+
Engine is the primary interface for executing SQL queries. AsyncEngine is its async counterpart.
|
|
158
|
+
|
|
159
|
+
**Creating an Engine:**
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from row_query import ConnectionConfig, ConnectionManager, Engine, SQLRegistry
|
|
163
|
+
|
|
164
|
+
config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
165
|
+
registry = SQLRegistry("/path/to/sql/files")
|
|
166
|
+
|
|
167
|
+
# Option 1: Factory method (recommended)
|
|
168
|
+
engine = Engine.from_config(config, registry)
|
|
169
|
+
|
|
170
|
+
# Option 2: Manual construction
|
|
171
|
+
connection_manager = ConnectionManager(config)
|
|
172
|
+
engine = Engine(connection_manager, registry)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Creating an AsyncEngine:**
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from row_query import ConnectionConfig, AsyncEngine, SQLRegistry
|
|
179
|
+
|
|
180
|
+
config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
181
|
+
registry = SQLRegistry("/path/to/sql/files")
|
|
182
|
+
engine = AsyncEngine.from_config(config, registry)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Methods:**
|
|
186
|
+
|
|
187
|
+
| Method | Signature | Returns | Description |
|
|
188
|
+
|--------|-----------|---------|-------------|
|
|
189
|
+
| `fetch_one` | `(query, params=None, *, mapper=None)` | `dict`, mapped object, or `None` | Fetch a single row. Raises `MultipleRowsError` if >1 row returned. |
|
|
190
|
+
| `fetch_all` | `(query, params=None, *, mapper=None)` | `list[dict]` or `list[T]` | Fetch all rows. |
|
|
191
|
+
| `fetch_scalar` | `(query, params=None)` | `Any` or `None` | Fetch first column of first row. |
|
|
192
|
+
| `execute` | `(query, params=None)` | `int` | Execute a write query. Returns affected row count. |
|
|
193
|
+
| `transaction` | `()` | `TransactionManager` | Returns a context manager for atomic operations. |
|
|
194
|
+
|
|
195
|
+
AsyncEngine has identical methods, all `async`.
|
|
196
|
+
|
|
197
|
+
**Query resolution:** The `query` parameter can be either:
|
|
198
|
+
- A **registry key** (no whitespace): `"users.get_by_id"` — resolved via SQLRegistry.
|
|
199
|
+
- **Inline SQL** (contains whitespace): `"SELECT * FROM users WHERE id = :id"` — used directly.
|
|
200
|
+
|
|
201
|
+
**Parameter passing:** The `params` parameter accepts:
|
|
202
|
+
- `dict` — Named parameters: `{"id": 1, "name": "Alice"}`
|
|
203
|
+
- `tuple` or `list` — Positional parameters: `(1, "Alice")`
|
|
204
|
+
- Scalar value — Wrapped as `(value,)` automatically
|
|
205
|
+
- `None` — No parameters (default)
|
|
206
|
+
|
|
207
|
+
RowQuery automatically converts `:name` parameter syntax to the format required by each backend (`%(name)s` for PostgreSQL and MySQL, `:name` for SQLite and Oracle).
|
|
208
|
+
|
|
209
|
+
**Optional SQLSanitizer:**
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from row_query import SQLSanitizer, Engine
|
|
213
|
+
|
|
214
|
+
sanitizer = SQLSanitizer(
|
|
215
|
+
strip_comments=True,
|
|
216
|
+
block_multiple_statements=True,
|
|
217
|
+
allowed_verbs=frozenset({"SELECT"}),
|
|
218
|
+
)
|
|
219
|
+
engine = Engine.from_config(config, registry, sanitizer=sanitizer)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
When a sanitizer is provided, it is applied only to inline SQL. Queries loaded from SQLRegistry are always trusted and bypass sanitization.
|
|
223
|
+
|
|
224
|
+
### ConnectionConfig
|
|
225
|
+
|
|
226
|
+
ConnectionConfig is a Pydantic BaseModel that holds all database connection settings.
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
from row_query import ConnectionConfig
|
|
230
|
+
|
|
231
|
+
# SQLite (minimal)
|
|
232
|
+
sqlite_config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
233
|
+
|
|
234
|
+
# SQLite with file path
|
|
235
|
+
sqlite_file_config = ConnectionConfig(driver="sqlite", database="/path/to/mydb.sqlite")
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Fields:**
|
|
239
|
+
|
|
240
|
+
| Field | Type | Default | Description |
|
|
241
|
+
|-------|------|---------|-------------|
|
|
242
|
+
| `driver` | `str` | (required) | Backend identifier: `"sqlite"`, `"postgresql"`, `"mysql"`, `"oracle"` |
|
|
243
|
+
| `host` | `str \| None` | `None` | Database server hostname |
|
|
244
|
+
| `port` | `int \| None` | `None` | Database server port |
|
|
245
|
+
| `user` | `str \| None` | `None` | Authentication username |
|
|
246
|
+
| `password` | `str \| None` | `None` | Authentication password |
|
|
247
|
+
| `database` | `str` | (required) | Database name or path (`:memory:` for SQLite in-memory) |
|
|
248
|
+
| `pool_size` | `int` | `5` | Maximum connections in pool |
|
|
249
|
+
| `pool_timeout` | `int` | `30` | Seconds to wait for a connection from pool |
|
|
250
|
+
| `pool_recycle` | `int` | `1800` | Seconds before recycling idle connections |
|
|
251
|
+
| `extra` | `dict[str, Any]` | `{}` | Additional driver-specific parameters |
|
|
252
|
+
|
|
253
|
+
See the Database Backends section for backend-specific ConnectionConfig examples.
|
|
254
|
+
|
|
255
|
+
### SQLRegistry
|
|
256
|
+
|
|
257
|
+
SQLRegistry loads `.sql` files from a directory tree and makes them available by dot-separated namespace keys.
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
from row_query import SQLRegistry
|
|
261
|
+
|
|
262
|
+
# Given this file structure:
|
|
263
|
+
# sql/
|
|
264
|
+
# users/
|
|
265
|
+
# get_by_id.sql -> "users.get_by_id"
|
|
266
|
+
# list_active.sql -> "users.list_active"
|
|
267
|
+
# orders/
|
|
268
|
+
# create.sql -> "orders.create"
|
|
269
|
+
|
|
270
|
+
registry = SQLRegistry("sql")
|
|
271
|
+
|
|
272
|
+
# Retrieve SQL text by key
|
|
273
|
+
sql = registry.get("users.get_by_id")
|
|
274
|
+
|
|
275
|
+
# Check if a key exists
|
|
276
|
+
exists = registry.has("users.get_by_id") # True
|
|
277
|
+
|
|
278
|
+
# List all registered keys (sorted)
|
|
279
|
+
keys = registry.query_names # ["orders.create", "users.get_by_id", "users.list_active"]
|
|
280
|
+
|
|
281
|
+
# Count registered queries
|
|
282
|
+
count = len(registry) # 3
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
**Namespace mapping:** The file path relative to the root directory is converted to a dot-separated key by replacing path separators with dots and stripping the `.sql` extension.
|
|
286
|
+
|
|
287
|
+
**Errors:**
|
|
288
|
+
- `QueryNotFoundError` — raised by `get()` when the key does not exist.
|
|
289
|
+
- `DuplicateQueryError` — raised during construction if two files produce the same key.
|
|
290
|
+
|
|
291
|
+
### SQLSanitizer
|
|
292
|
+
|
|
293
|
+
SQLSanitizer provides defense-in-depth input validation for inline SQL queries.
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
from row_query import SQLSanitizer
|
|
297
|
+
|
|
298
|
+
# Default: strip comments and block multiple statements
|
|
299
|
+
sanitizer = SQLSanitizer()
|
|
300
|
+
|
|
301
|
+
# Read-only enforcement: only allow SELECT queries
|
|
302
|
+
sanitizer = SQLSanitizer(allowed_verbs=frozenset({"SELECT"}))
|
|
303
|
+
|
|
304
|
+
# Custom configuration
|
|
305
|
+
sanitizer = SQLSanitizer(
|
|
306
|
+
strip_comments=True,
|
|
307
|
+
block_multiple_statements=True,
|
|
308
|
+
allowed_verbs=frozenset({"SELECT", "INSERT", "UPDATE"}),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Use with Engine
|
|
312
|
+
clean_sql = sanitizer.sanitize("SELECT * FROM users -- get all")
|
|
313
|
+
# Returns: "SELECT * FROM users"
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Fields:**
|
|
317
|
+
|
|
318
|
+
| Field | Type | Default | Description |
|
|
319
|
+
|-------|------|---------|-------------|
|
|
320
|
+
| `strip_comments` | `bool` | `True` | Remove `--` line comments and `/* */` block comments |
|
|
321
|
+
| `block_multiple_statements` | `bool` | `True` | Reject SQL containing `;` followed by non-whitespace |
|
|
322
|
+
| `allowed_verbs` | `frozenset[str] \| None` | `None` | If set, only allow SQL starting with these keywords |
|
|
323
|
+
|
|
324
|
+
**Important:** SQLSanitizer is defense-in-depth only. It does not protect against SQL injection from string concatenation. Always use parameterized queries (`:name` parameters) for user-provided values.
|
|
325
|
+
|
|
326
|
+
SQLSanitizer is only applied to inline SQL. Queries loaded from SQLRegistry are trusted and never sanitized.
|
|
327
|
+
|
|
328
|
+
Raises `SQLSanitizationError` if any enabled check fails.
|
|
329
|
+
|
|
330
|
+
## Database Backends
|
|
331
|
+
|
|
332
|
+
### SQLite
|
|
333
|
+
|
|
334
|
+
SQLite uses Python's built-in `sqlite3` module for sync operations and `aiosqlite` for async.
|
|
335
|
+
|
|
336
|
+
**Dependencies:**
|
|
337
|
+
- Sync: No extra dependencies (stdlib `sqlite3`)
|
|
338
|
+
- Async: `uv sync --extra sqlite` (installs `aiosqlite`)
|
|
339
|
+
|
|
340
|
+
**ConnectionConfig:**
|
|
341
|
+
|
|
342
|
+
```python
|
|
343
|
+
from row_query import ConnectionConfig
|
|
344
|
+
|
|
345
|
+
# In-memory database
|
|
346
|
+
config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
347
|
+
|
|
348
|
+
# File-based database
|
|
349
|
+
config = ConnectionConfig(driver="sqlite", database="/path/to/mydb.sqlite")
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Parameter style:** `:name` (named parameters). SQL uses `:name` syntax directly.
|
|
353
|
+
|
|
354
|
+
**Adapter details:** Connections use WAL journal mode and `sqlite3.Row` row factory for dict-like access.
|
|
355
|
+
|
|
356
|
+
### PostgreSQL
|
|
357
|
+
|
|
358
|
+
PostgreSQL uses psycopg v3 for both sync and async operations.
|
|
359
|
+
|
|
360
|
+
**Dependencies:** `uv sync --extra postgres` (installs `psycopg[binary] >=3.1.0`)
|
|
361
|
+
|
|
362
|
+
**ConnectionConfig:**
|
|
363
|
+
|
|
364
|
+
```python
|
|
365
|
+
from row_query import ConnectionConfig
|
|
366
|
+
|
|
367
|
+
config = ConnectionConfig(
|
|
368
|
+
driver="postgresql",
|
|
369
|
+
host="localhost",
|
|
370
|
+
port=5432,
|
|
371
|
+
user="myuser",
|
|
372
|
+
password="mypassword",
|
|
373
|
+
database="mydb",
|
|
374
|
+
)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
**Parameter style:** `pyformat`. RowQuery automatically converts `:name` in your SQL to `%(name)s` before execution.
|
|
378
|
+
|
|
379
|
+
**Adapter details:** Uses `psycopg.rows.dict_row` row factory. Connection string is built from ConnectionConfig fields.
|
|
380
|
+
|
|
381
|
+
### MySQL
|
|
382
|
+
|
|
383
|
+
MySQL uses `mysql-connector-python` for sync operations and `aiomysql` for async.
|
|
384
|
+
|
|
385
|
+
**Dependencies:** `uv sync --extra mysql` (installs `mysql-connector-python >=8.0.0` and `aiomysql >=0.2.0`)
|
|
386
|
+
|
|
387
|
+
**ConnectionConfig:**
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
from row_query import ConnectionConfig
|
|
391
|
+
|
|
392
|
+
config = ConnectionConfig(
|
|
393
|
+
driver="mysql",
|
|
394
|
+
host="localhost",
|
|
395
|
+
port=3306,
|
|
396
|
+
user="myuser",
|
|
397
|
+
password="mypassword",
|
|
398
|
+
database="mydb",
|
|
399
|
+
)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
**Parameter style:** `pyformat`. RowQuery automatically converts `:name` in your SQL to `%(name)s` before execution.
|
|
403
|
+
|
|
404
|
+
**Adapter details:** Sync adapter uses `cursor(dictionary=True)`. Async adapter uses `aiomysql.DictCursor`.
|
|
405
|
+
|
|
406
|
+
### Oracle
|
|
407
|
+
|
|
408
|
+
Oracle uses `oracledb` for both sync and async operations.
|
|
409
|
+
|
|
410
|
+
**Dependencies:** `uv sync --extra oracle` (installs `oracledb >=2.0.0`)
|
|
411
|
+
|
|
412
|
+
**ConnectionConfig:**
|
|
413
|
+
|
|
414
|
+
```python
|
|
415
|
+
from row_query import ConnectionConfig
|
|
416
|
+
|
|
417
|
+
config = ConnectionConfig(
|
|
418
|
+
driver="oracle",
|
|
419
|
+
host="localhost",
|
|
420
|
+
port=1521,
|
|
421
|
+
user="myuser",
|
|
422
|
+
password="mypassword",
|
|
423
|
+
database="mydb",
|
|
424
|
+
)
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Parameter style:** `named`. SQL uses `:name` syntax directly (same as SQLite).
|
|
428
|
+
|
|
429
|
+
**Adapter details:** DSN format is `host:port/database`. Uses a custom row factory to convert tuples to dicts with lowercased column names.
|
|
430
|
+
|
|
431
|
+
## Result Mapping
|
|
432
|
+
|
|
433
|
+
### ModelMapper
|
|
434
|
+
|
|
435
|
+
ModelMapper maps a single query row (dict) to a Python object. It supports Pydantic models, dataclasses, and plain classes.
|
|
436
|
+
|
|
437
|
+
```python
|
|
438
|
+
from dataclasses import dataclass
|
|
439
|
+
from row_query import ConnectionConfig, Engine, SQLRegistry, ModelMapper
|
|
440
|
+
|
|
441
|
+
@dataclass
|
|
442
|
+
class User:
|
|
443
|
+
id: int
|
|
444
|
+
name: str
|
|
445
|
+
|
|
446
|
+
config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
447
|
+
|
|
448
|
+
import tempfile
|
|
449
|
+
registry = SQLRegistry(tempfile.mkdtemp())
|
|
450
|
+
engine = Engine.from_config(config, registry)
|
|
451
|
+
|
|
452
|
+
engine.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
453
|
+
engine.execute("INSERT INTO users (id, name) VALUES (:id, :name)", {"id": 1, "name": "Alice"})
|
|
454
|
+
|
|
455
|
+
# Create a mapper
|
|
456
|
+
mapper = ModelMapper(User)
|
|
457
|
+
|
|
458
|
+
# Use with fetch_one
|
|
459
|
+
user = engine.fetch_one("SELECT id, name FROM users WHERE id = :id", {"id": 1}, mapper=mapper)
|
|
460
|
+
print(user) # User(id=1, name='Alice')
|
|
461
|
+
print(user.name) # 'Alice'
|
|
462
|
+
|
|
463
|
+
# Use with fetch_all
|
|
464
|
+
users = engine.fetch_all("SELECT id, name FROM users", mapper=mapper)
|
|
465
|
+
print(users) # [User(id=1, name='Alice')]
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
**Constructor:**
|
|
469
|
+
|
|
470
|
+
```python
|
|
471
|
+
ModelMapper(target_class: type[T], aliases: dict[str, str] | None = None)
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
- `target_class` — The class to instantiate. Detection order: Pydantic model → dataclass → plain class.
|
|
475
|
+
- `aliases` — Optional column-to-field name mapping: `{"column_name": "field_name"}`.
|
|
476
|
+
|
|
477
|
+
**Aliases example:**
|
|
478
|
+
|
|
479
|
+
```python
|
|
480
|
+
@dataclass
|
|
481
|
+
class UserProfile:
|
|
482
|
+
user_id: int
|
|
483
|
+
full_name: str
|
|
484
|
+
|
|
485
|
+
mapper = ModelMapper(UserProfile, aliases={"id": "user_id", "name": "full_name"})
|
|
486
|
+
user = engine.fetch_one("SELECT id, name FROM users WHERE id = :id", {"id": 1}, mapper=mapper)
|
|
487
|
+
print(user) # UserProfile(user_id=1, full_name='Alice')
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Raises `ColumnMismatchError` if the row columns do not match the target class fields.
|
|
491
|
+
|
|
492
|
+
### Aggregate Mapping
|
|
493
|
+
|
|
494
|
+
Aggregate mapping reconstructs complex object graphs (parent with collections, references, and value objects) from flat SQL JOIN result sets using a single-pass O(n) algorithm.
|
|
495
|
+
|
|
496
|
+
#### Building an AggregatePlan
|
|
497
|
+
|
|
498
|
+
Use the `aggregate()` function and fluent builder to define the mapping plan:
|
|
499
|
+
|
|
500
|
+
```python
|
|
501
|
+
from dataclasses import dataclass, field
|
|
502
|
+
from row_query.mapping.builder import aggregate
|
|
503
|
+
|
|
504
|
+
@dataclass
|
|
505
|
+
class OrderItem:
|
|
506
|
+
id: int
|
|
507
|
+
product: str
|
|
508
|
+
quantity: int
|
|
509
|
+
|
|
510
|
+
@dataclass
|
|
511
|
+
class Customer:
|
|
512
|
+
id: int
|
|
513
|
+
name: str
|
|
514
|
+
|
|
515
|
+
@dataclass
|
|
516
|
+
class Address:
|
|
517
|
+
street: str
|
|
518
|
+
city: str
|
|
519
|
+
|
|
520
|
+
@dataclass
|
|
521
|
+
class Order:
|
|
522
|
+
id: int
|
|
523
|
+
total: float
|
|
524
|
+
items: list[OrderItem] = field(default_factory=list)
|
|
525
|
+
customer: Customer | None = None
|
|
526
|
+
shipping: Address | None = None
|
|
527
|
+
|
|
528
|
+
plan = (
|
|
529
|
+
aggregate(Order, prefix="order__")
|
|
530
|
+
.key("id")
|
|
531
|
+
.auto_fields()
|
|
532
|
+
.collection("items", OrderItem, prefix="item__", key="id")
|
|
533
|
+
.reference("customer", Customer, prefix="customer__", key="id")
|
|
534
|
+
.value_object("shipping", Address, prefix="addr__")
|
|
535
|
+
.build()
|
|
536
|
+
)
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
**Builder methods:**
|
|
540
|
+
|
|
541
|
+
| Method | Description |
|
|
542
|
+
|--------|-------------|
|
|
543
|
+
| `aggregate(root_class, prefix=None)` | Entry point. `prefix` defaults to `lowercase(class_name) + "__"`. |
|
|
544
|
+
| `.key(field_name)` | **Required.** Sets the identity key field for the root entity. |
|
|
545
|
+
| `.auto_fields()` | Auto-maps all fields of the root class (excluding collections, references, value objects). |
|
|
546
|
+
| `.field(attr_name, column_name=None)` | Explicitly map a single field. `column_name` defaults to `attr_name`. |
|
|
547
|
+
| `.collection(name, entity_class, prefix, key)` | Define a child collection (one-to-many). |
|
|
548
|
+
| `.reference(name, entity_class, prefix, key=None)` | Define a reference (many-to-one). `key` defaults to `"id"`. |
|
|
549
|
+
| `.value_object(name, vo_class, prefix)` | Define an embedded value object. |
|
|
550
|
+
| `.strict(enabled=True)` | Enable strict mode: validates all mapped columns exist in result set. |
|
|
551
|
+
| `.build()` | Compile and return an `AggregatePlan`. Raises `PlanCompilationError` if `.key()` was not called or prefixes collide. |
|
|
552
|
+
|
|
553
|
+
#### AggregateMapper
|
|
554
|
+
|
|
555
|
+
AggregateMapper consumes the plan and reconstructs objects from flat rows:
|
|
556
|
+
|
|
557
|
+
```python
|
|
558
|
+
from row_query.mapping.aggregate import AggregateMapper
|
|
559
|
+
|
|
560
|
+
mapper = AggregateMapper(plan)
|
|
561
|
+
|
|
562
|
+
# Use with engine.fetch_all — AggregateMapper requires multiple rows
|
|
563
|
+
orders = engine.fetch_all(
|
|
564
|
+
"""
|
|
565
|
+
SELECT
|
|
566
|
+
o.id AS order__id,
|
|
567
|
+
o.total AS order__total,
|
|
568
|
+
i.id AS item__id,
|
|
569
|
+
i.product AS item__product,
|
|
570
|
+
i.quantity AS item__quantity,
|
|
571
|
+
c.id AS customer__id,
|
|
572
|
+
c.name AS customer__name,
|
|
573
|
+
a.street AS addr__street,
|
|
574
|
+
a.city AS addr__city
|
|
575
|
+
FROM orders o
|
|
576
|
+
LEFT JOIN order_items i ON o.id = i.order_id
|
|
577
|
+
LEFT JOIN customers c ON o.customer_id = c.id
|
|
578
|
+
LEFT JOIN addresses a ON o.shipping_address_id = a.id
|
|
579
|
+
""",
|
|
580
|
+
mapper=mapper,
|
|
581
|
+
)
|
|
582
|
+
# Returns: list[Order] with nested items, customer, and shipping populated
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
**Behavior:**
|
|
586
|
+
- Deduplicates root entities by key field (e.g., one Order per unique `order__id`).
|
|
587
|
+
- Deduplicates collection children by their key field.
|
|
588
|
+
- References and value objects are set once per root (first non-NULL occurrence).
|
|
589
|
+
- Rows with NULL root key are silently skipped.
|
|
590
|
+
- `map_one()` raises `NotImplementedError` — use `map_many()` only.
|
|
591
|
+
- In strict mode, raises `StrictModeViolation` if columns are missing or unknown prefix groups appear.
|
|
592
|
+
|
|
593
|
+
## Transactions
|
|
594
|
+
|
|
595
|
+
### Sync Transactions
|
|
596
|
+
|
|
597
|
+
Use `engine.transaction()` to execute multiple statements atomically:
|
|
598
|
+
|
|
599
|
+
```python
|
|
600
|
+
from row_query import ConnectionConfig, Engine, SQLRegistry
|
|
601
|
+
|
|
602
|
+
config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
603
|
+
|
|
604
|
+
import tempfile
|
|
605
|
+
registry = SQLRegistry(tempfile.mkdtemp())
|
|
606
|
+
engine = Engine.from_config(config, registry)
|
|
607
|
+
|
|
608
|
+
engine.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance REAL)")
|
|
609
|
+
engine.execute("INSERT INTO accounts (id, balance) VALUES (:id, :balance)", {"id": 1, "balance": 100.0})
|
|
610
|
+
engine.execute("INSERT INTO accounts (id, balance) VALUES (:id, :balance)", {"id": 2, "balance": 50.0})
|
|
611
|
+
|
|
612
|
+
# Transfer funds atomically
|
|
613
|
+
with engine.transaction() as tx:
|
|
614
|
+
tx.execute(
|
|
615
|
+
"UPDATE accounts SET balance = balance - :amount WHERE id = :id",
|
|
616
|
+
{"amount": 25.0, "id": 1},
|
|
617
|
+
)
|
|
618
|
+
tx.execute(
|
|
619
|
+
"UPDATE accounts SET balance = balance + :amount WHERE id = :id",
|
|
620
|
+
{"amount": 25.0, "id": 2},
|
|
621
|
+
)
|
|
622
|
+
# Auto-commits on successful exit from the `with` block
|
|
623
|
+
|
|
624
|
+
# If an exception occurs inside the `with` block, the transaction auto-rolls back.
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
**Available methods within a transaction:**
|
|
628
|
+
|
|
629
|
+
| Method | Signature | Returns |
|
|
630
|
+
|--------|-----------|---------|
|
|
631
|
+
| `execute` | `(query, params=None)` | `int` (affected row count) |
|
|
632
|
+
| `fetch_one` | `(query, params=None)` | `dict[str, Any] \| None` |
|
|
633
|
+
| `fetch_all` | `(query, params=None)` | `list[dict[str, Any]]` |
|
|
634
|
+
| `commit` | `()` | `None` |
|
|
635
|
+
| `rollback` | `()` | `None` |
|
|
636
|
+
|
|
637
|
+
**Auto-commit/rollback behavior:**
|
|
638
|
+
- On normal exit from the `with` block: transaction commits automatically.
|
|
639
|
+
- On exception: transaction rolls back automatically.
|
|
640
|
+
- You can call `commit()` or `rollback()` explicitly within the block.
|
|
641
|
+
|
|
642
|
+
Raises `TransactionStateError` if you call `commit()` on an already-committed or rolled-back transaction, or `rollback()` on an already-committed transaction.
|
|
643
|
+
|
|
644
|
+
### Async Transactions
|
|
645
|
+
|
|
646
|
+
Use `async with engine.transaction()` for async atomic operations:
|
|
647
|
+
|
|
648
|
+
```python
|
|
649
|
+
import asyncio
|
|
650
|
+
from row_query import ConnectionConfig, AsyncEngine, SQLRegistry
|
|
651
|
+
|
|
652
|
+
async def main():
|
|
653
|
+
config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
654
|
+
|
|
655
|
+
import tempfile
|
|
656
|
+
registry = SQLRegistry(tempfile.mkdtemp())
|
|
657
|
+
engine = AsyncEngine.from_config(config, registry)
|
|
658
|
+
|
|
659
|
+
await engine.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance REAL)")
|
|
660
|
+
await engine.execute("INSERT INTO accounts (id, balance) VALUES (:id, :balance)", {"id": 1, "balance": 100.0})
|
|
661
|
+
await engine.execute("INSERT INTO accounts (id, balance) VALUES (:id, :balance)", {"id": 2, "balance": 50.0})
|
|
662
|
+
|
|
663
|
+
async with engine.transaction() as tx:
|
|
664
|
+
await tx.execute(
|
|
665
|
+
"UPDATE accounts SET balance = balance - :amount WHERE id = :id",
|
|
666
|
+
{"amount": 25.0, "id": 1},
|
|
667
|
+
)
|
|
668
|
+
await tx.execute(
|
|
669
|
+
"UPDATE accounts SET balance = balance + :amount WHERE id = :id",
|
|
670
|
+
{"amount": 25.0, "id": 2},
|
|
671
|
+
)
|
|
672
|
+
# Auto-commits on successful exit
|
|
673
|
+
|
|
674
|
+
asyncio.run(main())
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
Async transactions have the same methods as sync transactions, all `async`. Auto-commit and auto-rollback behavior is identical.
|
|
678
|
+
|
|
679
|
+
## Migrations
|
|
680
|
+
|
|
681
|
+
MigrationManager provides SQL-based schema migrations with version tracking.
|
|
682
|
+
|
|
683
|
+
```python
|
|
684
|
+
from pathlib import Path
|
|
685
|
+
from row_query import ConnectionConfig, ConnectionManager, MigrationManager
|
|
686
|
+
|
|
687
|
+
config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
688
|
+
connection_manager = ConnectionManager(config)
|
|
689
|
+
|
|
690
|
+
# Migration files must follow the naming convention: NNN_description.sql
|
|
691
|
+
# Example directory:
|
|
692
|
+
# migrations/
|
|
693
|
+
# 001_create_users.sql
|
|
694
|
+
# 002_add_email_column.sql
|
|
695
|
+
# 003_create_orders.sql
|
|
696
|
+
|
|
697
|
+
manager = MigrationManager(
|
|
698
|
+
migration_dir=Path("migrations"),
|
|
699
|
+
connection_manager=connection_manager,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
# Discover all migrations and their applied status
|
|
703
|
+
all_migrations = manager.discover()
|
|
704
|
+
# Returns: [MigrationInfo(version='001', description='create_users', file_path=..., applied=False), ...]
|
|
705
|
+
|
|
706
|
+
# Check pending (unapplied) migrations
|
|
707
|
+
pending = manager.pending()
|
|
708
|
+
|
|
709
|
+
# Apply all pending migrations in order
|
|
710
|
+
applied = manager.apply()
|
|
711
|
+
# Returns list of successfully applied MigrationInfo objects
|
|
712
|
+
# Stops at first failure and raises MigrationExecutionError
|
|
713
|
+
|
|
714
|
+
# Check which migrations have been applied
|
|
715
|
+
already_applied = manager.applied()
|
|
716
|
+
|
|
717
|
+
# Get the current migration version
|
|
718
|
+
version = manager.current_version() # "003" or None if no migrations applied
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
**MigrationInfo fields:**
|
|
722
|
+
|
|
723
|
+
| Field | Type | Description |
|
|
724
|
+
|-------|------|-------------|
|
|
725
|
+
| `version` | `str` | Version identifier from filename (e.g., `"001"`) |
|
|
726
|
+
| `description` | `str` | Description from filename (e.g., `"create_users"`) |
|
|
727
|
+
| `file_path` | `Path` | Absolute path to the `.sql` file |
|
|
728
|
+
| `applied` | `bool` | Whether this migration has been applied (default: `False`) |
|
|
729
|
+
|
|
730
|
+
**File naming convention:** `NNN_description.sql` where `NNN` is a zero-padded numeric version and `description` uses underscores. Files not matching this pattern raise `MigrationFileError`.
|
|
731
|
+
|
|
732
|
+
**Tracking:** MigrationManager creates a `schema_migrations` table to track applied versions.
|
|
733
|
+
|
|
734
|
+
**Note:** The current implementation uses SQLite-specific SQL syntax for the tracking table. Multi-backend migration support may require adaptation.
|
|
735
|
+
|
|
736
|
+
## Repository Pattern
|
|
737
|
+
|
|
738
|
+
Repository and AsyncRepository provide DDD-style base classes for organizing database access around domain entities.
|
|
739
|
+
|
|
740
|
+
```python
|
|
741
|
+
from dataclasses import dataclass, field
|
|
742
|
+
from row_query import ConnectionConfig, Engine, SQLRegistry, ModelMapper
|
|
743
|
+
from row_query.repository.base import Repository
|
|
744
|
+
from row_query.mapping.builder import aggregate
|
|
745
|
+
from row_query.mapping.aggregate import AggregateMapper
|
|
746
|
+
|
|
747
|
+
@dataclass
|
|
748
|
+
class User:
|
|
749
|
+
id: int
|
|
750
|
+
name: str
|
|
751
|
+
email: str
|
|
752
|
+
|
|
753
|
+
class UserRepository(Repository[User]):
|
|
754
|
+
def find_by_id(self, user_id: int) -> User | None:
|
|
755
|
+
return self.engine.fetch_one(
|
|
756
|
+
"SELECT id, name, email FROM users WHERE id = :id",
|
|
757
|
+
{"id": user_id},
|
|
758
|
+
mapper=self.mapper,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
def find_all(self) -> list[User]:
|
|
762
|
+
return self.engine.fetch_all(
|
|
763
|
+
"SELECT id, name, email FROM users",
|
|
764
|
+
mapper=self.mapper,
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
def save(self, user: User) -> int:
|
|
768
|
+
return self.engine.execute(
|
|
769
|
+
"INSERT INTO users (id, name, email) VALUES (:id, :name, :email)",
|
|
770
|
+
{"id": user.id, "name": user.name, "email": user.email},
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
# Usage
|
|
774
|
+
config = ConnectionConfig(driver="sqlite", database=":memory:")
|
|
775
|
+
|
|
776
|
+
import tempfile
|
|
777
|
+
registry = SQLRegistry(tempfile.mkdtemp())
|
|
778
|
+
engine = Engine.from_config(config, registry)
|
|
779
|
+
|
|
780
|
+
mapper = ModelMapper(User)
|
|
781
|
+
repo = UserRepository(engine=engine, mapper=mapper)
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
**Constructor parameters:**
|
|
785
|
+
|
|
786
|
+
| Parameter | Type | Description |
|
|
787
|
+
|-----------|------|-------------|
|
|
788
|
+
| `engine` | `Engine` or `AsyncEngine` | The engine to use for queries |
|
|
789
|
+
| `mapping` | `AggregatePlan \| None` | If provided, wraps it in an `AggregateMapper` automatically |
|
|
790
|
+
| `mapper` | `Mapper[T] \| None` | Provide a mapper directly (takes priority over `mapping`) |
|
|
791
|
+
|
|
792
|
+
**Public attributes:**
|
|
793
|
+
- `self.engine` — The engine instance.
|
|
794
|
+
- `self.mapper` — The mapper instance (or `None`).
|
|
795
|
+
|
|
796
|
+
**With aggregate mapping:**
|
|
797
|
+
|
|
798
|
+
```python
|
|
799
|
+
plan = (
|
|
800
|
+
aggregate(Order, prefix="order__")
|
|
801
|
+
.key("id")
|
|
802
|
+
.auto_fields()
|
|
803
|
+
.collection("items", OrderItem, prefix="item__", key="id")
|
|
804
|
+
.build()
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
# Pass the plan directly — Repository wraps it in AggregateMapper
|
|
808
|
+
repo = OrderRepository(engine=engine, mapping=plan)
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
AsyncRepository has the same constructor and attributes. Subclass methods should use `await self.engine.fetch_one(...)`, etc.
|
|
812
|
+
|
|
813
|
+
## Exception Reference
|
|
814
|
+
|
|
815
|
+
All exceptions inherit from `RowQueryError`. The full hierarchy:
|
|
816
|
+
|
|
817
|
+
```text
|
|
818
|
+
RowQueryError
|
|
819
|
+
├── RegistryError
|
|
820
|
+
│ ├── QueryNotFoundError
|
|
821
|
+
│ └── DuplicateQueryError
|
|
822
|
+
├── ExecutionError
|
|
823
|
+
│ ├── MultipleRowsError
|
|
824
|
+
│ ├── ParameterBindingError
|
|
825
|
+
│ └── SQLSanitizationError
|
|
826
|
+
├── MappingError
|
|
827
|
+
│ ├── ColumnMismatchError
|
|
828
|
+
│ ├── StrictModeViolation
|
|
829
|
+
│ └── PlanCompilationError
|
|
830
|
+
├── TransactionError
|
|
831
|
+
│ └── TransactionStateError
|
|
832
|
+
├── MigrationError
|
|
833
|
+
│ ├── MigrationFileError
|
|
834
|
+
│ └── MigrationExecutionError
|
|
835
|
+
└── AdapterError
|
|
836
|
+
├── ConnectionError
|
|
837
|
+
└── PoolError
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
**Detailed reference:**
|
|
841
|
+
|
|
842
|
+
| Exception | Parent | When Raised | Notable Attributes |
|
|
843
|
+
|-----------|--------|-------------|-------------------|
|
|
844
|
+
| `RowQueryError` | `Exception` | Base class for all RowQuery exceptions | — |
|
|
845
|
+
| `RegistryError` | `RowQueryError` | Base for SQLRegistry errors | — |
|
|
846
|
+
| `QueryNotFoundError` | `RegistryError` | `SQLRegistry.get()` called with nonexistent key | `query_name: str` |
|
|
847
|
+
| `DuplicateQueryError` | `RegistryError` | Two SQL files produce the same registry key | `query_name: str` |
|
|
848
|
+
| `ExecutionError` | `RowQueryError` | Base for query execution errors | — |
|
|
849
|
+
| `MultipleRowsError` | `ExecutionError` | `fetch_one()` returns more than one row | `query_name: str`, `row_count: int` |
|
|
850
|
+
| `ParameterBindingError` | `ExecutionError` | Parameter binding fails during query execution | `query_name: str` |
|
|
851
|
+
| `SQLSanitizationError` | `ExecutionError` | SQLSanitizer rejects inline SQL | — |
|
|
852
|
+
| `MappingError` | `RowQueryError` | Base for result mapping errors | — |
|
|
853
|
+
| `ColumnMismatchError` | `MappingError` | Row columns don't match target class fields | `target_class: str`, `missing_fields: list[str]` |
|
|
854
|
+
| `StrictModeViolation` | `MappingError` | Strict mode: columns missing or unknown prefix groups | — |
|
|
855
|
+
| `PlanCompilationError` | `MappingError` | `AggregateMappingBuilder.build()` fails (no key set, prefix collision) | — |
|
|
856
|
+
| `TransactionError` | `RowQueryError` | Base for transaction errors | — |
|
|
857
|
+
| `TransactionStateError` | `TransactionError` | Operation on committed/rolled-back transaction | `current_state: str`, `attempted_action: str` |
|
|
858
|
+
| `MigrationError` | `RowQueryError` | Base for migration errors | — |
|
|
859
|
+
| `MigrationFileError` | `MigrationError` | Migration file has invalid name format | `file_name: str` |
|
|
860
|
+
| `MigrationExecutionError` | `MigrationError` | Migration SQL execution fails | `version: str` |
|
|
861
|
+
| `AdapterError` | `RowQueryError` | Base for adapter/connection errors | — |
|
|
862
|
+
| `ConnectionError` | `AdapterError` | Database connection cannot be established | — |
|
|
863
|
+
| `PoolError` | `AdapterError` | Connection pool error (exhausted, timeout) | — |
|
|
864
|
+
|
|
865
|
+
## Terminology Glossary
|
|
866
|
+
|
|
867
|
+
| Term | Definition |
|
|
868
|
+
|------|-----------|
|
|
869
|
+
| **Engine** | The primary interface for executing SQL queries. Created via `Engine.from_config()` or `AsyncEngine.from_config()`. |
|
|
870
|
+
| **adapter** | A database-specific implementation conforming to `SyncAdapter` or `AsyncAdapter` protocol. Each backend (SQLite, PostgreSQL, MySQL, Oracle) has its own adapter. |
|
|
871
|
+
| **ConnectionConfig** | A Pydantic model holding all database connection parameters: `driver`, `host`, `port`, credentials, `database`, and pool configuration. |
|
|
872
|
+
| **ConnectionManager** | Manages the lifecycle of database connections — pool creation, connection acquisition and release, and pool shutdown. `AsyncConnectionManager` is its async counterpart. |
|
|
873
|
+
| **SQLRegistry** | Loads `.sql` files from a directory tree and makes them available by dot-separated namespace keys (e.g., `"users.get_by_id"`). |
|
|
874
|
+
| **SQLSanitizer** | An optional defense-in-depth guard applied to inline SQL queries. Can strip comments, block multiple statements, and restrict SQL verbs. |
|
|
875
|
+
| **mapper** | An object implementing the `Mapper[T]` protocol (`map_one`, `map_many`) that transforms raw dict rows into typed Python objects. |
|
|
876
|
+
| **ModelMapper** | A mapper that converts a single row dict into a Pydantic model, dataclass, or plain class instance. |
|
|
877
|
+
| **AggregateMapper** | A mapper that reconstructs complex object graphs (with collections, references, and value objects) from flat JOIN result sets using single-pass O(n) processing. |
|
|
878
|
+
| **AggregatePlan** | A frozen dataclass describing how to map flat rows to an aggregate root with its nested entities. Built via the `aggregate()` DSL. |
|
|
879
|
+
| **aggregate()** | The DSL entry point function that returns an `AggregateMappingBuilder` for fluently defining an `AggregatePlan`. |
|
|
880
|
+
| **TransactionManager** | A context manager for executing multiple SQL statements atomically. Auto-commits on success, auto-rolls-back on exception. `AsyncTransactionManager` is its async counterpart. |
|
|
881
|
+
| **MigrationManager** | Discovers, tracks, and applies SQL migration files in version order using a `schema_migrations` tracking table. |
|
|
882
|
+
| **Repository** | A DDD-style base class for organizing database access around domain entities. Wraps an Engine and optional mapper. `AsyncRepository` is its async counterpart. |
|
|
883
|
+
| **:name parameter** | The parameter syntax used in SQL queries (e.g., `:id`, `:name`). RowQuery automatically converts this to the format required by each backend. |
|
rowquery-0.1.2/llms.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# RowQuery
|
|
2
|
+
|
|
3
|
+
> SQL-first query execution and projection engine for Python. Supports SQLite, PostgreSQL, MySQL, and Oracle via adapter pattern. Requires Python >=3.10.
|
|
4
|
+
|
|
5
|
+
## Documentation
|
|
6
|
+
|
|
7
|
+
- [What Is RowQuery](llms-full.txt#what-is-rowquery): One-paragraph overview, key features, and status
|
|
8
|
+
- [Installation](llms-full.txt#installation): Core install and optional database backend extras
|
|
9
|
+
- [Quick Start](llms-full.txt#quick-start): Sync and async examples with SQLite in-memory
|
|
10
|
+
- [Core Concepts](llms-full.txt#core-concepts): Engine, ConnectionConfig, SQLRegistry, SQLSanitizer
|
|
11
|
+
- [Database Backends](llms-full.txt#database-backends): SQLite, PostgreSQL, MySQL, Oracle adapter configuration
|
|
12
|
+
- [Result Mapping](llms-full.txt#result-mapping): ModelMapper for single entities, AggregateMapper for complex object graphs
|
|
13
|
+
- [Transactions](llms-full.txt#transactions): Sync and async transaction management
|
|
14
|
+
- [Migrations](llms-full.txt#migrations): Schema migration with MigrationManager
|
|
15
|
+
- [Repository Pattern](llms-full.txt#repository-pattern): DDD-style repository base classes
|
|
16
|
+
- [Exception Reference](llms-full.txt#exception-reference): Complete exception hierarchy (18 classes)
|
|
17
|
+
- [Terminology Glossary](llms-full.txt#terminology-glossary): Canonical terms used in RowQuery
|
|
18
|
+
|
|
19
|
+
## Optional
|
|
20
|
+
|
|
21
|
+
- [What RowQuery Is Not](llms-full.txt#what-rowquery-is-not): Scope boundaries and non-features
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|