diff --git a/docker-compose.yml b/docker-compose.yml
index 1a70a7c0..d2920ab8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,6 +8,8 @@ services:
MYSQL_PASSWORD: secret
ports:
- "3306:3306"
+ volumes:
+ - ./mysql-init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: [ "CMD", "mysqladmin", "ping", "--silent" ]
interval: 5s
diff --git a/example/config-app/uv.lock b/example/config-app/uv.lock
index 9cdb486a..e2c55a3d 100644
--- a/example/config-app/uv.lock
+++ b/example/config-app/uv.lock
@@ -162,7 +162,7 @@ wheels = [
[[package]]
name = "fastapi-startkit"
-version = "0.20.0"
+version = "0.21.0"
source = { editable = "../../fastapi_startkit" }
dependencies = [
{ name = "cleo" },
diff --git a/example/database-app/databases/migrations/2026_04_26_110113_create_users.py b/example/database-app/databases/migrations/2026_04_26_110113_create_users.py
index 82cb2f23..310864a3 100644
--- a/example/database-app/databases/migrations/2026_04_26_110113_create_users.py
+++ b/example/database-app/databases/migrations/2026_04_26_110113_create_users.py
@@ -1,6 +1,6 @@
"""Create-users Migration."""
-from fastapi_startkit.masoniteorm.migrations import Migration
+from fastapi_startkit.masoniteorm import Migration
class CreateUsers(Migration):
diff --git a/example/database-app/databases/migrations/2026_04_27_145006_create_categories_table.py b/example/database-app/databases/migrations/2026_04_27_145006_create_categories_table.py
index 3385379e..aac570b2 100644
--- a/example/database-app/databases/migrations/2026_04_27_145006_create_categories_table.py
+++ b/example/database-app/databases/migrations/2026_04_27_145006_create_categories_table.py
@@ -1,6 +1,6 @@
"""CreateCategoriesTable Migration."""
-from fastapi_startkit.masoniteorm.migrations import Migration
+from fastapi_startkit.masoniteorm import Migration
class CreateCategoriesTable(Migration):
diff --git a/example/database-app/databases/migrations/2026_04_27_145006_create_profiles_table.py b/example/database-app/databases/migrations/2026_04_27_145006_create_profiles_table.py
index 2b7e9f9e..6a600d3b 100644
--- a/example/database-app/databases/migrations/2026_04_27_145006_create_profiles_table.py
+++ b/example/database-app/databases/migrations/2026_04_27_145006_create_profiles_table.py
@@ -1,6 +1,6 @@
"""CreateProfilesTable Migration."""
-from fastapi_startkit.masoniteorm.migrations import Migration
+from fastapi_startkit.masoniteorm import Migration
class CreateProfilesTable(Migration):
diff --git a/example/database-app/databases/migrations/2026_04_27_145007_create_courses_table.py b/example/database-app/databases/migrations/2026_04_27_145007_create_courses_table.py
index 15c0b735..b26ffb06 100644
--- a/example/database-app/databases/migrations/2026_04_27_145007_create_courses_table.py
+++ b/example/database-app/databases/migrations/2026_04_27_145007_create_courses_table.py
@@ -1,6 +1,6 @@
"""CreateCoursesTable Migration."""
-from fastapi_startkit.masoniteorm.migrations import Migration
+from fastapi_startkit.masoniteorm import Migration
class CreateCoursesTable(Migration):
diff --git a/example/database-app/databases/migrations/2026_04_27_145008_create_course_user_table.py b/example/database-app/databases/migrations/2026_04_27_145008_create_course_user_table.py
index c1871638..e5c0a108 100644
--- a/example/database-app/databases/migrations/2026_04_27_145008_create_course_user_table.py
+++ b/example/database-app/databases/migrations/2026_04_27_145008_create_course_user_table.py
@@ -1,6 +1,6 @@
"""CreateCourseUserTable Migration."""
-from fastapi_startkit.masoniteorm.migrations import Migration
+from fastapi_startkit.masoniteorm import Migration
class CreateCourseUserTable(Migration):
diff --git a/example/database-app/databases/migrations/2026_04_27_145008_create_lessons_table.py b/example/database-app/databases/migrations/2026_04_27_145008_create_lessons_table.py
index 7d9ebfce..3f9582ab 100644
--- a/example/database-app/databases/migrations/2026_04_27_145008_create_lessons_table.py
+++ b/example/database-app/databases/migrations/2026_04_27_145008_create_lessons_table.py
@@ -1,6 +1,6 @@
"""CreateLessonsTable Migration."""
-from fastapi_startkit.masoniteorm.migrations import Migration
+from fastapi_startkit.masoniteorm import Migration
class CreateLessonsTable(Migration):
diff --git a/example/database-app/databases/migrations/2026_04_27_145009_create_reviews_table.py b/example/database-app/databases/migrations/2026_04_27_145009_create_reviews_table.py
index d6837241..ee8ee462 100644
--- a/example/database-app/databases/migrations/2026_04_27_145009_create_reviews_table.py
+++ b/example/database-app/databases/migrations/2026_04_27_145009_create_reviews_table.py
@@ -1,6 +1,6 @@
"""CreateReviewsTable Migration."""
-from fastapi_startkit.masoniteorm.migrations import Migration
+from fastapi_startkit.masoniteorm import Migration
class CreateReviewsTable(Migration):
diff --git a/example/database-app/databases/seeds/user_seeder.py b/example/database-app/databases/seeds/user_seeder.py
index 1cb64209..737612cc 100644
--- a/example/database-app/databases/seeds/user_seeder.py
+++ b/example/database-app/databases/seeds/user_seeder.py
@@ -46,5 +46,5 @@ async def run(self):
for data in users:
profile_data = data.pop("profile")
- user, _ = await User.first_or_create({"email": data["email"]}, data)
- await Profile.first_or_create({"user_id": user.id}, {"user_id": user.id, **profile_data})
\ No newline at end of file
+ user= await User.first_or_create({"email": data["email"]}, data)
+ await Profile.first_or_create({"user_id": user.id}, {"user_id": user.id, **profile_data})
diff --git a/example/database-app/uv.lock b/example/database-app/uv.lock
index 56a1b993..5a426a93 100644
--- a/example/database-app/uv.lock
+++ b/example/database-app/uv.lock
@@ -498,7 +498,7 @@ wheels = [
[[package]]
name = "fastapi-startkit"
-version = "0.20.0"
+version = "0.21.0"
source = { editable = "../../fastapi_startkit" }
dependencies = [
{ name = "cleo" },
diff --git a/fastapi_startkit/src/fastapi_startkit/__init__.py b/fastapi_startkit/src/fastapi_startkit/__init__.py
index 87cf3e61..892fc268 100644
--- a/fastapi_startkit/src/fastapi_startkit/__init__.py
+++ b/fastapi_startkit/src/fastapi_startkit/__init__.py
@@ -1,4 +1,5 @@
from .application import Application
+from .console import ConsoleApplication
from .configuration.config import Config
__all__ = ["Application", "Config"]
diff --git a/fastapi_startkit/src/fastapi_startkit/commands/__init__.py b/fastapi_startkit/src/fastapi_startkit/commands/__init__.py
deleted file mode 100644
index 8b137891..00000000
--- a/fastapi_startkit/src/fastapi_startkit/commands/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/fastapi_startkit/src/fastapi_startkit/console/__init__.py b/fastapi_startkit/src/fastapi_startkit/console/__init__.py
new file mode 100644
index 00000000..c98daee3
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/console/__init__.py
@@ -0,0 +1,7 @@
+from .application import ConsoleApplication
+from .command import Command
+
+__all__ = [
+ "ConsoleApplication",
+ "Command"
+]
diff --git a/fastapi_startkit/src/fastapi_startkit/console.py b/fastapi_startkit/src/fastapi_startkit/console/application.py
similarity index 63%
rename from fastapi_startkit/src/fastapi_startkit/console.py
rename to fastapi_startkit/src/fastapi_startkit/console/application.py
index 1b4f6ff6..bc853124 100644
--- a/fastapi_startkit/src/fastapi_startkit/console.py
+++ b/fastapi_startkit/src/fastapi_startkit/console/application.py
@@ -1,19 +1,26 @@
+from typing import TYPE_CHECKING
+
from cleo.application import Application as BaseApplication
from cleo.io.io import IO
-from fastapi_startkit.application import Application
+
+if TYPE_CHECKING:
+ from fastapi_startkit.application import Application
class ConsoleApplication(BaseApplication):
- def __init__(self, app: Application):
+ def __init__(self, app: "Application"):
super().__init__()
self.app = app
# Register commands from Application
for command in self.app.commands:
if isinstance(command, type):
- self.add(command())
+ instance = command()
else:
- self.add(command)
+ instance = command
+
+ instance.set_container(self.app)
+ self.add(instance)
def render_error(self, error: Exception, io: IO) -> None:
self.app.exception_manager.report(error)
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/CanOverrideConfig.py b/fastapi_startkit/src/fastapi_startkit/console/can_override_config.py
similarity index 100%
rename from fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/CanOverrideConfig.py
rename to fastapi_startkit/src/fastapi_startkit/console/can_override_config.py
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/CanOverrideOptionsDefault.py b/fastapi_startkit/src/fastapi_startkit/console/can_override_default_options.py
similarity index 100%
rename from fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/CanOverrideOptionsDefault.py
rename to fastapi_startkit/src/fastapi_startkit/console/can_override_default_options.py
diff --git a/fastapi_startkit/src/fastapi_startkit/console/command.py b/fastapi_startkit/src/fastapi_startkit/console/command.py
new file mode 100644
index 00000000..f67f592a
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/console/command.py
@@ -0,0 +1,14 @@
+from typing import TYPE_CHECKING
+
+from .can_override_config import CanOverrideConfig
+from .can_override_default_options import CanOverrideOptionsDefault
+
+if TYPE_CHECKING:
+ from fastapi_startkit import Application
+
+
+class Command(CanOverrideOptionsDefault, CanOverrideConfig):
+ container: "Application"
+
+ def set_container(self, container: "Application") -> None:
+ self.container = container
diff --git a/fastapi_startkit/src/fastapi_startkit/commands/publish_command.py b/fastapi_startkit/src/fastapi_startkit/console/publish_command.py
similarity index 97%
rename from fastapi_startkit/src/fastapi_startkit/commands/publish_command.py
rename to fastapi_startkit/src/fastapi_startkit/console/publish_command.py
index 5bbe2282..928e0516 100644
--- a/fastapi_startkit/src/fastapi_startkit/commands/publish_command.py
+++ b/fastapi_startkit/src/fastapi_startkit/console/publish_command.py
@@ -2,7 +2,7 @@
import shutil
from typing import TYPE_CHECKING
-from cleo.commands.command import Command
+from fastapi_startkit.console import Command
from cleo.helpers import option
from fastapi_startkit.helpers.string import Str
diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py b/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py
index 128012a9..9ba13166 100644
--- a/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py
+++ b/fastapi_startkit/src/fastapi_startkit/fastapi/commands/serve_command.py
@@ -1,4 +1,4 @@
-from cleo.commands.command import Command
+from fastapi_startkit.console.command import Command
from cleo.helpers import option
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py
index 18a6579e..731e8c69 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py
@@ -1,6 +1,8 @@
from .config.config import MySQLConfig, PostgresConfig, SQLiteConfig
from .facades import DB
+from .migrations.Migration import Migration
+from .migrations.Migrator import Migrator
from .models import Model
from .providers import DatabaseProvider
-__all__ = ["DatabaseProvider", "PostgresConfig", "MySQLConfig", "SQLiteConfig", "Model", "DB"]
+__all__ = ["DatabaseProvider", "PostgresConfig", "MySQLConfig", "SQLiteConfig", "Model", "DB", "Migration", "Migrator"]
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/Command.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/Command.py
deleted file mode 100644
index a4d13a10..00000000
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/Command.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from .CanOverrideConfig import CanOverrideConfig
-from .CanOverrideOptionsDefault import CanOverrideOptionsDefault
-
-
-class Command(CanOverrideOptionsDefault, CanOverrideConfig):
- pass
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/DBMigrateCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/DBMigrateCommand.py
index b8750f00..2d3e790e 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/DBMigrateCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/DBMigrateCommand.py
@@ -1,7 +1,10 @@
import os
-from .Command import Command
+
from cleo.helpers import option
+from fastapi_startkit.console import Command
+from fastapi_startkit.masoniteorm.migrations.Migrator import Migrator
+
class DBMigrateCommand(Command):
name = "db:migrate"
@@ -28,12 +31,6 @@ class DBMigrateCommand(Command):
flag=True,
description="Force migrations without prompt in production",
),
- option(
- "show",
- "s",
- flag=True,
- description="Shows the output of SQL for migrations that would be running",
- ),
option(
"directory",
"d",
@@ -49,29 +46,42 @@ def handle(self):
return asyncio.run(self.handle_async())
async def handle_async(self):
- from ..migrations import Migration
+ self.confirm_to_proceed()
- # prompt user for confirmation in production
- if os.getenv("APP_ENV") == "production" and not self.option("force"):
- answer = ""
- while answer not in ["y", "n"]:
- answer = input(
- "Do you want to run migrations in PRODUCTION ? (y/n)\n"
- ).lower()
- if answer != "y":
- self.info("Migrations cancelled")
- exit(0)
- migration = Migration(
+ directory = self.resolve_migration_path()
+
+ migration = Migrator(
command_class=self,
connection=self.option("connection"),
- migration_directory=self.option("directory"),
+ migration_directory=directory,
)
+
await migration.create_table_if_not_exists()
if not await migration.get_unran_migrations():
self.info("Nothing To Migrate!")
return
migration_name = self.option("migration")
- show_output = self.option("show")
- await migration.migrate(migration=migration_name, output=show_output)
+ await migration.migrate(migration=migration_name)
+
+ def resolve_migration_path(self) -> str:
+ path = self.option('directory')
+
+ config = self.container.make('config').get('database.migrations')
+ default_directory = config.get('directory')
+
+ migration_directory = path or default_directory
+ return self.container.use_base_path(migration_directory)
+
+ def confirm_to_proceed(self) -> None:
+ # prompt user for confirmation in production
+ if os.getenv("APP_ENV") == "production" and not self.option("force"):
+ answer = ""
+ while answer not in ["y", "n"]:
+ answer = input(
+ "Do you want to run migrations in PRODUCTION ? (y/n)\n"
+ ).lower()
+ if answer != "y":
+ self.info("Migrations cancelled")
+ exit(0)
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/DBSeedCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/DBSeedCommand.py
index 3b90c96a..ed6a2610 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/DBSeedCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/DBSeedCommand.py
@@ -1,7 +1,7 @@
from cleo.helpers import argument, option
from inflection import camelize, underscore
-from .Command import Command
+from fastapi_startkit.console import Command
class DBSeedCommand(Command):
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/Entry.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/Entry.py
deleted file mode 100644
index 67c83ea3..00000000
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/Entry.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Craft Command.
-
-This module is really used for backup only if the masonite CLI cannot import this for you.
-This can be used by running "python craft". This module is not ran when the CLI can
-successfully import commands for you.
-"""
-
-from cleo.application import Application
-
-from . import (
- MakeMigrationCommand,
- MakeModelCommand,
- MakeModelDocstringCommand,
- MakeObserverCommand,
- MakeSeedCommand,
- DBMigrateCommand,
- MigrateFreshCommand,
- MigrateRefreshCommand,
- MigrateResetCommand,
- MigrateRollbackCommand,
- MigrateStatusCommand,
- DBSeedCommand,
- ShellCommand,
-)
-
-application = Application("ORM Version:", "0.1")
-
-application.add(DBMigrateCommand())
-application.add(MigrateRollbackCommand())
-application.add(MigrateRefreshCommand())
-application.add(MigrateFreshCommand())
-application.add(MakeMigrationCommand())
-application.add(MakeModelCommand())
-application.add(MakeModelDocstringCommand())
-application.add(MakeObserverCommand())
-application.add(MigrateResetCommand())
-application.add(MigrateStatusCommand())
-application.add(MakeSeedCommand())
-application.add(DBSeedCommand())
-application.add(ShellCommand())
-
-if __name__ == "__main__":
- application.run()
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeMigrationCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeMigrationCommand.py
index 6b79baf7..4e32b378 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeMigrationCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeMigrationCommand.py
@@ -4,7 +4,7 @@
from inflection import tableize
from cleo.helpers import argument, option
from fastapi_startkit.helpers.string import Str
-from .Command import Command
+from fastapi_startkit.console import Command
class MakeMigrationCommand(Command):
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py
index 4666602f..d0fafecb 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py
@@ -1,7 +1,7 @@
import os
import pathlib
from inflection import camelize, tableize, underscore
-from .Command import Command
+from fastapi_startkit.console import Command
from cleo.helpers import argument, option
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelDocstringCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelDocstringCommand.py
index 732e6daa..38401ea6 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelDocstringCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelDocstringCommand.py
@@ -1,6 +1,6 @@
from cleo.helpers import argument, option
from ..config import load_config
-from .Command import Command
+from fastapi_startkit.console import Command
class MakeModelDocstringCommand(Command):
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeObserverCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeObserverCommand.py
index e46071d3..da2ea3e8 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeObserverCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeObserverCommand.py
@@ -2,7 +2,7 @@
import os
import pathlib
from inflection import camelize, underscore
-from .Command import Command
+from fastapi_startkit.console import Command
class MakeObserverCommand(Command):
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeSeedCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeSeedCommand.py
index 8349088d..3033ec03 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeSeedCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeSeedCommand.py
@@ -2,7 +2,7 @@
import os
import pathlib
from inflection import camelize, underscore
-from .Command import Command
+from fastapi_startkit.console import Command
class MakeSeedCommand(Command):
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateFreshCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateFreshCommand.py
index babfdc22..4aa8d494 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateFreshCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateFreshCommand.py
@@ -1,5 +1,6 @@
from cleo.helpers import option
-from .Command import Command
+from fastapi_startkit.console import Command
+from fastapi_startkit.masoniteorm.migrations.Migrator import Migrator
class MigrateFreshCommand(Command):
@@ -49,12 +50,12 @@ def handle(self):
return asyncio.run(self.handle_async())
async def handle_async(self):
- from ..migrations import Migration
+ directory = self.resolve_migration_path()
- migration = Migration(
+ migration = Migrator(
command_class=self,
connection=self.option("connection"),
- migration_directory=self.option("directory"),
+ migration_directory=directory,
)
await migration.fresh(ignore_fk=not self.option("no-fk"))
@@ -69,3 +70,12 @@ async def handle_async(self):
"seed:run",
f"{self.option('seed')} --directory {self.option('seed-directory')} --connection {self.option('connection')}",
)
+
+ def resolve_migration_path(self) -> str:
+ path = self.option('directory')
+
+ config = self.container.make('config').get('database.migrations')
+ default_directory = config.get('directory')
+
+ migration_directory = path or default_directory
+ return self.container.use_base_path(migration_directory)
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateRefreshCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateRefreshCommand.py
index c7a89836..fcfd369f 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateRefreshCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateRefreshCommand.py
@@ -1,9 +1,10 @@
from cleo.helpers import option
-from .Command import Command
+from fastapi_startkit.console import Command
+from fastapi_startkit.masoniteorm.migrations.Migrator import Migrator
class MigrateRefreshCommand(Command):
- name = "migrate:refresh"
+ name = "db:migrate:refresh"
description = "Refresh migrations."
options = [
@@ -21,13 +22,6 @@ class MigrateRefreshCommand(Command):
default="default",
description="The connection you want to run migrations on",
),
- option(
- "schema",
- None,
- flag=False,
- default=None,
- description="Sets the schema to be migrated",
- ),
option(
"directory",
"d",
@@ -57,14 +51,12 @@ def handle(self):
return asyncio.run(self.handle_async())
async def handle_async(self):
- from ..migrations import Migration
+ directory = self.resolve_migration_path()
- migration = Migration(
+ migration = Migrator(
command_class=self,
connection=self.option("connection"),
- migration_directory=self.option("directory"),
- config_path=self.option("config"),
- schema=self.option("schema"),
+ migration_directory=directory,
)
await migration.refresh(self.option("migration"))
@@ -79,3 +71,12 @@ async def handle_async(self):
"seed:run",
f"{self.option('seed')} --directory {self.option('seed-directory')} --connection {self.option('connection')}",
)
+
+ def resolve_migration_path(self) -> str:
+ path = self.option('directory')
+
+ config = self.container.make('config').get('database.migrations')
+ default_directory = config.get('directory')
+
+ migration_directory = path or default_directory
+ return self.container.use_base_path(migration_directory)
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateResetCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateResetCommand.py
index 3a1fd848..8aac6687 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateResetCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateResetCommand.py
@@ -1,9 +1,10 @@
from cleo.helpers import option
-from .Command import Command
+from fastapi_startkit.console import Command
+from fastapi_startkit.masoniteorm.migrations.Migrator import Migrator
class MigrateResetCommand(Command):
- name = "migrate:reset"
+ name = "db:migrate:reset"
description = "Reset migrations."
options = [
@@ -21,13 +22,6 @@ class MigrateResetCommand(Command):
default="default",
description="The connection you want to run migrations on",
),
- option(
- "schema",
- None,
- flag=False,
- default=None,
- description="Sets the schema to be migrated",
- ),
option(
"directory",
"d",
@@ -43,14 +37,21 @@ def handle(self):
return asyncio.run(self.handle_async())
async def handle_async(self):
- from ..migrations import Migration
+ directory = self.resolve_migration_path()
- migration = Migration(
+ migration = Migrator(
command_class=self,
connection=self.option("connection"),
- migration_directory=self.option("directory"),
- config_path=self.option("config"),
- schema=self.option("schema"),
+ migration_directory=directory,
)
await migration.reset(self.option("migration"))
+
+ def resolve_migration_path(self) -> str:
+ path = self.option('directory')
+
+ config = self.container.make('config').get('database.migrations')
+ default_directory = config.get('directory')
+
+ migration_directory = path or default_directory
+ return self.container.use_base_path(migration_directory)
\ No newline at end of file
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateRollbackCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateRollbackCommand.py
index da874193..1d359163 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateRollbackCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateRollbackCommand.py
@@ -1,5 +1,6 @@
from cleo.helpers import option
-from .Command import Command
+from fastapi_startkit.console import Command
+from fastapi_startkit.masoniteorm.migrations.Migrator import Migrator
class MigrateRollbackCommand(Command):
@@ -21,19 +22,6 @@ class MigrateRollbackCommand(Command):
default="default",
description="The connection you want to run migrations on",
),
- option(
- "show",
- "s",
- flag=True,
- description="Shows the output of SQL for migrations that would be running",
- ),
- option(
- "schema",
- None,
- flag=False,
- default=None,
- description="Sets the schema to be migrated",
- ),
option(
"directory",
"d",
@@ -49,12 +37,21 @@ def handle(self):
return asyncio.run(self.handle_async())
async def handle_async(self):
- from ..migrations import Migration
+ directory = self.resolve_migration_path()
- await Migration(
+ migration = Migrator(
command_class=self,
connection=self.option("connection"),
- migration_directory=self.option("directory"),
- config_path=self.option("config"),
- schema=self.option("schema"),
- ).rollback(migration=self.option("migration"), output=self.option("show"))
+ migration_directory=directory,
+ )
+
+ await migration.rollback(migration=self.option("migration"))
+
+ def resolve_migration_path(self) -> str:
+ path = self.option('directory')
+
+ config = self.container.make('config').get('database.migrations')
+ default_directory = config.get('directory')
+
+ migration_directory = path or default_directory
+ return self.container.use_base_path(migration_directory)
\ No newline at end of file
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateStatusCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateStatusCommand.py
index 66d4ae27..6f8ed8dd 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateStatusCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MigrateStatusCommand.py
@@ -1,6 +1,6 @@
from cleo.helpers import option
-from ..migrations import Migration
-from .Command import Command
+from fastapi_startkit.console import Command
+from fastapi_startkit.masoniteorm.migrations.Migrator import Migrator
class MigrateStatusCommand(Command):
@@ -15,13 +15,6 @@ class MigrateStatusCommand(Command):
default="default",
description="The connection you want to run migrations on",
),
- option(
- "schema",
- None,
- flag=False,
- default=None,
- description="Sets the schema to be migrated",
- ),
option(
"directory",
"d",
@@ -37,16 +30,17 @@ def handle(self):
return asyncio.run(self.handle_async())
async def handle_async(self):
- migration = Migration(
+ directory = self.resolve_migration_path()
+
+ migration = Migrator(
command_class=self,
connection=self.option("connection"),
- migration_directory=self.option("directory"),
- config_path=self.option("config"),
- schema=self.option("schema"),
+ migration_directory=directory,
)
+
await migration.create_table_if_not_exists()
table = self.table()
- table.set_header_row(["Ran?", "Migration", "Batch"])
+ table.set_headers(["Ran?", "Migration", "Batch"])
migrations = []
ran_migrations = await migration.get_ran_migrations()
@@ -74,4 +68,13 @@ async def handle_async(self):
table.set_rows(migrations)
- table.render(self.io)
+ table.render()
+
+ def resolve_migration_path(self) -> str:
+ path = self.option('directory')
+
+ config = self.container.make('config').get('database.migrations')
+ default_directory = config.get('directory')
+
+ migration_directory = path or default_directory
+ return self.container.use_base_path(migration_directory)
\ No newline at end of file
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/ShellCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/ShellCommand.py
index 0a33b466..b13b7eea 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/ShellCommand.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/ShellCommand.py
@@ -4,7 +4,7 @@
import shlex
import subprocess
from collections import OrderedDict
-from .Command import Command
+from fastapi_startkit.console import Command
class ShellCommand(Command):
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/create_migration.stub b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/create_migration.stub
index fe3573a3..4bceea2f 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/create_migration.stub
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/create_migration.stub
@@ -1,6 +1,6 @@
"""__MIGRATION_NAME__ Migration."""
-from fastapi_startkit.masoniteorm.migrations import Migration
+from fastapi_startkit.masoniteorm import Migration
class __MIGRATION_NAME__(Migration):
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/database.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/database.py
index 0db7e1d7..c175aa43 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/database.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/config/database.py
@@ -20,5 +20,5 @@ class DatabaseConfig:
)
migrations: Dict[str, Dict[str, Any]] = field(
- default_factory=lambda: {"table": "migrations", "path": "databases/migrations"}
+ default_factory=lambda: {"table": "migrations", "directory": "databases/migrations"}
)
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migration.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migration.py
index a88597df..f2a8208b 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migration.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migration.py
@@ -1,297 +1,11 @@
-import os
-from os import listdir
-from os.path import isfile, join
-from pydoc import locate
-from timeit import default_timer as timer
+from typing import TYPE_CHECKING
-from inflection import camelize
-
-from ..models.MigrationModel import MigrationModel
+if TYPE_CHECKING:
+ pass
class Migration:
- db_manager: "DatabaseManager"
-
- def __init__(
- self,
- connection="default",
- command_class=None,
- migration_directory="databases/migrations",
- ):
+ def __init__(self, connection=None, schema=None):
self.connection = connection
- self.migration_directory = migration_directory
- self.last_migrations_ran = []
- self.command_class = command_class
-
- self.schema = self.db_manager.get_schema_builder()
-
- self.migration_model = MigrationModel.on(self.connection)
-
- async def create_table_if_not_exists(self):
- async with await self.schema.create_table_if_not_exists("migrations") as table:
- table.increments("migration_id")
- table.string("migration")
- table.integer("batch")
-
- async def get_unran_migrations(self):
- directory_path = os.path.join(os.getcwd(), self.migration_directory)
- all_migrations = [
- f.replace(".py", "")
- for f in listdir(directory_path)
- if isfile(join(directory_path, f))
- and f != "__init__.py"
- and not f.startswith(".")
- ]
- all_migrations.sort()
- unran_migrations = []
- database_migrations = await self.migration_model.get()
- for migration in all_migrations:
- if migration not in database_migrations.pluck("migration"):
- unran_migrations.append(migration)
- return unran_migrations
-
- async def get_rollback_migrations(self):
- all_migrations = await self.migration_model.all()
- return (
- await self.migration_model.where("batch", all_migrations.max("batch"))
- .order_by("migration_id", "desc")
- .get()
- ).pluck("migration")
-
- async def get_all_migrations(self, reverse=False):
- if reverse:
- return (
- await self.migration_model.order_by("migration_id", "desc").get()
- ).pluck("migration")
-
- return (await self.migration_model.all()).pluck("migration")
-
- async def get_last_batch_number(self):
- return (await self.migration_model.all()).max("batch") or 0
-
- async def delete_migration(self, file_path):
- return await self.migration_model.where("migration", file_path).delete()
-
- def locate(self, file_name):
- migration_name = camelize("_".join(file_name.split("_")[4:]).replace(".py", ""))
- file_name = file_name.replace(".py", "")
- migration_directory = self.migration_directory.replace("/", ".").replace(
- "\\", "."
- )
- return locate(f"{migration_directory}.{file_name}.{migration_name}")
-
- async def get_ran_migrations(self):
- directory_path = os.path.join(os.getcwd(), self.migration_directory)
- all_migrations = [
- f.replace(".py", "")
- for f in listdir(directory_path)
- if isfile(join(directory_path, f))
- and f != "__init__.py"
- and not f.startswith(".")
- ]
- all_migrations.sort()
- ran = []
-
- database_migrations = await self.migration_model.all()
- for migration in all_migrations:
- matched_migration = database_migrations.where(
- "migration", migration
- ).first()
- if matched_migration:
- ran.append(
- {
- "migration_file": matched_migration.migration,
- "batch": matched_migration.batch,
- }
- )
- return ran
-
- async def migrate(self, migration="all", output=False):
- default_migrations = await self.get_unran_migrations()
- migrations = default_migrations if migration == "all" else [migration]
-
- batch = int(await self.get_last_batch_number() or 0) + 1
-
- for migration in migrations:
- try:
- migration_class = self.locate(migration)
- except TypeError:
- migration_class = None
-
- if migration_class is None:
- if self.command_class:
- self.command_class.line(f"Not Found: {migration}")
- continue
-
- self.last_migrations_ran.append(migration)
- if self.command_class:
- self.command_class.line(
- f"Migrating: {migration}"
- )
-
- migration_class = migration_class(connection=self.connection)
-
- if output:
- migration_class.schema.dry()
- start = timer()
- await migration_class.up()
- duration = "{:.2f}".format(timer() - start)
-
- if output:
- if self.command_class:
- table = self.command_class.table()
- table.set_header_row(["SQL"])
- sql = migration_class.schema._blueprint.to_sql()
- if isinstance(sql, list):
- sql = ",".join(sql)
- table.set_rows([[sql]])
- table.render(self.command_class.io)
- continue
- else:
- print(migration_class.schema._blueprint.to_sql())
-
- if self.command_class:
- self.command_class.line(
- f"Migrated: {migration} ({duration}s)"
- )
-
- await self.migration_model.create(
- {"batch": batch, "migration": migration.replace(".py", "")}
- )
-
- async def rollback(self, migration="all", output=False):
- default_migrations = await self.get_rollback_migrations()
- migrations = default_migrations if migration == "all" else [migration]
-
- for migration in migrations:
- if migration.endswith(".py"):
- migration = migration.replace(".py", "")
-
- if self.command_class:
- self.command_class.line(
- f"Rolling back: {migration}"
- )
-
- try:
- migration_class = self.locate(migration)
- except TypeError:
- self.command_class.line(f"Not Found: {migration}")
- continue
-
- migration_class = migration_class(
- connection=self.connection, schema=self.schema_name
- )
-
- if output:
- migration_class.schema.dry()
-
- start = timer()
- await migration_class.down()
- duration = "{:.2f}".format(timer() - start)
-
- if output:
- if self.command_class:
- table = self.command_class.table()
- table.set_header_row(["SQL"])
- if (
- hasattr(migration_class.schema, "_blueprint")
- and migration_class.schema._blueprint
- ):
- sql = migration_class.schema._blueprint.to_sql()
- if isinstance(sql, list):
- sql = ",".join(sql)
-
- table.set_rows([[sql]])
- elif migration_class.schema._sql:
- table.set_rows([[migration_class.schema._sql]])
- table.render(self.command_class.io)
- continue
- else:
- print(migration_class.schema._blueprint.to_sql())
-
- await self.delete_migration(migration)
-
- if self.command_class:
- self.command_class.line(
- f"Rolled back: {migration} ({duration}s)"
- )
-
- async def delete_migrations(self, migrations=None):
- return await self.migration_model.where_in(
- "migration", migrations or []
- ).delete()
-
- async def delete_last_batch(self):
- return await self.migration_model.where(
- "batch", await self.get_last_batch_number()
- ).delete()
-
- async def reset(self, migration="all"):
- default_migrations = await self.get_all_migrations(reverse=True)
- migrations = default_migrations if migration == "all" else [migration]
-
- if not len(migrations):
- if self.command_class:
- self.command_class.line("Nothing to reset")
- else:
- print("Nothing to reset")
-
- for migration in migrations:
- if self.command_class:
- self.command_class.line(
- f"Rolling back: {migration}"
- )
-
- try:
- migration_instance = self.locate(migration)(
- connection=self.connection, schema=self.schema_name
- )
- await migration_instance.down()
- except TypeError:
- self.command_class.line(f"Not Found: {migration}")
- continue
-
- # raise MigrationNotFound(f"Could not find {migration}")
-
- await self.delete_migration(migration)
-
- if self.command_class:
- self.command_class.line(
- f"Rolled back: {migration}"
- )
-
- await self.delete_migrations([migration])
-
- if self.command_class:
- self.command_class.line("")
-
- async def refresh(self, migration="all"):
- await self.reset(migration)
- await self.migrate(migration)
-
- async def drop_all_tables(self, ignore_fk=False):
- if self.command_class:
- self.command_class.line("Dropping all tables")
-
- if ignore_fk:
- await self.schema.disable_foreign_key_constraints()
-
- for table in await self.schema.get_all_tables():
- await self.schema.drop(table)
-
- if ignore_fk:
- await self.schema.enable_foreign_key_constraints()
-
- if self.command_class:
- self.command_class.line("All tables dropped")
-
- async def fresh(self, ignore_fk=False, migration="all"):
- await self.drop_all_tables(ignore_fk=ignore_fk)
- await self.create_table_if_not_exists()
-
- if not await self.get_unran_migrations():
- if self.command_class:
- self.command_class.line("Nothing to migrate")
- return
- await self.migrate(migration)
+ self.schema = schema
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migrator.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migrator.py
new file mode 100644
index 00000000..37870bc1
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/Migrator.py
@@ -0,0 +1,305 @@
+from os import listdir
+from os.path import isfile, join
+from pydoc import locate
+from timeit import default_timer as timer
+from typing import TYPE_CHECKING
+
+from inflection import camelize
+
+from ..models.MigrationModel import MigrationModel
+
+if TYPE_CHECKING:
+ from fastapi_startkit.masoniteorm import DatabaseManager
+
+
+class Migrator:
+ db_manager: "DatabaseManager"
+
+ def __init__(
+ self,
+ migration_directory: str,
+ connection="default",
+ command_class=None,
+ ):
+ self.connection = connection
+ self.migration_directory = migration_directory
+
+ self.last_migrations_ran = []
+ self.command_class = command_class
+
+ self.schema = self.db_manager.get_schema_builder()
+
+ self.migration_model = MigrationModel.on(self.connection)
+
+ async def create_table_if_not_exists(self):
+ async with await self.schema.create_table_if_not_exists("migrations") as table:
+ table.increments("id")
+ table.string("migration")
+ table.integer("batch")
+
+ async def get_unran_migrations(self):
+ directory_path = self.migration_directory
+ all_migrations = [
+ f.replace(".py", "")
+ for f in listdir(directory_path)
+ if isfile(join(directory_path, f))
+ and f != "__init__.py"
+ and not f.startswith(".")
+ ]
+ all_migrations.sort()
+ unran_migrations = []
+ database_migrations = await self.migration_model.get()
+ for migration in all_migrations:
+ if migration not in database_migrations.pluck("migration"):
+ unran_migrations.append(migration)
+ return unran_migrations
+
+ async def get_rollback_migrations(self):
+ all_migrations = await self.migration_model.all()
+ return (
+ await self.migration_model.where("batch", all_migrations.max("batch"))
+ .order_by("id", "desc")
+ .get()
+ ).pluck("migration")
+
+ async def get_all_migrations(self, reverse=False):
+ if reverse:
+ return (
+ await self.migration_model.new_query().order_by("id", "desc").get()
+ ).pluck("migration")
+
+ return (await self.migration_model.all()).pluck("migration")
+
+ async def get_last_batch_number(self):
+ return (await self.migration_model.all()).max("batch") or 0
+
+ async def delete_migration(self, file_path):
+ return await self.migration_model.where("migration", file_path).delete()
+
+ def locate(self, file_name):
+ import sys
+ migration_name = camelize("_".join(file_name.split("_")[4:]).replace(".py", ""))
+ file_name = file_name.replace(".py", "")
+ migration_directory = str(self.migration_directory)
+ for sys_path in sorted(sys.path, key=len, reverse=True):
+ if sys_path and migration_directory.startswith(sys_path):
+ migration_directory = migration_directory[len(sys_path):].lstrip("/\\")
+ break
+ migration_directory = migration_directory.replace("/", ".").replace("\\", ".")
+ return locate(f"{migration_directory}.{file_name}.{migration_name}")
+
+ async def get_ran_migrations(self):
+ directory_path = self.migration_directory
+ all_migrations = [
+ f.replace(".py", "")
+ for f in listdir(directory_path)
+ if isfile(join(directory_path, f))
+ and f != "__init__.py"
+ and not f.startswith(".")
+ ]
+ all_migrations.sort()
+ ran = []
+
+ database_migrations = await self.migration_model.all()
+ for migration in all_migrations:
+ matched_migration = database_migrations.where(
+ "migration", migration
+ ).first()
+ if matched_migration:
+ ran.append(
+ {
+ "migration_file": matched_migration.migration,
+ "batch": matched_migration.batch,
+ }
+ )
+ return ran
+
+ async def migrate(self, migration="all", output=False):
+ default_migrations = await self.get_unran_migrations()
+ migrations = default_migrations if migration == "all" else [migration]
+
+ batch = int(await self.get_last_batch_number() or 0) + 1
+
+ for migration in migrations:
+ try:
+ migration_class = self.locate(migration)
+ except TypeError:
+ migration_class = None
+
+ if migration_class is None:
+ if self.command_class:
+ self.command_class.line(f"Not Found: {migration}")
+ continue
+
+ self.last_migrations_ran.append(migration)
+ if self.command_class:
+ self.command_class.line(
+ f"Migrating: {migration}"
+ )
+
+ migration_class = migration_class(connection=self.connection, schema=self.schema)
+
+ if output:
+ migration_class.schema.dry()
+ start = timer()
+ await migration_class.up()
+ duration = "{:.2f}".format(timer() - start)
+
+ if output:
+ if self.command_class:
+ table = self.command_class.table()
+ table.set_headers(["SQL"])
+ sql = migration_class.schema._blueprint.to_sql()
+ if isinstance(sql, list):
+ sql = ",".join(sql)
+ table.set_rows([[sql]])
+ table.render()
+ continue
+ else:
+ print(migration_class.schema._blueprint.to_sql())
+
+ if self.command_class:
+ self.command_class.line(
+ f"Migrated: {migration} ({duration}s)"
+ )
+
+ await self.migration_model.create(
+ {"batch": batch, "migration": migration.replace(".py", "")}
+ )
+
+ async def rollback(self, migration="all", output=False):
+ default_migrations = await self.get_rollback_migrations()
+ migrations = default_migrations if migration == "all" else [migration]
+
+ for migration in migrations:
+ if migration.endswith(".py"):
+ migration = migration.replace(".py", "")
+
+ if self.command_class:
+ self.command_class.line(
+ f"Rolling back: {migration}"
+ )
+
+ try:
+ migration_class = self.locate(migration)
+ except TypeError:
+ self.command_class.line(f"Not Found: {migration}")
+ continue
+
+ migration_class = migration_class(
+ connection=self.connection, schema=self.schema
+ )
+
+ if output:
+ migration_class.schema.dry()
+
+ start = timer()
+ await migration_class.down()
+ duration = "{:.2f}".format(timer() - start)
+
+ if output:
+ if self.command_class:
+ table = self.command_class.table()
+ table.set_headers(["SQL"])
+ if (
+ hasattr(migration_class.schema, "_blueprint")
+ and migration_class.schema._blueprint
+ ):
+ sql = migration_class.schema._blueprint.to_sql()
+ if isinstance(sql, list):
+ sql = ",".join(sql)
+
+ table.set_rows([[sql]])
+ elif migration_class.schema._sql:
+ table.set_rows([[migration_class.schema._sql]])
+ table.render()
+ continue
+ else:
+ print(migration_class.schema._blueprint.to_sql())
+
+ await self.delete_migration(migration)
+
+ if self.command_class:
+ self.command_class.line(
+ f"Rolled back: {migration} ({duration}s)"
+ )
+
+ async def delete_migrations(self, migrations=None):
+ return await self.migration_model.where_in(
+ "migration", migrations or []
+ ).delete()
+
+ async def delete_last_batch(self):
+ return await self.migration_model.where(
+ "batch", await self.get_last_batch_number()
+ ).delete()
+
+ async def reset(self, migration="all"):
+ default_migrations = await self.get_all_migrations(reverse=True)
+ migrations = default_migrations if migration == "all" else [migration]
+
+ if not len(migrations):
+ if self.command_class:
+ self.command_class.line("Nothing to reset")
+ else:
+ print("Nothing to reset")
+
+ for migration in migrations:
+ if self.command_class:
+ self.command_class.line(
+ f"Rolling back: {migration}"
+ )
+
+ try:
+ migration_instance = self.locate(migration)(
+ connection=self.connection, schema=self.schema
+ )
+ await migration_instance.down()
+ except TypeError:
+ self.command_class.line(f"Not Found: {migration}")
+ continue
+
+ # raise MigrationNotFound(f"Could not find {migration}")
+
+ await self.delete_migration(migration)
+
+ if self.command_class:
+ self.command_class.line(
+ f"Rolled back: {migration}"
+ )
+
+ await self.delete_migrations([migration])
+
+ if self.command_class:
+ self.command_class.line("")
+
+ async def refresh(self, migration="all"):
+ await self.reset(migration)
+ await self.migrate(migration)
+
+ async def drop_all_tables(self, ignore_fk=False):
+ if self.command_class:
+ self.command_class.line("Dropping all tables")
+
+ if ignore_fk:
+ await self.schema.disable_foreign_key_constraints()
+
+ for table in await self.schema.get_all_tables():
+ await self.schema.drop(table)
+
+ if ignore_fk:
+ await self.schema.enable_foreign_key_constraints()
+
+ if self.command_class:
+ self.command_class.line("All tables dropped")
+
+ async def fresh(self, ignore_fk=False, migration="all"):
+ await self.drop_all_tables(ignore_fk=ignore_fk)
+ await self.create_table_if_not_exists()
+
+ if not await self.get_unran_migrations():
+ if self.command_class:
+ self.command_class.line("Nothing to migrate")
+ return
+
+ await self.migrate(migration)
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/__init__.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/__init__.py
index 5213e909..362177bd 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/__init__.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/migrations/__init__.py
@@ -1 +1,4 @@
from .Migration import Migration
+from .Migrator import Migrator
+
+__all__ = ["Migration", "Migrator"]
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/MigrationModel.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/MigrationModel.py
index a13e62e6..b25d8562 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/MigrationModel.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/MigrationModel.py
@@ -4,7 +4,7 @@
class MigrationModel(Model):
__table__ = "migrations"
__timestamps__ = False
- __primary_key__ = "migration_id"
+ __primary_key__ = "id"
migration: str
batch: int
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/providers/DatabaseProvider.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/providers/DatabaseProvider.py
index aa9c0c25..15d4b7b8 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/providers/DatabaseProvider.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/providers/DatabaseProvider.py
@@ -12,8 +12,8 @@
)
from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory
from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager
-from fastapi_startkit.masoniteorm.migrations import Migration
-from fastapi_startkit.masoniteorm.models import Model
+from fastapi_startkit.masoniteorm import Migrator
+from fastapi_startkit.masoniteorm import Model
from fastapi_startkit.providers.Provider import Provider
@@ -30,7 +30,7 @@ def register(self):
self.app.bind("schema", db.get_schema_builder())
Model.db_manager = db
- Migration.db_manager = db
+ Migrator.db_manager = db
def boot(self) -> None:
self.publishes(
diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/testing/transaction.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/testing/transaction.py
index 54308a68..cb275e3f 100644
--- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/testing/transaction.py
+++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/testing/transaction.py
@@ -19,7 +19,10 @@ async def asyncStartTestRun(self):
@staticmethod
async def migrate_database():
if not RefreshDatabase.migrated:
- from fastapi_startkit.masoniteorm.migrations import Migration
+ from fastapi_startkit.masoniteorm.migrations import Migrator
+ from fastapi_startkit.application import app as get_app
- await Migration(migration_directory="databases/migrations").fresh(ignore_fk=True)
+ migration_dir = get_app().use_base_path("databases/migrations")
+ migrator = Migrator(migration_directory=migration_dir)
+ await migrator.fresh(ignore_fk=True)
RefreshDatabase.migrated = True
diff --git a/fastapi_startkit/src/fastapi_startkit/providers/app_provider.py b/fastapi_startkit/src/fastapi_startkit/providers/app_provider.py
index f809e81c..f6b984ce 100644
--- a/fastapi_startkit/src/fastapi_startkit/providers/app_provider.py
+++ b/fastapi_startkit/src/fastapi_startkit/providers/app_provider.py
@@ -1,4 +1,4 @@
-from fastapi_startkit.commands.publish_command import PublishCommand
+from fastapi_startkit.console.publish_command import PublishCommand
from fastapi_startkit.providers import Provider
diff --git a/fastapi_startkit/tests/masoniteorm/commands/__init__.py b/fastapi_startkit/tests/masoniteorm/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/__init__.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/app.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/app.py
new file mode 100644
index 00000000..43004174
--- /dev/null
+++ b/fastapi_startkit/tests/masoniteorm/commands/fixtures/app.py
@@ -0,0 +1,28 @@
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, Any
+
+from fastapi_startkit.application import Application
+from fastapi_startkit.masoniteorm import SQLiteConfig
+from fastapi_startkit.masoniteorm.providers import DatabaseProvider
+
+BASE_DIR = Path(__file__).parent
+DB_PATH = BASE_DIR / "test.sqlite3"
+
+
+def create_app() -> Application:
+ return Application(
+ base_path=BASE_DIR,
+ providers=[
+ (DatabaseProvider, {
+ "default": "sqlite",
+ "connections": {
+ "sqlite": SQLiteConfig(
+ driver="sqlite",
+ url=f"sqlite+aiosqlite:///{DB_PATH}",
+ options=None,
+ ),
+ }
+ }),
+ ],
+ )
diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/__init__.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/migrations/2026_01_01_000000_create_posts_table.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/migrations/2026_01_01_000000_create_posts_table.py
new file mode 100644
index 00000000..a30e13eb
--- /dev/null
+++ b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/migrations/2026_01_01_000000_create_posts_table.py
@@ -0,0 +1,12 @@
+from fastapi_startkit.masoniteorm import Migration
+
+
+class CreatePostsTable(Migration):
+ async def up(self):
+ async with await self.schema.create("posts") as table:
+ table.id()
+ table.string("title")
+ table.timestamps()
+
+ async def down(self):
+ await self.schema.drop("posts")
diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/migrations/__init__.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py
new file mode 100644
index 00000000..64f7614e
--- /dev/null
+++ b/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py
@@ -0,0 +1,115 @@
+import asyncio
+import unittest
+
+from cleo.testers.command_tester import CommandTester
+
+from fastapi_startkit.masoniteorm.commands.DBMigrateCommand import DBMigrateCommand
+from fastapi_startkit.masoniteorm.commands.MigrateFreshCommand import MigrateFreshCommand
+from fastapi_startkit.masoniteorm.commands.MigrateResetCommand import MigrateResetCommand
+from fastapi_startkit.masoniteorm.commands.MigrateRollbackCommand import MigrateRollbackCommand
+from fastapi_startkit.masoniteorm.commands.MigrateStatusCommand import MigrateStatusCommand
+from fastapi_startkit.masoniteorm.migrations.Migrator import Migrator
+from .fixtures.app import create_app, DB_PATH
+
+
+class TestMigrateCommands(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.app = create_app()
+
+ def setUp(self):
+ asyncio.run(self._reset_db())
+
+ def tearDown(self):
+ if DB_PATH.exists():
+ DB_PATH.unlink()
+
+ async def _reset_db(self):
+ db = self.app.make("db")
+ await db.clear()
+ schema = db.get_schema_builder()
+ for table in await schema.get_all_tables():
+ await schema.drop_table_if_exists(table)
+
+ async def _migrate(self):
+ db = self.app.make("db")
+ await db.clear()
+ migrations_dir = self.app.use_base_path("databases/migrations")
+ migrator = Migrator(
+ connection="sqlite",
+ migration_directory=migrations_dir,
+ )
+ await migrator.create_table_if_not_exists()
+ await migrator.migrate()
+
+ def _make_command(self, command_class):
+ cmd = command_class()
+ cmd.set_container(self.app)
+ return cmd
+
+ def test_migrate_runs_pending_migrations(self):
+ cmd = self._make_command(DBMigrateCommand)
+ tester = CommandTester(cmd)
+ tester.execute("--connection sqlite")
+ output = tester.io.fetch_output()
+ self.assertIn("Migrated:", output)
+ self.assertIn("create_posts_table", output)
+
+ def test_migrate_reports_nothing_to_migrate(self):
+ cmd = self._make_command(DBMigrateCommand)
+ tester = CommandTester(cmd)
+ tester.execute("--connection sqlite")
+ tester.io.fetch_output()
+
+ tester.execute("--connection sqlite")
+ output = tester.io.fetch_output()
+ self.assertIn("Nothing To Migrate!", output)
+
+ def test_status_shows_unran_migrations(self):
+ cmd = self._make_command(MigrateStatusCommand)
+ tester = CommandTester(cmd)
+ tester.execute("--connection sqlite")
+ output = tester.io.fetch_output()
+ self.assertIn("create_posts_table", output)
+ self.assertIn("N", output)
+
+ def test_status_shows_ran_migrations(self):
+ asyncio.run(self._migrate())
+
+ cmd = self._make_command(MigrateStatusCommand)
+ tester = CommandTester(cmd)
+ tester.execute("--connection sqlite")
+ output = tester.io.fetch_output()
+ self.assertIn("create_posts_table", output)
+ self.assertIn("Y", output)
+
+ def test_rollback_rolls_back_last_batch(self):
+ asyncio.run(self._migrate())
+
+ cmd = self._make_command(MigrateRollbackCommand)
+ tester = CommandTester(cmd)
+ tester.execute("--connection sqlite")
+ output = tester.io.fetch_output()
+ self.assertIn("Rolled back:", output)
+ self.assertIn("create_posts_table", output)
+
+ def test_reset_rolls_back_all_migrations(self):
+ asyncio.run(self._migrate())
+
+ cmd = self._make_command(MigrateResetCommand)
+ tester = CommandTester(cmd)
+ tester.execute("--connection sqlite")
+ output = tester.io.fetch_output()
+ self.assertIn("Rolled back:", output)
+ self.assertIn("create_posts_table", output)
+
+ def test_fresh_drops_all_tables_and_remigrates(self):
+ asyncio.run(self._migrate())
+
+ cmd = self._make_command(MigrateFreshCommand)
+ tester = CommandTester(cmd)
+ tester.execute("--connection sqlite")
+ output = tester.io.fetch_output()
+ self.assertIn("Dropping all tables", output)
+ self.assertIn("Migrated:", output)
+ self.assertIn("create_posts_table", output)
diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/fixtures/db.py b/fastapi_startkit/tests/masoniteorm/sqlite/fixtures/db.py
index 25a88f94..d71315df 100644
--- a/fastapi_startkit/tests/masoniteorm/sqlite/fixtures/db.py
+++ b/fastapi_startkit/tests/masoniteorm/sqlite/fixtures/db.py
@@ -6,6 +6,7 @@
ConnectionFactory(),
{
"default": "sqlite",
+ "migrations": "fixtures/migrations",
"connections": {
"sqlite": {
"driver": "sqlite",
diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/fixtures/migrations/2026_01_01_000000_create_posts_table.py b/fastapi_startkit/tests/masoniteorm/sqlite/fixtures/migrations/2026_01_01_000000_create_posts_table.py
new file mode 100644
index 00000000..9964a31b
--- /dev/null
+++ b/fastapi_startkit/tests/masoniteorm/sqlite/fixtures/migrations/2026_01_01_000000_create_posts_table.py
@@ -0,0 +1,12 @@
+from fastapi_startkit.masoniteorm.migrations import Migration
+
+
+class CreatePostsTable(Migration):
+ async def up(self):
+ async with await self.schema.create("posts") as table:
+ table.id()
+ table.string("title")
+ table.timestamps()
+
+ async def down(self):
+ await self.schema.drop("posts")
\ No newline at end of file
diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/fixtures/migrations/__init__.py b/fastapi_startkit/tests/masoniteorm/sqlite/fixtures/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/schema/test_sqlite_schema_builder.py b/fastapi_startkit/tests/masoniteorm/sqlite/schema/test_sqlite_schema_builder.py
index 3bbc95cd..8ae7ca76 100644
--- a/fastapi_startkit/tests/masoniteorm/sqlite/schema/test_sqlite_schema_builder.py
+++ b/fastapi_startkit/tests/masoniteorm/sqlite/schema/test_sqlite_schema_builder.py
@@ -1,5 +1,8 @@
from unittest.mock import AsyncMock, MagicMock
+from fastapi_startkit.masoniteorm.migrations.Migration import Migration
+from fastapi_startkit.masoniteorm.models.MigrationModel import MigrationModel
+
from ..test_case import TestCase
diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock
index 49ee40c0..2065bd39 100644
--- a/fastapi_startkit/uv.lock
+++ b/fastapi_startkit/uv.lock
@@ -443,7 +443,7 @@ wheels = [
[[package]]
name = "fastapi-startkit"
-version = "0.20.0"
+version = "0.21.0"
source = { editable = "." }
dependencies = [
{ name = "cleo" },
diff --git a/mysql-init.sql b/mysql-init.sql
new file mode 100644
index 00000000..b7f4c42b
--- /dev/null
+++ b/mysql-init.sql
@@ -0,0 +1,3 @@
+CREATE DATABASE IF NOT EXISTS database_app;
+GRANT ALL PRIVILEGES ON database_app.* TO 'app'@'%';
+FLUSH PRIVILEGES;
\ No newline at end of file
diff --git a/taskiq_poc.py b/taskiq_poc.py
new file mode 100644
index 00000000..9e4b7c0e
--- /dev/null
+++ b/taskiq_poc.py
@@ -0,0 +1,142 @@
+import asyncio
+import json
+from datetime import datetime, timedelta
+from typing import Any, List, Optional, Union, Type, TypeVar
+from taskiq import AsyncBroker, InMemoryBroker, TaskiqResult
+
+# --- Mocking external dependencies for the PoC ---
+
+class NowMock:
+ """Mock for pendulum.now() or similar"""
+ def __init__(self, dt: datetime = None):
+ self.dt = dt or datetime.now()
+
+ def add(self, minutes: int = 0, seconds: int = 0) -> datetime:
+ return self.dt + timedelta(minutes=minutes, seconds=seconds)
+
+def now():
+ return NowMock()
+
+# --- The Core Logic ---
+
+T = TypeVar("T", bound="Queue")
+
+class Queue:
+ """
+ Base class for Taskiq tasks with a class-based syntax.
+ """
+ queue: str = 'default'
+ timeout: int = 60
+ max_exceptions: int = 3
+ tries: int | None = None
+ rate_limit: str | None = None
+ backoff: int | list[int] = 5
+
+ _broker: AsyncBroker = None
+ _taskiq_task = None
+
+
+ @classmethod
+ def bind(cls, broker: AsyncBroker):
+ """
+ Registers this class as a task in the given broker.
+ """
+ cls._broker = broker
+
+ async def _task_runner(*args, **kwargs):
+ # 1. Instantiate the class on the worker side
+ instance = cls(*args, **kwargs)
+ # 2. Execute the handle method
+ return await instance.handle()
+
+ # Register the task with proper labels so taskiq respects them
+ # This ensures that 'timeout', 'max_retry', etc. are actually used by the broker.
+ cls._taskiq_task = broker.register_task(
+ _task_runner,
+ task_name=f"{cls.__module__}.{cls.__name__}",
+ queue_name=cls.queue,
+ ).with_labels(
+ timeout=cls.timeout,
+ max_retry=cls.max_exceptions,
+ backoff=cls.backoff,
+ rate_limit=cls.rate_limit,
+ )
+
+ print(f"Registered task: {cls.__name__} on queue: {cls.queue}")
+
+ @classmethod
+ async def dispatch(cls, *args, **kwargs):
+ """
+ Dispatches the task to the broker using the class itself.
+ """
+ if not cls._taskiq_task:
+ raise RuntimeError(
+ f"Task {cls.__name__} is not bound to a broker. "
+ "Call ClassifyTask.bind(broker) first."
+ )
+
+ print(f"Dispatching {cls.__name__} with args: {args} {kwargs}")
+
+ # Send the arguments directly to the taskiq task
+ return await cls._taskiq_task.kiq(*args, **kwargs)
+
+ async def handle(self):
+ """
+ The actual logic to be executed by the worker.
+ """
+ raise NotImplementedError("Subclasses must implement the handle() method.")
+
+
+# --- The Requested POC Implementation ---
+
+class ClassifyTask(Queue):
+ queue: str = 'default'
+ timeout: int = 60 # Default timeout in seconds
+ max_exceptions: int = 3 # Maximum number of exceptions before task is marked as failed
+
+ tries: int | None = None
+ rate_limit: str | None = '20/m'
+ backoff: int | list[int] = 5
+
+ @property
+ def retry_until(self):
+ return now().add(minutes=1)
+
+ def __init__(self, user_id: int):
+ self.user_id = user_id
+
+ async def handle(self):
+ print(f"[Worker] Classifying data for user_id: {self.user_id}")
+ print(f"[Worker] Task settings: queue={self.queue}, timeout={self.timeout}")
+ # Simulate some work
+ await asyncio.sleep(0.1)
+ print(f"[Worker] Finished processing for user_id: {self.user_id}")
+
+
+# --- Running the POC ---
+
+async def main():
+ # 1. Setup a broker (using InMemoryBroker for testing)
+ broker = InMemoryBroker()
+
+ # 2. Bind the task class to the broker
+ # In a real app, you might automate this for all Queue subclasses
+ ClassifyTask.bind(broker)
+
+ # 3. Startup the broker (simulating worker/client initialization)
+ await broker.startup()
+
+ # 4. Dispatch a task
+ # This now uses the class method directly
+ handle = await ClassifyTask.dispatch(user_id=42)
+
+ print(f"Task dispatched! Task ID: {handle.task_id}")
+
+ # 5. Wait for the result (since it's an InMemoryBroker, it runs immediately)
+ result = await handle.wait_result(timeout=2)
+ print(f"Task result: {result.return_value}")
+
+ await broker.shutdown()
+
+if __name__ == "__main__":
+ asyncio.run(main())