Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions fastappkit/cli/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ def core(
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))

# Load registry to identify external apps (needed to exclude their tables from autogenerate)
loader = AppLoader(project_root)
registry = loader.load_all()

# Try to collect metadata from core module for core migrations
metadata_collector = MetadataCollector()
# Try to collect from core.models only
Expand All @@ -96,6 +100,7 @@ def core(
message,
core_migration_path,
target_metadata=core_metadata_obj,
registry=registry, # Pass registry so external app tables can be excluded
)
typer.echo(f"✅ Created migration: {migration_path}")
except Exception as e:
Expand Down
9 changes: 6 additions & 3 deletions fastappkit/cli/templates/app/external/README.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,12 @@ alembic downgrade -1
alembic upgrade head --sql
```

**Database URL**: The `DATABASE_URL` is read from the `.env` file automatically. You can also:
- Set `DATABASE_URL` environment variable (takes precedence)
- Edit `alembic.ini` sqlalchemy.url (fallback)
**Database URL**: The database URL is determined in this order:
1. App's Settings class (loads from `.env` file) - **RECOMMENDED**
2. `DATABASE_URL` environment variable
3. `alembic.ini` sqlalchemy.url (fallback only)

The app's `Settings` class automatically loads from `.env` file, so just configure your `.env` file and it will be used.

### When Using the External App in a Core Project

Expand Down
11 changes: 6 additions & 5 deletions fastappkit/cli/templates/app/external/alembic.ini.j2
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,16 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne

# Database URL configuration
#
# This can be configured in three ways (in order of precedence):
# 1. DATABASE_URL environment variable (from .env file when running independently)
# 2. This sqlalchemy.url setting (fallback)
# 3. Set programmatically in migrations/env.py
# IMPORTANT: The database URL is determined in this order (when running independently):
# 1. App's Settings class (loads from .env file) - RECOMMENDED
# 2. DATABASE_URL environment variable
# 3. This sqlalchemy.url setting (fallback only)
#
# For independent development, create a .env file with:
# DATABASE_URL=sqlite:///./{{ app_name }}.db
#
# The migrations/env.py will automatically read DATABASE_URL from the environment.
# The migrations/env.py will automatically use Settings to load from .env file.
# This sqlalchemy.url is only used as a last resort fallback.
# Default uses SQLite for development
sqlalchemy.url = sqlite:///./{{ app_name }}.db

Expand Down
14 changes: 3 additions & 11 deletions fastappkit/cli/templates/app/external/config.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,9 @@ class Settings(BaseSettings):
Loads from .env file automatically.
"""

database_url: str = Field(
default="sqlite:///./{{ app_name }}.db",
alias="DATABASE_URL"
)
debug: bool = Field(
default=False,
alias="DEBUG"
)
database_url: str = Field(default="sqlite:///./{{ app_name }}.db")
debug: bool = Field(default=False)

model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
populate_by_name=True
env_file=".env", env_file_encoding="utf-8", populate_by_name=True
)
8 changes: 4 additions & 4 deletions fastappkit/cli/templates/app/external/main.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ _current_dir = Path(__file__).parent
if str(_current_dir) not in sys.path:
sys.path.insert(0, str(_current_dir))

from fastapi import FastAPI
from fastapi import FastAPI # noqa: E402

from {{ app_name }}.config import Settings
from {{ app_name }} import register
from {{ app_name }}.config import Settings # noqa: E402
from {{ app_name }} import register # noqa: E402

# Load settings from .env
settings = Settings()
Expand All @@ -41,4 +41,4 @@ if router is not None:
if __name__ == "__main__":
import uvicorn

uvicorn.run(app, host="127.0.0.1", port=8000, reload=True)
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
46 changes: 33 additions & 13 deletions fastappkit/cli/templates/app/external/migrations/env.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,41 @@ target_metadata = Base.metadata
config = context.config

# Get database URL with the following precedence order:
# 1. Config (set by fastappkit when running from core project)
# 2. DATABASE_URL environment variable (from .env file when running independently)
# 3. alembic.ini sqlalchemy.url (fallback)
# 1. Config (set by fastappkit when running from core project - config_file_name is None)
# 2. App's Settings class (loads from .env file when running independently)
# 3. DATABASE_URL environment variable (direct fallback)
# 4. alembic.ini sqlalchemy.url (last resort)
#
# This allows the external app to work both:
# - When used in a core project (fastappkit sets it via config)
# - When developed independently (reads from .env file)
database_url = config.get_main_option("sqlalchemy.url")
if not database_url:
# If not set in config, try environment variable (from .env)
import os
database_url = os.getenv("DATABASE_URL")
if database_url:
config.set_main_option("sqlalchemy.url", database_url)
# If still not set, alembic.ini's sqlalchemy.url will be used by Alembic
# - When used in a core project (fastappkit sets it via config, config_file_name is None)
# - When developed independently (reads from .env file via Settings, config_file_name is set)
database_url = None

# Check if fastappkit is running this (config_file_name is None when fastappkit runs it)
# In that case, database URL is already set by fastappkit in config
if config.config_file_name is None:
# Running from core project via fastappkit - use URL already set in config
database_url = config.get_main_option("sqlalchemy.url")
else:
# Running independently - prioritize Settings/.env over alembic.ini
try:
from {{ app_name }}.config import Settings
settings = Settings()
database_url = settings.database_url
except (ImportError, AttributeError):
# Settings not available, try environment variable directly
import os
database_url = os.getenv("DATABASE_URL")

# If still not set, alembic.ini's sqlalchemy.url will be used by Alembic
if not database_url:
database_url = config.get_main_option("sqlalchemy.url")

# Strip async drivers (e.g., +asyncpg, +aiosqlite) since Alembic doesn't support async
if database_url:
for async_driver in ["+asyncpg", "+aiosqlite", "+asyncmy"]:
database_url = database_url.replace(async_driver, "")
config.set_main_option("sqlalchemy.url", database_url)

# Interpret the config file for Python logging
if config.config_file_name is not None:
Expand Down
2 changes: 1 addition & 1 deletion fastappkit/cli/templates/app/internal/migrations/env.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))

# Import core settings
from core.config import Settings
from core.config import Settings # noqa: E402

# Get settings
settings = Settings()
Expand Down
14 changes: 3 additions & 11 deletions fastappkit/cli/templates/project/core/config.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,9 @@ class Settings(BaseSettings):
Loads from .env file automatically.
"""

database_url: str = Field(
default="sqlite:///./{{ project_name }}.db",
alias="DATABASE_URL"
)
debug: bool = Field(
default=False,
alias="DEBUG"
)
database_url: str = Field(default="sqlite:///./{{ app_name }}.db")
debug: bool = Field(default=False)

model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
populate_by_name=True
env_file=".env", env_file_encoding="utf-8", populate_by_name=True
)
32 changes: 29 additions & 3 deletions fastappkit/cli/templates/project/core/db/migrations/env.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))

# Import your settings
from core.config import Settings
from sqlalchemy import MetaData
from core.config import Settings # noqa: E402
from sqlalchemy import MetaData # noqa: E402

# Get settings
settings = Settings()
Expand All @@ -37,6 +37,27 @@ if target_metadata is None:
# No core models yet, use empty MetaData for initial migration
target_metadata = MetaData()

# Get external app tables to exclude from autogenerate (set by fastappkit)
# This prevents core migrations from dropping external app tables
exclude_external_app_tables = config.attributes.get("exclude_external_app_tables", set())


def include_object(object, name, type_, reflected, compare_to):
"""
Filter objects during autogenerate to exclude external app tables.

This ensures core migrations don't try to drop tables that belong to external apps,
which manage their own migrations independently.
"""
if type_ == "table":
# Exclude external app tables and version tables
if name in exclude_external_app_tables:
return False
# Also exclude external app version tables (alembic_version_<appname>)
if name.startswith("alembic_version_") and name != "alembic_version":
return False
return True

# Set database URL from settings
# Strip async drivers (e.g., +asyncpg, +aiosqlite) since Alembic doesn't support async
database_url = settings.database_url
Expand All @@ -61,6 +82,7 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
include_object=include_object,
)

with context.begin_transaction():
Expand All @@ -76,7 +98,11 @@ def run_migrations_online() -> None:
)

with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
context.configure(
connection=connection,
target_metadata=target_metadata,
include_object=include_object,
)

with context.begin_transaction():
context.run_migrations()
Expand Down
2 changes: 1 addition & 1 deletion fastappkit/cli/templates/project/core/models.py.j2
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ class Base(DeclarativeBase):
#
# id = Column(Integer, primary_key=True, index=True)
# name = Column(String, nullable=False)
# created_at = Column(DateTime, default=datetime.utcnow)
# created_at = Column(DateTime, default=datetime.now(timezone.utc))
27 changes: 27 additions & 0 deletions fastappkit/migrations/autogen.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from fastappkit.core.registry import AppMetadata
from fastappkit.core.types import AppType
from fastappkit.migrations.context import MigrationContextBuilder
from fastappkit.migrations.version import VersionTableManager

if TYPE_CHECKING:
from fastappkit.core.registry import AppRegistry
Expand Down Expand Up @@ -79,17 +80,43 @@ def generate(
# External app or no registry - use only app's metadata
target_metadata = self.metadata_collector.collect_metadata(app_metadata)

# For core and internal app migrations (they share the same migration directory),
# collect external app table names to exclude from autogenerate
# This prevents core/internal migrations from dropping external app tables
external_app_tables: set[str] = set()
# Core and internal apps both use core/db/migrations/ and share alembic_version table
# So they both need to exclude external app tables
if app_metadata.app_type == AppType.INTERNAL and registry:
# Collect all external app tables and version tables
for ext_app in registry.get_by_type(AppType.EXTERNAL):
# Get external app version table
version_table = VersionTableManager.get_version_table(
AppType.EXTERNAL, ext_app.name
)
external_app_tables.add(version_table)

# Get external app model tables
ext_metadata = self.metadata_collector.collect_metadata(ext_app)
if ext_metadata:
for table_name in ext_metadata.tables.keys():
external_app_tables.add(table_name)

# Build Alembic config
# For internal apps, migration_path is core/db/migrations (no special handling needed)
alembic_cfg = self.context_builder.build_alembic_config(
app_metadata,
migration_path,
registry=registry,
)

# Set target metadata in config (for autogenerate)
if target_metadata:
alembic_cfg.attributes["target_metadata"] = target_metadata

# Set external app tables to exclude (for core migrations only)
if external_app_tables:
alembic_cfg.attributes["exclude_external_app_tables"] = external_app_tables

# Generate migration
try:
# Alembic revision command doesn't return path directly
Expand Down