pgsqlasync2fast-fastapi 0.2.0__py3-none-any.whl → 0.3.0__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.
@@ -1,16 +1,23 @@
1
1
  """
2
- pgsqlasync2fast-fastapi - Simple and fast PostgreSQL async module for FastAPI
3
-
4
- A comprehensive PostgreSQL async module for FastAPI with multi-database support,
5
- automatic database creation, and Pydantic settings configuration.
6
-
7
- Features:
8
- - Multiple database connection support
9
- - Async database operations with SQLAlchemy
10
- - Database creation utilities with superuser support
11
- - Connection pooling and health checks
12
- - FastAPI integration with dependencies
13
- - Lazy engine creation for optimal resource usage
2
+ pgsqlasync2fast-fastapi - PostgreSQL async extensions for FastAPI
3
+
4
+ This package provides database management and seeding capabilities.
5
+
6
+ The seeder module provides multi-package JSON seeder orchestration.
7
+
8
+ Example:
9
+ from pgsqlasync2fast_fastapi import seed_all, register_seeder, SeederConfig
10
+
11
+ # Register a package's seeder
12
+ register_seeder(SeederConfig(
13
+ connection_name="auth",
14
+ manifest_path="path/to/manifest.json",
15
+ package_name="my-package",
16
+ priority=50
17
+ ))
18
+
19
+ # Execute all seeders
20
+ result = await seed_all("dev")
14
21
  """
15
22
 
16
23
  from .__version__ import __version__
@@ -25,6 +32,26 @@ from .dependencies import (
25
32
  )
26
33
  from .settings import DatabaseConnectionSettings, DatabaseSettings, settings
27
34
 
