Skip to content

Commit

Permalink
Refine internals and fix tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
jaspertey committed Apr 7, 2024
1 parent 1d2971e commit 374af43
Show file tree
Hide file tree
Showing 12 changed files with 255 additions and 68 deletions.
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

All notable changes to `laravel-ddd` will be documented in this file.

## [Unversioned]
### Added
- Add `ddd:class` generator extending Laravel's `make:class` (Laravel 11 only).
- Add `ddd:interface` generator extending Laravel's `make:interface` (Laravel 11 only).
- Add `ddd:trait` generator extending Laravel's `make:trait` (Laravel 11 only).
- Allow overriding configured namespaces at runtime by specifying an absolute name starting with /:
```bash
# Generate a provider in the default configured namespace
# -> Domain\Invoicing\Providers\InvoiceServiceProvider
php artisan ddd:provider Invoicing:InvoiceServiceProvider

# Override the configured namespace at runtime
# -> Domain\Invoicing\InvoiceServiceProvider
php artisan ddd:provider Invoicing:/InvoiceServiceProvider

# Can be deeply nested if desired
# -> Domain\Invoicing\Models\Exceptions\InvoiceNotFoundException
php artisan ddd:exception Invoicing:/Models/Exceptions/InvoiceNotFoundException
```

### Changed
- Internals: Handle a variety of additional edge cases when generating base models and base view models.

