duckdb-sqlalchemy 1.5.4__tar.gz → 1.5.4.2__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 (84) hide show
  1. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/.gitignore +1 -0
  2. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/CHANGELOG.md +23 -1
  3. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/PKG-INFO +1 -1
  4. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/olap.md +8 -2
  5. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/__init__.py +101 -13
  6. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/_bulk_insert.py +4 -9
  7. duckdb_sqlalchemy-1.5.4.2/duckdb_sqlalchemy/_row_shape.py +42 -0
  8. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/bulk.py +10 -18
  9. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/olap.py +4 -0
  10. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/test_basic.py +35 -0
  11. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/test_core_units.py +80 -0
  12. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/pyproject.toml +1 -1
  13. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/uv.lock +260 -304
  14. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/.codex/environments/environment.toml +0 -0
  15. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  17. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/.github/dependabot.yml +0 -0
  18. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/.github/workflows/lint.yml +0 -0
  19. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/.github/workflows/pages.yml +0 -0
  20. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/.github/workflows/publish.yaml +0 -0
  21. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/.github/workflows/pythonapp.yaml +0 -0
  22. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/.pre-commit-config.yaml +0 -0
  23. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/AGENTS.md +0 -0
  24. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/ARCHITECTURE.md +0 -0
  25. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/CLAUDE.md +0 -0
  26. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/CODE_OF_CONDUCT.md +0 -0
  27. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/LICENSE.txt +0 -0
  28. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/PLANS.md +0 -0
  29. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/README.md +0 -0
  30. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/ROADMAP.md +0 -0
  31. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/codecov.yml +0 -0
  32. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/README.md +0 -0
  33. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/_config.yml +0 -0
  34. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/alembic.md +0 -0
  35. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/configuration.md +0 -0
  36. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/connection-urls.md +0 -0
  37. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/exec-plans/active/2026-03-27-maintenance-review.md +0 -0
  38. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/exec-plans/active/2026-03-30-maintenance-review.md +0 -0
  39. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/exec-plans/active/2026-03-31-maintenance-review.md +0 -0
  40. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/getting-started.md +0 -0
  41. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/index.md +0 -0
  42. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/migration-from-duckdb-engine.md +0 -0
  43. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/motherduck.md +0 -0
  44. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/overview.md +0 -0
  45. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/pandas-jupyter.md +0 -0
  46. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/robots.txt +0 -0
  47. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/seo-checklist.md +0 -0
  48. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/docs/types-and-caveats.md +0 -0
  49. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/_arrow.py +0 -0
  50. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/_query.py +0 -0
  51. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/_statements.py +0 -0
  52. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/_supports.py +0 -0
  53. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/_validation.py +0 -0
  54. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/capabilities.py +0 -0
  55. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/config.py +0 -0
  56. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/conftest.py +0 -0
  57. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/datatypes.py +0 -0
  58. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/motherduck.py +0 -0
  59. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/py.typed +0 -0
  60. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/requirements.py +0 -0
  61. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/__init__.py +0 -0
  62. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/conftest.py +0 -0
  63. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/snapshots/test_datatypes/test_interval/schema.sql +0 -0
  64. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/sqlalchemy_suite/conftest.py +0 -0
  65. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/sqlalchemy_suite/test_suite.py +0 -0
  66. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/test_datatypes.py +0 -0
  67. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/test_execution_options.py +0 -0
  68. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/test_helpers.py +0 -0
  69. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/test_integration.py +0 -0
  70. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/test_pandas.py +0 -0
  71. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/test_pyarrow.py +0 -0
  72. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/test_validation.py +0 -0
  73. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/tests/util.py +0 -0
  74. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/duckdb_sqlalchemy/url.py +0 -0
  75. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/examples/motherduck_arrow_reads.py +0 -0
  76. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/examples/motherduck_attach_modes.py +0 -0
  77. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/examples/motherduck_multi_instance_pool.py +0 -0
  78. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/examples/motherduck_queuepool_high_concurrency.py +0 -0
  79. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/examples/motherduck_read_scaling_per_user.py +0 -0
  80. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/examples/sqlalchemy_example.py +0 -0
  81. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/llms.txt +0 -0
  82. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/noxfile.py +0 -0
  83. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/renovate.json +0 -0
  84. {duckdb_sqlalchemy-1.5.4 → duckdb_sqlalchemy-1.5.4.2}/test.cfg +0 -0
