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.
Files changed (75) hide show
  1. {rowquery-0.1.0 → rowquery-0.1.2}/CHANGELOG.md +5 -1
  2. {rowquery-0.1.0 → rowquery-0.1.2}/CLAUDE.md +5 -0
  3. {rowquery-0.1.0 → rowquery-0.1.2}/PKG-INFO +1 -1
  4. rowquery-0.1.2/llms-full.txt +883 -0
  5. rowquery-0.1.2/llms.txt +21 -0
  6. {rowquery-0.1.0 → rowquery-0.1.2}/pyproject.toml +1 -1
  7. {rowquery-0.1.0 → rowquery-0.1.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  8. {rowquery-0.1.0 → rowquery-0.1.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  9. {rowquery-0.1.0 → rowquery-0.1.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  10. {rowquery-0.1.0 → rowquery-0.1.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  11. {rowquery-0.1.0 → rowquery-0.1.2}/.github/dependabot.yml +0 -0
  12. {rowquery-0.1.0 → rowquery-0.1.2}/.github/workflows/quality.yml +0 -0
  13. {rowquery-0.1.0 → rowquery-0.1.2}/.github/workflows/release.yml +0 -0
  14. {rowquery-0.1.0 → rowquery-0.1.2}/.github/workflows/test.yml +0 -0
  15. {rowquery-0.1.0 → rowquery-0.1.2}/.gitignore +0 -0
  16. {rowquery-0.1.0 → rowquery-0.1.2}/.pre-commit-config.yaml +0 -0
  17. {rowquery-0.1.0 → rowquery-0.1.2}/CODE_OF_CONDUCT.md +0 -0
  18. {rowquery-0.1.0 → rowquery-0.1.2}/CONTRIBUTING.md +0 -0
  19. {rowquery-0.1.0 → rowquery-0.1.2}/LICENSE +0 -0
  20. {rowquery-0.1.0 → rowquery-0.1.2}/README.md +0 -0
  21. {rowquery-0.1.0 → rowquery-0.1.2}/examples/01_basic_query.py +0 -0
  22. {rowquery-0.1.0 → rowquery-0.1.2}/examples/02_model_mapping.py +0 -0
  23. {rowquery-0.1.0 → rowquery-0.1.2}/examples/03_aggregate.py +0 -0
  24. {rowquery-0.1.0 → rowquery-0.1.2}/examples/04_transactions.py +0 -0
  25. {rowquery-0.1.0 → rowquery-0.1.2}/examples/05_async.py +0 -0
  26. {rowquery-0.1.0 → rowquery-0.1.2}/examples/06_migrations.py +0 -0
  27. {rowquery-0.1.0 → rowquery-0.1.2}/examples/07_repository.py +0 -0
  28. {rowquery-0.1.0 → rowquery-0.1.2}/examples/README.md +0 -0
  29. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/__init__.py +0 -0
  30. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/__init__.py +0 -0
  31. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/mysql.py +0 -0
  32. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/oracle.py +0 -0
  33. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/postgresql.py +0 -0
  34. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/protocol.py +0 -0
  35. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/adapters/sqlite.py +0 -0
  36. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/__init__.py +0 -0
  37. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/connection.py +0 -0
  38. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/engine.py +0 -0
  39. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/enums.py +0 -0
  40. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/exceptions.py +0 -0
  41. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/migration.py +0 -0
  42. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/params.py +0 -0
  43. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/registry.py +0 -0
  44. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/sanitizer.py +0 -0
  45. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/core/transaction.py +0 -0
  46. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/__init__.py +0 -0
  47. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/aggregate.py +0 -0
  48. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/builder.py +0 -0
  49. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/model.py +0 -0
  50. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/plan.py +0 -0
  51. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/mapping/protocol.py +0 -0
  52. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/repository/__init__.py +0 -0
  53. {rowquery-0.1.0 → rowquery-0.1.2}/row_query/repository/base.py +0 -0
  54. {rowquery-0.1.0 → rowquery-0.1.2}/tests/__init__.py +0 -0
  55. {rowquery-0.1.0 → rowquery-0.1.2}/tests/conftest.py +0 -0
  56. {rowquery-0.1.0 → rowquery-0.1.2}/tests/contract/__init__.py +0 -0
  57. {rowquery-0.1.0 → rowquery-0.1.2}/tests/contract/test_adapter_protocol.py +0 -0
  58. {rowquery-0.1.0 → rowquery-0.1.2}/tests/fixtures/sql/billing/invoice/list.sql +0 -0
  59. {rowquery-0.1.0 → rowquery-0.1.2}/tests/fixtures/sql/user/count.sql +0 -0
  60. {rowquery-0.1.0 → rowquery-0.1.2}/tests/fixtures/sql/user/get_by_id.sql +0 -0
  61. {rowquery-0.1.0 → rowquery-0.1.2}/tests/fixtures/sql/user/insert.sql +0 -0
  62. {rowquery-0.1.0 → rowquery-0.1.2}/tests/fixtures/sql/user/list.sql +0 -0
  63. {rowquery-0.1.0 → rowquery-0.1.2}/tests/integration/__init__.py +0 -0
  64. {rowquery-0.1.0 → rowquery-0.1.2}/tests/integration/test_sqlite.py +0 -0
  65. {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/__init__.py +0 -0
  66. {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_aggregate_mapper.py +0 -0
  67. {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_builder.py +0 -0
  68. {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_engine.py +0 -0
  69. {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_migration.py +0 -0
  70. {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_model_mapper.py +0 -0
  71. {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_params.py +0 -0
  72. {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_registry.py +0 -0
  73. {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_repository.py +0 -0
  74. {rowquery-0.1.0 → rowquery-0.1.2}/tests/unit/test_sanitizer.py +0 -0
  75. {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.0...HEAD
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.0
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. |
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rowquery"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "A SQL-first Python library for querying and mapping data across multiple database backends."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
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