diff --git a/pyproject.toml b/pyproject.toml index cdc68b5f..cf50931a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "bcrypt>=4,<6", "whitenoise>=6.6,<7", "python-dotenv>=1,<2", - "masonite-framework-orm>=3.1,<4", + "masonite-framework-orm[seeder]>=3.1,<4", "sqids>=0.5,<1", "cryptography>=46.0.7,<49", "tldextract>=5,<6", diff --git a/src/masonite/commands/MakeFactoryCommand.py b/src/masonite/commands/MakeFactoryCommand.py new file mode 100644 index 00000000..c879f924 --- /dev/null +++ b/src/masonite/commands/MakeFactoryCommand.py @@ -0,0 +1,56 @@ +"""New Factory Command.""" +import inflection +import os + +from ..utils.filesystem import make_directory, render_stub_file, get_module_dir +from ..utils.location import factories_path, models_path +from .Command import Command + + +class MakeFactoryCommand(Command): + """ + Creates a new model factory. + + factory + {name : Name of the factory} + {--m|model=? : The model this factory builds, defaults to the factory name} + {--f|force=? : Force overriding file if already exists} + """ + + def __init__(self, application): + super().__init__() + self.app = application + + def handle(self): + name = inflection.camelize(self.argument("name")) + if not name.endswith("Factory"): + name += "Factory" + + model = inflection.camelize(self.option("model") or name[: -len("Factory")]) + model_module = ( + models_path(model, absolute=False).replace("/", ".").replace("\\", ".") + ) + + content = render_stub_file(self.get_factories_path(), name) + content = content.replace("__model_module__", model_module) + content = content.replace("__model__", model) + + filename = f"{name}.py" + filepath = factories_path(filename) + make_directory(filepath) + if os.path.exists(filepath) and not self.option("force"): + self.warning( + f"{filepath} already exists! Run the command with -f (force) to override." + ) + return -1 + with open(filepath, "w") as f: + f.write(content) + + # add class to __init__.py + with open(os.path.join(os.path.dirname(filepath), "__init__.py"), "a") as f: + f.write(f"from .{name} import {name}\n") + + self.info(f"Factory Created ({factories_path(filename, absolute=False)})") + + def get_factories_path(self): + return os.path.join(get_module_dir(__file__), "../stubs/factories/Factory.py") diff --git a/src/masonite/commands/__init__.py b/src/masonite/commands/__init__.py index 8d5d66f0..1f09aec5 100644 --- a/src/masonite/commands/__init__.py +++ b/src/masonite/commands/__init__.py @@ -11,6 +11,7 @@ from .MakeJobCommand import MakeJobCommand from .MakeRequestCommand import MakeRequestCommand from .MakeMailableCommand import MakeMailableCommand +from .MakeFactoryCommand import MakeFactoryCommand from .MakeProviderCommand import MakeProviderCommand from .PublishPackageCommand import PublishPackageCommand from .MakePolicyCommand import MakePolicyCommand diff --git a/src/masonite/foundation/Kernel.py b/src/masonite/foundation/Kernel.py index 9ce7eb52..bc77e972 100644 --- a/src/masonite/foundation/Kernel.py +++ b/src/masonite/foundation/Kernel.py @@ -37,6 +37,7 @@ def register_commands(self) -> None: MakeJobCommand, MakeRequestCommand, MakeMailableCommand, + MakeFactoryCommand, MakeProviderCommand, PublishPackageCommand, MakeTestCommand, @@ -62,6 +63,7 @@ def register_commands(self) -> None: MakeJobCommand(self.application), MakeRequestCommand(self.application), MakeMailableCommand(self.application), + MakeFactoryCommand(self.application), MakeProviderCommand(self.application), PublishPackageCommand(self.application), MakeTestCommand(self.application), diff --git a/src/masonite/skeleton/app/Kernel.py b/src/masonite/skeleton/app/Kernel.py index f0684e7b..8792617c 100644 --- a/src/masonite/skeleton/app/Kernel.py +++ b/src/masonite/skeleton/app/Kernel.py @@ -114,6 +114,7 @@ def register_database(self): self.application.bind("migrations.location", "databases/migrations") self.application.bind("seeds.location", "databases/seeds") + self.application.bind("factories.location", "databases/factories") self.application.bind("resolver", config("database.db")) diff --git a/src/masonite/skeleton/databases/factories/__init__.py b/src/masonite/skeleton/databases/factories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/masonite/stubs/factories/Factory.py b/src/masonite/stubs/factories/Factory.py new file mode 100644 index 00000000..00a4471f --- /dev/null +++ b/src/masonite/stubs/factories/Factory.py @@ -0,0 +1,21 @@ +from masoniteorm.factories import Factory + +from __model_module__ import __model__ + + +class __class__: + """Factory for the __model__ model. + + Call `__class__.register()` once during app bootstrap (for example + from a service provider's `boot` method) to make this factory + available to `Factory(__model__).create()` / `.make()` in tests and + seeders. + """ + + @staticmethod + def register(): + Factory.register(__model__, __class__.definition) + + @staticmethod + def definition(faker): + return {} diff --git a/src/masonite/utils/location.py b/src/masonite/utils/location.py index aa01b231..31a4a8b7 100644 --- a/src/masonite/utils/location.py +++ b/src/masonite/utils/location.py @@ -96,3 +96,11 @@ def models_path(relative_path="", absolute=True): The relative path can be returned instead by setting absolute=False.""" return _build_path("models.location", relative_path, absolute) + + +def factories_path(relative_path="", absolute=True): + """Build the absolute path to the project factories directory or build the absolute path to a given + file relative to the project factories directory. + + The relative path can be returned instead by setting absolute=False.""" + return _build_path("factories.location", relative_path, absolute) diff --git a/tests/core/utils/test_location.py b/tests/core/utils/test_location.py index 767da4af..c7c82f3e 100644 --- a/tests/core/utils/test_location.py +++ b/tests/core/utils/test_location.py @@ -11,6 +11,7 @@ jobs_path, resources_path, models_path, + factories_path, ) @@ -115,3 +116,19 @@ def test_models_path(self): self.assertEqual("tests/integrations/app/admin/Log.py", location) location = models_path(absolute=False) self.assertEqual(location, "tests/integrations/app/") + + def test_factories_path(self): + location = factories_path("UserFactory.py") + self.assertTrue( + location.endswith("tests/integrations/databases/factories/UserFactory.py") + ) + location = factories_path("package/PostFactory.py") + self.assertTrue( + location.endswith( + "tests/integrations/databases/factories/package/PostFactory.py" + ) + ) + location = factories_path("UserFactory.py", absolute=False) + self.assertEqual( + "tests/integrations/databases/factories/UserFactory.py", location + ) diff --git a/tests/features/factories/test_make_factory_command.py b/tests/features/factories/test_make_factory_command.py new file mode 100644 index 00000000..78c7b362 --- /dev/null +++ b/tests/features/factories/test_make_factory_command.py @@ -0,0 +1,127 @@ +import importlib +import os + +from masoniteorm.factories import Factory + +from tests import TestCase +from tests.integrations.app.User import User +from src.masonite.utils.location import factories_path + + +class TestMakeFactoryCommand(TestCase): + def _cleanup(self, target, init_file, original_init): + if os.path.exists(target): + os.remove(target) + with open(init_file, "w") as f: + f.write(original_init) + + def test_command_creates_a_factory(self): + init_file = factories_path("__init__.py") + original_init = "" + if os.path.exists(init_file): + with open(init_file) as f: + original_init = f.read() + + target = factories_path("FakeGeneratedFactory.py") + try: + self.craft("factory", "FakeGenerated") + self.assertTrue(os.path.exists(target)) + with open(target) as f: + content = f.read() + self.assertIn("class FakeGeneratedFactory:", content) + self.assertIn( + "from tests.integrations.app.FakeGenerated import FakeGenerated", + content, + ) + self.assertIn("Factory.register(FakeGenerated,", content) + finally: + self._cleanup(target, init_file, original_init) + + def test_model_option_overrides_inferred_model(self): + init_file = factories_path("__init__.py") + original_init = "" + if os.path.exists(init_file): + with open(init_file) as f: + original_init = f.read() + + target = factories_path("PostFactory.py") + try: + self.craft("factory", "Post --model=Article") + with open(target) as f: + content = f.read() + self.assertIn( + "from tests.integrations.app.Article import Article", content + ) + self.assertIn("Factory.register(Article,", content) + finally: + self._cleanup(target, init_file, original_init) + + def test_force_option_required_to_overwrite(self): + init_file = factories_path("__init__.py") + original_init = "" + if os.path.exists(init_file): + with open(init_file) as f: + original_init = f.read() + + target = factories_path("FakeGeneratedFactory.py") + try: + self.craft("factory", "FakeGenerated") + self.craft("factory", "FakeGenerated").assertOutputContains( + "already exists" + ) + self.craft("factory", "FakeGenerated --force").assertSuccess() + finally: + self._cleanup(target, init_file, original_init) + + def test_generated_factory_can_make_and_create_real_records(self): + """End-to-end: generate a factory, import it for real, and confirm it + actually produces (and persists) model instances via masoniteorm's + Factory engine -- not just that the file's text looks right.""" + init_file = factories_path("__init__.py") + original_init = "" + if os.path.exists(init_file): + with open(init_file) as f: + original_init = f.read() + + target = factories_path("UserFactory.py") + created_id = None + try: + self.craft("factory", "User").assertSuccess() + + with open(target) as f: + content = f.read() + content = content.replace( + "return {}", + "return {\n" + ' "name": faker.name(),\n' + ' "email": faker.unique.email(),\n' + ' "password": "secret",\n' + " }", + ) + with open(target, "w") as f: + f.write(content) + + module = importlib.import_module( + "tests.integrations.databases.factories.UserFactory" + ) + importlib.reload(module) + module.UserFactory.register() + + made = Factory(User).make() + self.assertIsInstance(made, User) + self.assertTrue(made.name) + # make() only builds the in-memory model, it never touches the + # database, so no primary key has been assigned yet. + self.assertNotIn("id", made.__attributes__) + + created = Factory(User).create() + created_id = created.id + self.assertIsNotNone(created.id) + + found = User.where("id", created.id).first() + self.assertIsNotNone(found) + self.assertEqual(found.email, created.email) + finally: + if created_id is not None: + User.where("id", created_id).delete() + self._cleanup(target, init_file, original_init) diff --git a/tests/integrations/app/Kernel/Kernel.py b/tests/integrations/app/Kernel/Kernel.py index c2957a1c..535f68f0 100644 --- a/tests/integrations/app/Kernel/Kernel.py +++ b/tests/integrations/app/Kernel/Kernel.py @@ -116,6 +116,9 @@ def register_database(self): "migrations.location", "tests/integrations/databases/migrations" ) self.application.bind("seeds.location", "tests/integrations/databases/seeds") + self.application.bind( + "factories.location", "tests/integrations/databases/factories" + ) self.application.bind("resolver", config("database.db")) diff --git a/tests/integrations/databases/factories/__init__.py b/tests/integrations/databases/factories/__init__.py new file mode 100644 index 00000000..e69de29b