## [1.0.0] - 2024-03-31
### Added
- `ddd:list` to show a summary of current domains in the domain folder.
Expand Down
26 changes: 23 additions & 3 deletions src/Commands/Concerns/ResolvesDomainFromInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ trait ResolvesDomainFromInput
{
use CanPromptForDomain;

protected $nameIsAbsolute = false;

protected ?Domain $domain = null;

protected function getOptions()
Expand Down Expand Up @@ -41,7 +43,9 @@ protected function guessObjectType(): string
protected function getDefaultNamespace($rootNamespace)
{
if ($this->domain) {
return $this->domain->namespaceFor($this->guessObjectType());
return $this->nameIsAbsolute
? $this->domain->namespace->root
: $this->domain->namespaceFor($this->guessObjectType());
}

return parent::getDefaultNamespace($rootNamespace);
Expand All @@ -51,7 +55,11 @@ protected function getPath($name)
{
if ($this->domain) {
return Path::normalize($this->laravel->basePath(
$this->domain->object($this->guessObjectType(), class_basename($name))->path
$this->domain->object(
type: $this->guessObjectType(),
name: $name,
absolute: $this->nameIsAbsolute
)->path
));
}

Expand All @@ -68,7 +76,7 @@ public function handle()

if (Str::contains($nameInput, ':')) {
$domainExtractedFromName = Str::before($nameInput, ':');
$this->input->setArgument('name', Str::after($nameInput, ':'));
$nameInput = Str::after($nameInput, ':');
}

$this->domain = match (true) {
Expand All @@ -86,6 +94,18 @@ public function handle()
$this->domain = new Domain($this->promptForDomainName());
}

// Now that the domain part is handled,
// we will deal with the name portion.

// Normalize slash and dot separators
$nameInput = Str::replace(['.', '\\', '/'], '/', $nameInput);

if ($this->nameIsAbsolute = Str::startsWith($nameInput, ['/'])) {
// $nameInput = Str::after($nameInput, '/');
}

$this->input->setArgument('name', $nameInput);

parent::handle();
}
}
53 changes: 29 additions & 24 deletions src/Commands/DomainModelMakeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Lunarstorm\LaravelDDD\Commands;

use Illuminate\Support\Str;
use Lunarstorm\LaravelDDD\Support\DomainResolver;
use Symfony\Component\Console\Input\InputOption;

Expand Down Expand Up @@ -55,41 +56,45 @@ public function handle()

protected function createBaseModelIfNeeded()
{
$baseModel = config('ddd.base_model');

if (class_exists($baseModel)) {
if (! $this->shouldCreateModel()) {
return;
}

$this->warn("Configured base model {$baseModel} doesn't exist.");

// If the base model is out of scope, we won't attempt to create it
// because we don't want to interfere with external folders.
$allowedNamespacePrefixes = [
$this->rootNamespace(),
];
$baseModel = config('ddd.base_model');

if (! str($baseModel)->startsWith($allowedNamespacePrefixes)) {
return;
}
$this->warn("Base model {$baseModel} doesn't exist, generating...");

$domain = DomainResolver::guessDomainFromClass($baseModel);

if (! $domain) {
return;
}
$name = Str::after($baseModel, $domain);

$baseModelName = class_basename($baseModel);
$baseModelPath = $this->getPath($baseModel);
$this->call(DomainBaseModelMakeCommand::class, [
'--domain' => $domain,
'name' => $name,
]);
}

protected function shouldCreateModel(): bool
{
$baseModel = config('ddd.base_model');

if (! file_exists($baseModelPath)) {
$this->info("Generating {$baseModel}...");
// If the class exists, we don't need to create it.
if (class_exists($baseModel)) {
return false;
}

$this->call(DomainBaseModelMakeCommand::class, [
'--domain' => $domain,
'name' => $baseModelName,
]);
// If the class is outside of the domain layer, we won't attempt to create it.
if (! DomainResolver::isDomainClass($baseModel)) {
return false;
}

// At this point the class is probably a domain object, but we should
// check if the expected path exists.
if (file_exists(app()->basePath(DomainResolver::guessPathFromClass($baseModel)))) {
return false;
}

return true;
}

protected function createFactory()
Expand Down
2 changes: 1 addition & 1 deletion src/Commands/DomainViewModelMakeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public function handle()

$domain = DomainResolver::guessDomainFromClass($baseViewModel);

$name = Str::after($baseViewModel, "{$domain}\\");
$name = Str::after($baseViewModel, $domain);

$this->call(DomainBaseViewModelMakeCommand::class, [
'--domain' => $domain,
Expand Down
37 changes: 21 additions & 16 deletions src/Support/Domain.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,6 @@ public function __construct(string $domain, ?string $subdomain = null)
$this->path = Path::join(DomainResolver::domainPath(), $this->domainWithSubdomain);
}

protected function registerDomainObjects()
{
// WIP
}

protected function registerDomainObject()
{
// WIP
}

protected function getDomainBasePath()
{
return app()->basePath(DomainResolver::domainPath());
Expand Down Expand Up @@ -99,18 +89,33 @@ public function namespaceFor(string $type): string
return DomainResolver::getDomainObjectNamespace($this->domainWithSubdomain, $type);
}

public function object(string $type, string $name): DomainObject
public function guessNamespaceFromName(string $name): string
{
$baseName = class_basename($name);

return str($name)
->before($baseName)
->trim('\\')
->prepend(DomainResolver::domainRootNamespace().'\\'.$this->domainWithSubdomain.'\\')
->toString();
}

public function object(string $type, string $name, bool $absolute = false): DomainObject
{
$namespace = $this->namespaceFor($type);
$namespace = match (true) {
$absolute => $this->namespace->root,
str($name)->startsWith('\\') => $this->guessNamespaceFromName($name),
default => $this->namespaceFor($type),
};

$name = str($name)->replace("{$namespace}\\", '')->toString();
$baseName = str($name)->replace($namespace, '')->trim('\\')->toString();

return new DomainObject(
name: $name,
name: $baseName,
domain: $this->domain,
namespace: $namespace,
fullyQualifiedName: $namespace.'\\'.$name,
path: $this->path($namespace.'\\'.$name),
fullyQualifiedName: $namespace.'\\'.$baseName,
path: $this->path($namespace.'\\'.$baseName),
type: $type
);
}
Expand Down
6 changes: 5 additions & 1 deletion src/Support/DomainResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ public static function getRelativeObjectNamespace(string $type): string

public static function getDomainObjectNamespace(string $domain, string $type, ?string $object = null): string
{
$namespace = implode('\\', [static::domainRootNamespace(), $domain, static::getRelativeObjectNamespace($type)]);
$namespace = collect([
static::domainRootNamespace(),
$domain,
static::getRelativeObjectNamespace($type),
])->filter()->implode('\\');

if ($object) {
$namespace .= "\\{$object}";
Expand Down
39 changes: 33 additions & 6 deletions src/ValueObjects/DomainObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public static function fromClass(string $fullyQualifiedClass, ?string $objectTyp
: config('ddd.namespaces', []);

foreach ($possibleObjectNamespaces as $type => $namespace) {
if (blank($namespace)) {
continue;
}

$rootObjectNamespace = preg_quote($namespace);

$pattern = "/({$rootObjectNamespace})(.*)$/";
Expand All @@ -44,21 +48,27 @@ public static function fromClass(string $fullyQualifiedClass, ?string $objectTyp
continue;
}

$objectNamespace = str(data_get($matches, 0))->beforeLast('\\')->toString();
$objectNamespace = str(data_get($matches, 1))->toString();

$objectName = str(data_get($matches, 2))
->trim('\\')
->toString();

$objectType = $type;

break;
}

// If there wasn't a recognized namespace, we'll assume it's a
// domain object in an ad-hoc namespace.
// If there wasn't a resolvable namespace, we'll treat it
// as a root-level domain object.
if (! $objectNamespace) {
// e.g., Domain\Invoicing\AdHoc\Nested\Thing
$objectNamespace = str($fullyQualifiedClass)
// Examples:
// - Domain\Invoicing\[Nested\Thing]
// - Domain\Invoicing\[Deeply\Nested\Thing]
// - Domain\Invoicing\[Thing]
$objectName = str($fullyQualifiedClass)
->after(Str::finish(DomainResolver::domainRootNamespace(), '\\'))
->after('\\')
->before("\\{$objectName}")
->toString();
}

Expand All @@ -68,6 +78,14 @@ public static function fromClass(string $fullyQualifiedClass, ?string $objectTyp
->before("\\{$objectNamespace}")
->toString();

// Edge case to handle root-level domain objects
if (
$objectName === $objectNamespace
&& ! str($fullyQualifiedClass)->endsWith("{$objectNamespace}\\{$objectName}")
) {
$objectNamespace = '';
}

// Reconstruct the path
$path = Path::join(
DomainResolver::domainPath(),
Expand All @@ -76,6 +94,15 @@ public static function fromClass(string $fullyQualifiedClass, ?string $objectTyp
"{$objectName}.php",
);

// dump([
// 'fullyQualifiedClass' => $fullyQualifiedClass,
// 'fullNamespace' => $fullNamespace,
// 'domainName' => $domainName,
// 'objectNamespace' => $objectNamespace,
// 'objectName' => $objectName,
// 'objectType' => $objectType,
// ]);

return new self(
name: $objectName,
domain: $domainName,
Expand Down
50 changes: 50 additions & 0 deletions tests/Generator/AbsoluteNameTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Lunarstorm\LaravelDDD\Support\Path;

it('can override configured object namespace by using absolute dot-path', function ($type, $nameInput, $expectedNamespace, $expectedPath) {
if (in_array($type, ['class', 'enum', 'interface', 'trait'])) {
skipOnLaravelVersionsBelow('11');
}

$domainPath = 'src/Domain';
$domainRoot = 'Domain';

Config::set('ddd.domain_path', $domainPath);
Config::set('ddd.domain_namespace', $domainRoot);
Config::set("ddd.namespaces.{$type}", str($type)->headline()->plural()->toString());

$expectedFullPath = Path::normalize(base_path($expectedPath));

if (file_exists($expectedFullPath)) {
unlink($expectedFullPath);
}

expect(file_exists($expectedFullPath))->toBeFalse();

$command = "ddd:{$type} {$nameInput}";

Artisan::call($command);

$output = Artisan::output();

expect($output)->toContainFilepath($expectedPath);

expect(file_exists($expectedFullPath))->toBeTrue();

$contents = file_get_contents($expectedFullPath);

expect($contents)->toContain("namespace {$expectedNamespace};");
})->with([
'model' => ['model', 'Other:/MyModels/MyModel', 'Domain\Other\MyModels', 'src/Domain/Other/MyModels/MyModel.php'],
'model (without overriding)' => ['model', 'Other:MyModels/MyModel', 'Domain\Other\Models\MyModels', 'src/Domain/Other/Models/MyModels/MyModel.php'],
'exception inside models directory' => ['exception', 'Invoicing:/Models/Exceptions/InvoiceNotFoundException', 'Domain\Invoicing\Models\Exceptions', 'src/Domain/Invoicing/Models/Exceptions/InvoiceNotFoundException.php'],
'provider' => ['provider', 'Other:/RootLevelProvider', 'Domain\Other', 'src/Domain/Other/RootLevelProvider.php'],
'policy' => ['policy', 'Other:/RootLevelPolicy', 'Domain\Other', 'src/Domain/Other/RootLevelPolicy.php'],
'job' => ['job', 'Other:/Custom/Namespaced/Job', 'Domain\Other\Custom\Namespaced', 'src/Domain/Other/Custom/Namespaced/Job.php'],
'class' => ['class', 'Other:/Models/FakeModel', 'Domain\Other\Models', 'src/Domain/Other/Models/FakeModel.php'],
'class (subdomain)' => ['class', 'Other.Subdomain:/Models/FakeModel', 'Domain\Other\Subdomain\Models', 'src/Domain/Other/Subdomain/Models/FakeModel.php'],
'provider (subdomain)' => ['provider', 'Other.Subdomain:/RootLevelProvider', 'Domain\Other\Subdomain', 'src/Domain/Other/Subdomain/RootLevelProvider.php'],
]);
Loading

0 comments on commit 374af43

Please sign in to comment.