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.
@@ -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__
@@ -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
-
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,6 +0,0 @@
1
- """SQLAlchemy dialect for Cloudflare D1"""
2
-
3
- from .dialect import D1Dialect
4
- from sqlalchemy.dialects import registry
5
-
6
- registry.register("d1", "sqlalchemy_d1.dialect", "D1Dialect")
@@ -1,5 +0,0 @@
1
- from sqlalchemy.sql.compiler import SQLCompiler
2
-
3
-
4
- class D1Compiler(SQLCompiler):
5
- pass
@@ -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
@@ -1,5 +0,0 @@
1
- from sqlalchemy.sql.type_api import TypeEngine
2
-
3
-
4
- class D1TypeCompiler(TypeEngine):
5
- pass