Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Domain autoloader #49

Merged
merged 29 commits into from
Mar 30, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5e32899
Merge pull request #47 from lunarstorm/dependabot/github_actions/depe…
JasperTey Mar 25, 2024
a0a5c06
First pass for domain autoloader
pelmered Mar 25, 2024
a418b1d
Merge branch 'next' into feature/domain-autoloader
pelmered Mar 25, 2024
242ef15
Fix config keys in autoloader
pelmered Mar 25, 2024
edf44d6
Fix cache + update changed method names after merge
pelmered Mar 25, 2024
5d67e04
Clear cache when you run cache:clear command
pelmered Mar 25, 2024
d2d5130
Fix factory config key (should be singular)
pelmered Mar 26, 2024
4ff70cb
Merge remote-tracking branch 'origin/next' into feature/domain-autolo…
JasperTey Mar 27, 2024
36a993a
Formatting (pint)
JasperTey Mar 27, 2024
da51867
Update return type in docblock to make phpstan happy.
JasperTey Mar 27, 2024
d808673
Relocate event subscriber into Listeners, following laravel's convent…
JasperTey Mar 27, 2024
e943554
Minor cleanup.
JasperTey Mar 27, 2024
4168b29
WIP
JasperTey Mar 28, 2024
94112cb
Fix styling
JasperTey Mar 28, 2024
c79cb8b
Initial autoload test coverage.
JasperTey Mar 28, 2024
0f2fc85
A few cleanups.
JasperTey Mar 28, 2024
bde21b0
Fix styling
JasperTey Mar 28, 2024
62d4bcb
Simplify autoload.service_providers to autoload.providers
JasperTey Mar 28, 2024
fea680f
Unused import.
JasperTey Mar 28, 2024
bd1a5fa
Bump Pest version.
JasperTey Mar 28, 2024
c6e096a
Cleanup.
JasperTey Mar 28, 2024
91fe23f
Update tests.
JasperTey Mar 28, 2024
89354b5
No need to keep iterating after the first match.
JasperTey Mar 28, 2024
cfb5073
Further refinements:
JasperTey Mar 30, 2024
cbdb804
Fix typos and match Laravel's cache:clear output formatting.
JasperTey Mar 30, 2024
73a27ac
Update expectations.
JasperTey Mar 30, 2024
4159b64
Update readme.
JasperTey Mar 30, 2024
b0232a4
Sync config content.
JasperTey Mar 30, 2024
4d25561
Ensure cache directory exists.
JasperTey Mar 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions config/ddd.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,47 @@
'resource' => 'Resources',
'rule' => 'Rules',
'scope' => 'Scopes',
'factory' => 'Database\Factories',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on simplifying this namespace to Factories instead of Database\Factories? Or is this following a certain pattern?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is where I have been placing my factories, and from what I have seen in other projects that seems pretty common, although from a small sample size. The Database folder usually also contain folders for migrations and seeders for that domain, just like the /database folder in a vanilla Laravel app.

I think that is a sensible default, but because this is easy configure how you want it I have no strong feelings. I would be fine with Factories as well, and that looks nicer/cleaner in the config.

],

/*
* The folder where the domain cache files will be stored.
*/
'cache_directory' => 'bootstrap/cache',

'autoload' => [
/*
| Autoload service providers from the domain namespace.
| By default, it loads any file that ends with 'ServiceProvider.php' inside your domain.
| For example: Domain/Invoicing/Providers/InvoicingServiceProvider.php or Domain/Invoicing/InvoicingServiceProvider.php
*/
// To customize the pattern, you can use a glob pattern like '*/Providers/*.php'
'service_providers' => '*/*ServiceProvider.php',

/*
| Autoload commands from the domain namespace.
| By default, it loads any file inside the /Commands folder that ends '.php', extends Illuminate\Console\Command and is not abstract.
| For example: Domain/Invoicing/Commands/CreateInvoiceCommand.php
*/
// To customize the pattern, you can use a glob pattern like '*/Commands/*.php'
'commands' => '*/Commands/*.php',

/*
| Autoload policies from the domain namespace.
| By default, it loads any file inside the /Policies folder that ends 'Policy.php' and is not abstract.
| For example: Domain/Invoicing/Policies/InvoicePolicy.php
*/
'policies' => 'Policies\\{model}Policy',

/*
| Autoload factories from the domain namespace.
| By default, it loads any file inside the /Database/Factories folder that ends 'Factory.php' and is not abstract.
| For example: Domain/Invoicing/Database/Factories/InvoiceFactory.php
*/
'factories' => 'Database\\Factories\\{model}Factory',
],
Copy link
Member

