s3rds 0.1.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.
- s3rds-0.1.0/PKG-INFO +75 -0
- s3rds-0.1.0/README.md +56 -0
- s3rds-0.1.0/pyproject.toml +31 -0
- s3rds-0.1.0/s3rds/__init__.py +17 -0
- s3rds-0.1.0/s3rds/psycopg2.py +22 -0
- s3rds-0.1.0/s3rds/pymysql.py +387 -0
- s3rds-0.1.0/s3rds/sqlite.py +325 -0
- s3rds-0.1.0/s3rds.egg-info/PKG-INFO +75 -0
- s3rds-0.1.0/s3rds.egg-info/SOURCES.txt +11 -0
- s3rds-0.1.0/s3rds.egg-info/dependency_links.txt +1 -0
- s3rds-0.1.0/s3rds.egg-info/requires.txt +3 -0
- s3rds-0.1.0/s3rds.egg-info/top_level.txt +1 -0
- s3rds-0.1.0/setup.cfg +4 -0
s3rds-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: s3rds
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A truly serverless RDS on your own AWS account: Lambda URL endpoint, SQLite on S3, ≈ $0 when idle. Drivers named after the standard clients: s3rds.sqlite, s3rds.pymysql.
|
|
5
|
+
Author: Frank Zhang
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/cloudformer/s3rds
|
|
8
|
+
Keywords: serverless,rds,sqlite,s3,lambda,database,pymysql,dbapi
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Database
|
|
14
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Provides-Extra: mysql
|
|
18
|
+
Requires-Dist: sqlglot>=25.0; extra == "mysql"
|
|
19
|
+
|
|
20
|
+
# s3rds
|
|
21
|
+
|
|
22
|
+
**Python drivers for [s3rds](https://github.com/cloudformer/s3rds) — a truly serverless RDS:
|
|
23
|
+
a Lambda URL as your database endpoint, SQLite on S3, ≈ $0 when idle.**
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
Zero dependencies (standard library only). Each driver is a submodule **named after the standard
|
|
28
|
+
client it mirrors**, so moving to a real database is a one-line import change and nothing else:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install s3rds # s3rds.sqlite: SQLite dialect
|
|
32
|
+
pip install "s3rds[mysql]" # + s3rds.pymysql: write MySQL, translated on the client (sqlglot)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from s3rds import pymysql # today -- later: `import pymysql` on a real RDS, rest unchanged
|
|
37
|
+
|
|
38
|
+
conn = pymysql.connect(
|
|
39
|
+
host="xxxx.lambda-url.us-east-2.on.aws",
|
|
40
|
+
user="admin", password="...", database="blog")
|
|
41
|
+
|
|
42
|
+
with conn.cursor() as cur:
|
|
43
|
+
cur.execute("CREATE TABLE posts (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200))")
|
|
44
|
+
cur.execute("INSERT INTO posts (title) VALUES (%s)", ["hello"])
|
|
45
|
+
print(cur.lastrowid)
|
|
46
|
+
|
|
47
|
+
with conn.cursor(pymysql.cursors.DictCursor) as cur:
|
|
48
|
+
cur.execute("SELECT * FROM posts WHERE id = %s", [1])
|
|
49
|
+
print(cur.fetchall()) # [{'id': 1, 'title': 'hello'}]
|
|
50
|
+
|
|
51
|
+
conn.transaction([ # all-or-nothing, single S3 write
|
|
52
|
+
("UPDATE stock SET qty = qty - 1 WHERE id = %s", [7]),
|
|
53
|
+
("INSERT INTO orders (item) VALUES (%s)", [7]),
|
|
54
|
+
])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Submodule | SQL dialect | Mirrors | Status |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API | available |
|
|
60
|
+
| `s3rds.pymysql` | MySQL, translated on the client | pymysql | available — `pip install "s3rds[mysql]"` |
|
|
61
|
+
| `s3rds.psycopg2` | PostgreSQL | psycopg2 | placeholder; `connect()` raises until implemented |
|
|
62
|
+
|
|
63
|
+
## Semantics
|
|
64
|
+
|
|
65
|
+
- Each `execute()` is its own transaction and is durable in S3 when it returns (`autocommit=True`).
|
|
66
|
+
Use `transaction()` for multi-statement atomicity.
|
|
67
|
+
- Writes **never** duplicate: the driver re-sends only when the request provably never reached the
|
|
68
|
+
server (stale keep-alive connection). Timeouts raise an error with an explicit "result unknown".
|
|
69
|
+
- `s3rds.pymysql` raises pymysql-style exceptions with MySQL error codes (1062, 1146, 1054, 1064, …);
|
|
70
|
+
unsupported MySQL features raise `NotSupportedError` instead of misbehaving silently.
|
|
71
|
+
|
|
72
|
+
## Server
|
|
73
|
+
|
|
74
|
+
You need an s3rds server in your own AWS account — deploy it once with Terraform or CloudFormation
|
|
75
|
+
from the [main repository](https://github.com/cloudformer/s3rds). Same as any driver needs its database.
|
s3rds-0.1.0/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# s3rds
|
|
2
|
+
|
|
3
|
+
**Python drivers for [s3rds](https://github.com/cloudformer/s3rds) — a truly serverless RDS:
|
|
4
|
+
a Lambda URL as your database endpoint, SQLite on S3, ≈ $0 when idle.**
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
Zero dependencies (standard library only). Each driver is a submodule **named after the standard
|
|
9
|
+
client it mirrors**, so moving to a real database is a one-line import change and nothing else:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install s3rds # s3rds.sqlite: SQLite dialect
|
|
13
|
+
pip install "s3rds[mysql]" # + s3rds.pymysql: write MySQL, translated on the client (sqlglot)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from s3rds import pymysql # today -- later: `import pymysql` on a real RDS, rest unchanged
|
|
18
|
+
|
|
19
|
+
conn = pymysql.connect(
|
|
20
|
+
host="xxxx.lambda-url.us-east-2.on.aws",
|
|
21
|
+
user="admin", password="...", database="blog")
|
|
22
|
+
|
|
23
|
+
with conn.cursor() as cur:
|
|
24
|
+
cur.execute("CREATE TABLE posts (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200))")
|
|
25
|
+
cur.execute("INSERT INTO posts (title) VALUES (%s)", ["hello"])
|
|
26
|
+
print(cur.lastrowid)
|
|
27
|
+
|
|
28
|
+
with conn.cursor(pymysql.cursors.DictCursor) as cur:
|
|
29
|
+
cur.execute("SELECT * FROM posts WHERE id = %s", [1])
|
|
30
|
+
print(cur.fetchall()) # [{'id': 1, 'title': 'hello'}]
|
|
31
|
+
|
|
32
|
+
conn.transaction([ # all-or-nothing, single S3 write
|
|
33
|
+
("UPDATE stock SET qty = qty - 1 WHERE id = %s", [7]),
|
|
34
|
+
("INSERT INTO orders (item) VALUES (%s)", [7]),
|
|
35
|
+
])
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| Submodule | SQL dialect | Mirrors | Status |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API | available |
|
|
41
|
+
| `s3rds.pymysql` | MySQL, translated on the client | pymysql | available — `pip install "s3rds[mysql]"` |
|
|
42
|
+
| `s3rds.psycopg2` | PostgreSQL | psycopg2 | placeholder; `connect()` raises until implemented |
|
|
43
|
+
|
|
44
|
+
## Semantics
|
|
45
|
+
|
|
46
|
+
- Each `execute()` is its own transaction and is durable in S3 when it returns (`autocommit=True`).
|
|
47
|
+
Use `transaction()` for multi-statement atomicity.
|
|
48
|
+
- Writes **never** duplicate: the driver re-sends only when the request provably never reached the
|
|
49
|
+
server (stale keep-alive connection). Timeouts raise an error with an explicit "result unknown".
|
|
50
|
+
- `s3rds.pymysql` raises pymysql-style exceptions with MySQL error codes (1062, 1146, 1054, 1064, …);
|
|
51
|
+
unsupported MySQL features raise `NotSupportedError` instead of misbehaving silently.
|
|
52
|
+
|
|
53
|
+
## Server
|
|
54
|
+
|
|
55
|
+
You need an s3rds server in your own AWS account — deploy it once with Terraform or CloudFormation
|
|
56
|
+
from the [main repository](https://github.com/cloudformer/s3rds). Same as any driver needs its database.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "s3rds"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A truly serverless RDS on your own AWS account: Lambda URL endpoint, SQLite on S3, ≈ $0 when idle. Drivers named after the standard clients: s3rds.sqlite, s3rds.pymysql."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Frank Zhang" }]
|
|
13
|
+
keywords = ["serverless", "rds", "sqlite", "s3", "lambda", "database", "pymysql", "dbapi"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Database",
|
|
20
|
+
"Topic :: Database :: Front-Ends",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [] # standard library only
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
mysql = ["sqlglot>=25.0"] # s3rds.pymysql: MySQL -> SQLite dialect translation
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/cloudformer/s3rds"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
packages = ["s3rds"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""s3rds — a serverless RDS on your own AWS account (Lambda URL endpoint, SQLite on S3).
|
|
2
|
+
|
|
3
|
+
Each driver is a submodule named after the standard client it mirrors, so the day you move to a
|
|
4
|
+
real database you change one import line and nothing else:
|
|
5
|
+
|
|
6
|
+
from s3rds import pymysql # today: MySQL dialect on the s3rds endpoint
|
|
7
|
+
import pymysql # later: a real RDS MySQL -- pymysql.connect(...), pymysql.cursors, errors all unchanged
|
|
8
|
+
|
|
9
|
+
from s3rds import sqlite # SQLite dialect, no translation, zero dependencies
|
|
10
|
+
from s3rds import psycopg2 # PostgreSQL dialect: placeholder, connect() raises until implemented
|
|
11
|
+
"""
|
|
12
|
+
from . import sqlite # noqa: F401
|
|
13
|
+
from . import pymysql # noqa: F401 (sqlglot is only needed when a MySQL statement is actually translated)
|
|
14
|
+
from . import psycopg2 # noqa: F401 (placeholder: connect() raises NotSupportedError until implemented)
|
|
15
|
+
|
|
16
|
+
__all__ = ["sqlite", "pymysql", "psycopg2"]
|
|
17
|
+
__version__ = sqlite.__version__
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""s3rds.psycopg2 — the PostgreSQL-dialect driver for s3rds. Planned; not implemented yet.
|
|
2
|
+
|
|
3
|
+
It will mirror psycopg2 (connect(dsn or host=/user=/password=/dbname=), cursors, %s placeholders)
|
|
4
|
+
and translate PostgreSQL SQL to SQLite on the client, the same way s3rds.pymysql does for MySQL.
|
|
5
|
+
"""
|
|
6
|
+
apilevel = "2.0"
|
|
7
|
+
threadsafety = 1
|
|
8
|
+
paramstyle = "pyformat"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Error(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NotSupportedError(Error):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def connect(*args, **kwargs):
|
|
20
|
+
raise NotSupportedError(
|
|
21
|
+
"s3rds.psycopg2 is not implemented yet. Use `from s3rds import pymysql` (MySQL dialect) "
|
|
22
|
+
"or `from s3rds import sqlite` (SQLite dialect) for now.")
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""s3rds.pymysql — the MySQL-dialect driver for s3rds. Write MySQL; it runs on the s3rds endpoint (SQLite engine).
|
|
2
|
+
|
|
3
|
+
pip install "s3rds[mysql]" # pulls in sqlglot for the dialect translation
|
|
4
|
+
|
|
5
|
+
from s3rds import pymysql # later: `import pymysql` on a real RDS, nothing else changes
|
|
6
|
+
conn = pymysql.connect(host="xxx.lambda-url.us-east-2.on.aws",
|
|
7
|
+
user="admin", password="...", database="blog")
|
|
8
|
+
with conn.cursor() as cur:
|
|
9
|
+
cur.execute("CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL)")
|
|
10
|
+
cur.execute("INSERT INTO users (name) VALUES (%s)", ["alice"])
|
|
11
|
+
cur.execute("INSERT INTO users (name) VALUES (%(n)s)", {"n": "bob"})
|
|
12
|
+
cur.execute("SELECT * FROM users WHERE name = %s LIMIT 0, 10", ["ALICE"]) # case-insensitive, like MySQL
|
|
13
|
+
|
|
14
|
+
What is translated (client side, cached per statement):
|
|
15
|
+
- placeholders %s and %(name)s, %% -> % - backticks, LIMIT offset,count
|
|
16
|
+
- AUTO_INCREMENT -> INTEGER PRIMARY KEY AUTOINCREMENT - ENUM(...) -> TEXT CHECK (col IN (...))
|
|
17
|
+
- text columns get COLLATE NOCASE (MySQL's default case-insensitive comparison)
|
|
18
|
+
- INSERT IGNORE, ON DUPLICATE KEY UPDATE (VALUES(col) -> excluded.col)
|
|
19
|
+
- table options (ENGINE=, CHARSET=), ON UPDATE CURRENT_TIMESTAMP and inline KEY/INDEX are dropped
|
|
20
|
+
- functions sqlglot knows (NOW, IFNULL, CONCAT_WS, DATE_FORMAT, ...), LAST_INSERT_ID()
|
|
21
|
+
- SHOW TABLES / DATABASES / COLUMNS / CREATE TABLE / INDEX, DESCRIBE, SELECT VERSION(), SELECT DATABASE()
|
|
22
|
+
- errors come back as pymysql-style classes with MySQL codes: 1062, 1146, 1054, 1064, 1048, 1452, ...
|
|
23
|
+
|
|
24
|
+
Not supported, raised as NotSupportedError (never silently wrong): stored procedures/functions,
|
|
25
|
+
triggers, FULLTEXT, SELECT ... FOR UPDATE, LOCK TABLES, GRANT/REVOKE/users.
|
|
26
|
+
Values are returned as stored (no DATETIME -> datetime.datetime conversion in this version).
|
|
27
|
+
"""
|
|
28
|
+
import functools
|
|
29
|
+
import re
|
|
30
|
+
|
|
31
|
+
from . import sqlite as _sqlite
|
|
32
|
+
from .sqlite import Cursor, DictCursor, cursors # noqa: F401 (same DB-API cursors)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import sqlglot
|
|
36
|
+
from sqlglot import exp
|
|
37
|
+
from sqlglot.errors import ParseError
|
|
38
|
+
except ImportError: # the error surfaces on first query, with install instructions
|
|
39
|
+
sqlglot = None
|
|
40
|
+
|
|
41
|
+
__version__ = _sqlite.__version__
|
|
42
|
+
apilevel = "2.0"
|
|
43
|
+
threadsafety = 1
|
|
44
|
+
paramstyle = "pyformat"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------- exceptions (pymysql hierarchy; args[0] = MySQL error code) ----------------
|
|
48
|
+
|
|
49
|
+
class Error(Exception):
|
|
50
|
+
def __init__(self, code, message):
|
|
51
|
+
super().__init__(code, message)
|
|
52
|
+
self.code = code
|
|
53
|
+
self.message = message
|
|
54
|
+
|
|
55
|
+
def __str__(self):
|
|
56
|
+
return f"({self.code}, {self.message!r})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InterfaceError(Error): pass
|
|
60
|
+
class DatabaseError(Error): pass
|
|
61
|
+
class DataError(DatabaseError): pass
|
|
62
|
+
class OperationalError(DatabaseError): pass
|
|
63
|
+
class IntegrityError(DatabaseError): pass
|
|
64
|
+
class InternalError(DatabaseError): pass
|
|
65
|
+
class ProgrammingError(DatabaseError): pass
|
|
66
|
+
class NotSupportedError(DatabaseError): pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_SQLITE_TO_MYSQL = [
|
|
70
|
+
(r"UNIQUE constraint failed", IntegrityError, 1062, "Duplicate entry"),
|
|
71
|
+
(r"NOT NULL constraint failed", IntegrityError, 1048, "Column cannot be null"),
|
|
72
|
+
(r"FOREIGN KEY constraint failed", IntegrityError, 1452, "Cannot add or update a child row: a foreign key constraint fails"),
|
|
73
|
+
(r"CHECK constraint failed", IntegrityError, 3819, "Check constraint is violated"),
|
|
74
|
+
(r"no such table", ProgrammingError, 1146, "Table doesn't exist"),
|
|
75
|
+
(r"no such column", ProgrammingError, 1054, "Unknown column"),
|
|
76
|
+
(r"already exists", OperationalError, 1050, "Table already exists"),
|
|
77
|
+
(r"no such function", ProgrammingError, 1305, "FUNCTION does not exist"),
|
|
78
|
+
(r"syntax error|incomplete input", ProgrammingError, 1064, "You have an error in your SQL syntax"),
|
|
79
|
+
(r"readonly|read-only", OperationalError, 1290, "The server is running in read-only mode"),
|
|
80
|
+
(r"cannot store .* value in .* column", DataError, 1366, "Incorrect value for column"),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _map_error(e):
|
|
85
|
+
"""s3rds.sqlite.Error (HTTP status + server message) -> pymysql-style exception with a MySQL code."""
|
|
86
|
+
msg = e.message or str(e)
|
|
87
|
+
if e.status == 0:
|
|
88
|
+
return OperationalError(2013, f"Lost connection to MySQL server: {msg}")
|
|
89
|
+
if e.status == 401:
|
|
90
|
+
return OperationalError(1045, "Access denied for user (bad credentials)")
|
|
91
|
+
if e.status == 404 and "database" in msg:
|
|
92
|
+
return OperationalError(1049, f"Unknown database ({msg})")
|
|
93
|
+
if e.status == 409:
|
|
94
|
+
return OperationalError(1213, f"Write conflict; try restarting transaction ({msg})")
|
|
95
|
+
if e.status == 403:
|
|
96
|
+
return OperationalError(1290, msg)
|
|
97
|
+
for pattern, cls, code, text in _SQLITE_TO_MYSQL:
|
|
98
|
+
if re.search(pattern, msg, re.I):
|
|
99
|
+
return cls(code, f"{text} ({msg})")
|
|
100
|
+
return OperationalError(1105, msg)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------- placeholders: %s / %(name)s -> ?, dict params -> list ----------------
|
|
104
|
+
|
|
105
|
+
def _bind(sql, params):
|
|
106
|
+
"""Return (sql with ? placeholders, list of params). Quoted text is left alone."""
|
|
107
|
+
names, out, i, n, quote = [], [], 0, len(sql), None
|
|
108
|
+
while i < n:
|
|
109
|
+
ch = sql[i]
|
|
110
|
+
if quote:
|
|
111
|
+
out.append(ch)
|
|
112
|
+
if ch == quote:
|
|
113
|
+
quote = None
|
|
114
|
+
elif ch in ("'", '"', "`"):
|
|
115
|
+
quote = ch
|
|
116
|
+
out.append(ch)
|
|
117
|
+
elif ch == "%" and i + 1 < n:
|
|
118
|
+
nxt = sql[i + 1]
|
|
119
|
+
if nxt == "s":
|
|
120
|
+
out.append("?"); names.append(None); i += 1
|
|
121
|
+
elif nxt == "%":
|
|
122
|
+
out.append("%"); i += 1
|
|
123
|
+
elif nxt == "(":
|
|
124
|
+
end = sql.find(")s", i)
|
|
125
|
+
if end == -1:
|
|
126
|
+
raise ProgrammingError(1064, f"bad placeholder near {sql[i:i+20]!r}")
|
|
127
|
+
out.append("?"); names.append(sql[i + 2:end]); i = end + 1
|
|
128
|
+
else:
|
|
129
|
+
out.append(ch)
|
|
130
|
+
else:
|
|
131
|
+
out.append(ch)
|
|
132
|
+
i += 1
|
|
133
|
+
if isinstance(params, dict):
|
|
134
|
+
if any(k is None for k in names):
|
|
135
|
+
raise ProgrammingError(1064, "dict params need %(name)s placeholders")
|
|
136
|
+
return "".join(out), [params[k] for k in names]
|
|
137
|
+
return "".join(out), list(params or [])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------- unsupported features: fail loudly ----------------
|
|
141
|
+
|
|
142
|
+
_UNSUPPORTED = [
|
|
143
|
+
(r"^\s*CREATE\s+(OR\s+REPLACE\s+)?(DEFINER\s*=\s*\S+\s+)?(PROCEDURE|FUNCTION|TRIGGER|EVENT)\b", "stored procedures / functions / triggers / events"),
|
|
144
|
+
(r"^\s*(CALL|LOCK\s+TABLES|UNLOCK\s+TABLES|GRANT|REVOKE|FLUSH)\b", "CALL / LOCK TABLES / GRANT / REVOKE / FLUSH"),
|
|
145
|
+
(r"^\s*(CREATE|ALTER|DROP|RENAME)\s+USER\b", "user management"),
|
|
146
|
+
(r"\bFOR\s+UPDATE\s*;?\s*$|\bLOCK\s+IN\s+SHARE\s+MODE\s*;?\s*$", "SELECT ... FOR UPDATE / LOCK IN SHARE MODE"),
|
|
147
|
+
(r"\bFULLTEXT\b", "FULLTEXT indexes"),
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _check_supported(sql):
|
|
152
|
+
for pattern, what in _UNSUPPORTED:
|
|
153
|
+
if re.search(pattern, sql, re.I | re.S):
|
|
154
|
+
raise NotSupportedError(1235, f"{what} need a real MySQL - migrate to RDS when you do")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------- metadata commands: answered from sqlite_master / PRAGMA, shaped like MySQL ----------------
|
|
158
|
+
|
|
159
|
+
_META = re.compile(
|
|
160
|
+
r"^\s*(?:"
|
|
161
|
+
r"(?P<tables>SHOW\s+(?:FULL\s+)?TABLES)"
|
|
162
|
+
r"|(?P<dbs>SHOW\s+DATABASES)"
|
|
163
|
+
r"|(?:SHOW\s+(?:FULL\s+)?COLUMNS\s+FROM|DESCRIBE|DESC)\s+`?(?P<cols>\w+)`?"
|
|
164
|
+
r"|SHOW\s+CREATE\s+TABLE\s+`?(?P<create>\w+)`?"
|
|
165
|
+
r"|SHOW\s+INDEX(?:ES)?\s+FROM\s+`?(?P<index>\w+)`?"
|
|
166
|
+
r"|(?P<version>SELECT\s+VERSION\s*\(\s*\))"
|
|
167
|
+
r"|(?P<database>SELECT\s+DATABASE\s*\(\s*\))"
|
|
168
|
+
r")\s*;?\s*$", re.I)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _result(columns, rows):
|
|
172
|
+
return {"columns": columns, "rows": rows, "rowcount": -1, "lastrowid": None}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------- dialect translation (sqlglot) + fixes sqlglot does not do ----------------
|
|
176
|
+
|
|
177
|
+
# sqlglot may leave "ON DUPLICATE KEY UPDATE" or render "ON DUPLICATE KEY DO UPDATE SET"
|
|
178
|
+
_ON_DUP = re.compile(r"\bON\s+DUPLICATE\s+KEY(?:\s+DO)?\s+UPDATE(?:\s+SET)?\b", re.I)
|
|
179
|
+
_UPSERT_SPLIT = re.compile(r"\bON\s+CONFLICT\s+DO\s+UPDATE\s+SET\b", re.I)
|
|
180
|
+
_VALUES_REF = re.compile(r"\bVALUES\s*\(\s*(\"?[A-Za-z_]\w*\"?)\s*\)", re.I) # VALUES(col), never VALUES(1)
|
|
181
|
+
# LAST_INSERT_ID(): SQLite's last_insert_rowid() is per connection and the server opens one per
|
|
182
|
+
# request, so the driver substitutes the id it saw on its own last INSERT (MySQL's per-session semantics)
|
|
183
|
+
_LAST_ID_TOKEN = "__S3RDS_LAST_INSERT_ID__"
|
|
184
|
+
|
|
185
|
+
# MySQL functions sqlglot does not map for SQLite. ONE table, one line per function:
|
|
186
|
+
# MySQL name -> SQLite SQL template; {0} = first argument (defaults to 'now' when there is none).
|
|
187
|
+
# A function missing here comes back as error 1305 "FUNCTION does not exist" -- add a line, nothing else.
|
|
188
|
+
_FUNCS = {
|
|
189
|
+
"NOW": "CURRENT_TIMESTAMP",
|
|
190
|
+
"CURDATE": "CURRENT_DATE",
|
|
191
|
+
"CURTIME": "CURRENT_TIME",
|
|
192
|
+
"UNIX_TIMESTAMP": "CAST(strftime('%s', {0}) AS INTEGER)",
|
|
193
|
+
"FROM_UNIXTIME": "datetime({0}, 'unixepoch')",
|
|
194
|
+
"UNIX_TO_TIME": "datetime({0}, 'unixepoch')", # sqlglot's own node name for FROM_UNIXTIME
|
|
195
|
+
"LAST_INSERT_ID": _LAST_ID_TOKEN,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _rewrite_funcs(node):
|
|
200
|
+
if not isinstance(node, exp.Func):
|
|
201
|
+
return node
|
|
202
|
+
# unknown functions are exp.Anonymous whose real name is node.name; known ones answer to sql_name()
|
|
203
|
+
name = (node.name if isinstance(node, exp.Anonymous) else node.sql_name()).upper()
|
|
204
|
+
template = _FUNCS.get(name)
|
|
205
|
+
if template is None:
|
|
206
|
+
return node
|
|
207
|
+
args = node.expressions if isinstance(node, exp.Anonymous) else \
|
|
208
|
+
[v for v in node.args.values() if isinstance(v, exp.Expression)]
|
|
209
|
+
first = args[0].sql(dialect="sqlite") if args else "'now'"
|
|
210
|
+
return exp.maybe_parse(template.format(first), dialect="sqlite")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _fix_create_table(tree):
|
|
214
|
+
"""MySQL CREATE TABLE -> something SQLite accepts with MySQL-like semantics."""
|
|
215
|
+
if not (isinstance(tree, exp.Create) and (tree.args.get("kind") or "").upper() == "TABLE"):
|
|
216
|
+
return
|
|
217
|
+
schema = tree.this
|
|
218
|
+
if not isinstance(schema, exp.Schema):
|
|
219
|
+
return
|
|
220
|
+
tree.set("properties", None) # ENGINE=, CHARSET=, COMMENT=, ...
|
|
221
|
+
|
|
222
|
+
kept, table_pk_cols = [], set()
|
|
223
|
+
for item in schema.expressions:
|
|
224
|
+
if isinstance(item, exp.PrimaryKey):
|
|
225
|
+
cols = [c.name for c in item.expressions]
|
|
226
|
+
if len(cols) == 1: # single-column PK: moved into the column (needed for AUTOINCREMENT)
|
|
227
|
+
table_pk_cols.add(cols[0])
|
|
228
|
+
continue
|
|
229
|
+
if isinstance(item, (exp.ColumnDef, exp.PrimaryKey, exp.ForeignKey)) or "Unique" in type(item).__name__:
|
|
230
|
+
kept.append(item) # inline KEY/INDEX definitions are dropped (CREATE INDEX separately)
|
|
231
|
+
|
|
232
|
+
for col in kept:
|
|
233
|
+
if not isinstance(col, exp.ColumnDef):
|
|
234
|
+
continue
|
|
235
|
+
kind = col.args.get("kind")
|
|
236
|
+
constraints = [c for c in (col.args.get("constraints") or [])
|
|
237
|
+
if not isinstance(c.kind, exp.OnUpdateColumnConstraint)]
|
|
238
|
+
kinds = [type(c.kind) for c in constraints]
|
|
239
|
+
is_pk = exp.PrimaryKeyColumnConstraint in kinds or col.name in table_pk_cols
|
|
240
|
+
is_auto = exp.AutoIncrementColumnConstraint in kinds
|
|
241
|
+
|
|
242
|
+
if kind is not None and kind.this == exp.DataType.Type.ENUM:
|
|
243
|
+
values = [v.this for v in kind.expressions]
|
|
244
|
+
col.set("kind", exp.DataType.build("TEXT"))
|
|
245
|
+
constraints.append(exp.ColumnConstraint(kind=exp.CheckColumnConstraint(
|
|
246
|
+
this=exp.In(this=exp.column(col.name), expressions=[exp.Literal.string(v) for v in values]))))
|
|
247
|
+
kind = col.args["kind"]
|
|
248
|
+
|
|
249
|
+
if is_auto:
|
|
250
|
+
col.set("kind", exp.DataType.build("INTEGER"))
|
|
251
|
+
is_pk = True
|
|
252
|
+
if kind is not None and kind.this in exp.DataType.TEXT_TYPES \
|
|
253
|
+
and exp.CollateColumnConstraint not in kinds:
|
|
254
|
+
constraints.append(exp.ColumnConstraint(kind=exp.CollateColumnConstraint(this=exp.var("NOCASE"))))
|
|
255
|
+
|
|
256
|
+
others = [c for c in constraints
|
|
257
|
+
if not isinstance(c.kind, (exp.PrimaryKeyColumnConstraint, exp.AutoIncrementColumnConstraint))]
|
|
258
|
+
tail = []
|
|
259
|
+
if is_pk:
|
|
260
|
+
tail.append(exp.ColumnConstraint(kind=exp.PrimaryKeyColumnConstraint()))
|
|
261
|
+
if is_auto:
|
|
262
|
+
tail.append(exp.ColumnConstraint(kind=exp.AutoIncrementColumnConstraint())) # must follow PRIMARY KEY
|
|
263
|
+
col.set("constraints", others + tail)
|
|
264
|
+
|
|
265
|
+
schema.set("expressions", kept)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@functools.lru_cache(maxsize=1024)
|
|
269
|
+
def _translate(sql):
|
|
270
|
+
if sqlglot is None:
|
|
271
|
+
raise NotSupportedError(0, "the MySQL dialect needs sqlglot: pip install 's3rds[mysql]'")
|
|
272
|
+
try:
|
|
273
|
+
tree = sqlglot.parse_one(sql, read="mysql")
|
|
274
|
+
except ParseError as e:
|
|
275
|
+
raise ProgrammingError(1064, f"You have an error in your SQL syntax ({e})") from None
|
|
276
|
+
_fix_create_table(tree)
|
|
277
|
+
tree = tree.transform(_rewrite_funcs)
|
|
278
|
+
out = tree.sql(dialect="sqlite")
|
|
279
|
+
out = _ON_DUP.sub("ON CONFLICT DO UPDATE SET", out) # upsert
|
|
280
|
+
parts = _UPSERT_SPLIT.split(out, maxsplit=1)
|
|
281
|
+
if len(parts) == 2: # VALUES(col) -> excluded.col, only inside the DO UPDATE SET clause
|
|
282
|
+
out = parts[0] + "ON CONFLICT DO UPDATE SET" + _VALUES_REF.sub(r"excluded.\1", parts[1])
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------- connection ----------------
|
|
287
|
+
|
|
288
|
+
class Connection(_sqlite.Connection):
|
|
289
|
+
_last_insert_id = 0 # MySQL's per-session LAST_INSERT_ID()
|
|
290
|
+
|
|
291
|
+
def _prepare(self, sql):
|
|
292
|
+
sql = sql.strip().rstrip(";")
|
|
293
|
+
_check_supported(sql)
|
|
294
|
+
return sql
|
|
295
|
+
|
|
296
|
+
def _finalize(self, translated):
|
|
297
|
+
return translated.replace(_LAST_ID_TOKEN, str(self._last_insert_id))
|
|
298
|
+
|
|
299
|
+
def _remember(self, result):
|
|
300
|
+
if result.get("lastrowid"):
|
|
301
|
+
self._last_insert_id = result["lastrowid"]
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
def execute(self, sql, params=()):
|
|
305
|
+
sql = self._prepare(sql)
|
|
306
|
+
m = _META.match(sql)
|
|
307
|
+
if m:
|
|
308
|
+
return self._meta(m)
|
|
309
|
+
qmark, args = _bind(sql, params)
|
|
310
|
+
try:
|
|
311
|
+
return self._remember(super().execute(self._finalize(_translate(qmark)), args))
|
|
312
|
+
except _sqlite.Error as e:
|
|
313
|
+
raise _map_error(e) from None
|
|
314
|
+
|
|
315
|
+
def transaction(self, statements):
|
|
316
|
+
prepared = []
|
|
317
|
+
for s in statements:
|
|
318
|
+
sql, params = (s, []) if isinstance(s, str) else (s[0], s[1] if len(s) > 1 else [])
|
|
319
|
+
qmark, args = _bind(self._prepare(sql), params)
|
|
320
|
+
prepared.append((self._finalize(_translate(qmark)), args))
|
|
321
|
+
try:
|
|
322
|
+
results = super().transaction(prepared)
|
|
323
|
+
except _sqlite.Error as e:
|
|
324
|
+
raise _map_error(e) from None
|
|
325
|
+
for r in results:
|
|
326
|
+
self._remember(r)
|
|
327
|
+
return results
|
|
328
|
+
|
|
329
|
+
def create_database(self):
|
|
330
|
+
try:
|
|
331
|
+
super().create_database()
|
|
332
|
+
except _sqlite.Error as e:
|
|
333
|
+
raise _map_error(e) from None
|
|
334
|
+
|
|
335
|
+
def _raw(self, sql, params=()):
|
|
336
|
+
"""SQLite SQL straight to the endpoint (for metadata queries)."""
|
|
337
|
+
try:
|
|
338
|
+
return _sqlite.Connection.execute(self, sql, params)
|
|
339
|
+
except _sqlite.Error as e:
|
|
340
|
+
raise _map_error(e) from None
|
|
341
|
+
|
|
342
|
+
def _meta(self, m):
|
|
343
|
+
if m.group("tables"):
|
|
344
|
+
r = self._raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
345
|
+
return _result([f"Tables_in_{self.database}"], r["rows"])
|
|
346
|
+
if m.group("dbs"):
|
|
347
|
+
try:
|
|
348
|
+
names = self.list_databases()
|
|
349
|
+
except _sqlite.Error as e:
|
|
350
|
+
raise _map_error(e) from None
|
|
351
|
+
return _result(["Database"], [[n] for n in names])
|
|
352
|
+
if m.group("cols"):
|
|
353
|
+
table = m.group("cols")
|
|
354
|
+
info = self._raw(f'PRAGMA table_info("{table}")')["rows"]
|
|
355
|
+
if not info:
|
|
356
|
+
raise ProgrammingError(1146, f"Table '{table}' doesn't exist")
|
|
357
|
+
rows = []
|
|
358
|
+
for _cid, name, ctype, notnull, default, pk in info:
|
|
359
|
+
auto = "auto_increment" if pk and (ctype or "").upper() == "INTEGER" else ""
|
|
360
|
+
rows.append([name, (ctype or "").lower(), "NO" if notnull else "YES",
|
|
361
|
+
"PRI" if pk else "", default, auto])
|
|
362
|
+
return _result(["Field", "Type", "Null", "Key", "Default", "Extra"], rows)
|
|
363
|
+
if m.group("create"):
|
|
364
|
+
table = m.group("create")
|
|
365
|
+
r = self._raw("SELECT sql FROM sqlite_master WHERE type='table' AND name = ?", [table])
|
|
366
|
+
if not r["rows"]:
|
|
367
|
+
raise ProgrammingError(1146, f"Table '{table}' doesn't exist")
|
|
368
|
+
return _result(["Table", "Create Table"], [[table, r["rows"][0][0]]])
|
|
369
|
+
if m.group("index"):
|
|
370
|
+
table = m.group("index")
|
|
371
|
+
rows = []
|
|
372
|
+
for _seq, name, unique, _origin, _partial in self._raw(f'PRAGMA index_list("{table}")')["rows"]:
|
|
373
|
+
for seqno, _cid, colname in self._raw(f'PRAGMA index_info("{name}")')["rows"]:
|
|
374
|
+
rows.append([table, 0 if unique else 1, name, seqno + 1, colname])
|
|
375
|
+
return _result(["Table", "Non_unique", "Key_name", "Seq_in_index", "Column_name"], rows)
|
|
376
|
+
if m.group("version"):
|
|
377
|
+
return _result(["VERSION()"], [[f"8.0.0-s3rds-{__version__}"]])
|
|
378
|
+
if m.group("database"):
|
|
379
|
+
return _result(["DATABASE()"], [[self.database]])
|
|
380
|
+
raise InternalError(0, "unreachable")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def connect(host, user="", password="", database="", timeout=30,
|
|
384
|
+
cursorclass=Cursor, **_ignored):
|
|
385
|
+
"""Same signature as pymysql.connect; extra pymysql arguments are accepted and ignored."""
|
|
386
|
+
url = host if host.startswith("http") else "https://" + host
|
|
387
|
+
return Connection(url, database, user, password, timeout, cursorclass)
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""s3rds.sqlite — the native (SQLite-dialect) driver for s3rds: Lambda URL endpoint, SQLite on S3.
|
|
2
|
+
|
|
3
|
+
Zero dependencies (standard library only). The API is shaped like pymysql
|
|
4
|
+
(connect(host=, user=, password=, database=), cursors, %s placeholders); the SQL dialect is SQLite.
|
|
5
|
+
|
|
6
|
+
from s3rds import sqlite
|
|
7
|
+
conn = sqlite.connect(host="xxx.lambda-url.us-east-2.on.aws",
|
|
8
|
+
user="admin", password="...", database="blog")
|
|
9
|
+
|
|
10
|
+
with conn.cursor() as cur: # DB-API style
|
|
11
|
+
cur.execute("INSERT INTO items (text) VALUES (%s)", ["hi"])
|
|
12
|
+
new_id = cur.lastrowid
|
|
13
|
+
with conn.cursor(sqlite.cursors.DictCursor) as cur:
|
|
14
|
+
cur.execute("SELECT * FROM items WHERE id = %s", [new_id])
|
|
15
|
+
rows = cur.fetchall() # [{"id": 1, "text": "hi"}]
|
|
16
|
+
|
|
17
|
+
conn.execute("INSERT INTO items (text) VALUES (?)", ["hi"]) # convenience; ? and %s both accepted
|
|
18
|
+
rows = conn.query("SELECT * FROM items") # [dict, ...]
|
|
19
|
+
conn.transaction([("UPDATE ...", [..]), ("INSERT ...", [..])]) # one transaction, one S3 write
|
|
20
|
+
conn.last # {"ms": server time, "s3": "cache-hit"...}
|
|
21
|
+
|
|
22
|
+
Transactions: every execute() is its own transaction and is durable in S3 when it returns
|
|
23
|
+
(= pymysql autocommit=True). Use transaction() for multi-statement atomicity.
|
|
24
|
+
commit()/rollback() exist for compatibility and are no-ops.
|
|
25
|
+
|
|
26
|
+
Retry policy -- fail rather than duplicate a write:
|
|
27
|
+
the request is re-sent exactly once, and only when it provably never reached the server
|
|
28
|
+
(an idle keep-alive connection closed by the peer). Timeouts and anything after the request
|
|
29
|
+
was sent raise Error with "result unknown"; the caller decides.
|
|
30
|
+
(A connection is not thread-safe; use one per thread.)
|
|
31
|
+
"""
|
|
32
|
+
import base64
|
|
33
|
+
import http.client
|
|
34
|
+
import json
|
|
35
|
+
import socket
|
|
36
|
+
import urllib.parse
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
apilevel = "2.0"
|
|
41
|
+
threadsafety = 1
|
|
42
|
+
paramstyle = "format" # %s like pymysql; ? is accepted too
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------- exceptions ----------------
|
|
46
|
+
|
|
47
|
+
class Error(Exception):
|
|
48
|
+
def __init__(self, status, message):
|
|
49
|
+
super().__init__(f"HTTP {status}: {message}" if status else message)
|
|
50
|
+
self.status = status # HTTP status; 0 = network layer
|
|
51
|
+
self.message = message
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _NotDelivered(Exception):
|
|
55
|
+
"""The request provably never reached the server: sending failed, or no status line came back.
|
|
56
|
+
|
|
57
|
+
Only this case may be retried; any failure while reading the response is not --
|
|
58
|
+
the server may already have executed the statement.
|
|
59
|
+
"""
|
|
60
|
+
def __init__(self, cause, reused):
|
|
61
|
+
super().__init__(str(cause))
|
|
62
|
+
self.cause = cause
|
|
63
|
+
self.reused = reused # happened on a reused keep-alive connection (idle connection closed by peer)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------- placeholders: %s (pymysql style) -> ? (SQLite) ----------------
|
|
67
|
+
|
|
68
|
+
def _to_qmark(sql):
|
|
69
|
+
"""Turn %s into ? and %% into % outside quotes; leave quoted text alone. ?-style SQL is returned as is."""
|
|
70
|
+
if "%" not in sql:
|
|
71
|
+
return sql
|
|
72
|
+
out, i, n, quote = [], 0, len(sql), None
|
|
73
|
+
while i < n:
|
|
74
|
+
ch = sql[i]
|
|
75
|
+
if quote:
|
|
76
|
+
out.append(ch)
|
|
77
|
+
if ch == quote:
|
|
78
|
+
quote = None
|
|
79
|
+
elif ch in ("'", '"', "`"):
|
|
80
|
+
quote = ch
|
|
81
|
+
out.append(ch)
|
|
82
|
+
elif ch == "%" and i + 1 < n and sql[i + 1] == "s":
|
|
83
|
+
out.append("?")
|
|
84
|
+
i += 1
|
|
85
|
+
elif ch == "%" and i + 1 < n and sql[i + 1] == "%":
|
|
86
|
+
out.append("%")
|
|
87
|
+
i += 1
|
|
88
|
+
else:
|
|
89
|
+
out.append(ch)
|
|
90
|
+
i += 1
|
|
91
|
+
return "".join(out)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------- cursors (DB-API 2.0 subset, enough for common pymysql usage) ----------------
|
|
95
|
+
|
|
96
|
+
class Cursor:
|
|
97
|
+
def __init__(self, connection):
|
|
98
|
+
self.connection = connection
|
|
99
|
+
self.description = None
|
|
100
|
+
self.rowcount = -1
|
|
101
|
+
self.lastrowid = None
|
|
102
|
+
self.arraysize = 1
|
|
103
|
+
self._rows = []
|
|
104
|
+
self._pos = 0
|
|
105
|
+
|
|
106
|
+
def _row(self, columns, row):
|
|
107
|
+
return tuple(row)
|
|
108
|
+
|
|
109
|
+
def execute(self, sql, params=None):
|
|
110
|
+
r = self.connection.execute(sql, params or [])
|
|
111
|
+
cols = r.get("columns") or []
|
|
112
|
+
self.description = [(c, None, None, None, None, None, None) for c in cols] or None
|
|
113
|
+
self.rowcount = r.get("rowcount", -1)
|
|
114
|
+
self.lastrowid = r.get("lastrowid")
|
|
115
|
+
self._rows = [self._row(cols, row) for row in r.get("rows") or []]
|
|
116
|
+
self._pos = 0
|
|
117
|
+
return self.rowcount
|
|
118
|
+
|
|
119
|
+
def executemany(self, sql, seq_of_params):
|
|
120
|
+
"""Same statement, several parameter sets, one transaction (all or nothing)."""
|
|
121
|
+
self.connection.transaction([(sql, p) for p in seq_of_params])
|
|
122
|
+
self.rowcount = len(seq_of_params)
|
|
123
|
+
return self.rowcount
|
|
124
|
+
|
|
125
|
+
def fetchone(self):
|
|
126
|
+
if self._pos >= len(self._rows):
|
|
127
|
+
return None
|
|
128
|
+
row = self._rows[self._pos]
|
|
129
|
+
self._pos += 1
|
|
130
|
+
return row
|
|
131
|
+
|
|
132
|
+
def fetchmany(self, size=None):
|
|
133
|
+
size = size or self.arraysize
|
|
134
|
+
rows = self._rows[self._pos:self._pos + size]
|
|
135
|
+
self._pos += len(rows)
|
|
136
|
+
return rows
|
|
137
|
+
|
|
138
|
+
def fetchall(self):
|
|
139
|
+
rows = self._rows[self._pos:]
|
|
140
|
+
self._pos = len(self._rows)
|
|
141
|
+
return rows
|
|
142
|
+
|
|
143
|
+
def __iter__(self):
|
|
144
|
+
while True:
|
|
145
|
+
row = self.fetchone()
|
|
146
|
+
if row is None:
|
|
147
|
+
return
|
|
148
|
+
yield row
|
|
149
|
+
|
|
150
|
+
def close(self):
|
|
151
|
+
self._rows = []
|
|
152
|
+
|
|
153
|
+
def __enter__(self):
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def __exit__(self, *exc):
|
|
157
|
+
self.close()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class DictCursor(Cursor):
|
|
161
|
+
def _row(self, columns, row):
|
|
162
|
+
return dict(zip(columns, row))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class cursors: # so that sqlite.cursors.DictCursor reads like pymysql.cursors.DictCursor
|
|
166
|
+
Cursor = Cursor
|
|
167
|
+
DictCursor = DictCursor
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------- connection ----------------
|
|
171
|
+
|
|
172
|
+
class Connection:
|
|
173
|
+
def __init__(self, url, database, user="", password="", timeout=30, cursorclass=Cursor):
|
|
174
|
+
p = urllib.parse.urlparse(url.rstrip("/"))
|
|
175
|
+
self._host = p.netloc
|
|
176
|
+
self._base_path = p.path
|
|
177
|
+
self.database = database
|
|
178
|
+
self.cursorclass = cursorclass
|
|
179
|
+
self._auth = base64.b64encode(f"{user}:{password}".encode()).decode()
|
|
180
|
+
self.timeout = timeout
|
|
181
|
+
self._conn = None # reused HTTPS keep-alive connection
|
|
182
|
+
# server-side metrics of the last call: {"ms": ..., "s3": "cache-hit" | "download" | "...+upload"}
|
|
183
|
+
self.last = {}
|
|
184
|
+
|
|
185
|
+
# ---- transport ----
|
|
186
|
+
|
|
187
|
+
def _drop(self):
|
|
188
|
+
try:
|
|
189
|
+
if self._conn:
|
|
190
|
+
self._conn.close()
|
|
191
|
+
finally:
|
|
192
|
+
self._conn = None
|
|
193
|
+
|
|
194
|
+
def _send(self, path, body):
|
|
195
|
+
reused = self._conn is not None
|
|
196
|
+
if self._conn is None:
|
|
197
|
+
self._conn = http.client.HTTPSConnection(self._host, timeout=self.timeout)
|
|
198
|
+
headers = {"content-type": "application/json",
|
|
199
|
+
"authorization": f"Basic {self._auth}"}
|
|
200
|
+
# phase 1: send. A timeout is NOT "not delivered" (the body may have arrived in full)
|
|
201
|
+
try:
|
|
202
|
+
self._conn.request("POST", self._base_path + path, body, headers)
|
|
203
|
+
except (socket.timeout, TimeoutError):
|
|
204
|
+
raise
|
|
205
|
+
except (OSError, http.client.HTTPException) as e:
|
|
206
|
+
raise _NotDelivered(e, reused) from None
|
|
207
|
+
# phase 2: status line. Nothing received at all -> the server did not process it
|
|
208
|
+
try:
|
|
209
|
+
resp = self._conn.getresponse()
|
|
210
|
+
except (http.client.RemoteDisconnected, http.client.BadStatusLine) as e:
|
|
211
|
+
raise _NotDelivered(e, reused) from None
|
|
212
|
+
# phase 3: body. Any error here is never retried -- the server most likely executed it
|
|
213
|
+
raw = resp.read()
|
|
214
|
+
try:
|
|
215
|
+
data = json.loads(raw)
|
|
216
|
+
except ValueError:
|
|
217
|
+
data = {"error": raw[:200].decode(errors="replace")}
|
|
218
|
+
if resp.status >= 400:
|
|
219
|
+
raise Error(resp.status, data.get("error", ""))
|
|
220
|
+
return data
|
|
221
|
+
|
|
222
|
+
def _post(self, path, payload):
|
|
223
|
+
body = json.dumps(payload)
|
|
224
|
+
try:
|
|
225
|
+
return self._send(path, body)
|
|
226
|
+
except _NotDelivered as e:
|
|
227
|
+
self._drop()
|
|
228
|
+
if not e.reused: # a fresh connection failed: not a stale keep-alive, do not guess
|
|
229
|
+
raise Error(0, f"connection failed: {e.cause}") from None
|
|
230
|
+
try:
|
|
231
|
+
return self._send(path, body) # the only safe retry: provably not delivered
|
|
232
|
+
except _NotDelivered as e2:
|
|
233
|
+
self._drop()
|
|
234
|
+
raise Error(0, f"connection failed: {e2.cause}") from None
|
|
235
|
+
except (socket.timeout, TimeoutError):
|
|
236
|
+
self._drop()
|
|
237
|
+
raise Error(0, "timeout: result unknown (the statement may have run); do not blindly retry writes") from None
|
|
238
|
+
except (OSError, http.client.HTTPException) as e:
|
|
239
|
+
self._drop()
|
|
240
|
+
raise Error(0, f"network error (result unknown): {e}") from None
|
|
241
|
+
|
|
242
|
+
def _get(self, path):
|
|
243
|
+
"""A GET on a short-lived connection (the keep-alive path is POST-only)."""
|
|
244
|
+
conn = http.client.HTTPSConnection(self._host, timeout=self.timeout)
|
|
245
|
+
try:
|
|
246
|
+
conn.request("GET", self._base_path + path, headers={"authorization": f"Basic {self._auth}"})
|
|
247
|
+
resp = conn.getresponse()
|
|
248
|
+
data = json.loads(resp.read() or b"{}")
|
|
249
|
+
if resp.status >= 400:
|
|
250
|
+
raise Error(resp.status, data.get("error", ""))
|
|
251
|
+
return data
|
|
252
|
+
finally:
|
|
253
|
+
conn.close()
|
|
254
|
+
|
|
255
|
+
# ---- DB-API ----
|
|
256
|
+
|
|
257
|
+
def cursor(self, cursorclass=None):
|
|
258
|
+
return (cursorclass or self.cursorclass)(self)
|
|
259
|
+
|
|
260
|
+
def commit(self):
|
|
261
|
+
"""Every statement is already committed and durable; kept for pymysql compatibility."""
|
|
262
|
+
|
|
263
|
+
def rollback(self):
|
|
264
|
+
"""Nothing pending to roll back; kept for compatibility. Use transaction() for multi-statement atomicity."""
|
|
265
|
+
|
|
266
|
+
def close(self):
|
|
267
|
+
self._drop()
|
|
268
|
+
|
|
269
|
+
def ping(self, reconnect=True):
|
|
270
|
+
self.execute("SELECT 1")
|
|
271
|
+
|
|
272
|
+
def __enter__(self):
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
def __exit__(self, *exc):
|
|
276
|
+
self.close()
|
|
277
|
+
|
|
278
|
+
# ---- convenience ----
|
|
279
|
+
|
|
280
|
+
def create_database(self):
|
|
281
|
+
"""Create the database; a 409 (already exists) is silently fine, so this is safe on cold start."""
|
|
282
|
+
try:
|
|
283
|
+
self._post("/api/databases", {"name": self.database})
|
|
284
|
+
except Error as e:
|
|
285
|
+
if e.status != 409:
|
|
286
|
+
raise
|
|
287
|
+
|
|
288
|
+
def list_databases(self):
|
|
289
|
+
return self._get("/api/databases").get("databases", [])
|
|
290
|
+
|
|
291
|
+
def execute(self, sql, params=()):
|
|
292
|
+
"""Run one statement (? or %s placeholders). Returns {columns, rows, rowcount, lastrowid, truncated, ms, s3}."""
|
|
293
|
+
r = self._post(f"/api/db/{self.database}/query",
|
|
294
|
+
{"sql": _to_qmark(sql), "params": list(params)})
|
|
295
|
+
self.last = {"ms": r.get("ms"), "s3": r.get("s3")}
|
|
296
|
+
return r
|
|
297
|
+
|
|
298
|
+
def query(self, sql, params=()):
|
|
299
|
+
"""SELECT helper returning [dict, ...]. Results over 500 rows are truncated (see execute()['truncated'])."""
|
|
300
|
+
r = self.execute(sql, params)
|
|
301
|
+
return [dict(zip(r["columns"], row)) for row in r["rows"]]
|
|
302
|
+
|
|
303
|
+
def transaction(self, statements):
|
|
304
|
+
"""Run several statements in one transaction: all commit together or none do, and S3 is written once.
|
|
305
|
+
|
|
306
|
+
Each item is "sql" or (sql, params). Returns one result per statement.
|
|
307
|
+
"""
|
|
308
|
+
stmts = [{"sql": _to_qmark(s), "params": []} if isinstance(s, str)
|
|
309
|
+
else {"sql": _to_qmark(s[0]), "params": list(s[1]) if len(s) > 1 else []}
|
|
310
|
+
for s in statements]
|
|
311
|
+
r = self._post(f"/api/db/{self.database}/tx", {"statements": stmts})
|
|
312
|
+
self.last = {"ms": r.get("ms"), "s3": r.get("s3")}
|
|
313
|
+
return r["results"]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------- module entry point, same signature as pymysql.connect ----------------
|
|
317
|
+
|
|
318
|
+
def connect(host, user="", password="", database="", timeout=30,
|
|
319
|
+
cursorclass=Cursor, **_ignored):
|
|
320
|
+
"""Extra pymysql arguments (port, charset, autocommit, ...) are accepted and ignored.
|
|
321
|
+
|
|
322
|
+
host may be given with or without https:// (Lambda Function URLs are https only).
|
|
323
|
+
"""
|
|
324
|
+
url = host if host.startswith("http") else "https://" + host
|
|
325
|
+
return Connection(url, database, user, password, timeout, cursorclass)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: s3rds
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A truly serverless RDS on your own AWS account: Lambda URL endpoint, SQLite on S3, ≈ $0 when idle. Drivers named after the standard clients: s3rds.sqlite, s3rds.pymysql.
|
|
5
|
+
Author: Frank Zhang
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/cloudformer/s3rds
|
|
8
|
+
Keywords: serverless,rds,sqlite,s3,lambda,database,pymysql,dbapi
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Database
|
|
14
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Provides-Extra: mysql
|
|
18
|
+
Requires-Dist: sqlglot>=25.0; extra == "mysql"
|
|
19
|
+
|
|
20
|
+
# s3rds
|
|
21
|
+
|
|
22
|
+
**Python drivers for [s3rds](https://github.com/cloudformer/s3rds) — a truly serverless RDS:
|
|
23
|
+
a Lambda URL as your database endpoint, SQLite on S3, ≈ $0 when idle.**
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
Zero dependencies (standard library only). Each driver is a submodule **named after the standard
|
|
28
|
+
client it mirrors**, so moving to a real database is a one-line import change and nothing else:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install s3rds # s3rds.sqlite: SQLite dialect
|
|
32
|
+
pip install "s3rds[mysql]" # + s3rds.pymysql: write MySQL, translated on the client (sqlglot)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from s3rds import pymysql # today -- later: `import pymysql` on a real RDS, rest unchanged
|
|
37
|
+
|
|
38
|
+
conn = pymysql.connect(
|
|
39
|
+
host="xxxx.lambda-url.us-east-2.on.aws",
|
|
40
|
+
user="admin", password="...", database="blog")
|
|
41
|
+
|
|
42
|
+
with conn.cursor() as cur:
|
|
43
|
+
cur.execute("CREATE TABLE posts (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200))")
|
|
44
|
+
cur.execute("INSERT INTO posts (title) VALUES (%s)", ["hello"])
|
|
45
|
+
print(cur.lastrowid)
|
|
46
|
+
|
|
47
|
+
with conn.cursor(pymysql.cursors.DictCursor) as cur:
|
|
48
|
+
cur.execute("SELECT * FROM posts WHERE id = %s", [1])
|
|
49
|
+
print(cur.fetchall()) # [{'id': 1, 'title': 'hello'}]
|
|
50
|
+
|
|
51
|
+
conn.transaction([ # all-or-nothing, single S3 write
|
|
52
|
+
("UPDATE stock SET qty = qty - 1 WHERE id = %s", [7]),
|
|
53
|
+
("INSERT INTO orders (item) VALUES (%s)", [7]),
|
|
54
|
+
])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Submodule | SQL dialect | Mirrors | Status |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| `s3rds.sqlite` | SQLite (no translation) | pymysql-shaped API | available |
|
|
60
|
+
| `s3rds.pymysql` | MySQL, translated on the client | pymysql | available — `pip install "s3rds[mysql]"` |
|
|
61
|
+
| `s3rds.psycopg2` | PostgreSQL | psycopg2 | placeholder; `connect()` raises until implemented |
|
|
62
|
+
|
|
63
|
+
## Semantics
|
|
64
|
+
|
|
65
|
+
- Each `execute()` is its own transaction and is durable in S3 when it returns (`autocommit=True`).
|
|
66
|
+
Use `transaction()` for multi-statement atomicity.
|
|
67
|
+
- Writes **never** duplicate: the driver re-sends only when the request provably never reached the
|
|
68
|
+
server (stale keep-alive connection). Timeouts raise an error with an explicit "result unknown".
|
|
69
|
+
- `s3rds.pymysql` raises pymysql-style exceptions with MySQL error codes (1062, 1146, 1054, 1064, …);
|
|
70
|
+
unsupported MySQL features raise `NotSupportedError` instead of misbehaving silently.
|
|
71
|
+
|
|
72
|
+
## Server
|
|
73
|
+
|
|
74
|
+
You need an s3rds server in your own AWS account — deploy it once with Terraform or CloudFormation
|
|
75
|
+
from the [main repository](https://github.com/cloudformer/s3rds). Same as any driver needs its database.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
s3rds/__init__.py
|
|
4
|
+
s3rds/psycopg2.py
|
|
5
|
+
s3rds/pymysql.py
|
|
6
|
+
s3rds/sqlite.py
|
|
7
|
+
s3rds.egg-info/PKG-INFO
|
|
8
|
+
s3rds.egg-info/SOURCES.txt
|
|
9
|
+
s3rds.egg-info/dependency_links.txt
|
|
10
|
+
s3rds.egg-info/requires.txt
|
|
11
|
+
s3rds.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
s3rds
|
s3rds-0.1.0/setup.cfg
ADDED