pgsqlasync2fast-fastapi 0.3.1__py3-none-any.whl → 0.4.1__py3-none-any.whl

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.
@@ -50,6 +50,10 @@ from pgsqlasync2fast_fastapi.seeder import (
50
50
  clear_registry,
51
51
  # Main orchestrator
52
52
  seed_all,
53
+ # Shared idempotent insert-if-missing primitive
54
+ insert_if_missing,
55
+ # Shared PostgreSQL sequence re-sync primitive
56
+ sync_table_sequence,
53
57
  )
54
58
 
55
59
  __all__ = [
@@ -86,4 +90,8 @@ __all__ = [
86
90
  "clear_registry",
87
91
  # Main orchestrator (seeder)
88
92
  "seed_all",
93
+ # Shared idempotent insert-if-missing primitive
94
+ "insert_if_missing",
95
+ # Shared PostgreSQL sequence re-sync primitive
96
+ "sync_table_sequence",
89
97
  ]
@@ -1 +1 @@
1
- __version__ = "0.3.1"
1
+ __version__ = "0.4.1"
@@ -32,7 +32,7 @@ import logging
32
32
  from collections import defaultdict
33
33
  from dataclasses import dataclass, field
34
34
  from pathlib import Path
35
- from typing import TYPE_CHECKING, Any
35
+ from typing import TYPE_CHECKING, Any, Literal
36
36
 
37
37
  if TYPE_CHECKING:
38
38
  from sqlalchemy.ext.asyncio import AsyncEngine
@@ -133,25 +133,38 @@ class SeederResult:
133
133
  # ============================================================================
134
134
 
135
135
 
136
- _SEEDER_REGISTRY: list[SeederConfig] = []
136
+ _SEEDER_REGISTRY: dict[tuple[str, str], SeederConfig] = {}
137
137
 
138
138
 
139
- def register_seeder(config: SeederConfig) -> None:
139
+ def register_seeder(
140
+ config: SeederConfig,
141
+ mode: Literal["retain_base", "replace"] = "retain_base",
142
+ ) -> None:
140
143
  """
141
- Register a package's seeder configuration.
144
+ Register a package's seeder configuration, keyed by (connection, package).
142
145
 
143
- Validates that there are no table conflicts between packages using
144
- the same connection name. Two packages cannot seed the same table
145
- in the same connection.
146
+ The registry is a dict keyed by ``(connection_name, package_name)``, so a
147
+ repeated registration of the same key updates that single entry in place
148
+ (idempotent - no duplicate registry entries).
146
149
 
147
- The validation happens at registration time (not execution time) to fail
148
- fast and provide clear error messages.
150
+ Conflict detection only applies to *distinct* packages on the same
151
+ connection: if two different ``(connection, package)`` keys overlap on
152
+ shared manifest tables, ``SeederConflictError`` is raised. Re-registering
153
+ the same key NEVER raises - it is an override/update, not a conflict.
149
154
 
150
155
  Args:
151
- config: SeederConfig with connection_name, manifest_path, and priority
156
+ config: SeederConfig with connection_name, manifest_path, and priority.
157
+ mode: Override behavior when the same key is already registered:
158
+ - ``"retain_base"`` (default, backward-compatible): replace the
159
+ entry's config fields but MERGE the prior entry's manifest
160
+ ``model_classes`` into the new config's set (base tables are
161
+ preserved when an app extends rather than replaces).
162
+ - ``"replace"``: replace the prior entry wholesale.
152
163
 
153
164
  Raises:
154
- SeederConflictError: If tables overlap with an already-registered seeder
165
+ ValueError: If ``mode`` is not one of the supported values.
166
+ SeederConflictError: If a *distinct* package on the same connection
167
+ shares overlapping tables with an already-registered seeder.
155
168
 
156
169
  Example:
157
170
  register_seeder(SeederConfig(
@@ -161,12 +174,21 @@ def register_seeder(config: SeederConfig) -> None:
161
174
  priority=60
162
175
  ))
163
176
  """
164
- # Load manifests to check for conflicts
165
- for existing in _SEEDER_REGISTRY:
166
- if existing.connection_name != config.connection_name:
177
+ if mode not in ("retain_base", "replace"):
178
+ raise ValueError(f"Unsupported register_seeder mode: {mode!r}")
179
+
180
+ key = (config.connection_name, config.package_name)
181
+
182
+ # True conflict: a DIFFERENT package on the same connection overlapping
183
+ # on manifest tables. Same key is an override, never a conflict.
184
+ # The new config's manifest is only loaded when a candidate exists, so a
185
+ # lone registration with a missing manifest still succeeds (as before).
186
+ for (conn, pkg), existing in _SEEDER_REGISTRY.items():
187
+ if conn != config.connection_name:
188
+ continue
189
+ if (conn, pkg) == key:
167
190
  continue
168
191
 
169
- # Same connection - check for table overlap
170
192
  tables_a = set(_load_manifest(existing.manifest_path)["tables"].keys())
171
193
  tables_b = set(_load_manifest(config.manifest_path)["tables"].keys())
172
194
  overlap = tables_a & tables_b
@@ -180,9 +202,20 @@ def register_seeder(config: SeederConfig) -> None:
180
202
  f"on connection '{config.connection_name}'"
181
203
  )
