Skip to content
Closed
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
29 changes: 29 additions & 0 deletions docs/adr/041-database-install-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 041. database:install command

Date: 2026-10-22

## Problem

`fastapi-startkit init --database ...` sets up the database only for new projects. An existing application without a database has to copy a config file and edit `bootstrap/application.py` by hand: import `DatabaseProvider` and its config, and add `(DatabaseProvider, DatabaseConfig)` to `providers=[...]`. `provider:publish` cannot help, because it only knows about the resources of providers that are already registered and booted.

## Alternatives

1. Register the command from `DatabaseProvider`. It would only exist after the database is installed, which is too late.
2. Generate a provider list in a separate file such as `config/providers.py`. Apps do not use one. Every app built with the installer and every example lists providers inline in the `Application(...)` call in `bootstrap/application.py`.
3. Add a core command, registered by `AppProvider` next to `provider:publish`, that writes the config file and edits `bootstrap/application.py` in place.

## Decision

Option 3. `uv run artisan database:install [--driver sqlite|postgres|mysql] [--force]`:

- Renders `config/database.py` from the installer's `database/config.py.stub`, so it matches what `fastapi-startkit init` generates. `config/` is created if missing. If the file already exists it is skipped with a message, unless `--force` is passed.
- Parses `bootstrap/application.py` with `ast` and finds the `Application(...)` call. If its `providers` list already names `DatabaseProvider`, nothing is changed. Otherwise `(DatabaseProvider, DatabaseConfig)` is appended to the list, following its layout: a new line for multi-line lists, inline for single-line lists. If there is no `providers=` keyword, one is added. Missing `from fastapi_startkit.masoniteorm import DatabaseProvider` and `from config.database import DatabaseConfig` imports are inserted after the last top-level import.
- If `bootstrap/application.py` or the `Application(...)` call is missing, it prints the lines to add by hand and returns 1. The config file is still written.

The command lives in `fastapi_startkit/console/` rather than `masoniteorm/commands/`, because importing `masoniteorm.commands` loads SQLAlchemy, which is an optional extra. Text is inserted at AST-reported positions rather than regenerated with `ast.unparse`, so the user's formatting and comments are kept.

## Validation

