From a007e34948b108ba5ccfb52fa0fec35d547c726d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 4 Jun 2026 16:30:25 -0700 Subject: [PATCH] feat: remove the migrations and fixes --- .vitepress/config.mts | 4 +- docs/{ => ai}/mcp.md | 0 docs/frontend/inertia.md | 138 +++++--- docs/orm-factory.md | 326 ++++++++++++++++++ .../2026_05_08_000001_create_jobs_table.py | 20 -- ...6_05_08_000002_create_job_batches_table.py | 23 -- ...6_05_08_000003_create_failed_jobs_table.py | 20 -- queues/migrations/__init__.py | 0 8 files changed, 418 insertions(+), 113 deletions(-) rename docs/{ => ai}/mcp.md (100%) create mode 100644 docs/orm-factory.md delete mode 100644 queues/migrations/2026_05_08_000001_create_jobs_table.py delete mode 100644 queues/migrations/2026_05_08_000002_create_job_batches_table.py delete mode 100644 queues/migrations/2026_05_08_000003_create_failed_jobs_table.py delete mode 100644 queues/migrations/__init__.py diff --git a/.vitepress/config.mts b/.vitepress/config.mts index 96a76da..508b5ba 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -196,7 +196,7 @@ export default defineConfig({ { text: 'AI', items: [ - { text: 'Getting Started', link: '/docs/mcp' }, + { text: 'MCP', link: '/docs/ai/mcp' }, ] } ], @@ -206,7 +206,7 @@ export default defineConfig({ }, socialLinks: [ - { icon: 'github', link: 'https://github.com/fastapi-startkit/fastapi_startkit' } + { icon: 'github', link: 'https://github.com/fastapi-startkit/fastapi-startkit' } ] } }) diff --git a/docs/mcp.md b/docs/ai/mcp.md similarity index 100% rename from docs/mcp.md rename to docs/ai/mcp.md diff --git a/docs/frontend/inertia.md b/docs/frontend/inertia.md index 5f8991d..a64a8ac 100644 --- a/docs/frontend/inertia.md +++ b/docs/frontend/inertia.md @@ -61,73 +61,109 @@ This renders: ## Rendering Components -Use `Inertia.render()` in your controller: +Use `Inertia.render()` in your controller. It takes the component name and an optional props dict — no `request` argument is needed, because `InertiaMiddleware` stores the current request in a context variable automatically: ```python -from fastapi import Request from fastapi_startkit.inertia import Inertia -async def index(request: Request): - return Inertia.render(request, "Dashboard/Index", { +async def index(): + return Inertia.render("Dashboard/Index", { "user": {"name": "Alice"}, }) ``` +Props are optional. When a controller has no props to pass, omit the second argument: + +```python +async def index(): + return Inertia.render("Dashboard/Index") +``` + - On the **first page load**, returns an HTML response using the root template. - On **Inertia XHR requests** (`X-Inertia: true` header), returns a JSON response with component name, props, and URL. ## Shared Data -Share data globally — available as props on every component: +Share data globally — available as props on every component. The right place to call `share()` is inside a provider's `boot()` method, after all providers have been registered: ```python -# providers/fastapi_provider.py or bootstrap/application.py -from fastapi_startkit.application import app +# providers/fastapi_provider.py +from fastapi_startkit.fastapi import FastAPIProvider as BaseFastAPIProvider +from fastapi_startkit.inertia import Inertia -inertia = app().make("inertia") +class FastAPIProvider(BaseFastAPIProvider): + def boot(self) -> None: + super().boot() -# Static value -inertia.share("app_name", "PingCRM") + # Static value + Inertia.share("app_name", "PingCRM") -# Callable resolved per-request (receives the request object) -inertia.share("auth", lambda request: { - "user": request.state.user, -}) + # Callable resolved per-request (receives the request object) + Inertia.share("auth", lambda request: { + "user": getattr(request.state, "user", None), + }) -# Callable resolved once (no parameters) -inertia.share("flash", lambda: {}) + # Callable resolved per-request (receives the request object) + Inertia.share("flash", lambda request: { + "success": request.session.get("flash_success") if "session" in request.scope else None, + }) +``` + +You can also call `Inertia.share()` via the container binding (they are the same object): + +```python +inertia = self.app.make("inertia") +inertia.share("app_name", "PingCRM") ``` Shared data is merged with per-render props. Per-render props take precedence. ## Partial Reloads +Wrap prop values in callables so they are resolved lazily. On a partial reload only the requested keys are evaluated. Because async is not allowed inside a `lambda`, use a named `async def` for async props: + ```python -return Inertia.render('Users/Index', { - 'users': lambda : await User.all(), - 'companies': lambda await Organization.get() -}) +from fastapi_startkit.inertia import Inertia +from app.models import User, Organization + +async def index(): + async def get_users(): + return await User.all() + + async def get_organizations(): + return await Organization.get() + + return Inertia.render('Users/Index', { + 'users': get_users, + 'companies': get_organizations, + }) ``` -and it also provides an `Inertia.optional()` method to specify that a props should never be included unless requested using the `only` option. +Use `Inertia.optional()` to mark a prop as never included unless explicitly requested via the `only` option: ```python -return Inertia.render('Users/Index',{ - 'users': Inertia.optional(lambda : await User.all()) -}) +async def index(): + async def get_users(): + return await User.all() + + return Inertia.render('Users/Index', { + 'users': Inertia.optional(get_users), + }) ``` - + ## Asset Versioning -Set a version string so Inertia can detect asset changes and trigger a full-page reload: +`InertiaMiddleware` automatically uses the Vite manifest hash as the asset version when a `ViteProvider` is registered — no extra configuration is needed in the common case. + +To override with a fixed version string, call `Inertia.version()` in a provider's `boot()` method: ```python -inertia = app().make("inertia") -inertia.version("1.0.0") +from fastapi_startkit.inertia import Inertia -# Or tie it to the Vite manifest hash: -vite = app().make("vite") -inertia.version(vite.manifest_hash() or "1") +class FastAPIProvider(BaseFastAPIProvider): + def boot(self) -> None: + super().boot() + Inertia.version("1.0.0") ``` When the client's `X-Inertia-Version` header mismatches the server version, the middleware returns `409 Conflict` with an `X-Inertia-Location` header, causing the client to perform a full hard reload. @@ -145,8 +181,9 @@ When the client's `X-Inertia-Version` header mismatches the server version, the Change the root template name (default: `index.html`): ```python -inertia = app().make("inertia") -inertia.set_root_view("app.html") +from fastapi_startkit.inertia import Inertia + +Inertia.set_root_view("app.html") ``` ## Client-Side Setup (React) @@ -177,11 +214,7 @@ export default defineConfig({ }) ``` -### Entry point - -Create `resources/js/app.tsx`: - -### Basic setup +### Entry point (`resources/js/app.tsx`) ```tsx import '../css/app.css' @@ -235,19 +268,28 @@ function currentRouteName(): string { return parts[0] || 'dashboard' } -window.route = function(name, params) { - let url = name ? (routeMap[name] ?? '/' + name.split('.')[0]) : '/' - if (params) { - const action = name?.split('.')[1] - if (action === 'edit') url += `/${params}/edit` - else if (action === 'destroy' || action === 'update') url += `/${params}` +window.route = function(name, params, absolute) { + let path = "/" + if (name) { + if (routeMap[name]) { + path = routeMap[name] + } else { + const parts = name.split('.') + path = "/" + parts[0] + if (parts[1] === 'edit' && params) path += `/${params}/edit` + else if (parts[1] === 'destroy' && params) path += `/${params}` + else if (parts[1] === 'update' && params) path += `/${params}` + else if (parts[1] === 'create') path += "/create" + } } - const routeObj = new String(url) as string & { current: (pattern?: string) => string | boolean } - ;(routeObj as any).current = (pattern?: string) => { + // Return a URL instance — Inertia's visit() handles URL objects natively + const urlObj = new URL(path, window.location.href) as URL & { current: (pattern?: string) => string | boolean } + urlObj.current = function(pattern?: string) { if (!pattern) return currentRouteName() - return new RegExp('^' + pattern.replace(/\*/g, '.*') + '$').test(currentRouteName()) + const segment = window.location.pathname.replace(/^\//, '').split('/')[0] || 'dashboard' + return new RegExp('^' + pattern.replace(/\*/g, '.*') + '$').test(segment) } - return routeObj + return urlObj as any } ``` diff --git a/docs/orm-factory.md b/docs/orm-factory.md new file mode 100644 index 0000000..185ae4f --- /dev/null +++ b/docs/orm-factory.md @@ -0,0 +1,326 @@ +--- +outline: deep +title: ORM Factories & Seeders +description: Generate realistic test data and populate your database with factories and seeders in Fastapi Startkit. +keywords: orm, factory, seeder, test data, fake data, database, fastapi startkit +--- + +# ORM Factories & Seeders + +Factories and seeders are two complementary tools for populating your database with data. + +**Factories** define how to generate a single model instance with realistic fake data. They are designed for tests — each call produces a fresh record with randomised values, and they compose cleanly to handle relationships. + +**Seeders** are async classes that insert a known, deterministic dataset. They are typically used to bootstrap a development or staging environment with a curated set of records (admin users, categories, demo content, etc.). + +## Factories + +### Defining a Factory + +Create a factory class in `databases/factories/`. Extend `Factory`, declare the `model` it produces, and implement `definition()` to return a dictionary of field values. + +The `fake` attribute is a pre-wired [Faker](https://faker.readthedocs.io/) instance available on every factory: + +```python +# databases/factories/UserFactory.py +from fastapi_startkit.masoniteorm.factory import Factory +from app.models.User import User + + +class UserFactory(Factory): + model = User + + def definition(self): + return { + "first_name": self.fake.first_name(), + "last_name": self.fake.last_name(), + "email": self.fake.unique.email(), + "password": "secret", + "owner": False, + } +``` + +### `factory.create()` — Persist to the Database + +Call `Factory.new().create()` to insert a record and return the saved model instance: + +```python +user = await UserFactory.new().create() +print(user.id) # set by the database +print(user.email) # a unique fake email +``` + +Pass keyword arguments to override specific fields: + +```python +admin = await UserFactory.new().create(owner=True, email="admin@example.com") +``` + +### `factory.make()` — Build Without Saving + +`make()` builds a model instance in memory without writing to the database. Useful for unit tests that do not require a database connection: + +```python +user = await UserFactory.new().make() +assert user.first_name # populated from definition() +assert user.id is None # not persisted +``` + +Override fields the same way as `create()`: + +```python +user = await UserFactory.new().make(email="test@example.com") +``` + +### Creating Multiple Records + +Chain `.count(n)` before `create()` or `make()` to produce a list of `n` instances: + +```python +users = await UserFactory.new().count(10).create() +# returns a list of 10 User instances, each persisted + +drafts = await UserFactory.new().count(5).make() +# returns a list of 5 User instances, not persisted +``` + +When `count()` is used the return value is always a list. Without `count()` a single instance is returned. + +### States / Variations + +A **state** applies a partial override on top of `definition()`. Define states as methods on the factory that call `self.state()` with a callback returning the fields to merge: + +```python +class UserFactory(Factory): + model = User + + def definition(self): + return { + "first_name": self.fake.first_name(), + "last_name": self.fake.last_name(), + "email": self.fake.unique.email(), + "password": "secret", + "account_status": "active", + } + + def suspended(self): + return self.state(lambda attributes: { + "account_status": "suspended", + }) +``` + +Chain a state method before calling `create()` or `make()`: + +```python +user = await UserFactory.new().suspended().create() +assert user.account_status == "suspended" +``` + +Multiple states can be stacked — they are applied in order, each receiving the accumulated attributes: + +```python +user = await UserFactory.new().suspended().count(3).create() +``` + +### Lifecycle Hooks + +Use `configure()` to register callbacks that run after `make()` or `create()`: + +```python +class UserFactory(Factory): + model = User + + def definition(self): + return { + "first_name": self.fake.first_name(), + "email": self.fake.unique.email(), + "password": "secret", + } + + def configure(self): + async def after_making(user): + print(f"Built: {user.first_name}") + + async def after_creating(user): + print(f"Persisted: {user.first_name} (id={user.id})") + + return self.after_making(after_making).after_creating(after_creating) +``` + +`configure()` is called automatically by `Factory.new()`. It must return `self` (or the result of chaining `.after_making()` / `.after_creating()`). + +### Relationships + +`has()` creates a related factory record after the parent is saved. The foreign key is inferred automatically as `{parent_table_singular}_id`: + +```python +# Create an organization and automatically create 3 contacts for it +org = await OrganizationFactory.new().has(ContactFactory.new().count(3)).create() +``` + +`for_()` creates the parent first and injects its id as a foreign key into the child: + +```python +contact = await ContactFactory.new().for_(OrganizationFactory.new()).create() +``` + +### Complete Factory Example + +```python +# databases/factories/ContactFactory.py +from faker import Faker +from fastapi_startkit.masoniteorm.factory import Factory +from app.models.Contact import Contact + +fake = Faker() + + +class ContactFactory(Factory): + model = Contact + + def definition(self): + return { + "first_name": self.fake.first_name(), + "last_name": self.fake.last_name(), + "email": self.fake.unique.email(), + "phone": self.fake.phone_number(), + "city": self.fake.city(), + "country": "US", + } +``` + +## Seeders + +### Defining a Seeder + +Place seeder files in `databases/seeds/`. Extend `Seeder` and implement an `async run()` method: + +```python +# databases/seeds/category_seeder.py +from fastapi_startkit.masoniteorm.seeds import Seeder +from app.models.category import Category + + +class CategorySeeder(Seeder): + async def run(self): + categories = ["Programming", "Web Development", "Data Science", "Design"] + for name in categories: + await Category.first_or_create({"name": name}) +``` + +Using `first_or_create` makes seeders idempotent — running them multiple times will not create duplicate records. + +### Combining Factories and Seeders + +Factories and seeders work well together. Use factories inside a seeder to generate large volumes of varied data: + +```python +# databases/seeds/database_seeder.py +from fastapi_startkit.masoniteorm.seeds import Seeder +from app.models.Account import Account +from app.models.User import User +from databases.factories.UserFactory import UserFactory +from databases.factories.OrganizationFactory import OrganizationFactory +from databases.factories.ContactFactory import ContactFactory +import random + + +class DatabaseSeeder(Seeder): + async def run(self): + # Create a root account + account = await Account.create({"name": "Acme Corporation"}) + + # Create a known admin user + await User.first_or_create( + {"email": "admin@example.com"}, + { + "account_id": account.id, + "first_name": "Admin", + "last_name": "User", + "password": "secret", + "owner": True, + }, + ) + + # Generate 5 random users with the factory + await UserFactory.new().count(5).create(account_id=account.id) + + # Generate 100 organizations + organizations = await OrganizationFactory.new().count(100).create(account_id=account.id) + + # Generate 100 contacts, each tied to a random organization + for _ in range(100): + await ContactFactory.new().create( + account_id=account.id, + organization_id=random.choice(organizations).id, + ) +``` + +### Orchestrating Seeders with `self.call()` + +The `DatabaseSeeder` is the conventional entry point. Use `self.call()` to run other seeders in a defined order: + +```python +# databases/seeds/database_seeder.py +from fastapi_startkit.masoniteorm.seeds import Seeder +from .category_seeder import CategorySeeder +from .user_seeder import UserSeeder +from .course_seeder import CourseSeeder + + +class DatabaseSeeder(Seeder): + async def run(self): + await self.call(CategorySeeder) + await self.call(UserSeeder) + await self.call(CourseSeeder) +``` + +`self.call()` runs each seeder and waits for it to complete before starting the next. Order matters when seeders have dependencies — categories must exist before courses that reference them. + +### Running Seeders via Artisan + +Run the `DatabaseSeeder` entry point: + +```bash +python artisan db:seed +``` + +Run a specific seeder class by name: + +```bash +python artisan db:seed --class UserSeeder +``` + +Use a fully qualified dotted path to target a seeder in a sub-directory: + +```bash +python artisan db:seed --class databases.seeds.user_seeder.UserSeeder +``` + +Specify a custom seed directory with `--directory`: + +```bash +python artisan db:seed --directory databases/seeds +``` + +### Generating a Seeder File + +The `seed` artisan command scaffolds a new seeder file: + +```bash +python artisan seed Post +``` + +This creates `databases/seeds/post_table_seeder.py` with a `PostTableSeeder` stub: + +```python +"""PostTableSeeder Seeder.""" + +from fastapi_startkit.masoniteorm.seeds import Seeder + + +class PostTableSeeder(Seeder): + async def run(self): + """Run the database seeds.""" + pass +``` diff --git a/queues/migrations/2026_05_08_000001_create_jobs_table.py b/queues/migrations/2026_05_08_000001_create_jobs_table.py deleted file mode 100644 index 2e08ba9..0000000 --- a/queues/migrations/2026_05_08_000001_create_jobs_table.py +++ /dev/null @@ -1,20 +0,0 @@ -"""CreateJobsTable Migration.""" - -from fastapi_startkit.masoniteorm.migrations import Migration - - -class CreateJobsTable(Migration): - async def up(self): - """Run the migrations.""" - async with await self.schema.create("jobs") as table: - table.id("id") - table.string("queue").index() - table.long_text("payload") - table.small_integer("attempts").unsigned() - table.unsigned_integer("reserved_at").nullable() - table.unsigned_integer("available_at") - table.unsigned_integer("created_at") - - async def down(self): - """Revert the migrations.""" - await self.schema.drop("jobs") \ No newline at end of file diff --git a/queues/migrations/2026_05_08_000002_create_job_batches_table.py b/queues/migrations/2026_05_08_000002_create_job_batches_table.py deleted file mode 100644 index 53eb063..0000000 --- a/queues/migrations/2026_05_08_000002_create_job_batches_table.py +++ /dev/null @@ -1,23 +0,0 @@ -"""CreateJobBatchesTable Migration.""" - -from fastapi_startkit.masoniteorm.migrations import Migration - - -class CreateJobBatchesTable(Migration): - async def up(self): - """Run the migrations.""" - async with await self.schema.create("job_batches") as table: - table.string("id").primary() - table.string("name") - table.integer("total_jobs") - table.integer("pending_jobs") - table.integer("failed_jobs") - table.long_text("failed_job_ids") - table.text("options").nullable() - table.integer("cancelled_at").nullable() - table.integer("created_at") - table.integer("finished_at").nullable() - - async def down(self): - """Revert the migrations.""" - await self.schema.drop("job_batches") \ No newline at end of file diff --git a/queues/migrations/2026_05_08_000003_create_failed_jobs_table.py b/queues/migrations/2026_05_08_000003_create_failed_jobs_table.py deleted file mode 100644 index be1ce69..0000000 --- a/queues/migrations/2026_05_08_000003_create_failed_jobs_table.py +++ /dev/null @@ -1,20 +0,0 @@ -"""CreateFailedJobsTable Migration.""" - -from fastapi_startkit.masoniteorm.migrations import Migration - - -class CreateFailedJobsTable(Migration): - async def up(self): - """Run the migrations.""" - async with await self.schema.create("failed_jobs") as table: - table.id("id") - table.string("uuid").unique() - table.text("connection") - table.text("queue") - table.long_text("payload") - table.long_text("exception") - table.timestamp("failed_at") - - async def down(self): - """Revert the migrations.""" - await self.schema.drop("failed_jobs") \ No newline at end of file diff --git a/queues/migrations/__init__.py b/queues/migrations/__init__.py deleted file mode 100644 index e69de29..0000000