182
204
 
183
- _SEEDER_REGISTRY.append(config)
205
+ prior = _SEEDER_REGISTRY.get(key)
206
+
207
+ if prior is None or mode == "replace":
208
+ # No prior entry, or wholesale replace: take the new config as-is.
209
+ _SEEDER_REGISTRY[key] = config
210
+ else: # mode == "retain_base": merge prior base model_classes into the new
211
+ # Preserve prior base tables that the new config does not override.
212
+ merged = {**prior.model_classes, **config.model_classes}
213
+ config.model_classes = merged
214
+ _SEEDER_REGISTRY[key] = config
215
+
184
216
  logger.debug(f"Registered seeder: {config.package_name or config.connection_name} "
185
- f"(priority={config.priority}, is_tenant={config.is_tenant_seeder})")
217
+ f"(priority={config.priority}, is_tenant={config.is_tenant_seeder}, "
218
+ f"mode={mode})")
186
219
 
187
220
 
188
221
  def get_registered_seeders() -> list[SeederConfig]:
@@ -192,13 +225,63 @@ def get_registered_seeders() -> list[SeederConfig]:
192
225
  Returns:
193
226
  List of all registered SeederConfig objects
194
227
  """
195
- return list(_SEEDER_REGISTRY)
228
+ return list(_SEEDER_REGISTRY.values())
196
229
 
197
230
 
198
231
  def clear_registry() -> None:
199
232
  """Clear all registered seeders. Useful for testing."""
200
233
  global _SEEDER_REGISTRY
201
- _SEEDER_REGISTRY = []
234
+ _SEEDER_REGISTRY = {}
235
+
236
+
237
+ # ============================================================================
238
+ # Idempotent insert-if-missing primitive (shared across consumer packages)
239
+ # ============================================================================
240
+
241
+
242
+ async def insert_if_missing(
243
+ session: Any,
244
+ model: type,
245
+ lookup: dict[str, Any],
246
+ defaults: dict[str, Any] | None = None,
247
+ ):
248
+ """
249
+ Insert a row if no row matches the natural-key ``lookup`` fields.
250
+
251
+ This is the shared, reusable idempotent **insert-if-missing** primitive.
252
+ Consumer packages (permissions2fast GLOBAL route seeding, tenants2fast
253
+ TENANT route seeding) reuse it instead of hand-rolling per package.
254
+
255
+ A ``SELECT`` by the ``lookup`` fields decides whether the row already
256
+ exists: if it does, the existing row is returned untouched (idempotent);
257
+ otherwise a new row is built from ``lookup`` merged with ``defaults``,
258
+ added to the session and flushed (no commit — the caller decides the
259
+ transaction boundary).
260
+
261
+ Args:
262
+ session: SQLModel AsyncSession (supports both ``exec`` and ``execute``).
263
+ model: SQLModel table class to query/insert.
264
+ lookup: Mapping of column name -> value that forms the row's natural key.
265
+ defaults: Optional extra column values applied only on insert.
266
+
267
+ Returns:
268
+ The existing (found) or newly created row instance.
269
+ """
270
+ from sqlmodel import select
271
+
272
+ stmt = select(model)
273
+ for col, val in lookup.items():
274
+ stmt = stmt.where(getattr(model, col) == val)
275
+ result = await session.exec(stmt)
276
+ existing = result.first()
277
+ if existing is not None:
278
+ return existing
279
+
280
+ merged = {**lookup, **(defaults or {})}
281
+ row = model(**merged)
282
+ session.add(row)
283
+ await session.flush()
284
+ return row
202
285
 
203
286
 
204
287
  # ============================================================================
@@ -510,6 +593,60 @@ def _resolve_load_order(manifest: dict[str, Any]) -> list[str]:
510
593
  # ============================================================================
511
594
 
512
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
+
513
650
  async def _seed_table_idempotent(
514
651
  session: Any,
515
652
  table_name: str,
@@ -575,6 +712,9 @@ async def _seed_table_idempotent(
575
712
  logger.error(f"Failed to insert row in table '{table_name}': {e}")
576
713
  raise
577
714
 
715
+ if model_class is not None:
716
+ await sync_table_sequence(session, model_class)
717
+
578
718
  return rows_inserted, rows_skipped
579
719
 
580
720
 
@@ -644,6 +784,8 @@ async def _seed_table_idempotent_generic(
644
784
  logger.error(f"Failed to insert row in table '{table_name}': {e}")
645
785
  raise
646
786
 
787
+ await sync_table_sequence(session, model_class)
788
+
647
789
  return rows_inserted, rows_skipped
648
790
 
649
791
 
@@ -683,8 +825,8 @@ async def seed_all(
683
825
  """
