pyxle-db 0.2.0__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.
- pyxle_db-0.2.0/.gitignore +37 -0
- pyxle_db-0.2.0/CHANGELOG.md +83 -0
- pyxle_db-0.2.0/LICENSE +21 -0
- pyxle_db-0.2.0/PKG-INFO +265 -0
- pyxle_db-0.2.0/README.md +232 -0
- pyxle_db-0.2.0/pyproject.toml +57 -0
- pyxle_db-0.2.0/pyxle_db/__init__.py +95 -0
- pyxle_db-0.2.0/pyxle_db/backends/__init__.py +57 -0
- pyxle_db-0.2.0/pyxle_db/backends/base.py +172 -0
- pyxle_db-0.2.0/pyxle_db/backends/mysql.py +299 -0
- pyxle_db-0.2.0/pyxle_db/backends/postgresql.py +403 -0
- pyxle_db-0.2.0/pyxle_db/backends/sqlite.py +621 -0
- pyxle_db-0.2.0/pyxle_db/contract.py +66 -0
- pyxle_db-0.2.0/pyxle_db/database.py +299 -0
- pyxle_db-0.2.0/pyxle_db/errors.py +70 -0
- pyxle_db-0.2.0/pyxle_db/migrator.py +313 -0
- pyxle_db-0.2.0/pyxle_db/plugin.py +207 -0
- pyxle_db-0.2.0/pyxle_db/py.typed +0 -0
- pyxle_db-0.2.0/pyxle_db/rows.py +79 -0
- pyxle_db-0.2.0/pyxle_db/sql.py +298 -0
- pyxle_db-0.2.0/pyxle_db/url.py +124 -0
- pyxle_db-0.2.0/tests/__init__.py +0 -0
- pyxle_db-0.2.0/tests/conftest.py +29 -0
- pyxle_db-0.2.0/tests/test_backend_mysql.py +648 -0
- pyxle_db-0.2.0/tests/test_backend_postgresql.py +821 -0
- pyxle_db-0.2.0/tests/test_backend_sqlite.py +334 -0
- pyxle_db-0.2.0/tests/test_database.py +246 -0
- pyxle_db-0.2.0/tests/test_migrator.py +576 -0
- pyxle_db-0.2.0/tests/test_plugin.py +224 -0
- pyxle_db-0.2.0/tests/test_rows.py +157 -0
- pyxle_db-0.2.0/tests/test_sql.py +445 -0
- pyxle_db-0.2.0/tests/test_url.py +224 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Python bytecode
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
*.egg
|
|
11
|
+
.eggs/
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
venv/
|
|
15
|
+
.venv/
|
|
16
|
+
|
|
17
|
+
# Test / coverage
|
|
18
|
+
htmlcov/
|
|
19
|
+
.coverage
|
|
20
|
+
.coverage.*
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
coverage.xml
|
|
23
|
+
|
|
24
|
+
# Node
|
|
25
|
+
node_modules/
|
|
26
|
+
|
|
27
|
+
# IDE
|
|
28
|
+
.idea/
|
|
29
|
+
.vscode/
|
|
30
|
+
*.swp
|
|
31
|
+
|
|
32
|
+
# OS
|
|
33
|
+
.DS_Store
|
|
34
|
+
Thumbs.db
|
|
35
|
+
|
|
36
|
+
# Tooling caches
|
|
37
|
+
.ruff_cache/
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `pyxle-db` are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - 2026-06-11
|
|
9
|
+
|
|
10
|
+
### Changed (BREAKING)
|
|
11
|
+
|
|
12
|
+
- `Database.transaction()` now yields a transaction whose methods are
|
|
13
|
+
coroutines: `await tx.execute(...)`, `await tx.fetchone(...)`, etc.
|
|
14
|
+
In 0.1 these calls were synchronous inside the async context manager.
|
|
15
|
+
- `Database.close()` and `Database.sync_transaction()` are SQLite-only
|
|
16
|
+
and raise `UnsupportedOperationError` on server backends. Use
|
|
17
|
+
`await db.aclose()` and `async with db.transaction()` — both work on
|
|
18
|
+
every backend.
|
|
19
|
+
- Mapping (named) parameters are SQLite-only; portable SQL uses
|
|
20
|
+
positional `?` parameters.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- PostgreSQL backend via `asyncpg` (`pip install 'pyxle-db[postgres]'`)
|
|
25
|
+
and MySQL backend via `asyncmy` (`pip install 'pyxle-db[mysql]'`).
|
|
26
|
+
The base install remains SQLite-only with zero extra dependencies.
|
|
27
|
+
- Database URLs: `Database(...)`/`connect(...)` accept
|
|
28
|
+
`sqlite:///...`, `postgresql://...`, and `mysql://...` connection
|
|
29
|
+
strings alongside the 0.1 bare SQLite path. `DatabaseConfig` and
|
|
30
|
+
`parse_database_url` are exported for programmatic use.
|
|
31
|
+
- Portable placeholders: qmark (`?`) SQL is translated per backend
|
|
32
|
+
(`$1` for PostgreSQL, `%s` for MySQL) with a literal-aware rewriter;
|
|
33
|
+
`??` escapes a literal question mark for PostgreSQL JSON operators.
|
|
34
|
+
- Backend-neutral `Row` result type (index + name access) and `Dialect`
|
|
35
|
+
metadata, both exported from `pyxle_db`.
|
|
36
|
+
- New error types: `OperationalError` (retryable connection/timeout
|
|
37
|
+
family), `ConfigurationError`, and `UnsupportedOperationError`.
|
|
38
|
+
Driver exceptions are translated on every backend — application code
|
|
39
|
+
never handles `sqlite3`/`asyncpg`/`asyncmy` exceptions.
|
|
40
|
+
- Datetimes come back timezone-aware UTC on every backend; naive values
|
|
41
|
+
stored in the database are assumed UTC and tagged.
|
|
42
|
+
- Plugin: new `url` setting that takes precedence over `path`, with
|
|
43
|
+
`env:VAR_NAME` indirection so credentials stay out of the committed
|
|
44
|
+
`pyxle.config.json` (startup raises `ConfigurationError` when the
|
|
45
|
+
variable is unset). The plugin now also registers `db.url`, a
|
|
46
|
+
password-redacted connection string for logging.
|
|
47
|
+
- Migrations: backend-specific override files
|
|
48
|
+
(`0003-fulltext-index.postgresql.sql` next to
|
|
49
|
+
`0003-fulltext-index.sql`) for migrations that need per-dialect DDL.
|
|
50
|
+
|
|
51
|
+
- `DatabaseLike` / `TransactionLike` protocols (`pyxle_db.contract`,
|
|
52
|
+
exported at top level): the structural contract third-party database
|
|
53
|
+
layers implement to back plugins like pyxle-auth. `Database` is the
|
|
54
|
+
reference implementation; both protocols are `runtime_checkable` and
|
|
55
|
+
the conformance is regression-tested.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- Plugin shutdown awaits `Database.aclose()`, releasing async pools
|
|
60
|
+
cleanly instead of relying on the SQLite-only synchronous close.
|
|
61
|
+
- **Aware datetimes now bind on every backend** (found by the live-server
|
|
62
|
+
suites). PostgreSQL and MySQL pass parameters through
|
|
63
|
+
`utc_naive_params()`: an aware datetime is converted to UTC and bound
|
|
64
|
+
naive, matching the SQLite adapter and the read-side "naive equals
|
|
65
|
+
UTC" rule. Previously asyncpg rejected aware datetimes for `TIMESTAMP`
|
|
66
|
+
columns and asyncmy silently serialised the foreign wall clock.
|
|
67
|
+
- The MySQL pool pins each session to UTC (`SET time_zone = '+00:00'`),
|
|
68
|
+
so `TIMESTAMP` columns and `NOW()` are no longer shifted through the
|
|
69
|
+
server's system time zone on read.
|
|
70
|
+
- The `mysql` extra now includes `cryptography`: MySQL 8's default
|
|
71
|
+
`caching_sha2_password` auth fails at connect without it.
|
|
72
|
+
- Dependency floor corrected to `pyxle-framework>=0.4.0` — the
|
|
73
|
+
`pyxle.plugins` API first shipped in 0.4.0, so older resolutions
|
|
74
|
+
could not import.
|
|
75
|
+
|
|
76
|
+
## [0.1.0] - 2026-04-23
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- Initial release: SQLite `Database` wrapper (thread-local connections,
|
|
81
|
+
WAL journaling, foreign-key and performance PRAGMAs), filesystem
|
|
82
|
+
`Migrator` with SHA-256 checksum tracking, `connect()` one-call setup,
|
|
83
|
+
and the `pyxle-db` plugin registering `db.database`/`db.path`.
|
pyxle_db-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pyxle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pyxle_db-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyxle-db
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Database plugin for Pyxle: SQLite, PostgreSQL, and MySQL with one explicit-SQL API, portable qmark placeholders, and checksum-tracked migrations.
|
|
5
|
+
Project-URL: Homepage, https://pyxle.dev
|
|
6
|
+
Project-URL: Source, https://github.com/pyxle-dev/pyxle-plugins
|
|
7
|
+
Project-URL: Changelog, https://github.com/pyxle-dev/pyxle-plugins/blob/main/packages/pyxle-db/CHANGELOG.md
|
|
8
|
+
Author-email: Pyxle <dev@pyxle.dev>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: database,migrations,mysql,postgresql,pyxle,sqlite
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Database
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: pyxle-framework>=0.4.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Provides-Extra: mysql
|
|
28
|
+
Requires-Dist: asyncmy>=0.2.9; extra == 'mysql'
|
|
29
|
+
Requires-Dist: cryptography>=42; extra == 'mysql'
|
|
30
|
+
Provides-Extra: postgres
|
|
31
|
+
Requires-Dist: asyncpg>=0.29; extra == 'postgres'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# pyxle-db
|
|
35
|
+
|
|
36
|
+
The official database plugin for [Pyxle](https://pyxle.dev). One explicit-SQL
|
|
37
|
+
API over **SQLite**, **PostgreSQL**, and **MySQL** — no ORM, no query builder,
|
|
38
|
+
Django-grade ergonomics for people who like writing SQL.
|
|
39
|
+
|
|
40
|
+
- Write SQL once, in portable qmark style (`?` placeholders); pyxle-db
|
|
41
|
+
translates per backend with a literal-aware rewriter.
|
|
42
|
+
- Every backend returns the same `Row` type, raises the same
|
|
43
|
+
`pyxle_db` error types, and hands back timezone-aware UTC datetimes —
|
|
44
|
+
application code never imports `sqlite3`, `asyncpg`, or `asyncmy`.
|
|
45
|
+
- Filesystem migrations with SHA-256 checksum tracking, applied atomically.
|
|
46
|
+
- First-class Pyxle plugin: one entry in `pyxle.config.json` and your app
|
|
47
|
+
has an open database at startup.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install pyxle-db # SQLite — zero extra dependencies
|
|
53
|
+
pip install 'pyxle-db[postgres]' # + asyncpg
|
|
54
|
+
pip install 'pyxle-db[mysql]' # + asyncmy
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quickstart
|
|
58
|
+
|
|
59
|
+
Same code on every backend — only the connection string changes.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from pyxle_db import connect
|
|
63
|
+
|
|
64
|
+
# SQLite (a bare path, exactly like 0.1)
|
|
65
|
+
db = await connect("./data/app.db", migrations_dir="migrations")
|
|
66
|
+
|
|
67
|
+
# PostgreSQL
|
|
68
|
+
db = await connect("postgresql://app:s3cret@localhost/app")
|
|
69
|
+
|
|
70
|
+
# MySQL
|
|
71
|
+
db = await connect("mysql://app:s3cret@localhost/app")
|
|
72
|
+
|
|
73
|
+
await db.execute(
|
|
74
|
+
"INSERT INTO users (id, email) VALUES (?, ?)", (user_id, email)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
row = await db.fetchone("SELECT email, created_at FROM users WHERE id = ?", (user_id,))
|
|
78
|
+
row["email"] # name access
|
|
79
|
+
row[0] # index access
|
|
80
|
+
row["created_at"] # timezone-aware UTC datetime, on every backend
|
|
81
|
+
|
|
82
|
+
user = await db.get("SELECT * FROM users WHERE id = ?", (user_id,)) # raises NotFoundError if absent
|
|
83
|
+
|
|
84
|
+
await db.aclose()
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Constraint violations raise `pyxle_db.IntegrityError`; unreachable/timed-out
|
|
88
|
+
databases raise `pyxle_db.OperationalError` (the retryable family); everything
|
|
89
|
+
else is a `pyxle_db.DatabaseError` subclass. Driver exceptions never leak.
|
|
90
|
+
|
|
91
|
+
## Database URLs
|
|
92
|
+
|
|
93
|
+
`Database(...)`, `connect(...)`, and the plugin's `url` setting all accept:
|
|
94
|
+
|
|
95
|
+
| Form | Opens |
|
|
96
|
+
|------|-------|
|
|
97
|
+
| `./data/app.db` (no scheme) | SQLite at that path — 0.1-compatible |
|
|
98
|
+
| `sqlite:///relative/path.db` | SQLite, path relative to the working directory |
|
|
99
|
+
| `sqlite:////absolute/path.db` | SQLite, absolute path |
|
|
100
|
+
| `sqlite:///:memory:` | SQLite, in-memory |
|
|
101
|
+
| `postgresql://user:pass@host:5432/dbname?sslmode=require` | PostgreSQL (`postgres://` also accepted) |
|
|
102
|
+
| `mysql://user:pass@host:3306/dbname` | MySQL (`mariadb://` also accepted) |
|
|
103
|
+
|
|
104
|
+
Ports default to 5432/3306. Query-string options (e.g. `sslmode`) pass
|
|
105
|
+
through to the driver. Credentials are percent-decoded, so encode special
|
|
106
|
+
characters (`p@ss` → `p%40ss`).
|
|
107
|
+
|
|
108
|
+
## Placeholders
|
|
109
|
+
|
|
110
|
+
Always write `?`. pyxle-db rewrites it to the backend's native style —
|
|
111
|
+
`?` for SQLite, `$1`/`$2` for PostgreSQL, `%s` for MySQL.
|
|
112
|
+
|
|
113
|
+
When you need a *literal* question mark — PostgreSQL's JSON operators —
|
|
114
|
+
escape it as `??`:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
await db.fetchall(
|
|
118
|
+
"SELECT id FROM events WHERE payload ?? 'user_id' AND kind = ?",
|
|
119
|
+
("signup",),
|
|
120
|
+
)
|
|
121
|
+
# asyncpg receives: SELECT id FROM events WHERE payload ? 'user_id' AND kind = $1
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The rewriter is literal-aware: `?` inside string literals, quoted
|
|
125
|
+
identifiers, comments, and dollar-quoted bodies is never touched, so user
|
|
126
|
+
data can't become SQL structure during translation.
|
|
127
|
+
|
|
128
|
+
## Writing portable schemas
|
|
129
|
+
|
|
130
|
+
Rules proven against real PostgreSQL and MySQL servers (the live suites
|
|
131
|
+
in `tests/` enforce them):
|
|
132
|
+
|
|
133
|
+
- **`VARCHAR(n)` for every key or indexed column.** MySQL cannot index
|
|
134
|
+
bare `TEXT` (error 1170). SQLite and PostgreSQL treat `VARCHAR` as
|
|
135
|
+
`TEXT`, so nothing is lost. Keep `TEXT` for payloads.
|
|
136
|
+
- **Datetimes: bind anything, read aware UTC.** Columns store UTC wall
|
|
137
|
+
time. You may bind naive datetimes (assumed UTC) or aware ones (the
|
|
138
|
+
backend converts to UTC before binding); every read comes back as an
|
|
139
|
+
aware-UTC `datetime` on all three engines.
|
|
140
|
+
- **`TIMESTAMP` columns are fine on SQLite and PostgreSQL.** On MySQL
|
|
141
|
+
prefer `DATETIME(6)` via a per-dialect migration override
|
|
142
|
+
(`0002-x.mysql.sql`): MySQL's `TIMESTAMP` is capped at 2038 and rounds
|
|
143
|
+
to whole seconds. (The backend pins each session to UTC, so even
|
|
144
|
+
`TIMESTAMP` columns read back correct instants.)
|
|
145
|
+
- **MySQL has no `CREATE INDEX IF NOT EXISTS`.** Create indexes in
|
|
146
|
+
migrations (they run exactly once, checksum-tracked) or probe
|
|
147
|
+
`information_schema.statistics` before creating.
|
|
148
|
+
- **Spell out inserted values instead of relying on column `DEFAULT`s**,
|
|
149
|
+
which drift subtly between engines.
|
|
150
|
+
|
|
151
|
+
## Transactions
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
async with db.transaction() as tx:
|
|
155
|
+
await tx.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, src))
|
|
156
|
+
await tx.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, dst))
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Commits on exit, rolls back on exception. Concurrent transactions never
|
|
160
|
+
interleave statements on the same connection.
|
|
161
|
+
|
|
162
|
+
**SQLite only:** synchronous scripts and tests can use
|
|
163
|
+
`with db.sync_transaction() as tx: ...` and `db.close()`. Server backends
|
|
164
|
+
hold async pools, so both raise `UnsupportedOperationError` there — use
|
|
165
|
+
`async with db.transaction()` and `await db.aclose()`.
|
|
166
|
+
|
|
167
|
+
## Migrations
|
|
168
|
+
|
|
169
|
+
Each migration is a `.sql` file named `<NNNN>-<slug>.sql` in your
|
|
170
|
+
migrations directory:
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
migrations/
|
|
174
|
+
0001-initial-schema.sql
|
|
175
|
+
0002-add-sessions.sql
|
|
176
|
+
0003-fulltext-index.sql
|
|
177
|
+
0003-fulltext-index.postgresql.sql # backend-specific override
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
- The numeric prefix is the canonical order; two files may not share one.
|
|
181
|
+
- A dialect-suffixed file (`.sqlite.sql`, `.postgresql.sql`, `.mysql.sql`)
|
|
182
|
+
replaces the base file on that backend — for the rare migration that
|
|
183
|
+
genuinely needs backend-specific DDL.
|
|
184
|
+
- Each migration runs in its own transaction and is applied exactly once
|
|
185
|
+
per database, recorded in `schema_migrations` with the file's SHA-256.
|
|
186
|
+
- **Checksum policy:** every startup re-hashes applied migrations. An
|
|
187
|
+
edited file raises `MigrationChecksumMismatch`; a deleted one raises
|
|
188
|
+
`MigrationError`. Once applied anywhere, a migration is immutable —
|
|
189
|
+
write a follow-up migration instead.
|
|
190
|
+
|
|
191
|
+
Apply on startup via `connect(..., migrations_dir="migrations")`, the
|
|
192
|
+
plugin's `migrationsDir` setting, or explicitly:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from pathlib import Path
|
|
196
|
+
from pyxle_db import Migrator
|
|
197
|
+
|
|
198
|
+
await Migrator(db, Path("migrations")).apply_all()
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Using it as a Pyxle plugin
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"plugins": [
|
|
206
|
+
{
|
|
207
|
+
"name": "pyxle-db",
|
|
208
|
+
"settings": {
|
|
209
|
+
"url": "env:DATABASE_URL",
|
|
210
|
+
"migrationsDir": "migrations"
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Settings (all optional):
|
|
218
|
+
|
|
219
|
+
| Key | Meaning | Default |
|
|
220
|
+
|-----|---------|---------|
|
|
221
|
+
| `url` | Database URL; wins over `path`. The `env:VAR` form resolves the named environment variable at startup — keep secrets out of the committed config. Startup fails with `ConfigurationError` if the variable is unset. | — |
|
|
222
|
+
| `path` | SQLite path, resolved against the project root | `./data/app.db` |
|
|
223
|
+
| `migrationsDir` | Migrations directory, applied at startup if it exists | `migrations` |
|
|
224
|
+
| `waitForFileMs` | Poll this long for a SQLite file being created by another process | `0` |
|
|
225
|
+
|
|
226
|
+
Registered services: `db.database` (the open `Database`), `db.url`
|
|
227
|
+
(password-redacted connection string for logging), and — SQLite only —
|
|
228
|
+
`db.path` (the resolved file path, kept for 0.1 consumers).
|
|
229
|
+
|
|
230
|
+
In loaders and actions, skip the service registry boilerplate:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from pyxle_db import get_database
|
|
234
|
+
|
|
235
|
+
@server
|
|
236
|
+
async def load(request):
|
|
237
|
+
db = get_database()
|
|
238
|
+
return {"posts": await db.fetchall("SELECT * FROM posts ORDER BY id DESC")}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Upgrading from 0.1
|
|
242
|
+
|
|
243
|
+
> **Breaking changes in 0.2**
|
|
244
|
+
>
|
|
245
|
+
> - Transaction methods are now coroutines. `async with db.transaction()
|
|
246
|
+
> as tx:` then `await tx.execute(...)` / `await tx.fetchone(...)` —
|
|
247
|
+
> 0.1's sync calls inside the async block no longer work.
|
|
248
|
+
> - `db.close()` and `db.sync_transaction()` are now SQLite-only and raise
|
|
249
|
+
> `UnsupportedOperationError` on PostgreSQL/MySQL. Prefer
|
|
250
|
+
> `await db.aclose()` everywhere — it works on every backend.
|
|
251
|
+
> - Mapping (named) parameters are SQLite-only; portable SQL uses
|
|
252
|
+
> positional `?` parameters.
|
|
253
|
+
>
|
|
254
|
+
> Everything else is additive: bare SQLite paths, the migration format,
|
|
255
|
+
> and the plugin settings from 0.1 keep working unchanged.
|
|
256
|
+
|
|
257
|
+
## Roadmap
|
|
258
|
+
|
|
259
|
+
Short list, subject to change: pool-size tuning options for server
|
|
260
|
+
backends, streaming fetches for large result sets, and `pyxle db` CLI
|
|
261
|
+
commands for migration status/creation. Issues and PRs welcome.
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT.
|
pyxle_db-0.2.0/README.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# pyxle-db
|
|
2
|
+
|
|
3
|
+
The official database plugin for [Pyxle](https://pyxle.dev). One explicit-SQL
|
|
4
|
+
API over **SQLite**, **PostgreSQL**, and **MySQL** — no ORM, no query builder,
|
|
5
|
+
Django-grade ergonomics for people who like writing SQL.
|
|
6
|
+
|
|
7
|
+
- Write SQL once, in portable qmark style (`?` placeholders); pyxle-db
|
|
8
|
+
translates per backend with a literal-aware rewriter.
|
|
9
|
+
- Every backend returns the same `Row` type, raises the same
|
|
10
|
+
`pyxle_db` error types, and hands back timezone-aware UTC datetimes —
|
|
11
|
+
application code never imports `sqlite3`, `asyncpg`, or `asyncmy`.
|
|
12
|
+
- Filesystem migrations with SHA-256 checksum tracking, applied atomically.
|
|
13
|
+
- First-class Pyxle plugin: one entry in `pyxle.config.json` and your app
|
|
14
|
+
has an open database at startup.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install pyxle-db # SQLite — zero extra dependencies
|
|
20
|
+
pip install 'pyxle-db[postgres]' # + asyncpg
|
|
21
|
+
pip install 'pyxle-db[mysql]' # + asyncmy
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
Same code on every backend — only the connection string changes.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from pyxle_db import connect
|
|
30
|
+
|
|
31
|
+
# SQLite (a bare path, exactly like 0.1)
|
|
32
|
+
db = await connect("./data/app.db", migrations_dir="migrations")
|
|
33
|
+
|
|
34
|
+
# PostgreSQL
|
|
35
|
+
db = await connect("postgresql://app:s3cret@localhost/app")
|
|
36
|
+
|
|
37
|
+
# MySQL
|
|
38
|
+
db = await connect("mysql://app:s3cret@localhost/app")
|
|
39
|
+
|
|
40
|
+
await db.execute(
|
|
41
|
+
"INSERT INTO users (id, email) VALUES (?, ?)", (user_id, email)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
row = await db.fetchone("SELECT email, created_at FROM users WHERE id = ?", (user_id,))
|
|
45
|
+
row["email"] # name access
|
|
46
|
+
row[0] # index access
|
|
47
|
+
row["created_at"] # timezone-aware UTC datetime, on every backend
|
|
48
|
+
|
|
49
|
+
user = await db.get("SELECT * FROM users WHERE id = ?", (user_id,)) # raises NotFoundError if absent
|
|
50
|
+
|
|
51
|
+
await db.aclose()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Constraint violations raise `pyxle_db.IntegrityError`; unreachable/timed-out
|
|
55
|
+
databases raise `pyxle_db.OperationalError` (the retryable family); everything
|
|
56
|
+
else is a `pyxle_db.DatabaseError` subclass. Driver exceptions never leak.
|
|
57
|
+
|
|
58
|
+
## Database URLs
|
|
59
|
+
|
|
60
|
+
`Database(...)`, `connect(...)`, and the plugin's `url` setting all accept:
|
|
61
|
+
|
|
62
|
+
| Form | Opens |
|
|
63
|
+
|------|-------|
|
|
64
|
+
| `./data/app.db` (no scheme) | SQLite at that path — 0.1-compatible |
|
|
65
|
+
| `sqlite:///relative/path.db` | SQLite, path relative to the working directory |
|
|
66
|
+
| `sqlite:////absolute/path.db` | SQLite, absolute path |
|
|
67
|
+
| `sqlite:///:memory:` | SQLite, in-memory |
|
|
68
|
+
| `postgresql://user:pass@host:5432/dbname?sslmode=require` | PostgreSQL (`postgres://` also accepted) |
|
|
69
|
+
| `mysql://user:pass@host:3306/dbname` | MySQL (`mariadb://` also accepted) |
|
|
70
|
+
|
|
71
|
+
Ports default to 5432/3306. Query-string options (e.g. `sslmode`) pass
|
|
72
|
+
through to the driver. Credentials are percent-decoded, so encode special
|
|
73
|
+
characters (`p@ss` → `p%40ss`).
|
|
74
|
+
|
|
75
|
+
## Placeholders
|
|
76
|
+
|
|
77
|
+
Always write `?`. pyxle-db rewrites it to the backend's native style —
|
|
78
|
+
`?` for SQLite, `$1`/`$2` for PostgreSQL, `%s` for MySQL.
|
|
79
|
+
|
|
80
|
+
When you need a *literal* question mark — PostgreSQL's JSON operators —
|
|
81
|
+
escape it as `??`:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
await db.fetchall(
|
|
85
|
+
"SELECT id FROM events WHERE payload ?? 'user_id' AND kind = ?",
|
|
86
|
+
("signup",),
|
|
87
|
+
)
|
|
88
|
+
# asyncpg receives: SELECT id FROM events WHERE payload ? 'user_id' AND kind = $1
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The rewriter is literal-aware: `?` inside string literals, quoted
|
|
92
|
+
identifiers, comments, and dollar-quoted bodies is never touched, so user
|
|
93
|
+
data can't become SQL structure during translation.
|
|
94
|
+
|
|
95
|
+
## Writing portable schemas
|
|
96
|
+
|
|
97
|
+
Rules proven against real PostgreSQL and MySQL servers (the live suites
|
|
98
|
+
in `tests/` enforce them):
|
|
99
|
+
|
|
100
|
+
- **`VARCHAR(n)` for every key or indexed column.** MySQL cannot index
|
|
101
|
+
bare `TEXT` (error 1170). SQLite and PostgreSQL treat `VARCHAR` as
|
|
102
|
+
`TEXT`, so nothing is lost. Keep `TEXT` for payloads.
|
|
103
|
+
- **Datetimes: bind anything, read aware UTC.** Columns store UTC wall
|
|
104
|
+
time. You may bind naive datetimes (assumed UTC) or aware ones (the
|
|
105
|
+
backend converts to UTC before binding); every read comes back as an
|
|
106
|
+
aware-UTC `datetime` on all three engines.
|
|
107
|
+
- **`TIMESTAMP` columns are fine on SQLite and PostgreSQL.** On MySQL
|
|
108
|
+
prefer `DATETIME(6)` via a per-dialect migration override
|
|
109
|
+
(`0002-x.mysql.sql`): MySQL's `TIMESTAMP` is capped at 2038 and rounds
|
|
110
|
+
to whole seconds. (The backend pins each session to UTC, so even
|
|
111
|
+
`TIMESTAMP` columns read back correct instants.)
|
|
112
|
+
- **MySQL has no `CREATE INDEX IF NOT EXISTS`.** Create indexes in
|
|
113
|
+
migrations (they run exactly once, checksum-tracked) or probe
|
|
114
|
+
`information_schema.statistics` before creating.
|
|
115
|
+
- **Spell out inserted values instead of relying on column `DEFAULT`s**,
|
|
116
|
+
which drift subtly between engines.
|
|
117
|
+
|
|
118
|
+
## Transactions
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
async with db.transaction() as tx:
|
|
122
|
+
await tx.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (amount, src))
|
|
123
|
+
await tx.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, dst))
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Commits on exit, rolls back on exception. Concurrent transactions never
|
|
127
|
+
interleave statements on the same connection.
|
|
128
|
+
|
|
129
|
+
**SQLite only:** synchronous scripts and tests can use
|
|
130
|
+
`with db.sync_transaction() as tx: ...` and `db.close()`. Server backends
|
|
131
|
+
hold async pools, so both raise `UnsupportedOperationError` there — use
|
|
132
|
+
`async with db.transaction()` and `await db.aclose()`.
|
|
133
|
+
|
|
134
|
+
## Migrations
|
|
135
|
+
|
|
136
|
+
Each migration is a `.sql` file named `<NNNN>-<slug>.sql` in your
|
|
137
|
+
migrations directory:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
migrations/
|
|
141
|
+
0001-initial-schema.sql
|
|
142
|
+
0002-add-sessions.sql
|
|
143
|
+
0003-fulltext-index.sql
|
|
144
|
+
0003-fulltext-index.postgresql.sql # backend-specific override
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- The numeric prefix is the canonical order; two files may not share one.
|
|
148
|
+
- A dialect-suffixed file (`.sqlite.sql`, `.postgresql.sql`, `.mysql.sql`)
|
|
149
|
+
replaces the base file on that backend — for the rare migration that
|
|
150
|
+
genuinely needs backend-specific DDL.
|
|
151
|
+
- Each migration runs in its own transaction and is applied exactly once
|
|
152
|
+
per database, recorded in `schema_migrations` with the file's SHA-256.
|
|
153
|
+
- **Checksum policy:** every startup re-hashes applied migrations. An
|
|
154
|
+
edited file raises `MigrationChecksumMismatch`; a deleted one raises
|
|
155
|
+
`MigrationError`. Once applied anywhere, a migration is immutable —
|
|
156
|
+
write a follow-up migration instead.
|
|
157
|
+
|
|
158
|
+
Apply on startup via `connect(..., migrations_dir="migrations")`, the
|
|
159
|
+
plugin's `migrationsDir` setting, or explicitly:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from pathlib import Path
|
|
163
|
+
from pyxle_db import Migrator
|
|
164
|
+
|
|
165
|
+
await Migrator(db, Path("migrations")).apply_all()
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Using it as a Pyxle plugin
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"plugins": [
|
|
173
|
+
{
|
|
174
|
+
"name": "pyxle-db",
|
|
175
|
+
"settings": {
|
|
176
|
+
"url": "env:DATABASE_URL",
|
|
177
|
+
"migrationsDir": "migrations"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Settings (all optional):
|
|
185
|
+
|
|
186
|
+
| Key | Meaning | Default |
|
|
187
|
+
|-----|---------|---------|
|
|
188
|
+
| `url` | Database URL; wins over `path`. The `env:VAR` form resolves the named environment variable at startup — keep secrets out of the committed config. Startup fails with `ConfigurationError` if the variable is unset. | — |
|
|
189
|
+
| `path` | SQLite path, resolved against the project root | `./data/app.db` |
|
|
190
|
+
| `migrationsDir` | Migrations directory, applied at startup if it exists | `migrations` |
|
|
191
|
+
| `waitForFileMs` | Poll this long for a SQLite file being created by another process | `0` |
|
|
192
|
+
|
|
193
|
+
Registered services: `db.database` (the open `Database`), `db.url`
|
|
194
|
+
(password-redacted connection string for logging), and — SQLite only —
|
|
195
|
+
`db.path` (the resolved file path, kept for 0.1 consumers).
|
|
196
|
+
|
|
197
|
+
In loaders and actions, skip the service registry boilerplate:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from pyxle_db import get_database
|
|
201
|
+
|
|
202
|
+
@server
|
|
203
|
+
async def load(request):
|
|
204
|
+
db = get_database()
|
|
205
|
+
return {"posts": await db.fetchall("SELECT * FROM posts ORDER BY id DESC")}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Upgrading from 0.1
|
|
209
|
+
|
|
210
|
+
> **Breaking changes in 0.2**
|
|
211
|
+
>
|
|
212
|
+
> - Transaction methods are now coroutines. `async with db.transaction()
|
|
213
|
+
> as tx:` then `await tx.execute(...)` / `await tx.fetchone(...)` —
|
|
214
|
+
> 0.1's sync calls inside the async block no longer work.
|
|
215
|
+
> - `db.close()` and `db.sync_transaction()` are now SQLite-only and raise
|
|
216
|
+
> `UnsupportedOperationError` on PostgreSQL/MySQL. Prefer
|
|
217
|
+
> `await db.aclose()` everywhere — it works on every backend.
|
|
218
|
+
> - Mapping (named) parameters are SQLite-only; portable SQL uses
|
|
219
|
+
> positional `?` parameters.
|
|
220
|
+
>
|
|
221
|
+
> Everything else is additive: bare SQLite paths, the migration format,
|
|
222
|
+
> and the plugin settings from 0.1 keep working unchanged.
|
|
223
|
+
|
|
224
|
+
## Roadmap
|
|
225
|
+
|
|
226
|
+
Short list, subject to change: pool-size tuning options for server
|
|
227
|
+
backends, streaming fetches for large result sets, and `pyxle db` CLI
|
|
228
|
+
commands for migration status/creation. Issues and PRs welcome.
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
MIT.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25,<2.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyxle-db"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Database plugin for Pyxle: SQLite, PostgreSQL, and MySQL with one explicit-SQL API, portable qmark placeholders, and checksum-tracked migrations."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Pyxle", email = "dev@pyxle.dev" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["pyxle", "sqlite", "postgresql", "mysql", "database", "migrations"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Framework :: AsyncIO",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Database",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"pyxle-framework>=0.4.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
postgres = [
|
|
34
|
+
"asyncpg>=0.29",
|
|
35
|
+
]
|
|
36
|
+
mysql = [
|
|
37
|
+
"asyncmy>=0.2.9",
|
|
38
|
+
# MySQL 8's default caching_sha2_password auth needs this at connect time;
|
|
39
|
+
# without it every modern server greets users with a RuntimeError.
|
|
40
|
+
"cryptography>=42",
|
|
41
|
+
]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8.0",
|
|
44
|
+
"pytest-asyncio>=0.23",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://pyxle.dev"
|
|
49
|
+
Source = "https://github.com/pyxle-dev/pyxle-plugins"
|
|
50
|
+
Changelog = "https://github.com/pyxle-dev/pyxle-plugins/blob/main/packages/pyxle-db/CHANGELOG.md"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["pyxle_db"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
asyncio_mode = "auto"
|
|
57
|
+
testpaths = ["tests"]
|