35
+ # Import seeder module
36
+ from pgsqlasync2fast_fastapi import seeder
37
+
38
+ # Re-export seeder-specific exports
39
+ from pgsqlasync2fast_fastapi.seeder import (
40
+ # Dataclasses
41
+ SeederConfig,
42
+ SeederResult,
43
+ # Exceptions
44
+ SeederException,
45
+ SeederConflictError,
46
+ SeedValidationError,
47
+ # Registry functions
48
+ register_seeder,
49
+ get_registered_seeders,
50
+ clear_registry,
51
+ # Main orchestrator
52
+ seed_all,
53
+ )
54
+
28
55
  __all__ = [
29
56
  # Version
30
57
  "__version__",
@@ -46,4 +73,17 @@ __all__ = [
46
73
  "create_database",
47
74
  "drop_database",
48
75
  "list_databases",
76
+ # Dataclasses (seeder)
77
+ "SeederConfig",
78
+ "SeederResult",
79
+ # Exceptions (seeder)
80
+ "SeederException",
81
+ "SeederConflictError",
82
+ "SeedValidationError",
83
+ # Registry functions (seeder)
84
+ "register_seeder",
85
+ "get_registered_seeders",
86
+ "clear_registry",
87
+ # Main orchestrator (seeder)
88
+ "seed_all",
49
89
  ]
@@ -1 +1 @@
1
- __version__ = "0.2.0"
1
+ __version__ = "0.3.0"
@@ -0,0 +1,801 @@
1
+ """
2
+ pgsqlasync2fast_fastapi - Seeder Module
3
+
4
+ Multi-package JSON seeder orchestrator that executes seeders from multiple packages
5
+ following the standard seeder format with manifest.json per package.
6
+
7
+ This module provides the foundation for the seeder standard, including:
8
+ - SeederConfig: Configuration dataclass for package seeders
9
+ - register_seeder(): Register a package's seeder with conflict detection
10
+ - seed_all(): Execute all registered seeders in dependency order
11
+ - Idempotent seeding: rows are skipped if they already exist by ID
12
+
13
+ Example usage:
14
+ from pgsqlasync2fast_fastapi import seed_all, register_seeder, SeederConfig
15
+
16
+ # Register a package's seeder
17
+ register_seeder(SeederConfig(
18
+ connection_name="auth",
19
+ manifest_path="path/to/manifest.json",
20
+ package_name="my-package",
21
+ priority=50
22
+ ))
23
+
24
+ # Execute all seeders
25
+ result = await seed_all("dev")
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ from collections import defaultdict
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import TYPE_CHECKING, Any
36
+
37
+ if TYPE_CHECKING:
38
+ from sqlalchemy.ext.asyncio import AsyncEngine
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # ============================================================================
44
+ # Exceptions
45
+ # ============================================================================
46
+
47
+
48
+ class SeederException(Exception):
49
+ """Base exception for seeder errors."""
50
+ pass
51
+
52
+
53
+ class SeederConflictError(SeederException):
54
+ """
55
+ Raised when two packages define overlapping tables in the same connection.
56
+
57
+ This prevents silent data corruption where two packages might try to seed
58
+ the same table with different data.
59
+
60
+ Example:
61
+ Package A and Package B both try to seed the "roles" table.
62
+ This would cause unpredictable behavior - which data wins?
63
+ SeederConflictError is raised to prevent this.
64
+ """
65
+ pass
66
+
67
+
68
+ class SeedValidationError(SeederException):
69
+ """
70
+ Raised when FK references point to non-existent IDs or data is invalid.
71
+
72
+ This ensures data integrity before attempting any inserts.
73
+
74
+ Example:
75
+ A permission row references category_id=999, but no category
76
+ with id=999 exists in the data. This is caught before any inserts.
77
+ """
78
+ pass
79
+
80
+
81
+ # ============================================================================
82
+ # Dataclasses
83
+ # ============================================================================
84
+
85
+
86
+ @dataclass
87
+ class SeederConfig:
88
+ """
89
+ Configuration for a package's seeder.
90
+
91
+ Attributes:
92
+ connection_name: Name of the database connection to use
93
+ manifest_path: Path to the package's manifest.json
94
+ is_tenant_seeder: If True, this seeder seeds individual tenants
95
+ priority: Lower values seed first (default 50)
96
+ package_name: Name for conflict error messages and filtering
97
+ seed_fn: Async function that executes seeding (for complex seeders like tenants)
98
+ model_classes: Dict mapping table_name -> SQLModel class for inserts
99
+ fk_field_mapping: Optional FK field name mapping for JSON-to-model conversion
100
+ e.g., {"permissions": {"category_id": "permission_category_id"}}
101
+ """
102
+ connection_name: str
103
+ manifest_path: str
104
+ is_tenant_seeder: bool = False
105
+ priority: int = 50
106
+ package_name: str = ""
107
+ seed_fn: Any = None # Async function: async def seed(profile) -> dict
108
+ model_classes: dict[str, type] = field(default_factory=dict) # table_name -> Model class
109
+ fk_field_mapping: dict[str, dict[str, str]] = field(default_factory=dict) # table -> {json_field: model_field}
110
+
111
+
112
+ @dataclass
113
+ class SeederResult:
114
+ """
115
+ Result of a seed_all operation.
116
+
117
+ Attributes:
118
+ seeded_packages: List of package names that were seeded
119
+ tables_seeded: Total number of tables processed
120
+ rows_seeded: Total number of rows inserted
121
+ errors: List of error messages (empty if all succeeded)
122
+ skipped: List of tables that were skipped (already existed)
123
+ """
124
+ seeded_packages: list[str] = field(default_factory=list)
125
+ tables_seeded: int = 0
126
+ rows_seeded: int = 0
127
+ errors: list[str] = field(default_factory=list)
128
+ skipped: list[str] = field(default_factory=list)
129
+
130
+
131
+ # ============================================================================
132
+ # Registry
133
+ # ============================================================================
134
+
135
+
136
+ _SEEDER_REGISTRY: list[SeederConfig] = []
137
+
138
+
139
+ def register_seeder(config: SeederConfig) -> None:
140
+ """
141
+ Register a package's seeder configuration.
142
+
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
+
147
+ The validation happens at registration time (not execution time) to fail
148
+ fast and provide clear error messages.
149
+
150
+ Args:
151
+ config: SeederConfig with connection_name, manifest_path, and priority
152
+
153
+ Raises:
154
+ SeederConflictError: If tables overlap with an already-registered seeder
155
+
156
+ Example:
157
+ register_seeder(SeederConfig(
158
+ connection_name="auth",
159
+ manifest_path="permissions2fast_fastapi/seeders/manifest.json",
160
+ package_name="permissions2fast-fastapi",
161
+ priority=60
162
+ ))
163
+ """
164
+ # Load manifests to check for conflicts
165
+ for existing in _SEEDER_REGISTRY:
166
+ if existing.connection_name != config.connection_name:
167
+ continue
168
+
169
+ # Same connection - check for table overlap
170
+ tables_a = set(_load_manifest(existing.manifest_path)["tables"].keys())
171
+ tables_b = set(_load_manifest(config.manifest_path)["tables"].keys())
172
+ overlap = tables_a & tables_b
173
+
174
+ if overlap:
175
+ pkg_a = existing.package_name or "unknown"
176
+ pkg_b = config.package_name or "unknown"
177
+ raise SeederConflictError(
178
+ f"Tables {overlap} conflict between "
179
+ f"'{pkg_a}' and '{pkg_b}' "
180
+ f"on connection '{config.connection_name}'"
181
+ )
182
+
183
+ _SEEDER_REGISTRY.append(config)
184
+ logger.debug(f"Registered seeder: {config.package_name or config.connection_name} "
185
+ f"(priority={config.priority}, is_tenant={config.is_tenant_seeder})")
186
+
187
+
188
+ def get_registered_seeders() -> list[SeederConfig]:
189
+ """
190
+ Return a copy of the seeder registry.
191
+
192
+ Returns:
193
+ List of all registered SeederConfig objects
194
+ """
195
+ return list(_SEEDER_REGISTRY)
196
+
197
+
198
+ def clear_registry() -> None:
199
+ """Clear all registered seeders. Useful for testing."""
200
+ global _SEEDER_REGISTRY
201
+ _SEEDER_REGISTRY = []
202
+
203
+
204
+ # ============================================================================
205
+ # Manifest and Data Loading
206
+ # ============================================================================
207
+
208
+
209
+ def _load_manifest(manifest_path: str) -> dict[str, Any]:
210
+ """
211
+ Load and parse a manifest.json file.
212
+
213
+ The manifest is the source of truth for:
214
+ - Which tables this package seeds
215
+ - Which JSON file contains each table's data
216
+ - Dependencies between tables (for load order)
217
+
218
+ Args:
219
+ manifest_path: Path to manifest.json
220
+
221
+ Returns:
222
+ Parsed manifest dictionary with 'tables' and optional 'load_order' keys
223
+
224
+ Raises:
225
+ FileNotFoundError: If manifest doesn't exist
226
+ json.JSONDecodeError: If manifest is invalid JSON
227
+
228
+ Example manifest.json:
229
+ {
230
+ "tables": {
231
+ "categories": {"file": "categories.json"},
232
+ "roles": {"file": "roles.json"},
233
+ "permissions": {"file": "permissions.json", "depends_on": ["categories"]}
234
+ },
235
+ "load_order": ["categories", "roles", "permissions"]
236
+ }
237
+ """
238
+ path = Path(manifest_path)
239
+ if not path.exists():
240
+ raise FileNotFoundError(f"Manifest not found: {manifest_path}")
241
+
242
+ with open(path, "r", encoding="utf-8") as f:
243
+ return json.load(f)
244
+
245
+
246
+ def _load_table_data(table_name: str, manifest_path: str, profile: str) -> list[dict[str, Any]]:
247
+ """
248
+ Load table data from a JSON file in the profile folder.
249
+
250
+ Each table has its own JSON file containing rows with explicit IDs.
251
+ The file is looked up in: {manifest_dir}/{profile}/{file}
252
+
253
+ Args:
254
+ table_name: Name of the table (used to find the file in manifest)
255
+ manifest_path: Path to the package's manifest.json
256
+ profile: Profile folder name (e.g., "dev", "prod")
257
+
258
+ Returns:
259
+ List of row dictionaries with explicit IDs
260
+
261
+ Raises:
262
+ FileNotFoundError: If the JSON file doesn't exist
263
+ json.JSONDecodeError: If JSON is invalid
264
+ SeedValidationError: If data is not a list
265
+
266
+ Example roles.json:
267
+ [
268
+ {"id": 1, "name": "Admin", "description": "Administrator role"},
269
+ {"id": 2, "name": "User", "description": "Regular user role"}
270
+ ]
271
+ """
272
+ manifest = _load_manifest(manifest_path)
273
+ table_config = manifest["tables"].get(table_name, {})
274
+ file_name = table_config.get("file", f"{table_name}.json")
275
+
276
+ # Find the JSON file in the profile folder
277
+ manifest_dir = Path(manifest_path).parent
278
+ json_path = manifest_dir / profile / file_name
279
+
280
+ if not json_path.exists():
281
+ logger.warning(
282
+ f"Seed data not found for table '{table_name}' "
283
+ f"in profile '{profile}': {json_path}"
284
+ )
285
+ return []
286
+
287
+ with open(json_path, "r", encoding="utf-8") as f:
288
+ data = json.load(f)
289
+
290
+ if not isinstance(data, list):
291
+ raise SeedValidationError(
292
+ f"Table '{table_name}' data must be a list, got {type(data).__name__}"
293
+ )
294
+
295
+ return data
296
+
297
+
298
+ # Default FK field naming convention: singularize table name + "_id"
299
+ # e.g., "categories" -> "category_id", "roles" -> "role_id"
300
+ _DEFAULT_FK_MAPPING: dict[str, str] = {
301
+ "categories": "category_id",
302
+ "roles": "role_id",
303
+ "permissions": "permission_id",
304
+ "routes": "route_id",
305
+ }
306
+
307
+ # Package can override FK field mapping via manifest
308
+ # manifest can have: "fk_fields": {"categories": "category_id", ...}
309
+ _FK_FIELD_MAPPING: dict[str, dict[str, str]] = {}
310
+
311
+
312
+ def _get_fk_field(table_name: str, manifest: dict[str, Any] | None = None) -> str:
313
+ """
314
+ Get the FK field name for a given table.
315
+
316
+ The FK field is looked up in this order:
317
+ 1. Package-level override in manifest's fk_fields
318
+ 2. _FK_FIELD_MAPPING (if set by package)
319
+ 3. Default convention: table_name.rstrip('s') + "_id"
320
+
321
+ This allows packages to override the default naming for:
322
+ - Irregular plurals (e.g., "categories" -> "category_id")
323
+ - Non-English table names
324
+ - Explicit foreign key naming conventions
325
+
326
+ Args:
327
+ table_name: Plural name of the table (e.g., "categories")
328
+ manifest: Optional manifest dict to check for fk_fields override
329
+
330
+ Returns:
331
+ FK field name (e.g., "category_id")
332
+ """
333
+ # Check manifest first
334
+ if manifest:
335
+ fk_fields = manifest.get("fk_fields", {})
336
+ if table_name in fk_fields:
337
+ return fk_fields[table_name]
338
+
339
+ # Check global mapping
340
+ if table_name in _FK_FIELD_MAPPING:
341
+ return _FK_FIELD_MAPPING[table_name]
342
+
343
+ # Check default mapping
344
+ if table_name in _DEFAULT_FK_MAPPING:
345
+ return _DEFAULT_FK_MAPPING[table_name]
346
+
347
+ # Fallback to simple convention
348
+ return f"{table_name.rstrip('s')}_id"
349
+
350
+
351
+ # ============================================================================
352
+ # Validation
353
+ # ============================================================================
354
+
355
+
356
+ def _validate_fk_references(
357
+ table_name: str,
358
+ rows: list[dict[str, Any]],
359
+ manifest_path: str,
360
+ profile: str,
361
+ loaded_tables: dict[str, list[dict[str, Any]]]
362
+ ) -> None:
363
+ """
364
+ Validate that all FK references in rows point to existing IDs.
365
+
366
+ This is called BEFORE any inserts to prevent partial data or
367
+ FK constraint violations.
368
+
369
+ The FK resolution uses configurable naming convention:
370
+ - Check manifest's fk_fields override first
371
+ - Then _DEFAULT_FK_MAPPING (e.g., "categories" -> "category_id")
372
+ - Finally fallback to simple rstrip('s') + "_id"
373
+
374
+ Args:
375
+ table_name: Name of the table being validated
376
+ rows: List of row dictionaries
377
+ manifest_path: Path to manifest.json
378
+ profile: Profile folder
379
+ loaded_tables: Dict of table_name -> list of loaded rows
380
+
381
+ Raises:
382
+ SeedValidationError: If a referenced ID doesn't exist
383
+
384
+ Example:
385
+ If permissions.json has {"name": "read", "category_id": 1}
386
+ and categories.json doesn't have any row with id=1,
387
+ this raises SeedValidationError.
388
+ """
389
+ manifest = _load_manifest(manifest_path)
390
+ table_config = manifest["tables"].get(table_name, {})
391
+ depends_on = table_config.get("depends_on", [])
392
+
393
+ for row in rows:
394
+ row_id = row.get("id")
395
+ if row_id is None:
396
+ raise SeedValidationError(
397
+ f"Table '{table_name}' row missing 'id' field: {row}"
398
+ )
399
+
400
+ # Check each dependency
401
+ for dep_table in depends_on:
402
+ # Get FK field name using configurable mapping
403
+ fk_field = _get_fk_field(dep_table, manifest)
404
+ fk_value = row.get(fk_field)
405
+
406
+ if fk_value is not None:
407
+ # Find the dependency table's data
408
+ dep_data = loaded_tables.get(dep_table, [])
409
+ dep_ids = {dep_row.get("id") for dep_row in dep_data}
410
+
411
+ if fk_value not in dep_ids:
412
+ raise SeedValidationError(
413
+ f"Table '{table_name}' row id={row_id} references "
414
+ f"non-existent {dep_table} id={fk_value} "
415
+ f"(FK field: {fk_field})"
416
+ )
417
+
418
+
419
+ # ============================================================================
420
+ # Topological Sort for Load Order
421
+ # ============================================================================
422
+
423
+
424
+ def _resolve_load_order(manifest: dict[str, Any]) -> list[str]:
425
+ """
426
+ Resolve the correct load order using topological sort.
427
+
428
+ Tables are sorted by:
429
+ 1. Explicit depends_on relationships (tables dependencies first)
430
+ 2. If no dependencies, use load_order from manifest
431
+ 3. If no load_order either, use table name alphabetical
432
+
433
+ Uses Kahn's algorithm for topological sorting with cycle detection.
434
+
435
+ Args:
436
+ manifest: Parsed manifest dictionary with 'tables' key
437
+
438
+ Returns:
439
+ List of table names in correct load order
440
+
441
+ Raises:
442
+ SeedValidationError: If circular dependencies are detected
443
+
444
+ Example:
445
+ If permissions depends_on categories, categories appears before permissions
446
+ in the returned list.
447
+ """
448
+ tables = manifest.get("tables", {})
449
+ explicit_order = manifest.get("load_order", [])
450
+
451
+ # Build adjacency list for dependency graph
452
+ # graph[table] = set of tables that table depends on
453
+ graph: dict[str, set[str]] = {}
454
+ in_degree: dict[str, int] = {}
455
+
456
+ for table_name in tables:
457
+ table_config = tables[table_name]
458
+ deps = table_config.get("depends_on", [])
459
+ graph[table_name] = set(deps)
460
+ in_degree[table_name] = 0
461
+
462
+ # Calculate in-degrees (how many tables depend on each table)
463
+ for table_name in tables:
464
+ for dep in graph[table_name]:
465
+ if dep in in_degree:
466
+ in_degree[table_name] += 1
467
+
468
+ # Kahn's algorithm for topological sort
469
+ # Start with nodes that have no dependencies
470
+ queue = [t for t in tables if in_degree[t] == 0]
471
+ sorted_tables = []
472
+
473
+ while queue:
474
+ # Sort queue to ensure deterministic order (alphabetical by name)
475
+ queue.sort()
476
+ current = queue.pop(0)
477
+ sorted_tables.append(current)
478
+
479
+ # Reduce in-degree for all tables that depend on current
480
+ for table_name in tables:
481
+ if current in graph[table_name]:
482
+ in_degree[table_name] -= 1
483
+ if in_degree[table_name] == 0:
484
+ queue.append(table_name)
485
+
486
+ # Check for circular dependencies
487
+ if len(sorted_tables) != len(tables):
488
+ remaining = set(tables.keys()) - set(sorted_tables)
489
+ raise SeedValidationError(
490
+ f"Circular dependency detected involving tables: {remaining}"
491
+ )
492
+
493
+ # If explicit order exists, merge it with dependency order
494
+ # Tables not in explicit_order get appended at the end
495
+ if explicit_order:
496
+ final_order = []
497
+ for table in explicit_order:
498
+ if table in tables and table not in final_order:
499
+ final_order.append(table)
500
+ for table in sorted_tables:
501
+ if table not in final_order:
502
+ final_order.append(table)
503
+ return final_order
504
+
505
+ return sorted_tables
506
+
507
+
508
+ # ============================================================================
509
+ # Core Seeding Logic
510
+ # ============================================================================
511
+
512
+
513
+ async def _seed_table_idempotent(
514
+ session: Any,
515
+ table_name: str,
516
+ rows: list[dict[str, Any]],
517
+ model_class: type | None = None
518
+ ) -> tuple[int, int]:
519
+ """
520
+ Seed a table idempotently by checking if rows exist by ID.
521
+
522
+ This function is idempotent - running it multiple times with the same
523
+ data produces the same result (no duplicates).
524
+
525
+ Args:
526
+ session: SQLModel AsyncSession
527
+ table_name: Name of the table (for logging)
528
+ rows: List of row dictionaries with explicit IDs
529
+ model_class: SQLModel class for the table (optional for custom handling)
530
+
531
+ Returns:
532
+ Tuple of (rows_inserted, rows_skipped)
533
+ """
534
+ from sqlmodel import select
535
+
536
+ rows_inserted = 0
537
+ rows_skipped = 0
538
+
539
+ for row in rows:
540
+ row_id = row.get("id")
541
+ if row_id is None:
542
+ logger.warning(f"Skipping row without ID in table '{table_name}'")
543
+ rows_skipped += 1
544
+ continue
545
+
546
+ # Check if row already exists by ID
547
+ if model_class is not None:
548
+ result = await session.exec(
549
+ select(model_class).where(model_class.id == row_id)
550
+ )
551
+ existing = result.one_or_none()
552
+ else:
553
+ # Fallback: assume table has 'id' column
554
+ existing = None
555
+
556
+ if existing is not None:
557
+ logger.debug(f"Skipping existing row id={row_id} in table '{table_name}'")
558
+ rows_skipped += 1
559
+ continue
560
+
561
+ # Insert new row
562
+ try:
563
+ if model_class is not None:
564
+ obj = model_class(**row)
565
+ session.add(obj)
566
+ await session.commit()
567
+ rows_inserted += 1
568
+ logger.debug(f"Inserted row id={row_id} in table '{table_name}'")
569
+ else:
570
+ # Cannot insert without model class - skip
571
+ logger.warning(f"Cannot insert without model class for table '{table_name}'")
572
+ rows_skipped += 1
573
+ except Exception as e:
574
+ await session.rollback()
575
+ logger.error(f"Failed to insert row in table '{table_name}': {e}")
576
+ raise
577
+
578
+ return rows_inserted, rows_skipped
579
+
580
+
581
+ async def _seed_table_idempotent_generic(
582
+ session: Any,
583
+ table_name: str,
584
+ rows: list[dict[str, Any]],
585
+ model_class: type,
586
+ fk_field_mapping: dict[str, dict[str, str]] | None = None
587
+ ) -> tuple[int, int]:
588
+ """
589
+ Generic idempotent seed function for packages that use the orchestrator.
590
+
591
+ This is used when the package doesn't provide its own seed_fn.
592
+ The orchestrator handles all the seeding logic using the model_classes
593
+ provided in the SeederConfig.
594
+
595
+ Args:
596
+ session: SQLModel AsyncSession
597
+ table_name: Name of the table (for logging)
598
+ rows: List of row dictionaries with explicit IDs
599
+ model_class: SQLModel class for the table
600
+ fk_field_mapping: Optional mapping for FK field names (JSON -> model)
601
+
602
+ Returns:
603
+ Tuple of (rows_inserted, rows_skipped)
604
+ """
605
+ from sqlmodel import select
606
+
607
+ rows_inserted = 0
608
+ rows_skipped = 0
609
+
610
+ # Get FK field mapping for this table if provided
611
+ table_fk_map = (fk_field_mapping or {}).get(table_name, {})
612
+
613
+ for row in rows:
614
+ # Apply FK field mapping if needed (e.g., JSON has "category_id" but model uses "permission_category_id")
615
+ if table_fk_map:
616
+ row = {table_fk_map.get(k, k): v for k, v in row.items()}
617
+
618
+ row_id = row.get("id")
619
+ if row_id is None:
620
+ logger.warning(f"Skipping row without ID in table '{table_name}'")
621
+ rows_skipped += 1
622
+ continue
623
+
624
+ # Check if row already exists by ID
625
+ result = await session.exec(
626
+ select(model_class).where(model_class.id == row_id)
627
+ )
628
+ existing = result.one_or_none()
629
+
630
+ if existing is not None:
631
+ logger.debug(f"Skipping existing row id={row_id} in table '{table_name}'")
632
+ rows_skipped += 1
633
+ continue
634
+
635
+ # Insert new row
636
+ try:
637
+ obj = model_class(**row)
638
+ session.add(obj)
639
+ await session.commit()
640
+ rows_inserted += 1
641
+ logger.debug(f"Inserted row id={row_id} in table '{table_name}'")
642
+ except Exception as e:
643
+ await session.rollback()
644
+ logger.error(f"Failed to insert row in table '{table_name}': {e}")
645
+ raise
646
+
647
+ return rows_inserted, rows_skipped
648
+
649
+
650
+ # ============================================================================
651
+ # Main Orchestrator
652
+ # ============================================================================
653
+
654
+
655
+ async def seed_all(
656
+ profile: str,
657
+ package_filter: list[str] | None = None
658
+ ) -> SeederResult:
659
+ """
660
+ Execute all registered seeders in dependency order.
661
+
662
+ Seeders are executed in priority order (lower priority first).
663
+ Each package's tables are loaded in dependency order based on
664
+ the topological sort of depends_on relationships.
665
+
666
+ Args:
667
+ profile: Profile folder to use (e.g., "dev", "prod")
668
+ package_filter: Optional list of package names to include.
669
+ If None, all registered seeders run.
670
+
671
+ Returns:
672
+ SeederResult with counts and any errors
673
+
674
+ Raises:
675
+ Any exceptions from manifest loading or data validation are propagated.
676
+
677
+ Example:
678
+ # Seed all packages with dev profile
679
+ result = await seed_all("dev")
680
+
681
+ # Seed only permissions2fast-fastapi
682
+ result = await seed_all("dev", package_filter=["permissions2fast-fastapi"])
683
+ """
684
+ result = SeederResult()
685
+
686
+ # Sort by priority (lower = first)
687
+ sorted_seeders = sorted(get_registered_seeders(), key=lambda s: s.priority)
688
+
689
+ for seeder in sorted_seeders:
690
+ # Apply package filter if specified
691
+ if package_filter and seeder.package_name not in package_filter:
692
+ continue
693
+
694
+ try:
695
+ pkg_name = seeder.package_name or seeder.connection_name
696
+ logger.info(f"Seeding package: {pkg_name}")
697
+
698
+ # If package provides its own seed_fn, delegate to it
699
+ if seeder.seed_fn is not None:
700
+ logger.debug(f"Delegating to package's seed_fn for {pkg_name}")
701
+ seed_result = await seeder.seed_fn(profile)
702
+ # Merge result
703
+ result.seeded_packages.append(pkg_name)
704
+ result.errors.extend(seed_result.get("errors", []))
705
+ result.tables_seeded += seed_result.get("tables_seeded", 0)
706
+ result.rows_seeded += seed_result.get("rows_seeded", 0)
707
+ logger.info(f"Completed seeding via package fn: {pkg_name}")
708
+ continue
709
+
710
+ # Generic seeding for packages without seed_fn
711
+ # (Tenant seeders handle this differently - they use their own seed_all_tenants)
712
+ if seeder.is_tenant_seeder:
713
+ logger.warning(f"Tenant seeder {pkg_name} has no seed_fn - skipping (use seed_all_tenants directly)")
714
+ continue
715
+
716
+ # Get database session for this connection
717
+ from pgsqlasync2fast_fastapi.connection import get_manager
718
+ from sqlmodel.ext.asyncio.session import AsyncSession
719
+
720
+ manager = get_manager()
721
+ engine = manager.get_engine(seeder.connection_name)
722
+
723
+ async with AsyncSession(engine) as session:
724
+ # Load manifest
725
+ manifest = _load_manifest(seeder.manifest_path)
726
+
727
+ # Resolve load order using topological sort
728
+ table_order = _resolve_load_order(manifest)
729
+
730
+ # Track loaded tables for FK validation
731
+ loaded_tables: dict[str, list[dict[str, Any]]] = {}
732
+
733
+ for table_name in table_order:
734
+ rows = _load_table_data(table_name, seeder.manifest_path, profile)
735
+ if not rows:
736
+ continue
737
+
738
+ # Store for FK validation of dependent tables
739
+ loaded_tables[table_name] = rows
740
+
741
+ # Validate FK references before inserting any data
742
+ _validate_fk_references(
743
+ table_name, rows, seeder.manifest_path, profile, loaded_tables
744
+ )
745
+
746
+ # Seed each table using model classes from seeder config
747
+ for table_name in table_order:
748
+ rows = loaded_tables.get(table_name, [])
749
+ if not rows:
750
+ continue
751
+
752
+ model_class = seeder.model_classes.get(table_name)
753
+ if model_class is None:
754
+ logger.warning(f"No model class for table '{table_name}', skipping")
755
+ continue
756
+
757
+ # Use generic seed logic with model classes and FK mapping
758
+ inserted, skipped = await _seed_table_idempotent_generic(
759
+ session, table_name, rows, model_class,
760
+ fk_field_mapping=seeder.fk_field_mapping
761
+ )
762
+ result.rows_seeded += inserted
763
+ result.tables_seeded += 1
764
+
765
+ logger.info(f"Completed seeding: {pkg_name}")
766
+
767
+ except FileNotFoundError as e:
768
+ error_msg = f"Failed to seed {seeder.package_name or seeder.connection_name}: {e}"
769
+ logger.error(error_msg)
770
+ result.errors.append(error_msg)
771
+ except SeedValidationError as e:
772
+ error_msg = f"Validation error in {seeder.package_name or seeder.connection_name}: {e}"
773
+ logger.error(error_msg)
774
+ result.errors.append(error_msg)
775
+ except Exception as e:
776
+ error_msg = f"Failed to seed {seeder.package_name or seeder.connection_name}: {e}"
777
+ logger.error(error_msg)
778
+ result.errors.append(error_msg)
779
+
780
+ return result
781
+
782
+
783
+ # ============================================================================
784
+ # Exports
785
+ # ============================================================================
786
+
787
+ __all__ = [
788
+ # Dataclasses
789
+ "SeederConfig",
790
+ "SeederResult",
791
+ # Exceptions
792
+ "SeederException",
793
+ "SeederConflictError",
794
+ "SeedValidationError",
795
+ # Registry functions
796
+ "register_seeder",
797
+ "get_registered_seeders",
798
+ "clear_registry",
799
+ # Main orchestrator
800
+ "seed_all",
801
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsqlasync2fast-fastapi
3
- Version: 0.2.0
3
+ Version: 0.3.0
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,12 @@
1
+ pgsqlasync2fast_fastapi/__init__.py,sha256=zGV8J18n9bis4KiqEuMZyG31w_lSnVenuxhk355IZIA,2168
2
+ pgsqlasync2fast_fastapi/__version__.py,sha256=VrXpHDu3erkzwl_WXrqINBm9xWkcyUy53IQOj042dOs,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-0.3.0.dist-info/licenses/LICENSE,sha256=CkISX1hNEwxxrPTOXet3IYMEH28Bn7SoUyKniRjg68I,1086
9
+ pgsqlasync2fast_fastapi-0.3.0.dist-info/METADATA,sha256=6jRDeCgNZmaQT0j5bbPJjKb4rOb-HCrIBvIiUnqaEy8,9889
10
+ pgsqlasync2fast_fastapi-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ pgsqlasync2fast_fastapi-0.3.0.dist-info/top_level.txt,sha256=7pllDm9nlFGWKJDtG5zukxqdADqjZddU-GzaQJPLMVc,24
12
+ pgsqlasync2fast_fastapi-0.3.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- pgsqlasync2fast_fastapi/__init__.py,sha256=TYLh-ERPDG9HGvoQbFcy3Kwj7EyoVlibZC_vAHMEvqY,1351
2
- pgsqlasync2fast_fastapi/__version__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,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/settings.py,sha256=9SAmEVtotKJYlbTjI4qkKBjEOghTTtaICAFBBrR-eHk,6678
7
- pgsqlasync2fast_fastapi-0.2.0.dist-info/licenses/LICENSE,sha256=CkISX1hNEwxxrPTOXet3IYMEH28Bn7SoUyKniRjg68I,1086
8
- pgsqlasync2fast_fastapi-0.2.0.dist-info/METADATA,sha256=FJibseebbvRLouGwUQm4ufrHxAvbbUIJtj3Ry9fDJns,9889
9
- pgsqlasync2fast_fastapi-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
- pgsqlasync2fast_fastapi-0.2.0.dist-info/top_level.txt,sha256=7pllDm9nlFGWKJDtG5zukxqdADqjZddU-GzaQJPLMVc,24
11
- pgsqlasync2fast_fastapi-0.2.0.dist-info/RECORD,,