pgsqlasync2fast-fastapi 0.3.1__tar.gz → 0.4.0__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.
- pgsqlasync2fast_fastapi-0.4.0/MANIFEST.in +9 -0
- {pgsqlasync2fast_fastapi-0.3.1/src/pgsqlasync2fast_fastapi.egg-info → pgsqlasync2fast_fastapi-0.4.0}/PKG-INFO +1 -1
- pgsqlasync2fast_fastapi-0.4.0/docs/.env.example +50 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/docs/seeder-format.md +39 -3
- pgsqlasync2fast_fastapi-0.4.0/examples/basic_usage.py +58 -0
- pgsqlasync2fast_fastapi-0.4.0/examples/database_creation.py +93 -0
- pgsqlasync2fast_fastapi-0.4.0/examples/fastapi_integration.py +151 -0
- pgsqlasync2fast_fastapi-0.4.0/examples/multi_database.py +70 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/pyproject.toml +1 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/__init__.py +4 -0
- pgsqlasync2fast_fastapi-0.4.0/src/pgsqlasync2fast_fastapi/__version__.py +1 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/seeder.py +106 -21
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/skills/SKILL.md +15 -3
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0/src/pgsqlasync2fast_fastapi.egg-info}/PKG-INFO +1 -1
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi.egg-info/SOURCES.txt +8 -1
- pgsqlasync2fast_fastapi-0.4.0/tests/test_insert_if_missing.py +92 -0
- pgsqlasync2fast_fastapi-0.4.0/tests/test_seeder_override.py +263 -0
- pgsqlasync2fast_fastapi-0.3.1/MANIFEST.in +0 -4
- pgsqlasync2fast_fastapi-0.3.1/src/pgsqlasync2fast_fastapi/__version__.py +0 -1
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/LICENSE +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/README.md +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/dev/categories.json +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/dev/permissions.json +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/dev/roles.json +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/manifest.json +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/prod/categories.json +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/prod/permissions.json +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/prod/roles.json +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/setup.cfg +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/connection.py +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/database.py +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/dependencies.py +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi/settings.py +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi.egg-info/dependency_links.txt +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi.egg-info/requires.txt +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/src/pgsqlasync2fast_fastapi.egg-info/top_level.txt +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/tests/test_completo.py +0 -0
- {pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/tests/test_seeder.py +0 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
include README.md
|
|
2
|
+
include LICENSE
|
|
3
|
+
recursive-include docs *.md *.example
|
|
4
|
+
recursive-include examples *.py *.example *.json
|
|
5
|
+
recursive-include src/pgsqlasync2fast_fastapi *.py *.html *.json *.md
|
|
6
|
+
|
|
7
|
+
global-exclude __pycache__
|
|
8
|
+
global-exclude *.py[co]
|
|
9
|
+
global-exclude .DS_Store
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgsqlasync2fast-fastapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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,50 @@
|
|
|
1
|
+
# ============================================
|
|
2
|
+
# PostgreSQL Database Configuration
|
|
3
|
+
# ============================================
|
|
4
|
+
|
|
5
|
+
# ===== Connection DEFAULT (principal) =====
|
|
6
|
+
# Esta es la conexión por defecto que se usará cuando no especifiques ninguna
|
|
7
|
+
DB_CONNECTIONS__DEFAULT__HOST=localhost
|
|
8
|
+
DB_CONNECTIONS__DEFAULT__PORT=5432
|
|
9
|
+
DB_CONNECTIONS__DEFAULT__USERNAME=myuser
|
|
10
|
+
DB_CONNECTIONS__DEFAULT__PASSWORD=mypassword
|
|
11
|
+
DB_CONNECTIONS__DEFAULT__DATABASE=mydb
|
|
12
|
+
DB_CONNECTIONS__DEFAULT__IS_SUPERUSER=false
|
|
13
|
+
|
|
14
|
+
# Pool settings (opcional, estos son los valores por defecto)
|
|
15
|
+
DB_CONNECTIONS__DEFAULT__POOL_SIZE=5
|
|
16
|
+
DB_CONNECTIONS__DEFAULT__MAX_OVERFLOW=10
|
|
17
|
+
DB_CONNECTIONS__DEFAULT__POOL_TIMEOUT=30
|
|
18
|
+
DB_CONNECTIONS__DEFAULT__POOL_RECYCLE=3600
|
|
19
|
+
|
|
20
|
+
# Echo mode para esta conexión (opcional)
|
|
21
|
+
DB_CONNECTIONS__DEFAULT__ECHO=false
|
|
22
|
+
|
|
23
|
+
# ===== Connection BUSINESS (ejemplo de segunda conexión) =====
|
|
24
|
+
# Para usar esta conexión, especifica connection_name="business" en tus dependencias
|
|
25
|
+
DB_CONNECTIONS__BUSINESS__HOST=localhost
|
|
26
|
+
DB_CONNECTIONS__BUSINESS__PORT=5432
|
|
27
|
+
DB_CONNECTIONS__BUSINESS__USERNAME=business_user
|
|
28
|
+
DB_CONNECTIONS__BUSINESS__PASSWORD=business_password
|
|
29
|
+
DB_CONNECTIONS__BUSINESS__DATABASE=business_db
|
|
30
|
+
DB_CONNECTIONS__BUSINESS__IS_SUPERUSER=false
|
|
31
|
+
|
|
32
|
+
# ===== Connection ADMIN (ejemplo con superusuario) =====
|
|
33
|
+
# Esta conexión tiene privilegios de superusuario para crear/eliminar bases de datos
|
|
34
|
+
DB_CONNECTIONS__ADMIN__HOST=localhost
|
|
35
|
+
DB_CONNECTIONS__ADMIN__PORT=5432
|
|
36
|
+
DB_CONNECTIONS__ADMIN__USERNAME=postgres
|
|
37
|
+
DB_CONNECTIONS__ADMIN__PASSWORD=postgres_password
|
|
38
|
+
DB_CONNECTIONS__ADMIN__DATABASE=postgres
|
|
39
|
+
DB_CONNECTIONS__ADMIN__IS_SUPERUSER=true
|
|
40
|
+
|
|
41
|
+
# ============================================
|
|
42
|
+
# General Settings
|
|
43
|
+
# ============================================
|
|
44
|
+
|
|
45
|
+
# Conexión por defecto cuando no se especifica ninguna
|
|
46
|
+
# Opciones: "default", "business", "admin", o cualquier otra que hayas configurado
|
|
47
|
+
DB_DEFAULT_CONNECTION=default
|
|
48
|
+
|
|
49
|
+
# Echo mode global (puede ser sobrescrito por cada conexión)
|
|
50
|
+
DB_ECHO=false
|
|
@@ -148,25 +148,61 @@ register_seeder(SeederConfig(
|
|
|
148
148
|
|
|
149
149
|
## Conflict Detection
|
|
150
150
|
|
|
151
|
-
When registering a seeder, the system detects table conflicts.
|
|
151
|
+
When registering a seeder, the system detects table conflicts between **distinct** packages.
|
|
152
152
|
|
|
153
153
|
### Rule
|
|
154
154
|
|
|
155
|
-
Two packages CANNOT seed the same table in the same connection.
|
|
155
|
+
Two DIFFERENT packages (`(connection_name, package_name)` keys) CANNOT seed the same table in the same connection. Re-registering the same key is an override, never a conflict.
|
|
156
156
|
|
|
157
157
|
### Error
|
|
158
158
|
|
|
159
|
-
If
|
|
159
|
+
If a distinct package's tables overlap an already-registered package on the same connection, `SeederConflictError` is raised:
|
|
160
160
|
```
|
|
161
161
|
Tables {'roles'} conflict between 'pkg-a' and 'pkg-b' on connection 'auth'
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
## Override & Idempotency (single-entry)
|
|
165
|
+
|
|
166
|
+
The registry is keyed by `(connection_name, package_name)`, so registering the same package+connection repeatedly updates **one single entry** — no duplicate registry entries.
|
|
167
|
+
|
|
168
|
+
`register_seeder(config, mode=...)` supports two override behaviors:
|
|
169
|
+
|
|
170
|
+
| `mode` | Behavior |
|
|
171
|
+
|--------|----------|
|
|
172
|
+
| `"retain_base"` (default) | Replaces the entry's config fields but **merges** the prior entry's manifest `model_classes` into the new config's set. Base tables are preserved when an app extends a package seeder (e.g. keeps permissions2fast base tables while adding route tables). |
|
|
173
|
+
| `"replace"` | Replaces the prior entry wholesale. |
|
|
174
|
+
|
|
175
|
+
Re-registering the **same** `(connection, package)` key never raises `SeederConflictError`. Only a **distinct** package on the same connection with overlapping tables raises it.
|
|
176
|
+
|
|
177
|
+
### Example
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
# Base permissions2fast seeder auto-registered on "auth"
|
|
181
|
+
register_seeder(SeederConfig(
|
|
182
|
+
connection_name="auth",
|
|
183
|
+
manifest_path="permissions2fast_fastapi/seeders/manifest.json",
|
|
184
|
+
package_name="permissions2fast-fastapi",
|
|
185
|
+
priority=60,
|
|
186
|
+
))
|
|
187
|
+
|
|
188
|
+
# App extends it by ADDING route tables, keeping the base manifest set
|
|
189
|
+
register_seeder(SeederConfig(
|
|
190
|
+
connection_name="auth",
|
|
191
|
+
manifest_path="app/rbac_route_manifest.json",
|
|
192
|
+
package_name="permissions2fast-fastapi", # same key -> override
|
|
193
|
+
model_classes={"routes": RouteModel},
|
|
194
|
+
), mode="retain_base")
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The result is a single `permissions2fast-fastapi` entry whose `model_classes` contain both the base `roles`/`permissions` models and the new `routes` model.
|
|
198
|
+
|
|
164
199
|
## Idempotency
|
|
165
200
|
|
|
166
201
|
The seeder is idempotent - running multiple times produces the same result.
|
|
167
202
|
|
|
168
203
|
- Rows with existing IDs are skipped
|
|
169
204
|
- Only new rows are inserted
|
|
205
|
+
- Registry registration is idempotent per key - no duplicate entries
|
|
170
206
|
|
|
171
207
|
This allows safe re-runs without duplicating data.
|
|
172
208
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Basic usage example for pgsqlasync2fast-fastapi
|
|
3
|
+
|
|
4
|
+
This example demonstrates:
|
|
5
|
+
- Basic database connection
|
|
6
|
+
- Executing queries
|
|
7
|
+
- Using sessions
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
from pgsqlasync2fast_fastapi import get_manager, settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def main():
|
|
16
|
+
"""Main function demonstrating basic usage."""
|
|
17
|
+
|
|
18
|
+
# Get the database manager
|
|
19
|
+
manager = get_manager()
|
|
20
|
+
|
|
21
|
+
print("📊 Basic PostgreSQL Async Usage Example")
|
|
22
|
+
print("=" * 50)
|
|
23
|
+
|
|
24
|
+
# List configured connections
|
|
25
|
+
print(f"\n✅ Configured connections: {manager.list_connections()}")
|
|
26
|
+
|
|
27
|
+
# Get a session for the default connection
|
|
28
|
+
print(f"\n🔌 Connecting to default database...")
|
|
29
|
+
session = await manager.get_session()
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
# Execute a simple query
|
|
33
|
+
result = await session.execute(text("SELECT version()"))
|
|
34
|
+
version = result.scalar()
|
|
35
|
+
print(f"✅ PostgreSQL version: {version}")
|
|
36
|
+
|
|
37
|
+
# Execute another query
|
|
38
|
+
result = await session.execute(text("SELECT current_database()"))
|
|
39
|
+
db_name = result.scalar()
|
|
40
|
+
print(f"✅ Current database: {db_name}")
|
|
41
|
+
|
|
42
|
+
# Commit the session
|
|
43
|
+
await session.commit()
|
|
44
|
+
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print(f"❌ Error: {e}")
|
|
47
|
+
await session.rollback()
|
|
48
|
+
finally:
|
|
49
|
+
await session.close()
|
|
50
|
+
|
|
51
|
+
# Cleanup
|
|
52
|
+
print("\n🔌 Closing connections...")
|
|
53
|
+
await manager.close_all()
|
|
54
|
+
print("✅ Done!")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database creation example for pgsqlasync2fast-fastapi
|
|
3
|
+
|
|
4
|
+
This example demonstrates:
|
|
5
|
+
- Checking if a database exists
|
|
6
|
+
- Creating new databases
|
|
7
|
+
- Listing all databases
|
|
8
|
+
- Dropping databases
|
|
9
|
+
|
|
10
|
+
IMPORTANT: This requires a connection configured with is_superuser=true
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from pgsqlasync2fast_fastapi import (
|
|
15
|
+
database_exists,
|
|
16
|
+
create_database,
|
|
17
|
+
drop_database,
|
|
18
|
+
list_databases,
|
|
19
|
+
get_manager,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def main():
|
|
24
|
+
"""Main function demonstrating database creation utilities."""
|
|
25
|
+
|
|
26
|
+
print("📊 Database Creation Example")
|
|
27
|
+
print("=" * 50)
|
|
28
|
+
|
|
29
|
+
# Check if we have a superuser connection
|
|
30
|
+
manager = get_manager()
|
|
31
|
+
superuser_conn = manager.config.get_superuser_connection_name()
|
|
32
|
+
|
|
33
|
+
if not superuser_conn:
|
|
34
|
+
print("❌ No superuser connection configured!")
|
|
35
|
+
print(" Please configure a connection with IS_SUPERUSER=true")
|
|
36
|
+
print(" Example: DB_CONNECTIONS__ADMIN__IS_SUPERUSER=true")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
print(f"✅ Using superuser connection: '{superuser_conn}'")
|
|
40
|
+
|
|
41
|
+
# List all existing databases
|
|
42
|
+
print("\n📋 Listing all databases...")
|
|
43
|
+
databases = await list_databases()
|
|
44
|
+
for db in databases:
|
|
45
|
+
print(f" - {db}")
|
|
46
|
+
|
|
47
|
+
# Test database name
|
|
48
|
+
test_db_name = "test_created_db"
|
|
49
|
+
|
|
50
|
+
# Check if test database exists
|
|
51
|
+
print(f"\n🔍 Checking if '{test_db_name}' exists...")
|
|
52
|
+
exists = await database_exists(test_db_name)
|
|
53
|
+
print(f" {'✅ Exists' if exists else '❌ Does not exist'}")
|
|
54
|
+
|
|
55
|
+
# Create the database if it doesn't exist
|
|
56
|
+
if not exists:
|
|
57
|
+
print(f"\n🔨 Creating database '{test_db_name}'...")
|
|
58
|
+
created = await create_database(test_db_name)
|
|
59
|
+
if created:
|
|
60
|
+
print(f" ✅ Database '{test_db_name}' created successfully!")
|
|
61
|
+
else:
|
|
62
|
+
print(f" ❌ Failed to create database")
|
|
63
|
+
else:
|
|
64
|
+
print(f"\n⚠️ Database '{test_db_name}' already exists, skipping creation")
|
|
65
|
+
|
|
66
|
+
# Verify it exists now
|
|
67
|
+
print(f"\n🔍 Verifying '{test_db_name}' exists...")
|
|
68
|
+
exists = await database_exists(test_db_name)
|
|
69
|
+
print(f" {'✅ Confirmed' if exists else '❌ Not found'}")
|
|
70
|
+
|
|
71
|
+
# List databases again to show the new one
|
|
72
|
+
print("\n📋 Listing all databases (after creation)...")
|
|
73
|
+
databases = await list_databases()
|
|
74
|
+
for db in databases:
|
|
75
|
+
marker = " ← NEW" if db == test_db_name else ""
|
|
76
|
+
print(f" - {db}{marker}")
|
|
77
|
+
|
|
78
|
+
# Optional: Drop the test database
|
|
79
|
+
print(f"\n🗑️ Cleaning up: dropping '{test_db_name}'...")
|
|
80
|
+
dropped = await drop_database(test_db_name, force=True)
|
|
81
|
+
if dropped:
|
|
82
|
+
print(f" ✅ Database '{test_db_name}' dropped successfully!")
|
|
83
|
+
else:
|
|
84
|
+
print(f" ❌ Failed to drop database")
|
|
85
|
+
|
|
86
|
+
# Cleanup
|
|
87
|
+
print("\n🔌 Closing connections...")
|
|
88
|
+
await manager.close_all()
|
|
89
|
+
print("✅ Done!")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI integration example for pgsqlasync2fast-fastapi
|
|
3
|
+
|
|
4
|
+
This example demonstrates:
|
|
5
|
+
- FastAPI application setup
|
|
6
|
+
- Dependency injection
|
|
7
|
+
- Startup/shutdown events
|
|
8
|
+
- Using multiple database connections in endpoints
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from functools import partial
|
|
12
|
+
from fastapi import FastAPI, Depends
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
|
|
14
|
+
from sqlalchemy import text
|
|
15
|
+
|
|
16
|
+
from pgsqlasync2fast_fastapi import (
|
|
17
|
+
get_db_session,
|
|
18
|
+
get_db_engine,
|
|
19
|
+
get_db_manager,
|
|
20
|
+
startup_database,
|
|
21
|
+
shutdown_database,
|
|
22
|
+
DatabaseManager,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Create FastAPI app
|
|
26
|
+
app = FastAPI(
|
|
27
|
+
title="PostgreSQL Async Example",
|
|
28
|
+
description="Example FastAPI application with pgsqlasync2fast-fastapi"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.on_event("startup")
|
|
33
|
+
async def startup():
|
|
34
|
+
"""Initialize database connections on startup."""
|
|
35
|
+
await startup_database()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.on_event("shutdown")
|
|
39
|
+
async def shutdown():
|
|
40
|
+
"""Close database connections on shutdown."""
|
|
41
|
+
await shutdown_database()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.get("/")
|
|
45
|
+
async def root():
|
|
46
|
+
"""Root endpoint."""
|
|
47
|
+
return {
|
|
48
|
+
"message": "PostgreSQL Async FastAPI Example",
|
|
49
|
+
"endpoints": [
|
|
50
|
+
"/health",
|
|
51
|
+
"/connections",
|
|
52
|
+
"/db/info",
|
|
53
|
+
"/db/version",
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.get("/health")
|
|
59
|
+
async def health_check(engine: AsyncEngine = Depends(get_db_engine)):
|
|
60
|
+
"""Health check endpoint using default database."""
|
|
61
|
+
try:
|
|
62
|
+
async with engine.connect() as conn:
|
|
63
|
+
await conn.execute(text("SELECT 1"))
|
|
64
|
+
return {"status": "healthy", "database": "default"}
|
|
65
|
+
except Exception as e:
|
|
66
|
+
return {"status": "unhealthy", "error": str(e)}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.get("/connections")
|
|
70
|
+
async def list_connections(manager: DatabaseManager = Depends(get_db_manager)):
|
|
71
|
+
"""List all configured database connections."""
|
|
72
|
+
connections = manager.list_connections()
|
|
73
|
+
|
|
74
|
+
connection_info = []
|
|
75
|
+
for conn_name in connections:
|
|
76
|
+
is_super = manager.is_superuser_connection(conn_name)
|
|
77
|
+
connection_info.append({
|
|
78
|
+
"name": conn_name,
|
|
79
|
+
"is_superuser": is_super
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"total": len(connections),
|
|
84
|
+
"connections": connection_info
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.get("/db/info")
|
|
89
|
+
async def get_db_info(session: AsyncSession = Depends(get_db_session)):
|
|
90
|
+
"""Get database information using default connection."""
|
|
91
|
+
try:
|
|
92
|
+
# Get database name
|
|
93
|
+
result = await session.execute(text("SELECT current_database()"))
|
|
94
|
+
db_name = result.scalar()
|
|
95
|
+
|
|
96
|
+
# Get current user
|
|
97
|
+
result = await session.execute(text("SELECT current_user"))
|
|
98
|
+
user = result.scalar()
|
|
99
|
+
|
|
100
|
+
# Get server version
|
|
101
|
+
result = await session.execute(text("SELECT version()"))
|
|
102
|
+
version = result.scalar()
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"database": db_name,
|
|
106
|
+
"user": user,
|
|
107
|
+
"version": version
|
|
108
|
+
}
|
|
109
|
+
except Exception as e:
|
|
110
|
+
return {"error": str(e)}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.get("/db/version")
|
|
114
|
+
async def get_version(session: AsyncSession = Depends(get_db_session)):
|
|
115
|
+
"""Get PostgreSQL version."""
|
|
116
|
+
result = await session.execute(text("SELECT version()"))
|
|
117
|
+
version = result.scalar()
|
|
118
|
+
return {"version": version}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Example: Using a specific database connection
|
|
122
|
+
# Create a dependency for a specific connection (e.g., "business")
|
|
123
|
+
get_business_session = partial(get_db_session, connection_name="business")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.get("/business/info")
|
|
127
|
+
async def get_business_info(session: AsyncSession = Depends(get_business_session)):
|
|
128
|
+
"""Get database information using business connection."""
|
|
129
|
+
try:
|
|
130
|
+
result = await session.execute(text("SELECT current_database()"))
|
|
131
|
+
db_name = result.scalar()
|
|
132
|
+
|
|
133
|
+
result = await session.execute(text("SELECT current_user"))
|
|
134
|
+
user = result.scalar()
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"connection": "business",
|
|
138
|
+
"database": db_name,
|
|
139
|
+
"user": user
|
|
140
|
+
}
|
|
141
|
+
except Exception as e:
|
|
142
|
+
return {"error": str(e)}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
import uvicorn
|
|
147
|
+
|
|
148
|
+
print("🚀 Starting FastAPI application...")
|
|
149
|
+
print("📖 API docs available at: http://localhost:8000/docs")
|
|
150
|
+
|
|
151
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-database example for pgsqlasync2fast-fastapi
|
|
3
|
+
|
|
4
|
+
This example demonstrates:
|
|
5
|
+
- Configuring multiple database connections
|
|
6
|
+
- Switching between databases
|
|
7
|
+
- Using different connections simultaneously
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from sqlalchemy import text
|
|
12
|
+
from pgsqlasync2fast_fastapi import get_manager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def main():
|
|
16
|
+
"""Main function demonstrating multi-database usage."""
|
|
17
|
+
|
|
18
|
+
manager = get_manager()
|
|
19
|
+
|
|
20
|
+
print("📊 Multi-Database Usage Example")
|
|
21
|
+
print("=" * 50)
|
|
22
|
+
|
|
23
|
+
# List all configured connections
|
|
24
|
+
connections = manager.list_connections()
|
|
25
|
+
print(f"\n✅ Available connections: {connections}")
|
|
26
|
+
|
|
27
|
+
# Demonstrate using multiple connections
|
|
28
|
+
for conn_name in connections:
|
|
29
|
+
print(f"\n🔌 Connecting to '{conn_name}' database...")
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
# Health check
|
|
33
|
+
is_healthy = await manager.health_check(conn_name)
|
|
34
|
+
if not is_healthy:
|
|
35
|
+
print(f"❌ Connection '{conn_name}' is not healthy")
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
# Get a session for this connection
|
|
39
|
+
session = await manager.get_session(conn_name)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
# Get database info
|
|
43
|
+
result = await session.execute(text("SELECT current_database()"))
|
|
44
|
+
db_name = result.scalar()
|
|
45
|
+
|
|
46
|
+
result = await session.execute(text("SELECT current_user"))
|
|
47
|
+
user = result.scalar()
|
|
48
|
+
|
|
49
|
+
is_super = manager.is_superuser_connection(conn_name)
|
|
50
|
+
super_flag = " (SUPERUSER)" if is_super else ""
|
|
51
|
+
|
|
52
|
+
print(f"✅ Database: {db_name}")
|
|
53
|
+
print(f"✅ User: {user}{super_flag}")
|
|
54
|
+
|
|
55
|
+
await session.commit()
|
|
56
|
+
|
|
57
|
+
finally:
|
|
58
|
+
await session.close()
|
|
59
|
+
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f"❌ Error with connection '{conn_name}': {e}")
|
|
62
|
+
|
|
63
|
+
# Cleanup
|
|
64
|
+
print("\n🔌 Closing all connections...")
|
|
65
|
+
await manager.close_all()
|
|
66
|
+
print("✅ Done!")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
asyncio.run(main())
|
|
@@ -50,6 +50,8 @@ 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,
|
|
53
55
|
)
|
|
54
56
|
|
|
55
57
|
__all__ = [
|
|
@@ -86,4 +88,6 @@ __all__ = [
|
|
|
86
88
|
"clear_registry",
|
|
87
89
|
# Main orchestrator (seeder)
|
|
88
90
|
"seed_all",
|
|
91
|
+
# Shared idempotent insert-if-missing primitive
|
|
92
|
+
"insert_if_missing",
|
|
89
93
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -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:
|
|
136
|
+
_SEEDER_REGISTRY: dict[tuple[str, str], SeederConfig] = {}
|
|
137
137
|
|
|
138
138
|
|
|
139
|
-
def register_seeder(
|
|
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
|
-
|
|
144
|
-
the same
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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.
|
|
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
|
# ============================================================================
|
|
@@ -683,8 +766,8 @@ async def seed_all(
|
|
|
683
766
|
"""
|
|
684
767
|
result = SeederResult()
|
|
685
768
|
|
|
686
|
-
#
|
|
687
|
-
sorted_seeders = sorted(
|
|
769
|
+
# Iterate registry values, sorted by priority (lower = first)
|
|
770
|
+
sorted_seeders = sorted(_SEEDER_REGISTRY.values(), key=lambda s: s.priority)
|
|
688
771
|
|
|
689
772
|
for seeder in sorted_seeders:
|
|
690
773
|
# Apply package filter if specified
|
|
@@ -798,4 +881,6 @@ __all__ = [
|
|
|
798
881
|
"clear_registry",
|
|
799
882
|
# Main orchestrator
|
|
800
883
|
"seed_all",
|
|
884
|
+
# Shared idempotent insert-if-missing primitive
|
|
885
|
+
"insert_if_missing",
|
|
801
886
|
]
|
|
@@ -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
|
|
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
|
-
-
|
|
55
|
-
- `
|
|
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
|
+
Version: 0.4.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
|
|
@@ -2,7 +2,12 @@ LICENSE
|
|
|
2
2
|
MANIFEST.in
|
|
3
3
|
README.md
|
|
4
4
|
pyproject.toml
|
|
5
|
+
docs/.env.example
|
|
5
6
|
docs/seeder-format.md
|
|
7
|
+
examples/basic_usage.py
|
|
8
|
+
examples/database_creation.py
|
|
9
|
+
examples/fastapi_integration.py
|
|
10
|
+
examples/multi_database.py
|
|
6
11
|
examples/seeders/manifest.json
|
|
7
12
|
examples/seeders/dev/categories.json
|
|
8
13
|
examples/seeders/dev/permissions.json
|
|
@@ -24,4 +29,6 @@ src/pgsqlasync2fast_fastapi.egg-info/requires.txt
|
|
|
24
29
|
src/pgsqlasync2fast_fastapi.egg-info/top_level.txt
|
|
25
30
|
src/pgsqlasync2fast_fastapi/skills/SKILL.md
|
|
26
31
|
tests/test_completo.py
|
|
27
|
-
tests/
|
|
32
|
+
tests/test_insert_if_missing.py
|
|
33
|
+
tests/test_seeder.py
|
|
34
|
+
tests/test_seeder_override.py
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the reusable idempotent `insert_if_missing` primitive
|
|
3
|
+
(pgsqlasync2fast-fastapi).
|
|
4
|
+
|
|
5
|
+
This is the shared insert-if-missing-by-natural-key helper that consumer
|
|
6
|
+
packages (permissions2fast GLOBAL routes, tenants2fast TENANT routes) reuse
|
|
7
|
+
instead of hand-rolling per package (RBAC standardization decision D2).
|
|
8
|
+
|
|
9
|
+
Run with:
|
|
10
|
+
cd /Volumes/Desarrollo/Repos/Github/pgsqlasync2fast-fastapi \
|
|
11
|
+
&& uv run pytest tests/test_insert_if_missing.py -v
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
from sqlalchemy import BigInteger
|
|
18
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
19
|
+
from sqlalchemy.ext.compiler import compiles
|
|
20
|
+
from sqlmodel import Field, SQLModel
|
|
21
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@compiles(BigInteger, "sqlite")
|
|
25
|
+
def _compile_bigint_sqlite(type_, compiler, **kw):
|
|
26
|
+
return "INTEGER"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DB_URL = "sqlite+aiosqlite:///:memory:"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AFruit(SQLModel, table=True):
|
|
33
|
+
"""Minimal SQLModel table to exercise insert_if_missing."""
|
|
34
|
+
|
|
35
|
+
__tablename__ = "afruits"
|
|
36
|
+
|
|
37
|
+
name: str = Field(primary_key=True)
|
|
38
|
+
color: str = Field(default="")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _engine():
|
|
42
|
+
engine = create_async_engine(DB_URL, echo=False)
|
|
43
|
+
async with engine.begin() as conn:
|
|
44
|
+
await conn.run_sync(SQLModel.metadata.create_all)
|
|
45
|
+
return engine
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_insert_if_missing_inserts_new_row():
|
|
50
|
+
"""A row that matches no natural key is inserted."""
|
|
51
|
+
from pgsqlasync2fast_fastapi.seeder import insert_if_missing
|
|
52
|
+
|
|
53
|
+
engine = await _engine()
|
|
54
|
+
async with AsyncSession(engine) as session:
|
|
55
|
+
row = await insert_if_missing(
|
|
56
|
+
session, AFruit, lookup={"name": "apple"}, defaults={"color": "red"}
|
|
57
|
+
)
|
|
58
|
+
await session.commit()
|
|
59
|
+
|
|
60
|
+
# Re-read via a fresh query to confirm persistence
|
|
61
|
+
from sqlmodel import select
|
|
62
|
+
|
|
63
|
+
stored = (await session.exec(select(AFruit).where(AFruit.name == "apple"))).one()
|
|
64
|
+
assert stored.color == "red"
|
|
65
|
+
assert row.name == "apple"
|
|
66
|
+
await engine.dispose()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.asyncio
|
|
70
|
+
async def test_insert_if_missing_is_idempotent():
|
|
71
|
+
"""Calling twice with the same natural key returns the existing row."""
|
|
72
|
+
from pgsqlasync2fast_fastapi.seeder import insert_if_missing
|
|
73
|
+
|
|
74
|
+
engine = await _engine()
|
|
75
|
+
async with AsyncSession(engine) as session:
|
|
76
|
+
first = await insert_if_missing(
|
|
77
|
+
session, AFruit, lookup={"name": "pear"}, defaults={"color": "green"}
|
|
78
|
+
)
|
|
79
|
+
await session.commit()
|
|
80
|
+
|
|
81
|
+
second = await insert_if_missing(
|
|
82
|
+
session, AFruit, lookup={"name": "pear"}, defaults={"color": "green"}
|
|
83
|
+
)
|
|
84
|
+
await session.commit()
|
|
85
|
+
|
|
86
|
+
assert second is first # same object instance returned (cached in session identity map)
|
|
87
|
+
# Only one row exists
|
|
88
|
+
from sqlmodel import select
|
|
89
|
+
|
|
90
|
+
rows = (await session.exec(select(AFruit))).all()
|
|
91
|
+
assert len(rows) == 1
|
|
92
|
+
await engine.dispose()
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the seeder override primitive (pgsqlasync2fast-fastapi).
|
|
3
|
+
|
|
4
|
+
Covers the keyed (connection_name, package_name) registry + register_seeder(mode=)
|
|
5
|
+
override primitive:
|
|
6
|
+
- H1: an override can ADD new tables to an existing (connection, package) entry
|
|
7
|
+
- H2: an override can OVERWRITE an existing (connection, package) entry wholesale
|
|
8
|
+
- E1: a distinct (connection, package) on the same connection STILL raises
|
|
9
|
+
SeederConflictError on table overlap
|
|
10
|
+
- T1: repeated registration of the same key is idempotent (single entry)
|
|
11
|
+
|
|
12
|
+
Run with: cd /Volumes/Desarrollo/Repos/Github/pgsqlasync2fast-fastapi \\
|
|
13
|
+
&& uv run pytest tests/test_seeder_override.py -v
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from pgsqlasync2fast_fastapi.seeder import (
|
|
23
|
+
SeederConfig,
|
|
24
|
+
SeederConflictError,
|
|
25
|
+
register_seeder,
|
|
26
|
+
get_registered_seeders,
|
|
27
|
+
clear_registry,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(autouse=True)
|
|
32
|
+
def clear_seeders():
|
|
33
|
+
"""Clear the seeder registry before and after each test."""
|
|
34
|
+
clear_registry()
|
|
35
|
+
yield
|
|
36
|
+
clear_registry()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def base_manifest():
|
|
41
|
+
"""Build a temp manifest seeding base tables on the 'auth' connection."""
|
|
42
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
43
|
+
manifest_dir = Path(tmpdir) / "seeders"
|
|
44
|
+
dev_dir = manifest_dir / "dev"
|
|
45
|
+
manifest_dir.mkdir()
|
|
46
|
+
dev_dir.mkdir()
|
|
47
|
+
|
|
48
|
+
manifest = {
|
|
49
|
+
"tables": {
|
|
50
|
+
"roles": {"file": "roles.json"},
|
|
51
|
+
"permissions": {"file": "permissions.json"},
|
|
52
|
+
},
|
|
53
|
+
"load_order": ["roles", "permissions"],
|
|
54
|
+
}
|
|
55
|
+
manifest_path = manifest_dir / "manifest.json"
|
|
56
|
+
with open(manifest_path, "w") as f:
|
|
57
|
+
json.dump(manifest, f)
|
|
58
|
+
with open(dev_dir / "roles.json", "w") as f:
|
|
59
|
+
json.dump([{"id": 1, "name": "Admin"}], f)
|
|
60
|
+
with open(dev_dir / "permissions.json", "w") as f:
|
|
61
|
+
json.dump([{"id": 1, "name": "read:users"}], f)
|
|
62
|
+
|
|
63
|
+
yield (str(manifest_path), manifest, dev_dir)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def make_config(manifest_path, package, **kwargs) -> SeederConfig:
|
|
67
|
+
"""Build a SeederConfig for the given manifest and package."""
|
|
68
|
+
return SeederConfig(
|
|
69
|
+
connection_name="auth",
|
|
70
|
+
manifest_path=manifest_path,
|
|
71
|
+
package_name=package,
|
|
72
|
+
model_classes=kwargs.pop("model_classes", {}),
|
|
73
|
+
**kwargs,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ============================================================================
|
|
78
|
+
# T1 — Idempotency: same key registered twice -> single entry
|
|
79
|
+
# ============================================================================
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestOverrideIdempotency:
|
|
83
|
+
def test_same_key_twice_single_entry(self, base_manifest):
|
|
84
|
+
manifest_path, _, dev_dir = base_manifest
|
|
85
|
+
|
|
86
|
+
# New manifest that ADDS a table (extends the base manifest set)
|
|
87
|
+
extended = {
|
|
88
|
+
"tables": {
|
|
89
|
+
"roles": {"file": "roles.json"},
|
|
90
|
+
"permissions": {"file": "permissions.json"},
|
|
91
|
+
"routes": {"file": "routes.json"},
|
|
92
|
+
},
|
|
93
|
+
"load_order": ["roles", "permissions", "routes"],
|
|
94
|
+
}
|
|
95
|
+
ext_path = Path(manifest_path).parent / "manifest_override.json"
|
|
96
|
+
with open(ext_path, "w") as f:
|
|
97
|
+
json.dump(extended, f)
|
|
98
|
+
with open(Path(manifest_path).parent / "dev" / "routes.json", "w") as f:
|
|
99
|
+
json.dump([{"id": 1, "path": "/admin", "method": "GET"}], f)
|
|
100
|
+
|
|
101
|
+
base = make_config(manifest_path, "pkg-a",
|
|
102
|
+
model_classes={"roles": object, "permissions": object})
|
|
103
|
+
override = make_config(str(ext_path), "pkg-a",
|
|
104
|
+
model_classes={"roles": object, "routes": object})
|
|
105
|
+
|
|
106
|
+
# Register the same key twice with the default retain_base mode
|
|
107
|
+
register_seeder(base)
|
|
108
|
+
register_seeder(override)
|
|
109
|
+
|
|
110
|
+
seeders = get_registered_seeders()
|
|
111
|
+
assert len(seeders) == 1
|
|
112
|
+
assert seeders[0].package_name == "pkg-a"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ============================================================================
|
|
116
|
+
# H1 — retain_base ADD: base tables preserved, new tables added to model set
|
|
117
|
+
# ============================================================================
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestOverrideAdd:
|
|
121
|
+
def test_retain_base_merges_prior_model_classes(self, base_manifest):
|
|
122
|
+
manifest_path, _, _ = base_manifest
|
|
123
|
+
|
|
124
|
+
# Override manifest introduces a NEW table 'routes'
|
|
125
|
+
ext_manifest = {
|
|
126
|
+
"tables": {
|
|
127
|
+
"roles": {"file": "roles.json"},
|
|
128
|
+
"permissions": {"file": "permissions.json"},
|
|
129
|
+
"routes": {"file": "routes.json"},
|
|
130
|
+
},
|
|
131
|
+
"load_order": ["roles", "permissions", "routes"],
|
|
132
|
+
}
|
|
133
|
+
ext_path = Path(manifest_path).parent / "manifest_override.json"
|
|
134
|
+
with open(ext_path, "w") as f:
|
|
135
|
+
json.dump(ext_manifest, f)
|
|
136
|
+
with open(Path(manifest_path).parent / "dev" / "routes.json", "w") as f:
|
|
137
|
+
json.dump([{"id": 1, "path": "/admin"}], f)
|
|
138
|
+
|
|
139
|
+
class RoleModel:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
class RouteModel:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
base = make_config(manifest_path, "pkg-a",
|
|
146
|
+
model_classes={"roles": RoleModel, "permissions": object})
|
|
147
|
+
override = make_config(str(ext_path), "pkg-a",
|
|
148
|
+
model_classes={"roles": RoleModel, "routes": RouteModel})
|
|
149
|
+
|
|
150
|
+
register_seeder(base)
|
|
151
|
+
register_seeder(override)
|
|
152
|
+
|
|
153
|
+
seeders = get_registered_seeders()
|
|
154
|
+
assert len(seeders) == 1
|
|
155
|
+
entry = seeders[0]
|
|
156
|
+
# Extends rather than replaces: the base 'permissions' model is preserved
|
|
157
|
+
assert "permissions" in entry.model_classes
|
|
158
|
+
# And the new 'routes' model is present
|
|
159
|
+
assert entry.model_classes["routes"] is RouteModel
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ============================================================================
|
|
163
|
+
# H2 — replace: prior entry replaced wholesale
|
|
164
|
+
# ============================================================================
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TestOverrideReplace:
|
|
168
|
+
def test_replace_overwrites_wholesale(self, base_manifest):
|
|
169
|
+
manifest_path, _, _ = base_manifest
|
|
170
|
+
|
|
171
|
+
ext_manifest = {
|
|
172
|
+
"tables": {
|
|
173
|
+
"roles": {"file": "roles.json"},
|
|
174
|
+
"permissions": {"file": "permissions.json"},
|
|
175
|
+
"routes": {"file": "routes.json"},
|
|
176
|
+
},
|
|
177
|
+
"load_order": ["roles", "permissions", "routes"],
|
|
178
|
+
}
|
|
179
|
+
ext_path = Path(manifest_path).parent / "manifest_override.json"
|
|
180
|
+
with open(ext_path, "w") as f:
|
|
181
|
+
json.dump(ext_manifest, f)
|
|
182
|
+
with open(Path(manifest_path).parent / "dev" / "routes.json", "w") as f:
|
|
183
|
+
json.dump([{"id": 1, "path": "/admin"}], f)
|
|
184
|
+
|
|
185
|
+
class RouteModel:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
base = make_config(manifest_path, "pkg-a",
|
|
189
|
+
model_classes={"roles": object, "permissions": object})
|
|
190
|
+
override = make_config(str(ext_path), "pkg-a",
|
|
191
|
+
model_classes={"routes": RouteModel}, priority=90)
|
|
192
|
+
|
|
193
|
+
register_seeder(base)
|
|
194
|
+
# SeederConflictError must NOT be raised for same-key replace
|
|
195
|
+
register_seeder(override, mode="replace")
|
|
196
|
+
|
|
197
|
+
seeders = get_registered_seeders()
|
|
198
|
+
assert len(seeders) == 1
|
|
199
|
+
entry = seeders[0]
|
|
200
|
+
# Wholesale replace: only the new model set survives
|
|
201
|
+
assert "routes" in entry.model_classes
|
|
202
|
+
assert "permissions" not in entry.model_classes
|
|
203
|
+
assert entry.priority == 90
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ============================================================================
|
|
207
|
+
# E1 — True conflict: distinct package, same connection, overlapping tables
|
|
208
|
+
# ============================================================================
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TestOverrideConflict:
|
|
212
|
+
def test_distinct_package_same_connection_still_conflicts(self, base_manifest):
|
|
213
|
+
manifest_path, _, _ = base_manifest
|
|
214
|
+
|
|
215
|
+
# A second, DIFFERENT package on the same 'auth' connection that
|
|
216
|
+
# overlaps on the 'roles' table -> true conflict
|
|
217
|
+
conflict_manifest = {
|
|
218
|
+
"tables": {"roles": {"file": "roles.json"}},
|
|
219
|
+
"load_order": ["roles"],
|
|
220
|
+
}
|
|
221
|
+
conflict_path = Path(manifest_path).parent / "manifest_conflict.json"
|
|
222
|
+
with open(conflict_path, "w") as f:
|
|
223
|
+
json.dump(conflict_manifest, f)
|
|
224
|
+
|
|
225
|
+
register_seeder(make_config(manifest_path, "pkg-a"))
|
|
226
|
+
|
|
227
|
+
with pytest.raises(SeederConflictError) as exc_info:
|
|
228
|
+
register_seeder(make_config(str(conflict_path), "pkg-b"))
|
|
229
|
+
|
|
230
|
+
assert "roles" in str(exc_info.value)
|
|
231
|
+
assert "pkg-a" in str(exc_info.value)
|
|
232
|
+
assert "pkg-b" in str(exc_info.value)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ============================================================================
|
|
236
|
+
# Sanity: registry remains keyed -> distinct keys stay separate entries
|
|
237
|
+
# ============================================================================
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class TestOverrideKeying:
|
|
241
|
+
def test_distinct_keys_two_entries(self, base_manifest):
|
|
242
|
+
manifest_path, _, dev_dir = base_manifest
|
|
243
|
+
|
|
244
|
+
other = {
|
|
245
|
+
"tables": {"roles": {"file": "roles.json"}},
|
|
246
|
+
"load_order": ["roles"],
|
|
247
|
+
}
|
|
248
|
+
other_path = Path(manifest_path).parent / "manifest_other.json"
|
|
249
|
+
with open(other_path, "w") as f:
|
|
250
|
+
json.dump(other, f)
|
|
251
|
+
with open(dev_dir / "roles.json", "w") as f:
|
|
252
|
+
json.dump([{"id": 1, "name": "Admin"}], f)
|
|
253
|
+
|
|
254
|
+
# Distinct package on a DIFFERENT connection -> no conflict, two entries
|
|
255
|
+
register_seeder(make_config(manifest_path, "pkg-a"))
|
|
256
|
+
register_seeder(SeederConfig(
|
|
257
|
+
connection_name="business",
|
|
258
|
+
manifest_path=str(other_path),
|
|
259
|
+
package_name="pkg-b",
|
|
260
|
+
))
|
|
261
|
+
|
|
262
|
+
seeders = get_registered_seeders()
|
|
263
|
+
assert len(seeders) == 2
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.1"
|
|
File without changes
|
|
File without changes
|
{pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/dev/categories.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/dev/roles.json
RENAMED
|
File without changes
|
{pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/manifest.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgsqlasync2fast_fastapi-0.3.1 → pgsqlasync2fast_fastapi-0.4.0}/examples/seeders/prod/roles.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|