@JasperTey JasperTey Mar 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering, are these autoload config strings necessary, now that ddd.namespaces.provider, ddd.namespaces.command, ddd.namespaces.policy, and ddd.namespaces.factory exist? All these patterns can be derived based on whatever is configured in ddd.namespaces.*.

Instead, each ddd.autoload.* can be simplified to booleans only, i.e., whether you want to opt into the autoload behaviour or not. What do you think?

Copy link
Contributor Author

@pelmered pelmered Mar 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I actually made them as booleans first, but I thought it would be good to be able to customize. It also still works with booleans,. With true it will load from the default location. But I agree, true should probably be the default in the config file.
I was also thinking about supporting to pass an array there if you want to load from multiple locations inside each domain. For example:

'service_providers' => [
    '*/*ServiceProvider.php',
    '*/Providers/*.php',
],

That might be a bit over-engineered though. What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into how lorisleiva/laravel-actions implements discovery (in their case console commands), and saw that they use https://github.com/lorisleiva/lody. Extremely powerful and greatly simplifies things. It then allows the config to specify folders only (not patterns), and it can take care of the rest.

e.g.,

$finder = Finder::create()->files()->in($paths);

return Lody::classesFromFinder($finder)
    ->isNotAbstract()
    ->isInstanceOf(ServiceProvider::class)
    ->toArray();

I will push my updates soon and summarize everything in more detail there.



/*
|--------------------------------------------------------------------------
| Base Model
Expand Down
18 changes: 18 additions & 0 deletions src/LaravelDDDServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

namespace Lunarstorm\LaravelDDD;

use Illuminate\Support\Facades\Event;
use Lunarstorm\LaravelDDD\Commands\InstallCommand;
use Lunarstorm\LaravelDDD\Commands\MakeAction;
use Lunarstorm\LaravelDDD\Commands\MakeBaseModel;
use Lunarstorm\LaravelDDD\Commands\MakeBaseViewModel;
use Lunarstorm\LaravelDDD\Commands\MakeDTO;
use Lunarstorm\LaravelDDD\Commands\MakeFactory;
use Lunarstorm\LaravelDDD\Commands\MakeModel;
use Lunarstorm\LaravelDDD\Commands\MakeValueObject;
use Lunarstorm\LaravelDDD\Commands\MakeViewModel;
use Lunarstorm\LaravelDDD\Support\CacheClearSubscriber;
use Lunarstorm\LaravelDDD\Support\DomainAutoloader;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;

Expand Down Expand Up @@ -57,4 +69,10 @@ public function packageBooted()
$this->package->basePath('/../stubs') => resource_path("stubs/{$this->package->shortName()}"),
], "{$this->package->shortName()}-stubs");
}

public function packageRegistered()
{
(new DomainAutoloader())->autoload();
Event::subscribe(CacheClearSubscriber::class);
}
}
40 changes: 40 additions & 0 deletions src/Support/CacheClearSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php
namespace Lunarstorm\LaravelDDD\Support;

use ErrorException;
use Illuminate\Events\Dispatcher;

class CacheClearSubscriber
{
public function __construct()
{
}

public function handle(): void
{
$files = glob(base_path(config('ddd.cache_directory').'/ddd-*.php'));

foreach ($files as $file) {
try {
unlink($file);
} catch (ErrorException $exception) {
if (! str_contains($exception->getMessage(), 'No such file or directory')) {
dump($exception->getMessage());
throw $exception;
}
}
}
}

/**
* Register the listeners for the subscriber.
*
* @param Dispatcher $events
*
* @return array
*/
public function subscribe(Dispatcher $events): void
{
$events->listen('cache:clearing', [$this, 'handle']);
}
}
175 changes: 175 additions & 0 deletions src/Support/DomainAutoloader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<?php
namespace Lunarstorm\LaravelDDD\Support;

use Illuminate\Console\Application as ConsoleApplication;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use ReflectionClass;

class DomainAutoloader
{
protected string $cacheDirectory;

protected mixed $config;

public function __construct()
{
$this->config = config('ddd');
$this->cacheDirectory = $this->config['cache_directory'] ?? 'bootstrap/cache/ddd';
}

public function autoload(): void
{
if(isset($this->config['autoload']['service_providers'])) {
$this->registerDomainServiceProviders($this->config['autoload']['service_providers']);
}
if(isset($this->config['autoload']['commands'])) {
$this->registerDomainCommands($this->config['autoload']['commands']);
}
if(isset($this->config['autoload']['policies'])) {
$this->registerPolicies($this->config['autoload']['policies']);
}
if(isset($this->config['autoload']['factories'])) {
$this->registerFactories($this->config['autoload']['factories']);
}
}

protected function registerDomainServiceProviders(bool|string $domainPath = null): void
{
$domainPath = is_string($domainPath) ? $domainPath : '*/*ServiceProvider.php';

$serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($domainPath){
return Arr::map(
glob(base_path(DomainResolver::domainPath().'/'.$domainPath)),
(static function ($serviceProvider) {

return Path::filePathToNamespace(
$serviceProvider,
DomainResolver::domainPath(),
DomainResolver::domainRootNamespace()
);
}));
});

$app = app();
foreach ($serviceProviders as $serviceProvider) {
$app->register($serviceProvider);
}
}

