pgsqlasync2fast-fastapi 0.4.0__tar.gz → 0.4.1__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 (38) hide show
  1. {pgsqlasync2fast_fastapi-0.4.0/src/pgsqlasync2fast_fastapi.egg-info → pgsqlasync2fast_fastapi-0.4.1}/PKG-INFO +1 -1
  2. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi/__init__.py +4 -0
  3. pgsqlasync2fast_fastapi-0.4.1/src/pgsqlasync2fast_fastapi/__version__.py +1 -0
  4. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi/seeder.py +59 -0
  5. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1/src/pgsqlasync2fast_fastapi.egg-info}/PKG-INFO +1 -1
  6. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi.egg-info/SOURCES.txt +2 -1
  7. pgsqlasync2fast_fastapi-0.4.1/tests/test_sync_sequence.py +201 -0
  8. pgsqlasync2fast_fastapi-0.4.0/src/pgsqlasync2fast_fastapi/__version__.py +0 -1
  9. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/LICENSE +0 -0
  10. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/MANIFEST.in +0 -0
  11. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/README.md +0 -0
  12. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/docs/.env.example +0 -0
  13. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/docs/seeder-format.md +0 -0
  14. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/basic_usage.py +0 -0
  15. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/database_creation.py +0 -0
  16. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/fastapi_integration.py +0 -0
  17. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/multi_database.py +0 -0
  18. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/seeders/dev/categories.json +0 -0
  19. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/seeders/dev/permissions.json +0 -0
  20. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/seeders/dev/roles.json +0 -0
  21. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/seeders/manifest.json +0 -0
  22. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/seeders/prod/categories.json +0 -0
  23. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/seeders/prod/permissions.json +0 -0
  24. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/examples/seeders/prod/roles.json +0 -0
  25. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/pyproject.toml +0 -0
  26. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/setup.cfg +0 -0
  27. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi/connection.py +0 -0
  28. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi/database.py +0 -0
  29. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi/dependencies.py +0 -0
  30. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi/settings.py +0 -0
  31. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi/skills/SKILL.md +0 -0
  32. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi.egg-info/dependency_links.txt +0 -0
  33. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi.egg-info/requires.txt +0 -0
  34. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/src/pgsqlasync2fast_fastapi.egg-info/top_level.txt +0 -0
  35. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/tests/test_completo.py +0 -0
  36. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/tests/test_insert_if_missing.py +0 -0
  37. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/tests/test_seeder.py +0 -0
  38. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.4.1}/tests/test_seeder_override.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsqlasync2fast-fastapi
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Simple and fast PostgreSQL async module for FastAPI with multi-database support
5
5
  Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
6
6
  License: MIT License
@@ -52,6 +52,8 @@ from pgsqlasync2fast_fastapi.seeder import (
52
52
  seed_all,
53
53
  # Shared idempotent insert-if-missing primitive
54
54
  insert_if_missing,
55
+ # Shared PostgreSQL sequence re-sync primitive
56
+ sync_table_sequence,
55
57
  )
56
58
 