- Tests in `tests/console/test_database_install_command.py` run the command against a temporary app directory. They cover a fresh install, a second run that changes nothing, single-line and missing `providers` lists, an existing config with and without `--force`, a missing `config/` directory, a missing bootstrap file, an unsupported driver, and loading the edited bootstrap file.
- The full suite passes with coverage of at least 80%.
- Manual run, twice, against an app generated by `fastapi-startkit init --database none`.
1 change: 1 addition & 0 deletions docs/adr/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ Read this index first, then open the records relevant to the change. Before impl
| [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. |
| [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. |
| [038](038-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. `get` returns `bytes` for any non-UTF-8 content, including latin-1 text. `S3Driver.append` keeps the existing content. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. A pure ASGI `ViteMiddleware` (registered by `ViteProvider`) opens `Vite.request_scope()`, which holds the request's CSP nonce and preloaded assets in a per-instance `ContextVar` shared with child tasks. Outside a scope, the instance values are process-wide defaults, so a nonce set at startup still applies, and middleware inside the scope can read preloaded assets after `call_next`. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. |
| [041](041-database-install-command.md) | 2026-10-22 | database:install command | Core `database:install` command (registered by `AppProvider`, so it exists before the database is set up) renders `config/database.py` from the installer's database stub (`--driver`, defaults to sqlite) and skips an existing file unless `--force` is passed. It uses `ast` to find `Application(...)` in `bootstrap/application.py`, then inserts `(DatabaseProvider, DatabaseConfig)` and any missing imports at the reported positions, so formatting is kept. It does nothing when `DatabaseProvider` is already listed. If the bootstrap file is missing, it prints manual steps and exits 1. It lives in `console/` so the optional SQLAlchemy extra is not imported. |
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import ast
from collections.abc import Sequence
from importlib.resources import files
from pathlib import Path
from string import Template

from cleo.helpers import option

from fastapi_startkit.console.command import Command
from fastapi_startkit.installer.plan import DRIVER_CLASSES

CONFIG_PATH = "config/database.py"
BOOTSTRAP_PATH = "bootstrap/application.py"
PROVIDER = "(DatabaseProvider, DatabaseConfig)"
IMPORTS = {
"DatabaseProvider": "from fastapi_startkit.masoniteorm import DatabaseProvider",
"DatabaseConfig": "from config.database import DatabaseConfig",
}

Insert = tuple[int, str]


class DatabaseInstallCommand(Command):
name = "database:install"
description = "Publish config/database.py and register DatabaseProvider in bootstrap/application.py."

options = [
option(
"driver",
"d",
description=f"Database driver: {', '.join(DRIVER_CLASSES)}.",
flag=False,
default="sqlite",
),
option("force", "f", description=f"Overwrite an existing {CONFIG_PATH}."),
]

def handle(self) -> int:
driver = self.option("driver")
if driver not in DRIVER_CLASSES:
self.line_error(f"<error>Unsupported driver '{driver}'. Use one of: {', '.join(DRIVER_CLASSES)}.</error>")
return 1

base_path = Path(self.container.base_path)
self.write_config(base_path / CONFIG_PATH, driver)
return self.register_provider(base_path / BOOTSTRAP_PATH)

def write_config(self, path: Path, driver: str) -> None:
if path.exists() and not self.option("force"):
self.line(f"<comment>{CONFIG_PATH} already exists, skipped. Use --force to overwrite it.</comment>")
return

stub = files("fastapi_startkit.installer").joinpath("stubs/database/config.py.stub").read_text(encoding="utf-8")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(Template(stub).substitute(driver=driver, driver_class=DRIVER_CLASSES[driver]), encoding="utf-8")
self.line(f"Created <info>{CONFIG_PATH}</info>")

def register_provider(self, path: Path) -> int:
source = path.read_text(encoding="utf-8") if path.exists() else None
updated = register_database_provider(source) if source is not None else None

if updated is None:
self.line_error(f"<error>Could not find Application(providers=[...]) in {BOOTSTRAP_PATH}.</error>")
self.line_error("Register the provider manually:")
for statement in IMPORTS.values():
self.line_error(f" {statement}")
self.line_error(f" providers=[..., {PROVIDER}]")
return 1

if updated == source:
self.line(f"<comment>DatabaseProvider is already registered in {BOOTSTRAP_PATH}.</comment>")
return 0

path.write_text(updated, encoding="utf-8")
self.line(f"Registered <info>DatabaseProvider</info> in <info>{BOOTSTRAP_PATH}</info>")
return 0


def register_database_provider(source: str) -> str | None:
tree = ast.parse(source)
call = find_application_call(tree)
if call is None:
return None

lines = source.splitlines(keepends=True)
keyword = next((keyword for keyword in call.keywords if keyword.arg == "providers"), None)

if keyword is None:
inserts = append_item(source, lines, call, [*call.args, *call.keywords], f"providers=[{PROVIDER}]")
elif not isinstance(keyword.value, ast.List):
return None
elif any(isinstance(node, ast.Name) and node.id == "DatabaseProvider" for node in ast.walk(keyword.value)):
return source
else:
inserts = append_item(source, lines, keyword.value, keyword.value.elts, PROVIDER)

inserts.extend(import_inserts(tree, lines))
for position, text in sorted(inserts, key=lambda insert: insert[0], reverse=True):
source = source[:position] + text + source[position:]
return source


def find_application_call(tree: ast.Module) -> ast.Call | None:
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if (isinstance(func, ast.Name) and func.id == "Application") or (
isinstance(func, ast.Attribute) and func.attr == "Application"
):
return node
return None


def append_item(
source: str, lines: list[str], node: ast.expr, items: Sequence[ast.expr | ast.keyword], text: str
) -> list[Insert]:
assert node.end_lineno is not None and node.end_col_offset is not None
close = offset(lines, node.end_lineno, node.end_col_offset - 1)
if not items:
return [(close, text)]

last = max(items, key=lambda item: (item.end_lineno or 0, item.end_col_offset or 0))
assert last.end_lineno is not None and last.end_col_offset is not None
end = offset(lines, last.end_lineno, last.end_col_offset)
has_comma = source[end:close].lstrip().startswith(",")

if last.end_lineno == node.end_lineno:
return [(close, (" " if has_comma else ", ") + text)]

line = lines[last.end_lineno - 1]
line_end = offset(lines, last.end_lineno, 0) + len(line.rstrip("\r\n"))
indent = line[: len(line) - len(line.lstrip())]
inserts = [(line_end, f"\n{indent}{text},")]
if not has_comma:
inserts.append((end, ","))
return inserts


def import_inserts(tree: ast.Module, lines: list[str]) -> list[Insert]:
imports = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))]
imported = {alias.asname or alias.name for node in imports for alias in node.names}
missing = [statement for name, statement in IMPORTS.items() if name not in imported]
if not missing:
return []
if not imports:
return [(0, "\n".join(missing) + "\n\n")]

last = imports[-1].end_lineno
assert last is not None
return [(offset(lines, last, 0) + len(lines[last - 1].rstrip("\r\n")), "\n" + "\n".join(missing))]


def offset(lines: list[str], lineno: int, col: int) -> int:
return sum(len(line) for line in lines[: lineno - 1]) + len(lines[lineno - 1].encode()[:col].decode())
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from fastapi_startkit.console.database_install_command import DatabaseInstallCommand
from fastapi_startkit.console.publish_command import PublishCommand
from fastapi_startkit.support import Provider

Expand All @@ -7,4 +8,4 @@ def register(self) -> None:
pass

def boot(self) -> None:
self.commands([PublishCommand])
self.commands([PublishCommand, DatabaseInstallCommand])
4 changes: 3 additions & 1 deletion fastapi_startkit/src/fastapi_startkit/installer/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from pathlib import Path
from string import Template

DRIVER_CLASSES = {"sqlite": "SQLiteConfig", "postgres": "PostgresConfig", "mysql": "MySQLConfig"}


@dataclass(frozen=True)
class InstallOptions:
Expand Down Expand Up @@ -117,7 +119,7 @@ def define_web() -> Section:
def define_database(driver: str, project_name: str) -> Section:
if driver == "none":
return Section()
driver_class = {"sqlite": "SQLiteConfig", "postgres": "PostgresConfig", "mysql": "MySQLConfig"}[driver]
driver_class = DRIVER_CLASSES[driver]
environment = {"DB_CONNECTION": driver}
if driver == "sqlite":
environment["DB_DATABASE"] = "databases/database.sqlite"
Expand Down
Loading
Loading