sqlalchemy-cloudflare-d1 0.3.10__tar.gz → 0.3.11__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_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/CHANGELOG.md +13 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/PKG-INFO +1 -1
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/src/entry.py +170 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/uv.lock +1 -1
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/pyproject.toml +1 -1
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/compiler.py +10 -4
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/dialect.py +101 -10
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/integration/test_restapi_integration.py +97 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/integration/test_worker_integration.py +39 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/unit/test_dialect.py +121 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/uv.lock +1 -1
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.github/DEVELOPMENT.md +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.github/workflows/ci.yml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.github/workflows/release.yml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.gitignore +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.pre-commit-config.yaml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.python-version +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/LICENSE +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/Makefile +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/README.md +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/assets/d1-logo.png +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/assets/sqlalchemy-logo.png +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/restapi/example.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/README.md +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/db_init.sql +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/pyproject.toml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/wrangler.jsonc +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/__init__.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/connection.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/dialect_async.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/py.typed +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/__init__.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/conftest.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/integration/test_base_url_integration.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/test_utils.py +0 -0
|
@@ -13,6 +13,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
13
13
|
### Fixed
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
## [0.3.11]
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Added REST API and Worker integration coverage for composite primary key DDL and constraint reflection
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Fixed composite primary key DDL emitting duplicate `PRIMARY KEY` clauses, which D1 rejects with `SQLITE_ERROR`
|
|
25
|
+
- Fixed foreign key reflection to include SQLAlchemy's expected `referred_schema` key
|
|
26
|
+
- Implemented unique constraint reflection for D1 tables, including inline and named table-level unique constraints
|
|
27
|
+
|
|
28
|
+
|
|
16
29
|
## [0.3.10]
|
|
17
30
|
|
|
18
31
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemy-cloudflare-d1
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.11
|
|
4
4
|
Summary: A SQLAlchemy dialect for Cloudflare's D1 Serverless SQLite Database
|
|
5
5
|
Project-URL: Homepage, https://github.com/collierking/sqlalchemy-cloudflare-d1
|
|
6
6
|
Project-URL: Repository, https://github.com/collierking/sqlalchemy-cloudflare-d1
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/src/entry.py
RENAMED
|
@@ -41,8 +41,12 @@ class Default(WorkerEntrypoint):
|
|
|
41
41
|
return await self.test_sqlalchemy_select()
|
|
42
42
|
elif path == "sqlalchemy-crud":
|
|
43
43
|
return await self.test_sqlalchemy_crud()
|
|
44
|
+
elif path == "sqlalchemy-composite-pk":
|
|
45
|
+
return await self.test_sqlalchemy_composite_pk()
|
|
44
46
|
elif path == "sqlalchemy-reflect":
|
|
45
47
|
return await self.test_sqlalchemy_reflect()
|
|
48
|
+
elif path == "sqlalchemy-reflect-constraints":
|
|
49
|
+
return await self.test_sqlalchemy_reflect_constraints()
|
|
46
50
|
# Empty result set tests (GitHub issue #4)
|
|
47
51
|
elif path == "empty-result":
|
|
48
52
|
return await self.test_empty_result()
|
|
@@ -188,7 +192,9 @@ class Default(WorkerEntrypoint):
|
|
|
188
192
|
"/parameterized": "Test parameterized queries",
|
|
189
193
|
"/sqlalchemy-select": "Test SQLAlchemy Core SELECT (no raw SQL)",
|
|
190
194
|
"/sqlalchemy-crud": "Test SQLAlchemy Core CRUD (no raw SQL)",
|
|
195
|
+
"/sqlalchemy-composite-pk": "Test SQLAlchemy composite primary key DDL",
|
|
191
196
|
"/sqlalchemy-reflect": "Test SQLAlchemy table reflection",
|
|
197
|
+
"/sqlalchemy-reflect-constraints": "Test SQLAlchemy constraint reflection",
|
|
192
198
|
"/empty-result": "Test empty result set description (issue #4)",
|
|
193
199
|
"/empty-result-sqlalchemy": "Test SQLAlchemy empty result (issue #4)",
|
|
194
200
|
"/json-filter": "Test filtering on JSON array columns",
|
|
@@ -548,6 +554,76 @@ class Default(WorkerEntrypoint):
|
|
|
548
554
|
status=500,
|
|
549
555
|
)
|
|
550
556
|
|
|
557
|
+
async def test_sqlalchemy_composite_pk(self):
|
|
558
|
+
"""Test SQLAlchemy composite primary key DDL against D1 binding."""
|
|
559
|
+
from sqlalchemy import Column, MetaData, String, Table, select
|
|
560
|
+
|
|
561
|
+
table_name = f"test_composite_pk_{uuid.uuid4().hex[:8]}"
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
engine = self.get_engine()
|
|
565
|
+
metadata = MetaData()
|
|
566
|
+
|
|
567
|
+
test_table = Table(
|
|
568
|
+
table_name,
|
|
569
|
+
metadata,
|
|
570
|
+
Column("tenant_id", String, primary_key=True),
|
|
571
|
+
Column("record_key", String, primary_key=True),
|
|
572
|
+
Column("value", String),
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
metadata.create_all(engine)
|
|
576
|
+
|
|
577
|
+
with engine.connect() as conn:
|
|
578
|
+
conn.execute(
|
|
579
|
+
test_table.insert().values(
|
|
580
|
+
tenant_id="tenant_a",
|
|
581
|
+
record_key="label_a",
|
|
582
|
+
value="value_a",
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
conn.commit()
|
|
586
|
+
|
|
587
|
+
result = conn.execute(
|
|
588
|
+
select(
|
|
589
|
+
test_table.c.tenant_id,
|
|
590
|
+
test_table.c.record_key,
|
|
591
|
+
test_table.c.value,
|
|
592
|
+
)
|
|
593
|
+
)
|
|
594
|
+
row = result.fetchone()
|
|
595
|
+
columns = list(result.keys())
|
|
596
|
+
|
|
597
|
+
metadata.drop_all(engine)
|
|
598
|
+
|
|
599
|
+
success = row is not None and tuple(row) == (
|
|
600
|
+
"tenant_a",
|
|
601
|
+
"label_a",
|
|
602
|
+
"value_a",
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
return Response.json(
|
|
606
|
+
{
|
|
607
|
+
"test": "sqlalchemy_composite_pk",
|
|
608
|
+
"success": success,
|
|
609
|
+
"table_name": table_name,
|
|
610
|
+
"columns": columns,
|
|
611
|
+
"row": list(row) if row is not None else None,
|
|
612
|
+
}
|
|
613
|
+
)
|
|
614
|
+
except Exception as e:
|
|
615
|
+
try:
|
|
616
|
+
engine = self.get_engine()
|
|
617
|
+
metadata = MetaData()
|
|
618
|
+
test_table = Table(table_name, metadata)
|
|
619
|
+
metadata.drop_all(engine)
|
|
620
|
+
except Exception:
|
|
621
|
+
pass
|
|
622
|
+
return Response.json(
|
|
623
|
+
{"test": "sqlalchemy_composite_pk", "success": False, "error": str(e)},
|
|
624
|
+
status=500,
|
|
625
|
+
)
|
|
626
|
+
|
|
551
627
|
async def test_sqlalchemy_reflect(self):
|
|
552
628
|
"""Test SQLAlchemy table reflection.
|
|
553
629
|
|
|
@@ -628,6 +704,100 @@ class Default(WorkerEntrypoint):
|
|
|
628
704
|
status=500,
|
|
629
705
|
)
|
|
630
706
|
|
|
707
|
+
async def test_sqlalchemy_reflect_constraints(self):
|
|
708
|
+
"""Test SQLAlchemy foreign key and unique constraint reflection."""
|
|
709
|
+
parent_table_name = f"test_reflect_parent_{uuid.uuid4().hex[:8]}"
|
|
710
|
+
child_table_name = f"test_reflect_child_{uuid.uuid4().hex[:8]}"
|
|
711
|
+
unique_constraint_name = f"uq_{uuid.uuid4().hex[:12]}"
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
from sqlalchemy import (
|
|
715
|
+
Column,
|
|
716
|
+
ForeignKey,
|
|
717
|
+
Integer,
|
|
718
|
+
MetaData,
|
|
719
|
+
String,
|
|
720
|
+
Table,
|
|
721
|
+
UniqueConstraint,
|
|
722
|
+
inspect,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
engine = self.get_engine()
|
|
726
|
+
metadata = MetaData()
|
|
727
|
+
|
|
728
|
+
Table(
|
|
729
|
+
parent_table_name,
|
|
730
|
+
metadata,
|
|
731
|
+
Column("id", Integer, primary_key=True),
|
|
732
|
+
Column("slug", String, unique=True),
|
|
733
|
+
)
|
|
734
|
+
Table(
|
|
735
|
+
child_table_name,
|
|
736
|
+
metadata,
|
|
737
|
+
Column("id", Integer, primary_key=True),
|
|
738
|
+
Column("parent_id", Integer, ForeignKey(f"{parent_table_name}.id")),
|
|
739
|
+
Column("tenant_id", String),
|
|
740
|
+
Column("record_key", String),
|
|
741
|
+
UniqueConstraint(
|
|
742
|
+
"tenant_id",
|
|
743
|
+
"record_key",
|
|
744
|
+
name=unique_constraint_name,
|
|
745
|
+
),
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
metadata.create_all(engine)
|
|
749
|
+
|
|
750
|
+
inspector = inspect(engine)
|
|
751
|
+
foreign_keys = inspector.get_foreign_keys(child_table_name)
|
|
752
|
+
unique_constraints = inspector.get_unique_constraints(child_table_name)
|
|
753
|
+
parent_unique_constraints = inspector.get_unique_constraints(
|
|
754
|
+
parent_table_name
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
metadata.drop_all(engine)
|
|
758
|
+
|
|
759
|
+
expected_child_unique = {
|
|
760
|
+
"name": unique_constraint_name,
|
|
761
|
+
"column_names": ["tenant_id", "record_key"],
|
|
762
|
+
}
|
|
763
|
+
expected_parent_unique = {"name": None, "column_names": ["slug"]}
|
|
764
|
+
foreign_key = foreign_keys[0] if foreign_keys else {}
|
|
765
|
+
success = (
|
|
766
|
+
foreign_key.get("constrained_columns") == ["parent_id"]
|
|
767
|
+
and foreign_key.get("referred_schema") is None
|
|
768
|
+
and foreign_key.get("referred_table") == parent_table_name
|
|
769
|
+
and foreign_key.get("referred_columns") == ["id"]
|
|
770
|
+
and expected_child_unique in unique_constraints
|
|
771
|
+
and expected_parent_unique in parent_unique_constraints
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
return Response.json(
|
|
775
|
+
{
|
|
776
|
+
"test": "sqlalchemy_reflect_constraints",
|
|
777
|
+
"success": success,
|
|
778
|
+
"foreign_keys": foreign_keys,
|
|
779
|
+
"unique_constraints": unique_constraints,
|
|
780
|
+
"parent_unique_constraints": parent_unique_constraints,
|
|
781
|
+
}
|
|
782
|
+
)
|
|
783
|
+
except Exception as e:
|
|
784
|
+
try:
|
|
785
|
+
conn = self.get_connection()
|
|
786
|
+
cursor = conn.cursor()
|
|
787
|
+
await cursor.execute_async(f"DROP TABLE IF EXISTS {child_table_name}")
|
|
788
|
+
await cursor.execute_async(f"DROP TABLE IF EXISTS {parent_table_name}")
|
|
789
|
+
conn.close()
|
|
790
|
+
except Exception:
|
|
791
|
+
pass
|
|
792
|
+
return Response.json(
|
|
793
|
+
{
|
|
794
|
+
"test": "sqlalchemy_reflect_constraints",
|
|
795
|
+
"success": False,
|
|
796
|
+
"error": str(e),
|
|
797
|
+
},
|
|
798
|
+
status=500,
|
|
799
|
+
)
|
|
800
|
+
|
|
631
801
|
# MARK: - Empty Result Set Tests (GitHub issue #4)
|
|
632
802
|
|
|
633
803
|
async def test_empty_result(self):
|
|
@@ -157,10 +157,16 @@ class CloudflareD1DDLCompiler(SQLiteDDLCompiler):
|
|
|
157
157
|
if not column.nullable:
|
|
158
158
|
colspec += " NOT NULL"
|
|
159
159
|
|
|
160
|
-
# Only add PRIMARY KEY inline
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
|
|
160
|
+
# Only add PRIMARY KEY inline for a SINGLE-column primary key. A
|
|
161
|
+
# composite primary key is emitted at the table level (see
|
|
162
|
+
# create_table_constraints), so inlining it here as well would produce
|
|
163
|
+
# two PRIMARY KEY clauses, which D1 rejects ("more than one primary
|
|
164
|
+
# key": SQLITE_ERROR).
|
|
165
|
+
if (
|
|
166
|
+
column.primary_key
|
|
167
|
+
and first_pk
|
|
168
|
+
and len(column.table.primary_key.columns) == 1
|
|
169
|
+
):
|
|
164
170
|
colspec += " PRIMARY KEY"
|
|
165
171
|
|
|
166
172
|
if column.computed is not None:
|
|
@@ -4,6 +4,7 @@ SQLAlchemy dialect for Cloudflare D1.
|
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
6
|
import enum as enum_module
|
|
7
|
+
import re
|
|
7
8
|
import uuid as uuid_module
|
|
8
9
|
from datetime import date, datetime, time
|
|
9
10
|
from typing import Any, Callable, Dict, List, Optional
|
|
@@ -627,6 +628,7 @@ class CloudflareD1Dialect(default.DefaultDialect):
|
|
|
627
628
|
fks[fk_id] = {
|
|
628
629
|
"name": None,
|
|
629
630
|
"constrained_columns": [],
|
|
631
|
+
"referred_schema": schema,
|
|
630
632
|
"referred_table": row[2],
|
|
631
633
|
"referred_columns": [],
|
|
632
634
|
"options": {"onupdate": row[5], "ondelete": row[6]},
|
|
@@ -637,6 +639,42 @@ class CloudflareD1Dialect(default.DefaultDialect):
|
|
|
637
639
|
|
|
638
640
|
return list(fks.values())
|
|
639
641
|
|
|
642
|
+
def get_unique_constraints( # type: ignore[override]
|
|
643
|
+
self, connection: Any, table_name: str, schema: Optional[str] = None, **kw: Any
|
|
644
|
+
) -> List[Dict[str, Any]]:
|
|
645
|
+
"""Get unique constraint information."""
|
|
646
|
+
query = text(
|
|
647
|
+
f"PRAGMA index_list({self.identifier_preparer.quote_identifier(table_name)})"
|
|
648
|
+
)
|
|
649
|
+
result = connection.execute(query)
|
|
650
|
+
|
|
651
|
+
unique_constraints_by_sig: Dict[tuple[str, ...], Dict[str, Any]] = {}
|
|
652
|
+
for row in result:
|
|
653
|
+
# PRAGMA index_list returns: seq, name, unique, origin, partial
|
|
654
|
+
if not bool(row[2]):
|
|
655
|
+
continue
|
|
656
|
+
|
|
657
|
+
origin = row[3] if len(row) > 3 else None
|
|
658
|
+
if origin in {"c", "pk"}:
|
|
659
|
+
# Explicit unique indexes are reflected by get_indexes(); primary
|
|
660
|
+
# key autoindexes are reflected by get_pk_constraint().
|
|
661
|
+
continue
|
|
662
|
+
|
|
663
|
+
index_name = row[1]
|
|
664
|
+
column_names = self._get_index_column_names(connection, index_name)
|
|
665
|
+
unique_constraints_by_sig[tuple(column_names)] = {
|
|
666
|
+
"name": None,
|
|
667
|
+
"column_names": column_names,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
table_sql = self._get_table_sql(connection, table_name)
|
|
671
|
+
for constraint_name, column_names in self._parse_unique_constraints(table_sql):
|
|
672
|
+
constraint = unique_constraints_by_sig.get(tuple(column_names))
|
|
673
|
+
if constraint is not None:
|
|
674
|
+
constraint["name"] = constraint_name
|
|
675
|
+
|
|
676
|
+
return list(unique_constraints_by_sig.values())
|
|
677
|
+
|
|
640
678
|
def get_indexes( # type: ignore[override]
|
|
641
679
|
self, connection: Any, table_name: str, schema: Optional[str] = None, **kw: Any
|
|
642
680
|
) -> List[Dict[str, Any]]:
|
|
@@ -647,22 +685,15 @@ class CloudflareD1Dialect(default.DefaultDialect):
|
|
|
647
685
|
result = connection.execute(query)
|
|
648
686
|
|
|
649
687
|
indexes = []
|
|
688
|
+
include_auto_indexes = kw.get("include_auto_indexes", False)
|
|
650
689
|
for row in result:
|
|
651
690
|
# PRAGMA index_list returns: seq, name, unique, origin, partial
|
|
652
691
|
index_name = row[1]
|
|
653
|
-
if index_name.startswith("sqlite_autoindex_"):
|
|
692
|
+
if index_name.startswith("sqlite_autoindex_") and not include_auto_indexes:
|
|
654
693
|
continue # Skip auto-generated indexes
|
|
655
694
|
|
|
656
695
|
# Get column information for this index
|
|
657
|
-
|
|
658
|
-
f"PRAGMA index_info({self.identifier_preparer.quote_identifier(index_name)})"
|
|
659
|
-
)
|
|
660
|
-
col_result = connection.execute(col_query)
|
|
661
|
-
|
|
662
|
-
column_names = []
|
|
663
|
-
for col_row in col_result:
|
|
664
|
-
# PRAGMA index_info returns: seqno, cid, name
|
|
665
|
-
column_names.append(col_row[2])
|
|
696
|
+
column_names = self._get_index_column_names(connection, index_name)
|
|
666
697
|
|
|
667
698
|
indexes.append(
|
|
668
699
|
{
|
|
@@ -673,3 +704,63 @@ class CloudflareD1Dialect(default.DefaultDialect):
|
|
|
673
704
|
)
|
|
674
705
|
|
|
675
706
|
return indexes
|
|
707
|
+
|
|
708
|
+
def _get_index_column_names(self, connection: Any, index_name: str) -> List[str]:
|
|
709
|
+
"""Get column names for an index."""
|
|
710
|
+
col_query = text(
|
|
711
|
+
f"PRAGMA index_info({self.identifier_preparer.quote_identifier(index_name)})"
|
|
712
|
+
)
|
|
713
|
+
col_result = connection.execute(col_query)
|
|
714
|
+
|
|
715
|
+
column_names = []
|
|
716
|
+
for col_row in col_result:
|
|
717
|
+
# PRAGMA index_info returns: seqno, cid, name
|
|
718
|
+
column_names.append(col_row[2])
|
|
719
|
+
|
|
720
|
+
return column_names
|
|
721
|
+
|
|
722
|
+
def _get_table_sql(self, connection: Any, table_name: str) -> Optional[str]:
|
|
723
|
+
"""Get the CREATE TABLE SQL stored in sqlite_master."""
|
|
724
|
+
query = text("""
|
|
725
|
+
SELECT sql FROM sqlite_master
|
|
726
|
+
WHERE type='table' AND name=:table_name
|
|
727
|
+
""")
|
|
728
|
+
row = connection.execute(query, {"table_name": table_name}).fetchone()
|
|
729
|
+
return row[0] if row is not None else None
|
|
730
|
+
|
|
731
|
+
def _parse_unique_constraints(
|
|
732
|
+
self, table_sql: Optional[str]
|
|
733
|
+
) -> List[tuple[Optional[str], List[str]]]:
|
|
734
|
+
"""Parse unique constraint names and column lists from CREATE TABLE SQL."""
|
|
735
|
+
if table_sql is None:
|
|
736
|
+
return []
|
|
737
|
+
|
|
738
|
+
unique_constraints = []
|
|
739
|
+
unique_pattern = re.compile(
|
|
740
|
+
r'(?:CONSTRAINT\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))\s+)?'
|
|
741
|
+
r"UNIQUE\s*\(([^)]+)\)",
|
|
742
|
+
re.IGNORECASE,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
for match in unique_pattern.finditer(table_sql):
|
|
746
|
+
constraint_name = next(
|
|
747
|
+
(group for group in match.group(1, 2, 3, 4) if group), None
|
|
748
|
+
)
|
|
749
|
+
column_names = self._parse_column_list(match.group(5))
|
|
750
|
+
unique_constraints.append((constraint_name, column_names))
|
|
751
|
+
|
|
752
|
+
return unique_constraints
|
|
753
|
+
|
|
754
|
+
def _parse_column_list(self, column_list_sql: str) -> List[str]:
|
|
755
|
+
"""Parse a comma-separated identifier list."""
|
|
756
|
+
column_names = []
|
|
757
|
+
for raw_column in column_list_sql.split(","):
|
|
758
|
+
column = raw_column.strip()
|
|
759
|
+
if (
|
|
760
|
+
(column.startswith('"') and column.endswith('"'))
|
|
761
|
+
or (column.startswith("`") and column.endswith("`"))
|
|
762
|
+
or (column.startswith("[") and column.endswith("]"))
|
|
763
|
+
):
|
|
764
|
+
column = column[1:-1]
|
|
765
|
+
column_names.append(column)
|
|
766
|
+
return column_names
|
|
@@ -416,6 +416,103 @@ class TestSQLAlchemyEngine:
|
|
|
416
416
|
# Clean up
|
|
417
417
|
metadata.drop_all(d1_engine)
|
|
418
418
|
|
|
419
|
+
def test_engine_create_table_with_composite_primary_key(
|
|
420
|
+
self, d1_engine, test_table_name
|
|
421
|
+
):
|
|
422
|
+
"""Test creating and using a composite primary key table on D1."""
|
|
423
|
+
metadata = MetaData()
|
|
424
|
+
|
|
425
|
+
test_table = Table(
|
|
426
|
+
test_table_name,
|
|
427
|
+
metadata,
|
|
428
|
+
Column("tenant_id", String, primary_key=True),
|
|
429
|
+
Column("record_key", String, primary_key=True),
|
|
430
|
+
Column("value", String),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
metadata.create_all(d1_engine)
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
with d1_engine.connect() as conn:
|
|
437
|
+
conn.execute(
|
|
438
|
+
test_table.insert().values(
|
|
439
|
+
tenant_id="tenant_a",
|
|
440
|
+
record_key="label_a",
|
|
441
|
+
value="value_a",
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
conn.commit()
|
|
445
|
+
|
|
446
|
+
result = conn.execute(
|
|
447
|
+
select(
|
|
448
|
+
test_table.c.tenant_id,
|
|
449
|
+
test_table.c.record_key,
|
|
450
|
+
test_table.c.value,
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
row = result.fetchone()
|
|
454
|
+
|
|
455
|
+
assert row is not None
|
|
456
|
+
assert tuple(row) == ("tenant_a", "label_a", "value_a")
|
|
457
|
+
finally:
|
|
458
|
+
metadata.drop_all(d1_engine)
|
|
459
|
+
|
|
460
|
+
def test_engine_reflects_foreign_keys_and_unique_constraints(
|
|
461
|
+
self, d1_engine, test_table_name
|
|
462
|
+
):
|
|
463
|
+
"""Test SQLAlchemy reflection for D1 foreign keys and unique constraints."""
|
|
464
|
+
from sqlalchemy import ForeignKey, UniqueConstraint, inspect
|
|
465
|
+
|
|
466
|
+
metadata = MetaData()
|
|
467
|
+
parent_table_name = f"{test_table_name}_parent"
|
|
468
|
+
child_table_name = f"{test_table_name}_child"
|
|
469
|
+
unique_constraint_name = f"uq_{test_table_name}_tenant_record"
|
|
470
|
+
|
|
471
|
+
Table(
|
|
472
|
+
parent_table_name,
|
|
473
|
+
metadata,
|
|
474
|
+
Column("id", Integer, primary_key=True),
|
|
475
|
+
Column("slug", String, unique=True),
|
|
476
|
+
)
|
|
477
|
+
Table(
|
|
478
|
+
child_table_name,
|
|
479
|
+
metadata,
|
|
480
|
+
Column("id", Integer, primary_key=True),
|
|
481
|
+
Column("parent_id", Integer, ForeignKey(f"{parent_table_name}.id")),
|
|
482
|
+
Column("tenant_id", String),
|
|
483
|
+
Column("record_key", String),
|
|
484
|
+
UniqueConstraint(
|
|
485
|
+
"tenant_id",
|
|
486
|
+
"record_key",
|
|
487
|
+
name=unique_constraint_name,
|
|
488
|
+
),
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
metadata.create_all(d1_engine)
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
inspector = inspect(d1_engine)
|
|
495
|
+
foreign_keys = inspector.get_foreign_keys(child_table_name)
|
|
496
|
+
unique_constraints = inspector.get_unique_constraints(child_table_name)
|
|
497
|
+
parent_unique_constraints = inspector.get_unique_constraints(
|
|
498
|
+
parent_table_name
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
assert foreign_keys
|
|
502
|
+
foreign_key = foreign_keys[0]
|
|
503
|
+
assert foreign_key["constrained_columns"] == ["parent_id"]
|
|
504
|
+
assert foreign_key["referred_schema"] is None
|
|
505
|
+
assert foreign_key["referred_table"] == parent_table_name
|
|
506
|
+
assert foreign_key["referred_columns"] == ["id"]
|
|
507
|
+
|
|
508
|
+
assert {
|
|
509
|
+
"name": unique_constraint_name,
|
|
510
|
+
"column_names": ["tenant_id", "record_key"],
|
|
511
|
+
} in unique_constraints
|
|
512
|
+
assert {"name": None, "column_names": ["slug"]} in parent_unique_constraints
|
|
513
|
+
finally:
|
|
514
|
+
metadata.drop_all(d1_engine)
|
|
515
|
+
|
|
419
516
|
def test_engine_insert_and_select(self, d1_engine, test_table_name):
|
|
420
517
|
"""Test INSERT and SELECT using SQLAlchemy ORM-style."""
|
|
421
518
|
metadata = MetaData()
|
|
@@ -181,6 +181,19 @@ class TestSQLAlchemyCore:
|
|
|
181
181
|
assert "name" in data["columns"]
|
|
182
182
|
assert "value" in data["columns"]
|
|
183
183
|
|
|
184
|
+
def test_sqlalchemy_composite_primary_key(self, dev_server):
|
|
185
|
+
"""Test SQLAlchemy creates a composite primary key table in Workers."""
|
|
186
|
+
port = dev_server
|
|
187
|
+
response = requests.get(f"http://localhost:{port}/sqlalchemy-composite-pk")
|
|
188
|
+
|
|
189
|
+
assert response.status_code == 200
|
|
190
|
+
data = response.json()
|
|
191
|
+
|
|
192
|
+
assert data["test"] == "sqlalchemy_composite_pk"
|
|
193
|
+
assert data["success"] is True
|
|
194
|
+
assert data["row"] == ["tenant_a", "label_a", "value_a"]
|
|
195
|
+
assert data["columns"] == ["tenant_id", "record_key", "value"]
|
|
196
|
+
|
|
184
197
|
def test_sqlalchemy_reflect(self, dev_server):
|
|
185
198
|
"""Test SQLAlchemy table reflection with autoload_with."""
|
|
186
199
|
port = dev_server
|
|
@@ -200,6 +213,32 @@ class TestSQLAlchemyCore:
|
|
|
200
213
|
assert "username" in column_names
|
|
201
214
|
assert "email" in column_names
|
|
202
215
|
|
|
216
|
+
def test_sqlalchemy_reflect_constraints(self, dev_server):
|
|
217
|
+
"""Test SQLAlchemy reflection for foreign keys and unique constraints."""
|
|
218
|
+
port = dev_server
|
|
219
|
+
response = requests.get(
|
|
220
|
+
f"http://localhost:{port}/sqlalchemy-reflect-constraints"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
assert response.status_code == 200
|
|
224
|
+
data = response.json()
|
|
225
|
+
|
|
226
|
+
assert data["test"] == "sqlalchemy_reflect_constraints"
|
|
227
|
+
assert data["success"] is True
|
|
228
|
+
|
|
229
|
+
assert data["foreign_keys"]
|
|
230
|
+
foreign_key = data["foreign_keys"][0]
|
|
231
|
+
assert foreign_key["constrained_columns"] == ["parent_id"]
|
|
232
|
+
assert foreign_key["referred_schema"] is None
|
|
233
|
+
assert foreign_key["referred_columns"] == ["id"]
|
|
234
|
+
|
|
235
|
+
child_unique = data["unique_constraints"][0]
|
|
236
|
+
assert child_unique["column_names"] == ["tenant_id", "record_key"]
|
|
237
|
+
assert child_unique["name"] is not None
|
|
238
|
+
assert {"name": None, "column_names": ["slug"]} in data[
|
|
239
|
+
"parent_unique_constraints"
|
|
240
|
+
]
|
|
241
|
+
|
|
203
242
|
|
|
204
243
|
# MARK: - Empty Result Set Tests
|
|
205
244
|
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/unit/test_dialect.py
RENAMED
|
@@ -176,6 +176,127 @@ def test_create_table_no_autoincrement_on_text():
|
|
|
176
176
|
assert "AUTOINCREMENT" not in sql.upper(), f"Unexpected AUTOINCREMENT in: {sql}"
|
|
177
177
|
|
|
178
178
|
|
|
179
|
+
def test_create_table_composite_primary_key():
|
|
180
|
+
"""Test that a composite PRIMARY KEY emits exactly one constraint.
|
|
181
|
+
|
|
182
|
+
Regression: D1 rejects "more than one primary key" (SQLITE_ERROR). A
|
|
183
|
+
multi-column primary key must be rendered only as a table-level
|
|
184
|
+
PRIMARY KEY (...), never also inlined on the first column.
|
|
185
|
+
"""
|
|
186
|
+
from sqlalchemy import Column, MetaData, String, Table
|
|
187
|
+
from sqlalchemy.schema import CreateTable
|
|
188
|
+
|
|
189
|
+
dialect = CloudflareD1Dialect()
|
|
190
|
+
metadata = MetaData()
|
|
191
|
+
|
|
192
|
+
test_table = Table(
|
|
193
|
+
"test_table",
|
|
194
|
+
metadata,
|
|
195
|
+
Column("tenant_id", String, primary_key=True),
|
|
196
|
+
Column("key", String, primary_key=True),
|
|
197
|
+
Column("value", String),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
sql = str(CreateTable(test_table).compile(dialect=dialect))
|
|
201
|
+
|
|
202
|
+
pk_count = sql.upper().count("PRIMARY KEY")
|
|
203
|
+
assert pk_count == 1, f"Expected 1 PRIMARY KEY, found {pk_count} in: {sql}"
|
|
204
|
+
# The single constraint must be the table-level composite form.
|
|
205
|
+
assert "PRIMARY KEY (" in sql.upper(), f"composite PK not table-level in: {sql}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_get_foreign_keys_includes_referred_schema():
|
|
209
|
+
"""Test that foreign key reflection includes SQLAlchemy's schema key."""
|
|
210
|
+
from sqlalchemy import create_engine, text
|
|
211
|
+
|
|
212
|
+
dialect = CloudflareD1Dialect()
|
|
213
|
+
engine = create_engine("sqlite://")
|
|
214
|
+
|
|
215
|
+
with engine.begin() as conn:
|
|
216
|
+
conn.execute(text("CREATE TABLE parent (id INTEGER PRIMARY KEY)"))
|
|
217
|
+
conn.execute(
|
|
218
|
+
text("""
|
|
219
|
+
CREATE TABLE child (
|
|
220
|
+
id INTEGER PRIMARY KEY,
|
|
221
|
+
parent_id INTEGER,
|
|
222
|
+
FOREIGN KEY (parent_id) REFERENCES parent(id)
|
|
223
|
+
)
|
|
224
|
+
""")
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
foreign_keys = dialect.get_foreign_keys(conn, "child")
|
|
228
|
+
|
|
229
|
+
assert foreign_keys == [
|
|
230
|
+
{
|
|
231
|
+
"name": None,
|
|
232
|
+
"constrained_columns": ["parent_id"],
|
|
233
|
+
"referred_schema": None,
|
|
234
|
+
"referred_table": "parent",
|
|
235
|
+
"referred_columns": ["id"],
|
|
236
|
+
"options": {"onupdate": "NO ACTION", "ondelete": "NO ACTION"},
|
|
237
|
+
}
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_get_unique_constraints_reflects_inline_and_named_constraints():
|
|
242
|
+
"""Test that unique constraint reflection returns SQLAlchemy's shape."""
|
|
243
|
+
from sqlalchemy import create_engine, text
|
|
244
|
+
|
|
245
|
+
dialect = CloudflareD1Dialect()
|
|
246
|
+
engine = create_engine("sqlite://")
|
|
247
|
+
|
|
248
|
+
with engine.begin() as conn:
|
|
249
|
+
conn.execute(
|
|
250
|
+
text("""
|
|
251
|
+
CREATE TABLE example (
|
|
252
|
+
id INTEGER PRIMARY KEY,
|
|
253
|
+
slug TEXT UNIQUE,
|
|
254
|
+
tenant_id TEXT,
|
|
255
|
+
record_key TEXT,
|
|
256
|
+
CONSTRAINT uq_tenant_record UNIQUE (tenant_id, record_key)
|
|
257
|
+
)
|
|
258
|
+
""")
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
unique_constraints = dialect.get_unique_constraints(conn, "example")
|
|
262
|
+
|
|
263
|
+
assert unique_constraints == [
|
|
264
|
+
{"name": "uq_tenant_record", "column_names": ["tenant_id", "record_key"]},
|
|
265
|
+
{"name": None, "column_names": ["slug"]},
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_get_unique_constraints_excludes_unique_indexes():
|
|
270
|
+
"""Test that unique indexes stay in index reflection, not constraints."""
|
|
271
|
+
from sqlalchemy import create_engine, text
|
|
272
|
+
|
|
273
|
+
dialect = CloudflareD1Dialect()
|
|
274
|
+
engine = create_engine("sqlite://")
|
|
275
|
+
|
|
276
|
+
with engine.begin() as conn:
|
|
277
|
+
conn.execute(
|
|
278
|
+
text("""
|
|
279
|
+
CREATE TABLE example (
|
|
280
|
+
id INTEGER PRIMARY KEY,
|
|
281
|
+
name TEXT
|
|
282
|
+
)
|
|
283
|
+
""")
|
|
284
|
+
)
|
|
285
|
+
conn.execute(text("CREATE UNIQUE INDEX ix_example_name ON example (name)"))
|
|
286
|
+
|
|
287
|
+
unique_constraints = dialect.get_unique_constraints(conn, "example")
|
|
288
|
+
indexes = dialect.get_indexes(conn, "example")
|
|
289
|
+
|
|
290
|
+
assert unique_constraints == []
|
|
291
|
+
assert indexes == [
|
|
292
|
+
{
|
|
293
|
+
"name": "ix_example_name",
|
|
294
|
+
"column_names": ["name"],
|
|
295
|
+
"unique": True,
|
|
296
|
+
}
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
|
|
179
300
|
def test_async_dialect_import():
|
|
180
301
|
"""Test that the async dialect can be imported."""
|
|
181
302
|
from sqlalchemy_cloudflare_d1 import CloudflareD1Dialect_async
|
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.github/workflows/ci.yml
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.github/workflows/release.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/assets/sqlalchemy-logo.png
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/restapi/example.py
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/README.md
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/db_init.sql
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/pyproject.toml
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/wrangler.jsonc
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|