684
826
  result = SeederResult()
685
827
 
686
- # Sort by priority (lower = first)
687
- sorted_seeders = sorted(get_registered_seeders(), key=lambda s: s.priority)
828
+ # Iterate registry values, sorted by priority (lower = first)
829
+ sorted_seeders = sorted(_SEEDER_REGISTRY.values(), key=lambda s: s.priority)
688
830
 
689
831
  for seeder in sorted_seeders:
690
832
  # Apply package filter if specified
@@ -798,4 +940,6 @@ __all__ = [
798
940
  "clear_registry",
799
941
  # Main orchestrator
800
942
  "seed_all",
943
+ # Shared idempotent insert-if-missing primitive
944
+ "insert_if_missing",
801
945
  ]
@@ -4,7 +4,7 @@ description: "Trigger: working on or with pgsqlasync2fast-fastapi. Multi-databas
4
4
  license: MIT
5
5
  metadata:
6
6
  author: AngelDanielSanchezCastillo
7
- version: "1.0"
7
+ version: "2.1"
8
8
  ---
9
9
 
10
10
  ## Purpose
@@ -26,6 +26,13 @@ engines/session factories keyed by connection name (`default`, `auth`,
26
26
  - `shutdown_database()` → `manager.close_all()` (disposes all engines).
27
27
  - DB ops `database_exists/create_database/drop_database/list_databases` — ALL require a superuser connection (`is_superuser=True`; first configured one wins); use AUTOCOMMIT isolation.
28
28
  - Seeder: `SeederConfig`, `register_seeder`, `seed_all`, `get_registered_seeders`, `SeederConflictError`.
29
+ - `insert_if_missing(session, model, lookup, defaults=None)` — **shared idempotent
30
+ insert-if-missing** primitive: SELECT by natural-key `lookup` fields, return the
31
+ existing row if present or insert (merge `lookup`+`defaults`) and flush if absent.
32
+ Consumer packages (permissions2fast GLOBAL routes, tenants2fast TENANT routes)
33
+ reuse this instead of hand-rolling per package (RBAC standardization D2). No
34
+ commit — caller owns the transaction boundary. Also re-exported from the top-level
35
+ `pgsqlasync2fast_fastapi` package.
29
36
 
30
37
  ## Architecture
31
38
 
@@ -51,8 +58,13 @@ Downstream imports (stable contracts): oauth2fast `get_db_session`→`partial(..
51
58
 
52
59
  ## Seeder orchestrator
53
60
 
54
- - `register_seeder` does registration-time **table-conflict detection** per connection (`SeederConflictError` if two seeders on the same connection share a manifest `tables` key).
55
- - `seed_all(profile, package_filter=None)` sorts by `priority` (LOWER first). **Skips** registered seeders with `is_tenant_seeder=True` and no `seed_fn` (warns — tenant seeding must be driven by `seed_all_tenants`/the tenant package itself).
61
+ - The seeder registry is **keyed by `(connection_name, package_name)`** — re-registering the same key updates that single entry in place (idempotent, no duplicates).
62
+ - `register_seeder(config, mode="retain_base")` is the **override primitive**:
63
+ - `mode="retain_base"` (default, backward-compatible): an existing same-key entry's config fields are replaced but its prior manifest `model_classes` are **merged** into the new config's set — base tables are preserved when an app extends a package seeder.
64
+ - `mode="replace"`: the prior same-key entry is replaced wholesale.
65
+ - Re-registering the same key NEVER raises `SeederConflictError`.
66
+ - Registration-time **table-conflict detection** applies only to *distinct* `(connection, package)` keys: `SeederConflictError` if two different packages on the same connection share a manifest `tables` key.
67
+ - `seed_all(profile, package_filter=None)` sorts registered values by `priority` (LOWER first). **Skips** registered seeders with `is_tenant_seeder=True` and no `seed_fn` (warns — tenant seeding must be driven by `seed_all_tenants`/the tenant package itself).
56
68
  - Generic path: topological sort of `depends_on` (cycle → `SeedValidationError`), FK resolution `fk_field_mapping`/`fk_fields`/`rstrip('s')+'_id'`, insert **per row** with explicit `id` → idempotent (SELECT by id, skip if exists, commit per row).
57
69
 
58
70
  ## Settings
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsqlasync2fast-fastapi
3
- Version: 0.3.1
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
@@ -0,0 +1,13 @@
1
+ pgsqlasync2fast_fastapi/__init__.py,sha256=jc45a0ClPL76d2BTkBIM6DvRzJj3O9mv1sgGEn17dmA,2474
2
+ pgsqlasync2fast_fastapi/__version__.py,sha256=pMtTmSUht-XtbR_7Doz6bsQqopJJd8rZ8I8zy2HwwoA,22
3
+ pgsqlasync2fast_fastapi/connection.py,sha256=vyR3SLpLTENYGKWkEoou0ud5IwUOJ9ksfkysUW5tz-I,6168
4
+ pgsqlasync2fast_fastapi/database.py,sha256=t6i8V4Bbec0sPoWYsBPScSqQpde1tVkqGx0CBRnAupk,8856
5
+ pgsqlasync2fast_fastapi/dependencies.py,sha256=jgX14S6h6i4Uk6edMDGh-wiDpxTlB16qvYMlIm1QofQ,4444
6
+ pgsqlasync2fast_fastapi/seeder.py,sha256=b_7fKP19Qh08NaTyY4kA7HL48dqZIE-Re5I-ki3M5a4,33284
7
+ pgsqlasync2fast_fastapi/settings.py,sha256=9SAmEVtotKJYlbTjI4qkKBjEOghTTtaICAFBBrR-eHk,6678
8
+ pgsqlasync2fast_fastapi/skills/SKILL.md,sha256=Yqge3ydNnZXO5hN5Ac4aynCG0hn-WYPCy_nkzToUDsM,6787
9
+ pgsqlasync2fast_fastapi-0.4.1.dist-info/licenses/LICENSE,sha256=CkISX1hNEwxxrPTOXet3IYMEH28Bn7SoUyKniRjg68I,1086
10
+ pgsqlasync2fast_fastapi-0.4.1.dist-info/METADATA,sha256=_giXkOTGxUOtf__J-zPoRXtzyznR0WedKi-42Bgi4Ws,10105
11
+ pgsqlasync2fast_fastapi-0.4.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ pgsqlasync2fast_fastapi-0.4.1.dist-info/top_level.txt,sha256=7pllDm9nlFGWKJDtG5zukxqdADqjZddU-GzaQJPLMVc,24
13
+ pgsqlasync2fast_fastapi-0.4.1.dist-info/RECORD,,
@@ -1,13 +0,0 @@
1
- pgsqlasync2fast_fastapi/__init__.py,sha256=zGV8J18n9bis4KiqEuMZyG31w_lSnVenuxhk355IZIA,2168
2
- pgsqlasync2fast_fastapi/__version__.py,sha256=r4xAFihOf72W9TD-lpMi6ntWSTKTP2SlzKP1ytkjRbI,22
3
- pgsqlasync2fast_fastapi/connection.py,sha256=vyR3SLpLTENYGKWkEoou0ud5IwUOJ9ksfkysUW5tz-I,6168
4
- pgsqlasync2fast_fastapi/database.py,sha256=t6i8V4Bbec0sPoWYsBPScSqQpde1tVkqGx0CBRnAupk,8856
5
- pgsqlasync2fast_fastapi/dependencies.py,sha256=jgX14S6h6i4Uk6edMDGh-wiDpxTlB16qvYMlIm1QofQ,4444
6
- pgsqlasync2fast_fastapi/seeder.py,sha256=wRBAhiUsIn6pDlMq63q1HilgV8JHo8FuYUJ3xL0FPwo,27388
7
- pgsqlasync2fast_fastapi/settings.py,sha256=9SAmEVtotKJYlbTjI4qkKBjEOghTTtaICAFBBrR-eHk,6678
8
- pgsqlasync2fast_fastapi/skills/SKILL.md,sha256=csaMzyhXqu573EbqbUJtKJfSsVVofMdvfyUz8sm1FuU,5558
9
- pgsqlasync2fast_fastapi-0.3.1.dist-info/licenses/LICENSE,sha256=CkISX1hNEwxxrPTOXet3IYMEH28Bn7SoUyKniRjg68I,1086
10
- pgsqlasync2fast_fastapi-0.3.1.dist-info/METADATA,sha256=-1y2MEla31uhqbpLg786EMu4DOdDMg_nTPQi5_t8DVE,10105
11
- pgsqlasync2fast_fastapi-0.3.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
- pgsqlasync2fast_fastapi-0.3.1.dist-info/top_level.txt,sha256=7pllDm9nlFGWKJDtG5zukxqdADqjZddU-GzaQJPLMVc,24
13
- pgsqlasync2fast_fastapi-0.3.1.dist-info/RECORD,,