@@ -240,3 +240,4 @@ __marimo__/
240
240
 
241
241
  # Local Codex sandbox rules (machine-specific)
242
242
  .codex/rules/
243
+ notes/
@@ -6,7 +6,29 @@ preserved from the upstream project for historical context.
6
6
 
7
7
  ## Maintained in this fork
8
8
 
9
- ## Unreleased
9
+ ## [1.5.4.2](https://github.com/leonardovida/duckdb-sqlalchemy/compare/v1.5.4.1...v1.5.4.2) (2026-07-03)
10
+
11
+ ### Features
12
+
13
+ - expose MotherDuck Dive status metadata columns on `md_list_dives()`, `md_create_dive()`, `md_get_dive()`, and other summary-returning Dive helpers
14
+
15
+ ### Bug Fixes
16
+
17
+ - reject `copy_from_rows(..., include_header=True)` for sequence rows without explicit `columns` instead of letting DuckDB treat the first data row as the header
18
+
19
+ ### Maintenance
20
+
21
+ - centralize reflection result default handling for multi-reflection methods without changing public behavior
22
+
23
+ ### Tooling
24
+
25
+ - refresh the locked development dependency set to the latest non-breaking transitive releases within existing version caps
26
+
27
+ ## [1.5.4.1](https://github.com/leonardovida/duckdb-sqlalchemy/compare/v1.5.4...v1.5.4.1) (2026-07-01)
28
+
29
+ ### Bug Fixes
30
+
31
+ - render autoincrement integer primary keys with an implicit DuckDB sequence (`DEFAULT nextval(...)`) instead of PostgreSQL's unsupported `SERIAL`, so `Column(Integer, primary_key=True)` works without an explicit `Sequence` ([#128](https://github.com/leonardovida/duckdb-sqlalchemy/issues/128))
10
32
 
11
33
  ## [1.5.4](https://github.com/leonardovida/duckdb-sqlalchemy/compare/v1.5.3...v1.5.4) (2026-06-26)
12
34
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duckdb-sqlalchemy
3
- Version: 1.5.4
3
+ Version: 1.5.4.2
4
4
  Summary: DuckDB SQLAlchemy dialect for DuckDB and MotherDuck
5
5
  Project-URL: Bug Tracker, https://github.com/leonardovida/duckdb-sqlalchemy/issues
6
6
  Project-URL: Changelog, https://github.com/leonardovida/duckdb-sqlalchemy/releases
@@ -103,7 +103,13 @@ user_stmt = select(
103
103
  )
104
104
 
105
105
  dives = md_list_dives()
106
- dives_stmt = select(dives.c.id, dives.c.title, dives.c.required_resources)
106
+ dives_stmt = select(
107
+ dives.c.id,
108
+ dives.c.title,
109
+ dives.c.status,
110
+ dives.c.status_applies_to_version,
111
+ dives.c.required_resources,
112
+ )
107
113
 
108
114
  tokens = md_access_tokens()
109
115
  tokens_stmt = select(tokens.c.token_name, tokens.c.token_type, tokens.c.expire_at)
