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.
@@ -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.
@@ -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.
@@ -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"]