57
59
  __all__ = [
@@ -90,4 +92,6 @@ __all__ = [
90
92
  "seed_all",
91
93
  # Shared idempotent insert-if-missing primitive
92
94
  "insert_if_missing",
95
+ # Shared PostgreSQL sequence re-sync primitive
96
+ "sync_table_sequence",
93
97
  ]
@@ -0,0 +1 @@
1
+ __version__ = "0.4.1"
@@ -593,6 +593,60 @@ def _resolve_load_order(manifest: dict[str, Any]) -> list[str]:
593
593
  # ============================================================================
594
594
 
595
595
 
596
+ async def sync_table_sequence(
597
+ session: Any,
598
+ model_class: type,
599
+ ) -> None:
600
+ """
601
+ Re-sync a PostgreSQL table identity sequence to its current MAX(id).
602
+
603
+ The seeder inserts rows with explicit ``id`` values straight from the
604
+ manifest JSON. PostgreSQL's plain ``serial``/``sequence``-backed columns do
605
+ NOT advance the sequence when a row is inserted with an explicit id, so a
606
+ later sequence-backed insert (e.g. ``insert_if_missing``) can generate an
607
+ id that collides with an existing explicit-id row (UniqueViolation).
608
+
609
+ This issues one ``setval`` to ``MAX(id)`` after the explicit-id inserts so
610
+ the next auto-generated id continues past the seeded rows. It only runs
611
+ against PostgreSQL — SQLite assigns ``INTEGER PRIMARY KEY`` as max+1
612
+ automatically and has no incompatible sequence catalog, so it is a no-op
613
+ there.
614
+
615
+ Args:
616
+ session: SQLModel AsyncSession bound to the target DB.
617
+ model_class: The SQLModel/SQLAlchemy table class to re-sync.
618
+ """
619
+ from sqlalchemy import text
620
+
621
+ try:
622
+ bind = getattr(session, "bind", None)
623
+ if bind is None:
624
+ return
625
+ dialect = getattr(bind.dialect, "name", None)
626
+ if dialect != "postgresql":
627
+ return
628
+
629
+ table = model_class.__tablename__
630
+ seq = f"{table}_id_seq"
631
+ async with bind.begin() as conn:
632
+ max_id = await conn.scalar(
633
+ text(f'SELECT COALESCE(MAX(id), 0) FROM "{table}"')
634
+ )
635
+ if max_id:
636
+ await conn.execute(
637
+ text("SELECT setval(:seq, :val)"),
638
+ {"seq": seq, "val": max_id},
639
+ )
640
+ logger.info(
641
+ f"Synced sequence '{seq}' to MAX(id)={max_id} "
642
+ f"(table '{table}')"
643
+ )
644
+ except Exception as exc: # pragma: no cover - defensive, non-fatal
645
+ logger.warning(
646
+ f"Could not re-sync sequence for '{model_class.__name__}': {exc}"
647
+ )
648
+
649
+
596
650
  async def _seed_table_idempotent(
597
651
  session: Any,
598
652
  table_name: str,
@@ -658,6 +712,9 @@ async def _seed_table_idempotent(
658
712
  logger.error(f"Failed to insert row in table '{table_name}': {e}")
659
713
  raise
660
714
 
715
+ if model_class is not None:
716
+ await sync_table_sequence(session, model_class)
717
+
661
718
  return rows_inserted, rows_skipped
662
719
 
663
720
 
@@ -727,6 +784,8 @@ async def _seed_table_idempotent_generic(
727
784
  logger.error(f"Failed to insert row in table '{table_name}': {e}")
728
785
  raise
729
786
 
787
+ await sync_table_sequence(session, model_class)
788
+
730
789
  return rows_inserted, rows_skipped
731
790
 
732
791
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsqlasync2fast-fastapi
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Simple and fast PostgreSQL async module for FastAPI with multi-database support
5
5
  Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
6
6
  License: MIT License
@@ -31,4 +31,5 @@ src/pgsqlasync2fast_fastapi/skills/SKILL.md
31
31
  tests/test_completo.py
32
32
  tests/test_insert_if_missing.py
33
33
  tests/test_seeder.py
34
- tests/test_seeder_override.py
34
+ tests/test_seeder_override.py
35
+ tests/test_sync_sequence.py
@@ -0,0 +1,201 @@
1
+ """
2
+ Tests for the shared PostgreSQL sequence re-sync primitive
3
+ `sync_table_sequence` (pgsqlasync2fast-fastapi).
4
+
5
+ The generic and by-id seeders insert rows with explicit ``id`` values from the
6
+ manifest JSON. PostgreSQL's plain ``serial``/``sequence``-backed columns do not
7
+ advance the sequence when a row is inserted with an explicit id, so a later
8
+ sequence-backed insert (e.g. ``insert_if_missing``) can generate an id that
9
+ collides with an existing explicit-id row (UniqueViolation).
10
+
11
+ This module verifies:
12
+ 1. ``sync_table_sequence`` is a no-op on non-PostgreSQL dialects (SQLite).
13
+ 2. On PostgreSQL it issues ``setval`` to the table's ``MAX(id)``.
14
+ 3. Both seeding paths (``_seed_table_idempotent`` and
15
+ ``_seed_table_idempotent_generic``) call the primitive.
16
+
17
+ Run with:
18
+ cd /Volumes/Desarrollo/Repos/Github/pgsqlasync2fast-fastapi \
19
+ && uv run pytest tests/test_sync_sequence.py -v
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from unittest.mock import AsyncMock, MagicMock, patch
25
+
26
+ import pytest
27
+ from sqlalchemy import BigInteger
28
+ from sqlalchemy.ext.asyncio import create_async_engine
29
+ from sqlalchemy.ext.compiler import compiles
30
+ from sqlmodel import Field, SQLModel
31
+ from sqlmodel.ext.asyncio.session import AsyncSession
32
+
33
+
34
+ @compiles(BigInteger, "sqlite")
35
+ def _compile_bigint_sqlite(type_, compiler, **kw):
36
+ return "INTEGER"
37
+
38
+
39
+ DB_URL = "sqlite+aiosqlite:///:memory:"
40
+
41
+
42
+ class SeqFruit(SQLModel, table=True):
43
+ """Minimal SQLModel table to exercise the sequence helper."""
44
+
45
+ __tablename__ = "seq_fruits"
46
+
47
+ id: int | None = Field(default=None, primary_key=True)
48
+ name: str = Field(default="")
49
+
50
+
51
+ async def _engine():
52
+ engine = create_async_engine(DB_URL, echo=False)
53
+ async with engine.begin() as conn:
54
+ await conn.run_sync(SQLModel.metadata.create_all)
55
+ return engine
56
+
57
+
58
+ # ============================================================================
59
+ # Unit: no-op on SQLite / selects dialect before setval
60
+ # ============================================================================
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_sync_sequence_noop_on_sqlite():
65
+ """On SQLite the helper returns without issuing any setval SQL."""
66
+ from pgsqlasync2fast_fastapi.seeder import sync_table_sequence
67
+
68
+ engine = await _engine()
69
+ async with AsyncSession(engine) as session:
70
+ # Should not raise, and must not attempt PostgreSQL sequence SQL.
71
+ await sync_table_sequence(session, SeqFruit)
72
+ await engine.dispose()
73
+
74
+
75
+ @pytest.mark.asyncio
76
+ async def test_sync_sequence_skips_non_postgresql():
77
+ """A non-PostgreSQL bind is detected and skipped before any query."""
78
+ from pgsqlasync2fast_fastapi.seeder import sync_table_sequence
79
+
80
+ # Bind that reports a non-PostgreSQL dialect with a tracked .begin()
81
+ bind = MagicMock()
82
+ bind.dialect.name = "sqlite"
83
+
84
+ session = AsyncMock()
85
+ session.bind = bind
86
+
87
+ await sync_table_sequence(session, SeqFruit)
88
+
89
+ bind.begin.assert_not_called()
90
+
91
+
92
+ # ============================================================================
93
+ # Unit: PostgreSQL path issues setval to MAX(id)
94
+ # ============================================================================
95
+
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_sync_sequence_issues_setval_to_max_id():
99
+ """On PostgreSQL the helper selects MAX(id) and runs setval."""
100
+ from pgsqlasync2fast_fastapi.seeder import sync_table_sequence
101
+
102
+ conn = AsyncMock()
103
+ conn.scalar = AsyncMock(return_value=11)
104
+ conn.execute = AsyncMock()
105
+
106
+ bind = MagicMock()
107
+ bind.dialect.name = "postgresql"
108
+ bind.begin.return_value.__aenter__.return_value = conn
109
+
110
+ session = AsyncMock()
111
+ session.bind = bind
112
+
113
+ await sync_table_sequence(session, SeqFruit)
114
+
115
+ # MAX(id) was read from the table
116
+ conn.scalar.assert_awaited_once()
117
+ assert '"seq_fruits"' in str(conn.scalar.await_args.args[0])
118
+ # setval ran with the sequence name and MAX(id)
119
+ conn.execute.assert_awaited_once()
120
+ args, kwargs = conn.execute.await_args
121
+ assert "setval" in str(args[0])
122
+ # parameters passed as second positional arg: {"seq": ..., "val": 11}
123
+ params = args[1] if len(args) > 1 else kwargs
124
+ assert params["val"] == 11
125
+ assert params["seq"] == "seq_fruits_id_seq"
126
+
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_sync_sequence_skips_setval_when_table_empty():
130
+ """MAX(id) of 0 means no setval is issued (empty table)."""
131
+ from pgsqlasync2fast_fastapi.seeder import sync_table_sequence
132
+
133
+ conn = AsyncMock()
134
+ conn.scalar = AsyncMock(return_value=0)
135
+ conn.execute = AsyncMock()
136
+
137
+ bind = MagicMock()
138
+ bind.dialect.name = "postgresql"
139
+ bind.begin.return_value.__aenter__.return_value = conn
140
+
141
+ session = AsyncMock()
142
+ session.bind = bind
143
+
144
+ await sync_table_sequence(session, SeqFruit)
145
+
146
+ conn.execute.assert_not_called()
147
+
148
+
149
+ # ============================================================================
150
+ # Unit: both seeding paths invoke the primitive
151
+ # ============================================================================
152
+
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_idempotent_seeder_calls_sync_sequence():
156
+ """_seed_table_idempotent invokes sync_table_sequence at the end."""
157
+ from pgsqlasync2fast_fastapi.seeder import _seed_table_idempotent
158
+
159
+ session = AsyncMock()
160
+ exec_result = MagicMock()
161
+ exec_result.one_or_none = MagicMock(return_value=None)
162
+ session.exec = AsyncMock(return_value=exec_result)
163
+ session.add = MagicMock()
164
+ session.commit = AsyncMock()
165
+ session.rollback = AsyncMock()
166
+
167
+ with patch(
168
+ "pgsqlasync2fast_fastapi.seeder.sync_table_sequence",
169
+ new=AsyncMock(),
170
+ ) as mock_sync:
171
+ inserted, skipped = await _seed_table_idempotent(
172
+ session, "seq_fruits", [{"id": 1, "name": "apple"}], SeqFruit
173
+ )
174
+
175
+ assert inserted == 1
176
+ mock_sync.assert_awaited_once_with(session, SeqFruit)
177
+
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_generic_seeder_calls_sync_sequence():
181
+ """_seed_table_idempotent_generic invokes sync_table_sequence at the end."""
182
+ from pgsqlasync2fast_fastapi.seeder import _seed_table_idempotent_generic
183
+
184
+ session = AsyncMock()
185
+ exec_result = MagicMock()
186
+ exec_result.one_or_none = MagicMock(return_value=None)
187
+ session.exec = AsyncMock(return_value=exec_result)
188
+ session.add = MagicMock()
189
+ session.commit = AsyncMock()
190
+ session.rollback = AsyncMock()
191
+
192
+ with patch(
193
+ "pgsqlasync2fast_fastapi.seeder.sync_table_sequence",
194
+ new=AsyncMock(),
195
+ ) as mock_sync:
196
+ inserted, skipped = await _seed_table_idempotent_generic(
197
+ session, "seq_fruits", [{"id": 1, "name": "apple"}], SeqFruit
198
+ )
199
+
200
+ assert inserted == 1
201
+ mock_sync.assert_awaited_once_with(session, SeqFruit)
@@ -1 +0,0 @@
1
- __version__ = "0.4.0"