@@ -132,7 +138,7 @@ created = md_create_dive(
132
138
  create_stmt = select(created.c.id, created.c.version_id)
133
139
 
134
140
  dive = md_get_dive(id="00000000-0000-0000-0000-000000000000")
135
- dive_stmt = select(dive.c.title, dive.c.content)
141
+ dive_stmt = select(dive.c.title, dive.c.status, dive.c.content)
136
142
 
137
143
  updated = md_update_dive_content(
138
144
  id="00000000-0000-0000-0000-000000000000",
@@ -15,6 +15,7 @@ from typing import (
15
15
  Dict,
16
16
  Iterable,
17
17
  List,
18
+ Mapping,
18
19
  Optional,
19
20
  Sequence,
20
21
  Tuple,
@@ -24,10 +25,12 @@ from typing import (
24
25
  import duckdb
25
26
  import sqlalchemy
26
27
  from packaging.version import Version
27
- from sqlalchemy import pool, select, sql, text, util
28
+ from sqlalchemy import event, pool, select, sql, text, util
29
+ from sqlalchemy import schema as sa_schema
28
30
  from sqlalchemy import types as sqltypes
29
31
  from sqlalchemy.dialects.postgresql import UUID, insert
30
32
  from sqlalchemy.dialects.postgresql.base import (
33
+ PGDDLCompiler,
31
34
  PGDialect,
32
35
  PGIdentifierPreparer,
33
36
  PGInspector,
@@ -39,6 +42,7 @@ from sqlalchemy.engine.url import URL as SAURL
39
42
  from sqlalchemy.exc import InvalidRequestError, NoSuchTableError
40
43
  from sqlalchemy.ext.compiler import compiles
41
44
  from sqlalchemy.sql import bindparam
45
+ from sqlalchemy.sql.compiler import IdentifierPreparer
42
46
  from sqlalchemy.sql.selectable import Select
43
47
 
44
48
  from ._arrow import DuckDBArrowResult
@@ -126,7 +130,7 @@ else:
126
130
  try:
127
131
  __version__ = package_version("duckdb-sqlalchemy")
128
132
  except PackageNotFoundError: # pragma: no cover - source tree import fallback
129
- __version__ = "1.5.4"
133
+ __version__ = "1.5.4.2"
130
134
  sqlalchemy_version = sqlalchemy.__version__
131
135
  SQLALCHEMY_VERSION = Version(sqlalchemy_version)
132
136
  SQLALCHEMY_2 = SQLALCHEMY_VERSION >= Version("2.0.0")
@@ -671,6 +675,82 @@ class DuckDBIdentifierPreparer(PGIdentifierPreparer):
671
675
  return self.format_schema(schema)
672
676
 
673
677
 
678
+ def _column_needs_implicit_sequence(column: Any) -> bool:
679
+ """Columns that PostgreSQL would render as SERIAL/BIGSERIAL.
680
+
681
+ DuckDB has no SERIAL type, so these columns are backed by an explicitly
682
+ created sequence instead (see :class:`DuckDBDDLCompiler` and the
683
+ ``before_create``/``after_drop`` hooks below).
684
+ """
685
+ table = getattr(column, "table", None)
686
+ if table is None:
687
+ return False
688
+ if not column.primary_key or column is not table._autoincrement_column:
689
+ return False
690
+ if getattr(column, "identity", None) is not None:
691
+ return False
692
+ if column.default is not None and not (
693
+ isinstance(column.default, sa_schema.Sequence) and column.default.optional
694
+ ):
695
+ return False
696
+ if column.server_default is not None:
697
+ return False
698
+ return True
699
+
700
+
701
+ def _implicit_sequence_ddl_name(preparer: IdentifierPreparer, column: Any) -> str:
702
+ name = preparer.quote(f"{column.table.name}_{column.name}_seq")
703
+ schema = column.table.schema
704
+ if schema:
705
+ name = f"{preparer.quote_schema(schema)}.{name}"
706
+ return name
707
+
708
+
709
+ def _create_implicit_sequences(target: Any, connection: Any, **kw: Any) -> None:
710
+ if connection.dialect.name != "duckdb":
711
+ return
712
+ preparer = connection.dialect.identifier_preparer
713
+ for column in target.columns:
714
+ if _column_needs_implicit_sequence(column):
715
+ connection.exec_driver_sql(
716
+ "CREATE SEQUENCE IF NOT EXISTS "
717
+ f"{_implicit_sequence_ddl_name(preparer, column)}"
718
+ )
719
+
720
+
721
+ def _drop_implicit_sequences(target: Any, connection: Any, **kw: Any) -> None:
722
+ if connection.dialect.name != "duckdb":
723
+ return
724
+ preparer = connection.dialect.identifier_preparer
725
+ for column in target.columns:
726
+ if _column_needs_implicit_sequence(column):
727
+ connection.exec_driver_sql(
728
+ "DROP SEQUENCE IF EXISTS "
729
+ f"{_implicit_sequence_ddl_name(preparer, column)}"
730
+ )
731
+
732
+
733
+ event.listen(sa_schema.Table, "before_create", _create_implicit_sequences)
734
+ event.listen(sa_schema.Table, "after_drop", _drop_implicit_sequences)
735
+
736
+
737
+ class DuckDBDDLCompiler(PGDDLCompiler):
738
+ def get_column_specification(self, column: Any, **kwargs: Any) -> str:
739
+ if not _column_needs_implicit_sequence(column):
740
+ return super().get_column_specification(column, **kwargs)
741
+ colspec = self.preparer.format_column(column)
742
+ colspec += " " + self.dialect.type_compiler_instance.process(
743
+ column.type, type_expression=column
744
+ )
745
+ seq_name = _implicit_sequence_ddl_name(self.preparer, column)
746
+ colspec += " DEFAULT nextval(%s)" % self.sql_compiler.render_literal_value(
747
+ seq_name, sqltypes.String()
748
+ )
749
+ if not column.nullable:
750
+ colspec += " NOT NULL"
751
+ return colspec
752
+
753
+
674
754
  class DuckDBNullType(sqltypes.NullType):
675
755
  def result_processor(self, dialect: Any, coltype: object) -> Any:
676
756
  if coltype == "JSON":
@@ -711,6 +791,7 @@ class Dialect(PGDialect_psycopg2):
711
791
  )
712
792
  preparer = DuckDBIdentifierPreparer
713
793
  identifier_preparer: DuckDBIdentifierPreparer
794
+ ddl_compiler = DuckDBDDLCompiler
714
795
 
715
796
  def __init__(self, *args: Any, **kwargs: Any) -> None:
716
797
  kwargs["use_native_hstore"] = False
@@ -1326,6 +1407,20 @@ class Dialect(PGDialect_psycopg2):
1326
1407
  def _reflection_schema_key(self, schema: Optional[str]) -> Optional[str]:
1327
1408
  return schema
1328
1409
 
1410
+ def _iter_reflection_results(
1411
+ self,
1412
+ schema: Optional[str],
1413
+ table_names: Iterable[str],
1414
+ reflected: Mapping[str, Any],
1415
+ default_factory: Callable[[], Any],
1416
+ ) -> Iterable[Tuple[Any, Any]]:
1417
+ schema_key = self._reflection_schema_key(schema)
1418
+ for table_name in table_names:
1419
+ value = reflected.get(table_name)
1420
+ if value is None:
1421
+ value = default_factory()
1422
+ yield (schema_key, table_name), value
1423
+
1329
1424
  def _get_single_reflection_result(
1330
1425
  self,
1331
1426
  connection: "Connection",
@@ -1422,13 +1517,8 @@ class Dialect(PGDialect_psycopg2):
1422
1517
  }
1423
1518
  for row in constraint_rows
1424
1519
  }
1425
- schema_key = self._reflection_schema_key(schema)
1426
- return (
1427
- (
1428
- (schema_key, table_name),
1429
- constraints.get(table_name, ReflectionDefaults.pk_constraint()),
1430
- )
1431
- for table_name in table_names
1520
+ return self._iter_reflection_results(
1521
+ schema, table_names, constraints, ReflectionDefaults.pk_constraint
1432
1522
  )
1433
1523
 
1434
1524
  @cache # type: ignore[call-arg]
@@ -1531,10 +1621,8 @@ class Dialect(PGDialect_psycopg2):
1531
1621
  if any(column_name is None for column_name in column_names):
1532
1622
  reflected_index["expressions"] = expressions
1533
1623
  indexes[row["table_name"]].append(reflected_index)
1534
- schema_key = self._reflection_schema_key(schema)
1535
- return (
1536
- ((schema_key, table_name), indexes.get(table_name, []))
1537
- for table_name in table_names
1624
+ return self._iter_reflection_results(
1625
+ schema, table_names, indexes, ReflectionDefaults.indexes
1538
1626
  )
1539
1627
 
1540
1628
  def create_connect_args(self, url: SAURL) -> Tuple[tuple, dict]:
@@ -1,17 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Mapping
4
3
  from typing import Any, Optional, Sequence, cast
5
4
 
6
-
7
- def _rows_use_mapping_shape(rows: Sequence[Any]) -> bool:
8
- return bool(rows) and isinstance(rows[0], Mapping)
5
+ from ._row_shape import infer_mapping_column_keys, rows_use_mapping_shape
9
6
 
10
7
 
11
8
  def infer_bulk_insert_column_keys(rows: Sequence[Any]) -> Optional[list[str]]:
12
- if not _rows_use_mapping_shape(rows):
13
- return None
14
- return [str(key) for key in cast(Mapping[str, Any], rows[0]).keys()]
9
+ return infer_mapping_column_keys(rows)
15
10
 
16
11
 
17
12
  def build_bulk_insert_dataframe(
@@ -23,7 +18,7 @@ def build_bulk_insert_dataframe(
23
18
  return None
24
19
 
25
20
  try:
26
- if _rows_use_mapping_shape(rows):
21
+ if rows_use_mapping_shape(rows):
27
22
  return pd.DataFrame.from_records(rows, columns=column_names)
28
23
  return pd.DataFrame(rows, columns=cast(Any, column_names))
29
24
  except Exception:
@@ -39,7 +34,7 @@ def build_bulk_insert_arrow_table(
39
34
  return None
40
35
 
41
36
  try:
42
- if _rows_use_mapping_shape(rows):
37
+ if rows_use_mapping_shape(rows):
43
38
  table = pa.Table.from_pylist(rows)
44
39
  if column_names:
45
40
  return table.select(column_names)
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Mapping, Sequence
4
+ from itertools import chain
5
+ from typing import Any, Optional, Tuple, Union, cast
6
+
7
+
8
+ def rows_use_mapping_shape(rows: Sequence[Any]) -> bool:
9
+ return bool(rows) and isinstance(rows[0], Mapping)
10
+
11
+
12
+ def infer_mapping_column_keys(rows: Sequence[Any]) -> Optional[list[str]]:
13
+ if not rows_use_mapping_shape(rows):
14
+ return None
15
+ return [str(key) for key in cast(Mapping[str, Any], rows[0]).keys()]
16
+
17
+
18
+ def mapping_row_as_sequence(
19
+ row: Mapping[str, Any], columns: Sequence[str]
20
+ ) -> Sequence[Any]:
21
+ return [row.get(col) for col in columns]
22
+
23
+
24
+ def rows_as_sequences(
25
+ first: Union[Mapping[str, Any], Sequence[Any]],
26
+ rows: Iterable[Union[Mapping[str, Any], Sequence[Any]]],
27
+ columns: Optional[Sequence[str]],
28
+ ) -> Tuple[Iterable[Sequence[Any]], Optional[Sequence[str]]]:
29
+ if isinstance(first, Mapping):
30
+ if columns is None:
31
+ columns = [str(col) for col in cast(Mapping[str, Any], first).keys()]
32
+
33
+ first_row = mapping_row_as_sequence(cast(Mapping[str, Any], first), columns)
34
+ remaining_rows = (
35
+ mapping_row_as_sequence(cast(Mapping[str, Any], row), columns)
36
+ for row in rows
37
+ )
38
+ return chain((first_row,), remaining_rows), columns
39
+
40
+ first_row = cast(Sequence[Any], first)
41
+ remaining_rows = (cast(Sequence[Any], row) for row in rows)
42
+ return chain((first_row,), remaining_rows), columns
@@ -1,9 +1,9 @@
1
1
  import csv
2
2
  import tempfile
3
- from itertools import chain
4
3
  from pathlib import Path
5
- from typing import Any, Iterable, Mapping, Optional, Sequence, Tuple, Union, cast
4
+ from typing import Any, Iterable, Mapping, Optional, Sequence, Tuple, Union
6
5
 
6
+ from ._row_shape import rows_as_sequences
7
7
  from ._validation import (
8
8
  validate_dotted_identifier,
9
9
  validate_identifier,
@@ -160,20 +160,7 @@ def _copy_rows_as_sequences(
160
160
  rows: Iterable[Union[Mapping[str, Any], Sequence[Any]]],
161
161
  columns: Optional[Sequence[str]],
162
162
  ) -> Tuple[Iterable[Sequence[Any]], Optional[Sequence[str]]]:
163
- if isinstance(first, Mapping):
164
- if columns is None:
165
- columns = [str(col) for col in cast(Mapping[str, Any], first).keys()]
166
-
167
- def row_to_seq(row: Mapping[str, Any]) -> Sequence[Any]:
168
- return [row.get(col) for col in columns or []]
169
-
170
- first_row = row_to_seq(cast(Mapping[str, Any], first))
171
- remaining_rows = (row_to_seq(cast(Mapping[str, Any], row)) for row in rows)
172
- return chain((first_row,), remaining_rows), columns
173
-
174
- first_row = cast(Sequence[Any], first)
175
- remaining_rows = (cast(Sequence[Any], row) for row in rows)
176
- return chain((first_row,), remaining_rows), columns
163
+ return rows_as_sequences(first, rows, columns)
177
164
 
178
165
 
179
166
  def copy_from_parquet(
@@ -246,16 +233,21 @@ def copy_from_rows(
246
233
  if first is None:
247
234
  return None
248
235
 
249
- copy_options = {"header": include_header, **copy_options}
236
+ header = copy_options.pop("header", include_header)
237
+ copy_options = {"header": header, **copy_options}
250
238
 
251
239
  chunked_rows, columns = _copy_rows_as_sequences(first, iterator, columns)
240
+ if header and columns is None:
241
+ raise ValueError(
242
+ "copy_from_rows header mode requires columns for sequence rows"
243
+ )
252
244
  _copy_rows_as_csv_chunks(
253
245
  connection,
254
246
  table,
255
247
  chunked_rows,
256
248
  columns=columns,
257
249
  chunk_size=chunk_size,
258
- include_header=include_header,
250
+ include_header=header,
259
251
  copy_options=copy_options,
260
252
  )
261
253
  return None
@@ -22,6 +22,10 @@ MOTHERDUCK_LIST_DIVES_COLUMNS = (
22
22
  "created_at",
23
23
  "updated_at",
24
24
  "owner_name",
25
+ "status",
26
+ "status_changed_at",
27
+ "status_set_by",
28
+ "status_applies_to_version",
25
29
  "required_resources",
26
30
  )
27
31
  MOTHERDUCK_ACCESS_TOKENS_COLUMNS = (
@@ -37,6 +37,7 @@ from sqlalchemy.engine.reflection import Inspector
37
37
  from sqlalchemy.exc import DBAPIError, OperationalError
38
38
  from sqlalchemy.orm import Session, declarative_base, relationship, sessionmaker
39
39
  from sqlalchemy.pool import QueuePool, SingletonThreadPool
40
+ from sqlalchemy.schema import CreateTable
40
41
 
41
42
  from .. import Dialect, checkpoint, insert, supports_attach, supports_user_agent
42
43
  from .._supports import has_comment_support
@@ -771,3 +772,37 @@ def test_register_filesystem() -> None:
771
772
  with engine.connect() as conn:
772
773
  duckdb_conn = getattr(conn.connection.dbapi_connection, "_ConnectionWrapper__c")
773
774
  assert duckdb.list_filesystems(connection=duckdb_conn) == ["memory", "file"]
775
+
776
+
777
+ def test_autoincrement_pk_without_sequence() -> None:
778
+ """Plain Integer primary keys are backed by an implicit sequence (GH-128).
779
+
780
+ PostgreSQL renders these columns as SERIAL, which DuckDB does not support.
781
+ """
782
+ LocalBase = declarative_base()
783
+
784
+ class AutoUser(LocalBase):
785
+ __tablename__ = "auto_users"
786
+
787
+ id = Column(Integer, primary_key=True)
788
+ name = Column(String)
789
+
790
+ engine = create_engine("duckdb:///:memory:")
791
+ ddl = str(CreateTable(AutoUser.__table__).compile(engine))
792
+ assert "SERIAL" not in ddl
793
+ assert "DEFAULT nextval('auto_users_id_seq')" in ddl
794
+
795
+ LocalBase.metadata.create_all(engine)
796
+ with Session(engine) as session:
797
+ session.add(AutoUser(name="Ada"))
798
+ session.add(AutoUser(name="Grace"))
799
+ session.commit()
800
+ assert sorted(u.id for u in session.query(AutoUser).all()) == [1, 2]
801
+
802
+ # drop_all removes the implicit sequence so create_all starts fresh
803
+ LocalBase.metadata.drop_all(engine)
804
+ LocalBase.metadata.create_all(engine)
805
+ with Session(engine) as session:
806
+ session.add(AutoUser(name="Alan"))
807
+ session.commit()
808
+ assert session.query(AutoUser).one().id == 1
@@ -720,6 +720,10 @@ def test_md_list_dives_uses_released_motherduck_columns() -> None:
720
720
  "created_at",
721
721
  "updated_at",
722
722
  "owner_name",
723
+ "status",
724
+ "status_changed_at",
725
+ "status_set_by",
726
+ "status_applies_to_version",
723
727
  "required_resources",
724
728
  ]
725
729
 
@@ -932,6 +936,10 @@ def test_motherduck_dive_helpers_use_released_columns() -> None:
932
936
  "created_at",
933
937
  "updated_at",
934
938
  "owner_name",
939
+ "status",
940
+ "status_changed_at",
941
+ "status_set_by",
942
+ "status_applies_to_version",
935
943
  "required_resources",
936
944
  "version_id",
937
945
  "version_storage_url",
@@ -989,6 +997,21 @@ def test_motherduck_dive_helpers_render_named_parameters() -> None:
989
997
  ]
990
998
 
991
999
 
1000
+ def test_motherduck_dive_status_columns_compile() -> None:
1001
+ dives = olap.md_list_dives()
1002
+ stmt = select(
1003
+ dives.c.id,
1004
+ dives.c.status,
1005
+ dives.c.status_changed_at,
1006
+ dives.c.status_set_by,
1007
+ dives.c.status_applies_to_version,
1008
+ ).select_from(dives)
1009
+ sql = str(stmt.compile(dialect=Dialect()))
1010
+
1011
+ assert "md_list_dives" in sql
1012
+ assert "status_applies_to_version" in sql
1013
+
1014
+
992
1015
  def test_cursorwrapper_execute_basic_paths() -> None:
993
1016
  class DummyConn:
994
1017
  def __init__(self) -> None:
@@ -1339,6 +1362,31 @@ def test_reflection_fallback_returns_empty_only_for_existing_tables(
1339
1362
  )
1340
1363
 
1341
1364
 
1365
+ def test_iter_reflection_results_defaults_only_missing_tables() -> None:
1366
+ dialect = Dialect()
1367
+ default_calls = 0
1368
+
1369
+ def default_factory() -> list[Any]:
1370
+ nonlocal default_calls
1371
+ default_calls += 1
1372
+ return []
1373
+
1374
+ results = list(
1375
+ dialect._iter_reflection_results(
1376
+ "analytics",
1377
+ ["orders", "customers"],
1378
+ {"orders": [{"name": "orders_pk"}]},
1379
+ default_factory,
1380
+ )
1381
+ )
1382
+
1383
+ assert results == [
1384
+ (("analytics", "orders"), [{"name": "orders_pk"}]),
1385
+ (("analytics", "customers"), []),
1386
+ ]
1387
+ assert default_calls == 1
1388
+
1389
+
1342
1390
  def test_parse_duckdb_enum_labels_unquotes_escaped_strings() -> None:
1343
1391
  labels, enum_name = Dialect()._parse_duckdb_enum_labels(
1344
1392
  "ENUM('alpha', 'it''s fine')",
@@ -1473,6 +1521,38 @@ def test_copy_from_rows_sequence_uses_explicit_columns_and_chunks() -> None:
1473
1521
  assert rows == [(1, "one"), (2, "two"), (3, "three")]
1474
1522
 
1475
1523
 
1524
+ def test_copy_from_rows_sequence_header_requires_columns() -> None:
1525
+ conn = duckdb.connect(":memory:")
1526
+ conn.execute("CREATE TABLE safe(i INTEGER, label VARCHAR)")
1527
+
1528
+ with pytest.raises(ValueError, match="header mode requires columns"):
1529
+ copy_from_rows(
1530
+ conn,
1531
+ "safe",
1532
+ [(1, "one"), (2, "two")],
1533
+ include_header=True,
1534
+ )
1535
+
1536
+ rows = conn.execute("SELECT i, label FROM safe ORDER BY i").fetchall()
1537
+ assert rows == []
1538
+
1539
+
1540
+ def test_copy_from_rows_header_option_writes_header_with_columns() -> None:
1541
+ conn = duckdb.connect(":memory:")
1542
+ conn.execute("CREATE TABLE safe(i INTEGER, label VARCHAR)")
1543
+
1544
+ copy_from_rows(
1545
+ conn,
1546
+ "safe",
1547
+ [(1, "one"), (2, "two")],
1548
+ columns=["i", "label"],
1549
+ header=True,
1550
+ )
1551
+
1552
+ rows = conn.execute("SELECT i, label FROM safe ORDER BY i").fetchall()
1553
+ assert rows == [(1, "one"), (2, "two")]
1554
+
1555
+
1476
1556
  def test_copy_rows_as_sequences_infers_mapping_columns() -> None:
1477
1557
  rows, columns = duckdb_sqlalchemy.bulk._copy_rows_as_sequences(
1478
1558
  {"id": 1, "label": "one"},
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "duckdb-sqlalchemy"
3
- version = "1.5.4"
3
+ version = "1.5.4.2"
4
4
  description = "DuckDB SQLAlchemy dialect for DuckDB and MotherDuck"
5
5
  authors = [
6
6
  {name = "Leonardo Vida", email = "lleonardovida@gmail.com"},