diff --git a/docs/adr/033-async-seeder-stub.md b/docs/adr/033-async-seeder-stub.md new file mode 100644 index 00000000..7dc9efa5 --- /dev/null +++ b/docs/adr/033-async-seeder-stub.md @@ -0,0 +1,26 @@ +# 033. Generated seeders have an async run + +Date: 2026-10-21 + +## Problem + +The `seed` command writes table seeders from `create_seed.stub`. The stub has a synchronous `def run(self)` and docstrings. `Seeder.call`, `run_database_seed` and `run_specific_seed` all `await ...run()`, so every generated seeder fails when it is seeded: + +``` +TypeError: 'NoneType' object can't be awaited +``` + +## Alternatives + +1. Make `Seeder` accept both forms by awaiting the result only when it is awaitable. This would keep blocking seeders working, but seeders write through the async ORM, so a synchronous `run` cannot await queries anyway. +2. Make the stub match the async seeder API. + +## Decision + +Option 2. The stub generates `async def run(self) -> None` with no docstrings. The import, `from fastapi_startkit.masoniteorm.seeders import Seeder`, is already correct and stays. `Seeder` is unchanged. + +## Validation + +- A new test generates `PostTableSeeder` with `MakeSeedCommand`, checks the import, the `async def run` and that there are no docstrings, and imports it through `Seeder.run_specific_seed`, which awaits `run()`. +- The test fails on the old stub. +- Running the old stub's output through `run_specific_seed` raises the `TypeError` above. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index b65dfe83..737ad4e5 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -33,3 +33,4 @@ Read this index first, then open the records relevant to the change. Before impl | [025](025-model-lifecycle-events.md) | 2026-10-20 | Model lifecycle events, automatic timestamps and datetime casts | `save()` fires `saving → creating/updating → query → created/updated → saved`. Observer changes are persisted, and `updating` fires only when the model is dirty. A new `Model.delete()` fires `deleting → DELETE → deleted`. `observe_events` is async and awaits async handlers. Mass query-builder writes fire no events. Laravel-style auto timestamps: `created_at`/`updated_at` are filled on create unless supplied, `updated_at` moves on update, and `__timestamps__ = False` disables both. `DateCast.set` returns a UTC datetime; `Connection.prepare_binding` passes it unchanged to Postgres/MySQL, and SQLite converts it to a naive UTC string. Postgres registers an asyncpg `timestamp` codec that stores aware values as naive UTC; `timestamptz` keeps asyncpg's codec. `Annotated[T, Cast]` (a `BaseCast` subclass or instance) overrides a column's cast, and `Attribute.__casts__` is removed. | | [026](026-schema-column-change.md) | 2026-10-20 | Altering existing columns with `.change()` | `.change()` fully redefines the column, the way Laravel does. MySQL (`MODIFY`) and SQLite (table rebuild) were already correct. Postgres now emits `TYPE ... USING` casts, correct lengths, quoted defaults or `DROP DEFAULT`, `SET`/`DROP NOT NULL`, and replaces the enum check constraint. MSSQL drops the current default constraint (looked up in `sys.default_constraints`), runs one `ALTER COLUMN` per column, then adds `[DF__]`. Identifiers are bracket- or double-quote-escaped and the MSSQL drop target is built server-side with `QUOTENAME`; changing a Postgres serial column drops its `nextval` default. | | [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. | diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/create_seed.stub b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/create_seed.stub index 9ce9b583..cb993761 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/create_seed.stub +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/create_seed.stub @@ -1,9 +1,6 @@ -"""__SEEDER_NAME__ Seeder.""" - from fastapi_startkit.masoniteorm.seeders import Seeder class __SEEDER_NAME__(Seeder): - def run(self): - """Run the database seeds.""" + async def run(self) -> None: pass diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py index 5ad20843..d479afa8 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py @@ -2,6 +2,7 @@ import importlib.util import os import shutil +import sys import tempfile import unittest @@ -12,6 +13,7 @@ from fastapi_startkit.masoniteorm.commands.MakeObserverCommand import MakeObserverCommand from fastapi_startkit.masoniteorm.commands.MakeSeedCommand import MakeSeedCommand from fastapi_startkit.masoniteorm.migrations.Migration import Migration +from fastapi_startkit.masoniteorm.seeders import Seeder from .fixtures.app import create_app, DB_PATH @@ -236,6 +238,29 @@ def test_reports_when_seed_already_exists(self): output = tester.io.fetch_output() self.assertIn("already exists.", output) + def test_generated_seeder_runs_through_seeding_path(self): + os.makedirs("generated_seeds/seeders") + self._tester(MakeSeedCommand).execute("Post --directory generated_seeds/seeders") + + content = open(os.path.join("generated_seeds/seeders", "post_table_seeder.py")).read() + self.assertIn("from fastapi_startkit.masoniteorm.seeders import Seeder", content) + self.assertIn("async def run(self) -> None:", content) + self.assertNotIn('"""', content) + + sys.path.insert(0, self._tmp_dir) + self.addCleanup(sys.path.remove, self._tmp_dir) + self.addCleanup(self._forget_modules, "generated_seeds") + + seeder = Seeder(seed_path="generated_seeds/seeders") + asyncio.run(seeder.run_specific_seed("post_table_seeder.PostTableSeeder")) + + self.assertEqual([seed.__name__ for seed in seeder.ran_seeds], ["PostTableSeeder"]) + + @staticmethod + def _forget_modules(package): + for name in [name for name in sys.modules if name == package or name.startswith(f"{package}.")]: + del sys.modules[name] + class TestMakeObserverCommand(_TempCwdTestCase): # make:observer emits a plain observer class with no schema side effects, so