protected function registerDomainCommands(bool|string $domainPath = null): void
{
$domainPath = is_string($domainPath) ? $domainPath : '*/Commands/*.php';
$commands = $this->remember('ddd-domain-commands', static function () use ($domainPath){
$commands = Arr::map(
glob(base_path(DomainResolver::domainPath().'/'.$domainPath)),
static function ($command) {
return Path::filePathToNamespace(
$command,
DomainResolver::domainPath(),
DomainResolver::domainRootNamespace()
);
});

// Filter out invalid commands (Abstract classes and classes not extending Illuminate\Console\Command)
return Arr::where($commands, static function($command) {
if (is_subclass_of($command, Command::class) &&
! (new ReflectionClass($command))->isAbstract()) {
ConsoleApplication::starting(static function ($artisan) use ($command): void {
$artisan->resolve($command);
});
}
});
});
ConsoleApplication::starting(static function ($artisan) use ($commands): void {
foreach ($commands as $command) {
$artisan->resolve($command);
}
});
}

protected function registerPolicies(bool|string $domainPath = null): void
{
$domainPath = is_string($domainPath) ? $domainPath : 'Policies\\{model}Policy';

Gate::guessPolicyNamesUsing(static function (string $modelClass) use ($domainPath): ?string {

[$domain, $model] = static::extractDomainAndModelFromModelNamespace($modelClass);

if (is_null($domain)) {
return null;
}

$policy = DomainResolver::domainRootNamespace().'\\'.$domain.'\\'.str_replace('{model}', $model, $domainPath);

return $policy;
});
}

protected function registerFactories(bool|string $domainPath = null): void
{
$domainPath = is_string($domainPath) ? $domainPath : 'Database\\Factories\\{model}Factory';

Factory::guessFactoryNamesUsing( function (string $modelClass) use ($domainPath){

[$domain, $model] = $this->extractDomainAndModelFromModelNamespace($modelClass);

if (is_null($domain)) {
return null;
}

// Look for domain model factory in \<DomainNamespace>\Database\\Factories\<model>Factory.php
$classPath = 'Domain\\'.$domain.'\\'.str_replace('{model}', $model, $domainPath);
if (class_exists($classPath)) {
return $classPath;
}

// Look for domain factory in /database/factories/<domain>/<model>Factory.php
$classPath = 'Database\\Factories\\'.$domain.'\\'.$model.'Factory';
if (class_exists($classPath)) {
return $classPath;
}

// Default Laravel factory location
return 'Database\Factories\\'.class_basename($modelClass).'Factory';
});
}

protected function extractDomainAndModelFromModelNamespace(string $modelName): array
{
// Matches <DomainNamespace>\{domain}\<ModelNamespace>\{model} and extracts domain and model
// For example: Domain\Invoicing\Models\Invoice gives ['domain' => 'Invoicing', 'model' => 'Invoice']
$regex = '/'.DomainResolver::domainRootNamespace().'\\\\(?<domain>.+)\\\\'.$this->config['namespaces.models'].'\\\\(?<model>.+)/';

if (preg_match($regex, $modelName, $matches, PREG_OFFSET_CAPTURE, 0)) {
return [
'domain' => $matches['domain'][0],
'model' => $matches['model'][0]
];
}

return [];
}

protected function remember($fileName, $callback)
{
// The cache is not available during booting, so we need to roll our own file based cache
$cacheFilePath = base_path($this->cacheDirectory.'/'.$fileName.'.php');

$data = file_exists($cacheFilePath) ? include $cacheFilePath : null;

if (is_null($data)) {
$data = $callback();

file_put_contents(
$cacheFilePath,
'<?php ' . PHP_EOL . 'return ' . var_export($data, true) . ';'
);
}

return $data;
}
}
9 changes: 9 additions & 0 deletions src/Support/Path.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,13 @@ public static function join(...$parts)

return implode(DIRECTORY_SEPARATOR, $parts);
}

public static function filePathToNamespace(string $path, string $namespacePath, string $namespace): string
{
return str_replace(
[base_path().'/'.$namespacePath, '/', '.php'],
[$namespace, '\\', ''],
$path
);
}
}
8 changes: 8 additions & 0 deletions tests/Autoloader/AutoloadServiceProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Lunarstorm\LaravelDDD\Tests\Fixtures\Enums\Feature;