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.
Files changed (35) hide show
  1. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/CHANGELOG.md +13 -0
  2. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/PKG-INFO +1 -1
  3. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/src/entry.py +170 -0
  4. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/uv.lock +1 -1
  5. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/pyproject.toml +1 -1
  6. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/compiler.py +10 -4
  7. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/dialect.py +101 -10
  8. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/integration/test_restapi_integration.py +97 -0
  9. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/integration/test_worker_integration.py +39 -0
  10. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/unit/test_dialect.py +121 -0
  11. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/uv.lock +1 -1
  12. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.github/DEVELOPMENT.md +0 -0
  13. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.github/workflows/ci.yml +0 -0
  14. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.github/workflows/release.yml +0 -0
  15. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.gitignore +0 -0
  16. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.pre-commit-config.yaml +0 -0
  17. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/.python-version +0 -0
  18. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/LICENSE +0 -0
  19. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/Makefile +0 -0
  20. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/README.md +0 -0
  21. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/assets/d1-logo.png +0 -0
  22. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/assets/sqlalchemy-logo.png +0 -0
  23. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/restapi/example.py +0 -0
  24. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/README.md +0 -0
  25. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/db_init.sql +0 -0
  26. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/pyproject.toml +0 -0
  27. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/examples/workers/wrangler.jsonc +0 -0
  28. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/__init__.py +0 -0
  29. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/connection.py +0 -0
  30. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/dialect_async.py +0 -0
  31. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/src/sqlalchemy_cloudflare_d1/py.typed +0 -0
  32. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/__init__.py +0 -0
  33. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/conftest.py +0 -0
  34. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.3.11}/tests/integration/test_base_url_integration.py +0 -0
  35. {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.10
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
@@ -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):
@@ -614,7 +614,7 @@ wheels = [
614
614
 
615
615
  [[package]]
616
616
  name = "sqlalchemy-cloudflare-d1"
617
- version = "0.3.10"
617
+ version = "0.3.11"
618
618
  source = { editable = "../../" }
619
619
  dependencies = [
620
620
  { name = "httpx" },
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlalchemy-cloudflare-d1"
3
- version = "0.3.10"
3
+ version = "0.3.11"
4
4
  description = "A SQLAlchemy dialect for Cloudflare's D1 Serverless SQLite Database"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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 if first_pk=True
161
- # This prevents duplicate PRIMARY KEY constraints
162
- # (one inline, one as separate constraint)
163
- if column.primary_key and first_pk:
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
- col_query = text(
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
 
@@ -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
@@ -922,7 +922,7 @@ wheels = [
922
922
 
923
923
  [[package]]
924
924
  name = "sqlalchemy-cloudflare-d1"
925
- version = "0.3.10"
925
+ version = "0.3.11"
926
926
  source = { editable = "." }
927
927
  dependencies = [
928
928
  { name = "httpx" },