sqlalchemy-d1 0.1.0__tar.gz → 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.
- sqlalchemy_d1-0.2.0/LICENSE +202 -0
- sqlalchemy_d1-0.2.0/PKG-INFO +138 -0
- sqlalchemy_d1-0.2.0/README.md +110 -0
- sqlalchemy_d1-0.2.0/pyproject.toml +66 -0
- sqlalchemy_d1-0.2.0/src/sqlalchemy_d1/__init__.py +17 -0
- sqlalchemy_d1-0.2.0/src/sqlalchemy_d1/dialect.py +171 -0
- sqlalchemy_d1-0.2.0/src/sqlalchemy_d1/types.py +48 -0
- sqlalchemy_d1-0.1.0/PKG-INFO +0 -14
- sqlalchemy_d1-0.1.0/README.md +0 -0
- sqlalchemy_d1-0.1.0/pyproject.toml +0 -37
- sqlalchemy_d1-0.1.0/src/sqlalchemy_d1/__init__.py +0 -6
- sqlalchemy_d1-0.1.0/src/sqlalchemy_d1/compiler.py +0 -5
- sqlalchemy_d1-0.1.0/src/sqlalchemy_d1/dialect.py +0 -299
- sqlalchemy_d1-0.1.0/src/sqlalchemy_d1/type_compiler.py +0 -5
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlalchemy-d1
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: SQLAlchemy dialect for Cloudflare D1
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Chad Rossouw
|
|
8
|
+
Author-email: chadrossouw7247@gmail.com
|
|
9
|
+
Maintainer: Daniel Alyoshin
|
|
10
|
+
Maintainer-email: daniel.alyoshin@gmail.com
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: mypy (>=1.18.2,<2.0.0) ; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest (>=8.4.2,<9.0.0) ; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-socket (>=0.7.0) ; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff (>=0.16.8,<0.17.0) ; extra == "dev"
|
|
23
|
+
Requires-Dist: sqlalchemy (>=2.0,<2.1)
|
|
24
|
+
Requires-Dist: sqlalchemy-cloudflare-d1 (>=0.3.11,<0.5)
|
|
25
|
+
Project-URL: Repository, https://github.com/sqlalchemy-cf-d1/sqlalchemy-d1
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# sqlalchemy-d1
|
|
29
|
+
|
|
30
|
+
A **SQLAlchemy dialect** for **Cloudflare D1** that keeps the `d1://` connection string working in **Apache Superset**.
|
|
31
|
+
|
|
32
|
+
Since version 0.2.0 this package is a thin layer over [sqlalchemy-cloudflare-d1](https://github.com/CollierKing/sqlalchemy-cloudflare-d1). That project is the real dialect and the real driver. This package registers it under the `d1` name and adds the few things Superset needs.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Why this package exists
|
|
37
|
+
|
|
38
|
+
Superset ships a built-in **Cloudflare D1** engine spec that expects `d1://` connection strings. See the [Superset D1 docs page](https://superset.apache.org/user-docs/databases/supported/cloudflare-d1/).
|
|
39
|
+
|
|
40
|
+
Version 0.1.0 of this package only worked with SQLAlchemy 1.4. Superset has moved to SQLAlchemy 2.0, and its [UPDATING.md](https://github.com/apache/superset/blob/master/UPDATING.md) lists `d1` among the connectors that were held back because of that. Version 0.2.0 removes the block.
|
|
41
|
+
|
|
42
|
+
If you do not use Superset, install `sqlalchemy-cloudflare-d1` directly and use its `cloudflare_d1://` connection string.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install sqlalchemy-d1
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This also installs `sqlalchemy-cloudflare-d1` and SQLAlchemy 2.0. Python 3.11 or newer is required.
|
|
53
|
+
|
|
54
|
+
If your Superset still uses **SQLAlchemy 1.4** (Superset 6.1.0, for example), stay on version 0.1.0. Newer versions would upgrade SQLAlchemy and break it.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install "sqlalchemy-d1==0.1.0"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from sqlalchemy import create_engine, text
|
|
66
|
+
|
|
67
|
+
engine = create_engine("d1://<CF_ACCOUNT_ID>:<CF_API_TOKEN>@<D1_DB_ID>")
|
|
68
|
+
|
|
69
|
+
with engine.connect() as conn:
|
|
70
|
+
print(conn.execute(text("SELECT 1")).scalar())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
In Superset, use the same URL as the SQLAlchemy URI of the database connection.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## What it adds
|
|
78
|
+
|
|
79
|
+
| Feature | Description |
|
|
80
|
+
|---------|-------------|
|
|
81
|
+
| `d1://` name | Registers the upstream dialect under the `d1` name that Superset uses. |
|
|
82
|
+
| Date and boolean reflection | Columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME`, `BOOLEAN` or `BOOL` are reflected as the types in `sqlalchemy_d1.types`. They behave like the upstream date and boolean types and keep the declared name. Superset reads that name to decide if a column is a date. Upstream reflects these columns as `TEXT`. |
|
|
83
|
+
| `DECIMAL` reflection | Columns declared as `DECIMAL` are reflected as numeric. Upstream reflects them as `TEXT`. |
|
|
84
|
+
| `autoincrement` | Reflected columns report `autoincrement`. It is true only for the column SQLite fills in by itself: a single primary key column declared as `INTEGER`, in a table that is not `WITHOUT ROWID`. |
|
|
85
|
+
| Primary key order | A composite primary key is reflected in the order of the key, not the order of the columns. |
|
|
86
|
+
| Schemas and views | `get_schema_names` returns `main`, `get_view_names` lists views and `get_view_definition` returns the SQL of a view. Upstream has none of these. `has_table` is also true for a view. |
|
|
87
|
+
| Internal tables hidden | Tables and views that start with `_cf_`, such as `_cf_KV`, are left out of the lists. |
|
|
88
|
+
|
|
89
|
+
Everything else comes from `sqlalchemy-cloudflare-d1` unchanged. That includes the connection, the cursor, the SQL compiler, and the reflection of foreign keys, indexes and unique constraints.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Known limits
|
|
94
|
+
|
|
95
|
+
Tables **created through SQLAlchemy** are identical to the ones upstream creates. A `DateTime` column is declared as `TEXT` and a `Boolean` column as `INTEGER`, so they are reflected as text and integer afterwards. Values still round trip correctly when you use the same `Table` object.
|
|
96
|
+
|
|
97
|
+
Tables created with plain SQL and a `DATETIME` column, which is the normal case for D1, are reflected as dates. A table reflected from D1 keeps its declared types when SQLAlchemy creates it again on D1. On another database the same columns compile like SQLAlchemy's generic date and boolean types.
|
|
98
|
+
|
|
99
|
+
Only the first word of a declared type is matched, so `TIMESTAMP WITH TIME ZONE` is a date and `UPDATED_INT` is an integer.
|
|
100
|
+
|
|
101
|
+
**CHECK constraints** are not reflected. `get_check_constraints` returns an empty list, and `get_table_comment` returns no comment because SQLite has none.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
python -m venv .venv
|
|
109
|
+
source .venv/bin/activate
|
|
110
|
+
pip install -e ".[dev]"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
With **Poetry**, run `poetry install --all-extras` instead.
|
|
114
|
+
|
|
115
|
+
Run the checks:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
ruff check . && ruff format --check . && mypy src
|
|
119
|
+
pytest -m "not integration" --disable-socket
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The integration tests need a real D1 database. Use a throwaway one. They create and drop their own tables.
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
export CF_ACCOUNT_ID=<CF_ACCOUNT_ID>
|
|
126
|
+
export CF_API_TOKEN=<CF_API_TOKEN>
|
|
127
|
+
export CF_D1_DATABASE_ID=<D1_DB_ID>
|
|
128
|
+
pytest -m integration
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
This package is licensed under the **Apache License 2.0**. See [LICENSE](LICENSE).
|
|
136
|
+
|
|
137
|
+
`sqlalchemy-cloudflare-d1` is a separate project under the MIT license. It is a dependency and none of its code is copied here.
|
|
138
|
+
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# sqlalchemy-d1
|
|
2
|
+
|
|
3
|
+
A **SQLAlchemy dialect** for **Cloudflare D1** that keeps the `d1://` connection string working in **Apache Superset**.
|
|
4
|
+
|
|
5
|
+
Since version 0.2.0 this package is a thin layer over [sqlalchemy-cloudflare-d1](https://github.com/CollierKing/sqlalchemy-cloudflare-d1). That project is the real dialect and the real driver. This package registers it under the `d1` name and adds the few things Superset needs.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why this package exists
|
|
10
|
+
|
|
11
|
+
Superset ships a built-in **Cloudflare D1** engine spec that expects `d1://` connection strings. See the [Superset D1 docs page](https://superset.apache.org/user-docs/databases/supported/cloudflare-d1/).
|
|
12
|
+
|
|
13
|
+
Version 0.1.0 of this package only worked with SQLAlchemy 1.4. Superset has moved to SQLAlchemy 2.0, and its [UPDATING.md](https://github.com/apache/superset/blob/master/UPDATING.md) lists `d1` among the connectors that were held back because of that. Version 0.2.0 removes the block.
|
|
14
|
+
|
|
15
|
+
If you do not use Superset, install `sqlalchemy-cloudflare-d1` directly and use its `cloudflare_d1://` connection string.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install sqlalchemy-d1
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This also installs `sqlalchemy-cloudflare-d1` and SQLAlchemy 2.0. Python 3.11 or newer is required.
|
|
26
|
+
|
|
27
|
+
If your Superset still uses **SQLAlchemy 1.4** (Superset 6.1.0, for example), stay on version 0.1.0. Newer versions would upgrade SQLAlchemy and break it.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install "sqlalchemy-d1==0.1.0"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from sqlalchemy import create_engine, text
|
|
39
|
+
|
|
40
|
+
engine = create_engine("d1://<CF_ACCOUNT_ID>:<CF_API_TOKEN>@<D1_DB_ID>")
|
|
41
|
+
|
|
42
|
+
with engine.connect() as conn:
|
|
43
|
+
print(conn.execute(text("SELECT 1")).scalar())
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
In Superset, use the same URL as the SQLAlchemy URI of the database connection.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## What it adds
|
|
51
|
+
|
|
52
|
+
| Feature | Description |
|
|
53
|
+
|---------|-------------|
|
|
54
|
+
| `d1://` name | Registers the upstream dialect under the `d1` name that Superset uses. |
|
|
55
|
+
| Date and boolean reflection | Columns declared as `DATETIME`, `TIMESTAMP`, `DATE`, `TIME`, `BOOLEAN` or `BOOL` are reflected as the types in `sqlalchemy_d1.types`. They behave like the upstream date and boolean types and keep the declared name. Superset reads that name to decide if a column is a date. Upstream reflects these columns as `TEXT`. |
|
|
56
|
+
| `DECIMAL` reflection | Columns declared as `DECIMAL` are reflected as numeric. Upstream reflects them as `TEXT`. |
|
|
57
|
+
| `autoincrement` | Reflected columns report `autoincrement`. It is true only for the column SQLite fills in by itself: a single primary key column declared as `INTEGER`, in a table that is not `WITHOUT ROWID`. |
|
|
58
|
+
| Primary key order | A composite primary key is reflected in the order of the key, not the order of the columns. |
|
|
59
|
+
| Schemas and views | `get_schema_names` returns `main`, `get_view_names` lists views and `get_view_definition` returns the SQL of a view. Upstream has none of these. `has_table` is also true for a view. |
|
|
60
|
+
| Internal tables hidden | Tables and views that start with `_cf_`, such as `_cf_KV`, are left out of the lists. |
|
|
61
|
+
|
|
62
|
+
Everything else comes from `sqlalchemy-cloudflare-d1` unchanged. That includes the connection, the cursor, the SQL compiler, and the reflection of foreign keys, indexes and unique constraints.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Known limits
|
|
67
|
+
|
|
68
|
+
Tables **created through SQLAlchemy** are identical to the ones upstream creates. A `DateTime` column is declared as `TEXT` and a `Boolean` column as `INTEGER`, so they are reflected as text and integer afterwards. Values still round trip correctly when you use the same `Table` object.
|
|
69
|
+
|
|
70
|
+
Tables created with plain SQL and a `DATETIME` column, which is the normal case for D1, are reflected as dates. A table reflected from D1 keeps its declared types when SQLAlchemy creates it again on D1. On another database the same columns compile like SQLAlchemy's generic date and boolean types.
|
|
71
|
+
|
|
72
|
+
Only the first word of a declared type is matched, so `TIMESTAMP WITH TIME ZONE` is a date and `UPDATED_INT` is an integer.
|
|
73
|
+
|
|
74
|
+
**CHECK constraints** are not reflected. `get_check_constraints` returns an empty list, and `get_table_comment` returns no comment because SQLite has none.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python -m venv .venv
|
|
82
|
+
source .venv/bin/activate
|
|
83
|
+
pip install -e ".[dev]"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
With **Poetry**, run `poetry install --all-extras` instead.
|
|
87
|
+
|
|
88
|
+
Run the checks:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
ruff check . && ruff format --check . && mypy src
|
|
92
|
+
pytest -m "not integration" --disable-socket
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The integration tests need a real D1 database. Use a throwaway one. They create and drop their own tables.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
export CF_ACCOUNT_ID=<CF_ACCOUNT_ID>
|
|
99
|
+
export CF_API_TOKEN=<CF_API_TOKEN>
|
|
100
|
+
export CF_D1_DATABASE_ID=<D1_DB_ID>
|
|
101
|
+
pytest -m integration
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
This package is licensed under the **Apache License 2.0**. See [LICENSE](LICENSE).
|
|
109
|
+
|
|
110
|
+
`sqlalchemy-cloudflare-d1` is a separate project under the MIT license. It is a dependency and none of its code is copied here.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sqlalchemy-d1"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "SQLAlchemy dialect for Cloudflare D1"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Chad Rossouw",email = "chadrossouw7247@gmail.com"},
|
|
7
|
+
{name = "Murphy Lee",email = "murphylee2004@gmail.com"},
|
|
8
|
+
{name = "Shreyas Rao",email = "raoshreyas2004@gmail.com"},
|
|
9
|
+
{name = "Alan Zhang",email = "zhangalan54@gmail.com"},
|
|
10
|
+
{name = "Daniel Alyoshin",email = "daniel.alyoshin@gmail.com"}
|
|
11
|
+
]
|
|
12
|
+
maintainers = [
|
|
13
|
+
{name = "Daniel Alyoshin",email = "daniel.alyoshin@gmail.com"}
|
|
14
|
+
]
|
|
15
|
+
readme = "README.md"
|
|
16
|
+
license = "Apache-2.0"
|
|
17
|
+
license-files = ["LICENSE"]
|
|
18
|
+
requires-python = ">=3.11"
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Topic :: Database",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"sqlalchemy>=2.0,<2.1",
|
|
29
|
+
"sqlalchemy-cloudflare-d1>=0.3.11,<0.5",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.4.2,<9.0.0",
|
|
35
|
+
"pytest-socket>=0.7.0",
|
|
36
|
+
"ruff>=0.16.8,<0.17.0",
|
|
37
|
+
"mypy>=1.18.2,<2.0.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Repository = "https://github.com/sqlalchemy-cf-d1/sqlalchemy-d1"
|
|
42
|
+
|
|
43
|
+
[project.entry-points."sqlalchemy.dialects"]
|
|
44
|
+
d1 = "sqlalchemy_d1.dialect:D1Dialect"
|
|
45
|
+
|
|
46
|
+
[tool.poetry]
|
|
47
|
+
packages = [{include = "sqlalchemy_d1", from = "src"}]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
markers = [
|
|
52
|
+
"integration: runs against a real D1 database",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 79
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = ["E", "F", "W", "I"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint.isort]
|
|
62
|
+
combine-as-imports = true
|
|
63
|
+
|
|
64
|
+
[build-system]
|
|
65
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
66
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""SQLAlchemy dialect for Cloudflare D1"""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.dialects import registry
|
|
6
|
+
|
|
7
|
+
from .dialect import D1Dialect
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("sqlalchemy-d1")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
# Running from a source tree or a vendored copy
|
|
13
|
+
__version__ = "0+unknown"
|
|
14
|
+
|
|
15
|
+
__all__ = ["D1Dialect", "__version__"]
|
|
16
|
+
|
|
17
|
+
registry.register("d1", "sqlalchemy_d1.dialect", "D1Dialect")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# sqlalchemy_d1/dialect.py
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import exc, text, types as sqltypes
|
|
5
|
+
from sqlalchemy.engine import reflection
|
|
6
|
+
from sqlalchemy_cloudflare_d1.dialect import CloudflareD1Dialect
|
|
7
|
+
|
|
8
|
+
from . import types as d1types
|
|
9
|
+
|
|
10
|
+
INTERNAL_PREFIX = "_cf_"
|
|
11
|
+
|
|
12
|
+
# Keyed by the first word of the declared type
|
|
13
|
+
DECLARED_TYPES = {
|
|
14
|
+
"DATETIME": d1types.DATETIME,
|
|
15
|
+
"TIMESTAMP": d1types.TIMESTAMP,
|
|
16
|
+
"DATE": d1types.DATE,
|
|
17
|
+
"TIME": d1types.TIME,
|
|
18
|
+
"BOOLEAN": d1types.BOOLEAN,
|
|
19
|
+
"BOOL": d1types.BOOLEAN,
|
|
20
|
+
"DECIMAL": sqltypes.DECIMAL,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Table options come after the last closing bracket of CREATE TABLE
|
|
24
|
+
WITHOUT_ROWID = re.compile(r"\bWITHOUT\s+ROWID\b[^)]*$", re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _visible(names):
|
|
28
|
+
# Cloudflare keeps its own tables, such as _cf_KV, in the same database
|
|
29
|
+
return [n for n in names if not n.startswith(INTERNAL_PREFIX)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class D1Dialect(CloudflareD1Dialect):
|
|
33
|
+
name = "d1"
|
|
34
|
+
supports_statement_cache = True
|
|
35
|
+
|
|
36
|
+
@reflection.cache
|
|
37
|
+
def get_schema_names(self, connection, **kwargs):
|
|
38
|
+
# D1 is built on SQLite, which only uses one schema
|
|
39
|
+
return ["main"]
|
|
40
|
+
|
|
41
|
+
@reflection.cache
|
|
42
|
+
def get_table_names(self, connection, schema=None, **kw):
|
|
43
|
+
"""
|
|
44
|
+
Return list of table names in the D1 database.
|
|
45
|
+
"""
|
|
46
|
+
return _visible(super().get_table_names(connection, schema, **kw))
|
|
47
|
+
|
|
48
|
+
@reflection.cache
|
|
49
|
+
def get_view_names(self, connection, schema=None, **kw):
|
|
50
|
+
"""
|
|
51
|
+
Return list of view names in the D1 database.
|
|
52
|
+
"""
|
|
53
|
+
result = connection.execute(
|
|
54
|
+
text(
|
|
55
|
+
"SELECT name FROM sqlite_master "
|
|
56
|
+
"WHERE type='view' ORDER BY name"
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
return _visible([row[0] for row in result])
|
|
60
|
+
|
|
61
|
+
@reflection.cache
|
|
62
|
+
def get_view_definition(self, connection, view_name, schema=None, **kw):
|
|
63
|
+
"""
|
|
64
|
+
Return the CREATE VIEW statement of a view.
|
|
65
|
+
"""
|
|
66
|
+
result = connection.execute(
|
|
67
|
+
text(
|
|
68
|
+
"SELECT sql FROM sqlite_master "
|
|
69
|
+
"WHERE type='view' AND name=:name"
|
|
70
|
+
),
|
|
71
|
+
{"name": view_name},
|
|
72
|
+
)
|
|
73
|
+
row = result.first()
|
|
74
|
+
if row is None:
|
|
75
|
+
raise exc.NoSuchTableError(view_name)
|
|
76
|
+
return row[0]
|
|
77
|
+
|
|
78
|
+
@reflection.cache
|
|
79
|
+
def has_table(self, connection, table_name, schema=None, **kw):
|
|
80
|
+
"""
|
|
81
|
+
Return True if a table or a view with this name exists.
|
|
82
|
+
"""
|
|
83
|
+
# Upstream only looks for tables. SQLAlchemy 2.0 expects views too
|
|
84
|
+
result = connection.execute(
|
|
85
|
+
text(
|
|
86
|
+
"SELECT 1 FROM sqlite_master "
|
|
87
|
+
"WHERE type IN ('table', 'view') AND name=:name "
|
|
88
|
+
"AND name NOT LIKE 'sqlite_%'"
|
|
89
|
+
),
|
|
90
|
+
{"name": table_name},
|
|
91
|
+
)
|
|
92
|
+
return result.first() is not None
|
|
93
|
+
|
|
94
|
+
@reflection.cache
|
|
95
|
+
def _get_table_info(self, connection, table_name, schema=None, **kw):
|
|
96
|
+
# Rows are: cid, name, type, notnull, dflt_value, pk
|
|
97
|
+
quoted = self.identifier_preparer.quote_identifier(table_name)
|
|
98
|
+
result = connection.execute(text(f"PRAGMA table_info({quoted})"))
|
|
99
|
+
return [tuple(row) for row in result]
|
|
100
|
+
|
|
101
|
+
@reflection.cache
|
|
102
|
+
def get_columns(self, connection, table_name, schema=None, **kw):
|
|
103
|
+
"""
|
|
104
|
+
Return column info for a given table in D1.
|
|
105
|
+
"""
|
|
106
|
+
rows = self._get_table_info(connection, table_name, schema, **kw)
|
|
107
|
+
rowid_alias = self._get_rowid_alias(connection, table_name, rows)
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
"name": name,
|
|
111
|
+
"type": self._get_column_type(declared),
|
|
112
|
+
"nullable": not notnull,
|
|
113
|
+
"default": default,
|
|
114
|
+
"primary_key": bool(pk),
|
|
115
|
+
"autoincrement": name == rowid_alias,
|
|
116
|
+
}
|
|
117
|
+
for _cid, name, declared, notnull, default, pk in rows
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
@reflection.cache
|
|
121
|
+
def get_pk_constraint(self, connection, table_name, schema=None, **kw):
|
|
122
|
+
"""
|
|
123
|
+
Return the primary key columns in the order of the key.
|
|
124
|
+
"""
|
|
125
|
+
rows = self._get_table_info(connection, table_name, schema, **kw)
|
|
126
|
+
# pk is the position of the column inside the key, or 0
|
|
127
|
+
pk_rows = sorted((r for r in rows if r[5]), key=lambda r: r[5])
|
|
128
|
+
return {
|
|
129
|
+
"constrained_columns": [r[1] for r in pk_rows],
|
|
130
|
+
"name": None,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def get_check_constraints(self, connection, table_name, schema=None, **kw):
|
|
134
|
+
# CHECK constraints are not reflected
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
def get_table_comment(self, connection, table_name, schema=None, **kw):
|
|
138
|
+
# SQLite has no table comments
|
|
139
|
+
return {"text": None}
|
|
140
|
+
|
|
141
|
+
def _get_rowid_alias(self, connection, table_name, rows):
|
|
142
|
+
"""
|
|
143
|
+
Return the name of the column that SQLite fills in by itself.
|
|
144
|
+
|
|
145
|
+
That is a single primary key column declared as exactly INTEGER,
|
|
146
|
+
in a table that is not WITHOUT ROWID.
|
|
147
|
+
"""
|
|
148
|
+
pk_rows = [r for r in rows if r[5]]
|
|
149
|
+
if len(pk_rows) != 1:
|
|
150
|
+
return None
|
|
151
|
+
name, declared = pk_rows[0][1], pk_rows[0][2]
|
|
152
|
+
if (declared or "").strip().upper() != "INTEGER":
|
|
153
|
+
return None
|
|
154
|
+
table_sql = connection.execute(
|
|
155
|
+
text(
|
|
156
|
+
"SELECT sql FROM sqlite_master "
|
|
157
|
+
"WHERE type='table' AND name=:name"
|
|
158
|
+
),
|
|
159
|
+
{"name": table_name},
|
|
160
|
+
).scalar()
|
|
161
|
+
if WITHOUT_ROWID.search(table_sql or ""):
|
|
162
|
+
return None
|
|
163
|
+
return name
|
|
164
|
+
|
|
165
|
+
def _get_column_type(self, type_string):
|
|
166
|
+
declared = (type_string or "").upper()
|
|
167
|
+
# Only the first word counts, so UPDATED_INT stays an integer
|
|
168
|
+
first_word = re.match(r"\s*(\w*)", declared).group(1)
|
|
169
|
+
if first_word in DECLARED_TYPES:
|
|
170
|
+
return DECLARED_TYPES[first_word]()
|
|
171
|
+
return super()._get_column_type(declared)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# sqlalchemy_d1/types.py
|
|
2
|
+
"""
|
|
3
|
+
Types that reflection returns for columns declared with a date or boolean
|
|
4
|
+
type name.
|
|
5
|
+
|
|
6
|
+
Each one behaves like the upstream D1 type it extends. On the d1 dialect it
|
|
7
|
+
compiles to its class name, which is the name the column was declared with.
|
|
8
|
+
Generic types such as DateTime are left to upstream.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from sqlalchemy.ext.compiler import compiles
|
|
12
|
+
from sqlalchemy_cloudflare_d1.dialect import (
|
|
13
|
+
D1Boolean,
|
|
14
|
+
D1Date,
|
|
15
|
+
D1DateTime,
|
|
16
|
+
D1Time,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DATETIME(D1DateTime):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TIMESTAMP(D1DateTime):
|
|
25
|
+
# Other dialects print TIMESTAMP too, as for sqlalchemy.TIMESTAMP
|
|
26
|
+
__visit_name__ = "TIMESTAMP"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DATE(D1Date):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TIME(D1Time):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BOOLEAN(D1Boolean):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Other dialects compile these like the generic types they extend
|
|
42
|
+
@compiles(DATETIME, "d1")
|
|
43
|
+
@compiles(TIMESTAMP, "d1")
|
|
44
|
+
@compiles(DATE, "d1")
|
|
45
|
+
@compiles(TIME, "d1")
|
|
46
|
+
@compiles(BOOLEAN, "d1")
|
|
47
|
+
def compile_declared_name(type_, compiler, **kw):
|
|
48
|
+
return type(type_).__name__
|
sqlalchemy_d1-0.1.0/PKG-INFO
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: sqlalchemy-d1
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary:
|
|
5
|
-
Author: Chad Rossouw
|
|
6
|
-
Author-email: chadrossouw7247@gmail.com
|
|
7
|
-
Requires-Python: >=3.11,<3.12
|
|
8
|
-
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
-
Requires-Dist: dbapi-d1 (>=0.1.0)
|
|
11
|
-
Requires-Dist: sqlalchemy (>=1.4,<2)
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
|
-
|
sqlalchemy_d1-0.1.0/README.md
DELETED
|
File without changes
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "sqlalchemy-d1"
|
|
3
|
-
version = "0.1.0"
|
|
4
|
-
description = ""
|
|
5
|
-
authors = [
|
|
6
|
-
{name = "Chad Rossouw",email = "chadrossouw7247@gmail.com"},
|
|
7
|
-
{name = "Murphy Lee",email = "murphylee2004@gmail.com"},
|
|
8
|
-
{name = "Shreyas Rao",email = "raoshreyas2004@gmail.com"},
|
|
9
|
-
{name = "Alan Zhang",email = "zhangalan54@gmail.com"},
|
|
10
|
-
{name = "Daniel Alyoshin",email = "daniel.alyoshin@gmail.com"}
|
|
11
|
-
]
|
|
12
|
-
readme = "README.md"
|
|
13
|
-
requires-python = ">=3.11,<3.12"
|
|
14
|
-
dynamic = ["dependencies"]
|
|
15
|
-
|
|
16
|
-
[tool.poetry]
|
|
17
|
-
packages = [{include = "sqlalchemy_d1", from = "src"}]
|
|
18
|
-
|
|
19
|
-
[tool.poetry.dependencies]
|
|
20
|
-
python = ">=3.11,<3.12"
|
|
21
|
-
sqlalchemy = ">=1.4,<2"
|
|
22
|
-
dbapi-d1 = ">=0.1.0" # published version
|
|
23
|
-
|
|
24
|
-
[tool.poetry.group.dev.dependencies]
|
|
25
|
-
pytest = ">=8.4.2,<9.0.0"
|
|
26
|
-
black = ">=25.9.0,<26.0.0"
|
|
27
|
-
mypy = ">=1.18.2,<2.0.0"
|
|
28
|
-
|
|
29
|
-
[build-system]
|
|
30
|
-
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
31
|
-
build-backend = "poetry.core.masonry.api"
|
|
32
|
-
|
|
33
|
-
[project.entry-points."sqlalchemy.dialects"]
|
|
34
|
-
d1 = "sqlalchemy_d1.dialect:D1Dialect"
|
|
35
|
-
|
|
36
|
-
# [tool.poetry.plugins."sqlalchemy.dialects"]
|
|
37
|
-
# d1 = "sqlalchemy_d1.dialect:D1Dialect"
|
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
# sqlalchemy_d1/dialect.py
|
|
2
|
-
import dbapi_d1
|
|
3
|
-
from sqlalchemy.engine.default import DefaultDialect
|
|
4
|
-
from sqlalchemy import text, types as sqltypes
|
|
5
|
-
from sqlalchemy.engine import reflection
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class D1Dialect(DefaultDialect):
|
|
9
|
-
name = "d1"
|
|
10
|
-
driver = "dbapi-d1"
|
|
11
|
-
supports_alter = False
|
|
12
|
-
supports_sane_rowcount = True
|
|
13
|
-
supports_sane_multi_rowcount = True
|
|
14
|
-
supports_statement_cache = True
|
|
15
|
-
paramstyle = "qmark"
|
|
16
|
-
|
|
17
|
-
def create_connect_args(self, url):
|
|
18
|
-
# URL format: d1://<account_id>:<api_token>@<database_id>
|
|
19
|
-
account_id = url.username
|
|
20
|
-
api_token = url.password
|
|
21
|
-
database_id = url.host
|
|
22
|
-
return (
|
|
23
|
-
(),
|
|
24
|
-
{
|
|
25
|
-
"account_id": account_id,
|
|
26
|
-
"api_token": api_token,
|
|
27
|
-
"database_id": database_id,
|
|
28
|
-
},
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
def do_ping(self, dbapi_connection) -> bool:
|
|
32
|
-
"""
|
|
33
|
-
Return if the database can be reached.
|
|
34
|
-
"""
|
|
35
|
-
try:
|
|
36
|
-
dbapi_connection.execute(text("SELECT 1"))
|
|
37
|
-
except Exception as ex:
|
|
38
|
-
return False
|
|
39
|
-
|
|
40
|
-
return True
|
|
41
|
-
|
|
42
|
-
def do_execute(self, cursor, statement, parameters, context=None):
|
|
43
|
-
cursor.execute(statement, parameters)
|
|
44
|
-
|
|
45
|
-
def do_on_first_connect(self, conn, branch):
|
|
46
|
-
# This is called by SQLAlchemy on the first raw connection
|
|
47
|
-
# Reset the flag so future rollbacks raise error
|
|
48
|
-
conn._first_connect = False
|
|
49
|
-
|
|
50
|
-
def import_dbapi():
|
|
51
|
-
return dbapi_d1
|
|
52
|
-
|
|
53
|
-
def dbapi():
|
|
54
|
-
return dbapi_d1
|
|
55
|
-
|
|
56
|
-
@reflection.cache
|
|
57
|
-
def get_schema_names(self, connection, **kwargs):
|
|
58
|
-
# D1 is built on SQLite, which only uses one schema
|
|
59
|
-
return ["main"]
|
|
60
|
-
|
|
61
|
-
@reflection.cache
|
|
62
|
-
def get_table_names(self, connection, schema=None, **kw):
|
|
63
|
-
"""
|
|
64
|
-
Return list of table names in the D1 database.
|
|
65
|
-
"""
|
|
66
|
-
try:
|
|
67
|
-
result = connection.execute(
|
|
68
|
-
text("SELECT name FROM sqlite_master WHERE type='table';")
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
all_tables = [row[0] for row in result.fetchall()]
|
|
72
|
-
# Filter out cloudflare tables
|
|
73
|
-
visible_tables = [t for t in all_tables if not t.startswith("_cf")]
|
|
74
|
-
return visible_tables
|
|
75
|
-
except Exception as e:
|
|
76
|
-
raise RuntimeError(f"Failed to fetch table names: {e}")
|
|
77
|
-
|
|
78
|
-
@reflection.cache
|
|
79
|
-
def get_view_names(self, connection, schema=None, **kw):
|
|
80
|
-
"""
|
|
81
|
-
Return list of view names in the D1 database.
|
|
82
|
-
"""
|
|
83
|
-
try:
|
|
84
|
-
result = connection.execute(
|
|
85
|
-
text("SELECT name FROM sqlite_master WHERE type='view';")
|
|
86
|
-
)
|
|
87
|
-
all_views = [row[0] for row in result.fetchall()]
|
|
88
|
-
visible_views = [v for v in all_views if not v.startswith("_cf")]
|
|
89
|
-
return visible_views
|
|
90
|
-
except Exception as e:
|
|
91
|
-
raise RuntimeError(f"Failed to fetch view names: {e}")
|
|
92
|
-
|
|
93
|
-
@reflection.cache
|
|
94
|
-
def get_columns(self, connection, table_name, schema=None, **kw):
|
|
95
|
-
"""
|
|
96
|
-
Return column info for a given table in D1.
|
|
97
|
-
"""
|
|
98
|
-
try:
|
|
99
|
-
query = text(f"PRAGMA table_info({table_name});")
|
|
100
|
-
result = connection.execute(query).mappings()
|
|
101
|
-
columns = []
|
|
102
|
-
for row in result.fetchall():
|
|
103
|
-
columns.append(
|
|
104
|
-
{
|
|
105
|
-
"name": row["name"],
|
|
106
|
-
"type": self._resolve_type(row["type"]),
|
|
107
|
-
"nullable": not row["notnull"],
|
|
108
|
-
"default": row["dflt_value"],
|
|
109
|
-
"autoincrement": row["pk"] == 1,
|
|
110
|
-
}
|
|
111
|
-
)
|
|
112
|
-
return columns
|
|
113
|
-
except Exception as e:
|
|
114
|
-
raise RuntimeError(
|
|
115
|
-
f"Failed to fetch columns for table '{table_name}': {e}"
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
@reflection.cache
|
|
119
|
-
def get_primary_keys(self, connection, table_name, schema=None, **kw):
|
|
120
|
-
try:
|
|
121
|
-
query = text(f"PRAGMA table_info({table_name});")
|
|
122
|
-
result = connection.execute(query).mappings()
|
|
123
|
-
pks = [row["name"] for row in result.fetchall() if row["pk"] == 1]
|
|
124
|
-
return pks
|
|
125
|
-
except Exception as e:
|
|
126
|
-
raise RuntimeError(
|
|
127
|
-
f"Failed to fetch primary keys for table '{table_name}': {e}"
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
@reflection.cache
|
|
131
|
-
def get_pk_constraint(self, connection, table_name, schema=None, **kw):
|
|
132
|
-
"""
|
|
133
|
-
Return the primary key for the given table as a dict with:
|
|
134
|
-
- constrained_columns: list of columns in the PK
|
|
135
|
-
- name: name of the PK constraint (SQLite doesn't store names, so None)
|
|
136
|
-
"""
|
|
137
|
-
try:
|
|
138
|
-
result = connection.execute(
|
|
139
|
-
text(f"PRAGMA table_info({table_name});")
|
|
140
|
-
).mappings()
|
|
141
|
-
pk_columns = [
|
|
142
|
-
row["name"] for row in result.fetchall() if row["pk"] != 0
|
|
143
|
-
]
|
|
144
|
-
return {"constrained_columns": pk_columns, "name": None}
|
|
145
|
-
except Exception as e:
|
|
146
|
-
raise RuntimeError(
|
|
147
|
-
f"Failed to fetch primary key for '{table_name}': {e}"
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
@reflection.cache
|
|
151
|
-
def get_foreign_keys(self, connection, table_name, schema=None, **kw):
|
|
152
|
-
"""
|
|
153
|
-
Return list of foreign keys for the given table.
|
|
154
|
-
Each foreign key is a dict with keys: name, constrained_columns, referred_schema,
|
|
155
|
-
referred_table, referred_columns
|
|
156
|
-
"""
|
|
157
|
-
try:
|
|
158
|
-
result = connection.execute(
|
|
159
|
-
text(f"PRAGMA foreign_key_list({table_name});")
|
|
160
|
-
).mappings()
|
|
161
|
-
fks = []
|
|
162
|
-
for row in result.fetchall():
|
|
163
|
-
fks.append(
|
|
164
|
-
{
|
|
165
|
-
"name": row["id"], # SQLite assigns an integer id
|
|
166
|
-
"constrained_columns": [row["from"]],
|
|
167
|
-
"referred_schema": None,
|
|
168
|
-
"referred_table": row["table"],
|
|
169
|
-
"referred_columns": [row["to"]],
|
|
170
|
-
"options": {
|
|
171
|
-
"onupdate": row["on_update"],
|
|
172
|
-
"ondelete": row["on_delete"],
|
|
173
|
-
},
|
|
174
|
-
}
|
|
175
|
-
)
|
|
176
|
-
return fks
|
|
177
|
-
except Exception as e:
|
|
178
|
-
raise RuntimeError(
|
|
179
|
-
f"Failed to fetch foreign keys for '{table_name}': {e}"
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
@reflection.cache
|
|
183
|
-
def get_indexes(self, connection, table_name, schema=None, **kw):
|
|
184
|
-
"""
|
|
185
|
-
Return list of indexes for the given table.
|
|
186
|
-
Each index is a dict with keys: name, column_names, unique, primary_key
|
|
187
|
-
"""
|
|
188
|
-
try:
|
|
189
|
-
result = connection.execute(
|
|
190
|
-
text(
|
|
191
|
-
f"SELECT name, sql FROM sqlite_schema WHERE type='index' AND tbl_name='{table_name}';"
|
|
192
|
-
)
|
|
193
|
-
).mappings()
|
|
194
|
-
indexes = []
|
|
195
|
-
for row in result.fetchall():
|
|
196
|
-
sql = row["sql"] or ""
|
|
197
|
-
indexes.append(
|
|
198
|
-
{
|
|
199
|
-
"name": row["name"],
|
|
200
|
-
"column_names": self._parse_index_columns(sql),
|
|
201
|
-
"unique": "UNIQUE" in sql.upper(),
|
|
202
|
-
"primary_key": False, # primary keys handled separately
|
|
203
|
-
}
|
|
204
|
-
)
|
|
205
|
-
return indexes
|
|
206
|
-
except Exception as e:
|
|
207
|
-
raise RuntimeError(
|
|
208
|
-
f"Failed to fetch indexes for '{table_name}': {e}"
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
@reflection.cache
|
|
212
|
-
def get_unique_constraints(
|
|
213
|
-
self, connection, table_name, schema=None, **kw
|
|
214
|
-
):
|
|
215
|
-
"""
|
|
216
|
-
Return list of unique constraints for the table.
|
|
217
|
-
SQLite stores unique constraints as unique indexes.
|
|
218
|
-
"""
|
|
219
|
-
unique_constraints = []
|
|
220
|
-
indexes = self.get_indexes(connection, table_name, schema=schema)
|
|
221
|
-
for idx in indexes:
|
|
222
|
-
if idx["unique"]:
|
|
223
|
-
unique_constraints.append(
|
|
224
|
-
{
|
|
225
|
-
"name": idx["name"],
|
|
226
|
-
"column_names": idx["column_names"],
|
|
227
|
-
}
|
|
228
|
-
)
|
|
229
|
-
return unique_constraints
|
|
230
|
-
|
|
231
|
-
# Helper to parse index columns
|
|
232
|
-
def _parse_index_columns(self, sql):
|
|
233
|
-
"""
|
|
234
|
-
Extract column names from CREATE INDEX SQL statement.
|
|
235
|
-
e.g., "CREATE UNIQUE INDEX idx_name ON mytable(col1, col2)"
|
|
236
|
-
"""
|
|
237
|
-
import re
|
|
238
|
-
|
|
239
|
-
m = re.search(r"\((.*?)\)", sql)
|
|
240
|
-
if m:
|
|
241
|
-
return [c.strip().strip('"') for c in m.group(1).split(",")]
|
|
242
|
-
return []
|
|
243
|
-
|
|
244
|
-
@reflection.cache
|
|
245
|
-
def has_table(self, connection, table_name, schema=None, **kw):
|
|
246
|
-
"""
|
|
247
|
-
Return True if the table exists in the database.
|
|
248
|
-
"""
|
|
249
|
-
try:
|
|
250
|
-
result = connection.execute(
|
|
251
|
-
text(
|
|
252
|
-
"SELECT 1 FROM sqlite_master "
|
|
253
|
-
"WHERE type='table' AND name=:table_name;"
|
|
254
|
-
),
|
|
255
|
-
{"table_name": table_name},
|
|
256
|
-
)
|
|
257
|
-
return result.scalar() is not None
|
|
258
|
-
except Exception as e:
|
|
259
|
-
raise RuntimeError(
|
|
260
|
-
f"Failed to check existence of table '{table_name}': {e}"
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
def get_check_constraints(
|
|
264
|
-
self, connection, table_name, schema=None, **kwargs
|
|
265
|
-
):
|
|
266
|
-
return []
|
|
267
|
-
|
|
268
|
-
def get_table_comment(self, connection, table_name, schema=None, **kwargs):
|
|
269
|
-
return {"text": ""}
|
|
270
|
-
|
|
271
|
-
def get_view_definition(
|
|
272
|
-
self, connection, view_name, schema=None, **kwargs
|
|
273
|
-
):
|
|
274
|
-
pass
|
|
275
|
-
|
|
276
|
-
def do_rollback(self, dbapi_connection):
|
|
277
|
-
pass
|
|
278
|
-
|
|
279
|
-
def _resolve_type(self, d1_type: str):
|
|
280
|
-
"""
|
|
281
|
-
Map D1/SQLite type string to SQLAlchemy type.
|
|
282
|
-
"""
|
|
283
|
-
if d1_type is None:
|
|
284
|
-
return sqltypes.NullType()
|
|
285
|
-
t = d1_type.upper()
|
|
286
|
-
if "INT" in t:
|
|
287
|
-
return sqltypes.Integer()
|
|
288
|
-
elif "CHAR" in t or "CLOB" in t or "TEXT" in t:
|
|
289
|
-
return sqltypes.String()
|
|
290
|
-
elif "BLOB" in t:
|
|
291
|
-
return sqltypes.LargeBinary()
|
|
292
|
-
elif "REAL" in t or "FLOA" in t or "DOUB" in t:
|
|
293
|
-
return sqltypes.Float()
|
|
294
|
-
elif "NUMERIC" in t or "DECIMAL" in t:
|
|
295
|
-
return sqltypes.Numeric()
|
|
296
|
-
elif "BOOL" in t:
|
|
297
|
-
return sqltypes.Boolean()
|
|
298
|
-
else:
|
|
299
|
-
return sqltypes.String() # fallback
|