From 9343dfeb8abbcb10a9e92e273d2df410c34f902d Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Tue, 6 Oct 2026 07:28:11 -0400 Subject: [PATCH] Vendor-scope the Str macros and add a registry-free Identifiers API Str's macro registry is one flat static map keyed by name, so the bare Str::sku(), Str::orderNumber() and Str::ticketNumber() could be silently replaced by any other package or the host application. Worse, the model generators called those macros, so such a registration changed every SKU, order number and ticket number the traits wrote. Supports\Identifiers now holds the logic as a final class with no registry in the path, and the shipped generators call it directly. The macros are registered as Str::simtabiLacommerce{Sku,OrderNumber,TicketNumber}() with the bare signatures. The bare names stay registered as deprecated aliases that forward with the same arguments and output and raise one E_USER_DEPRECATED per name per boot; register_legacy_macros (default true) lets a host stop registering them. Earliest removal: 0.2.0. --- CHANGELOG.md | 21 +++ README.md | 10 ++ UPGRADING.md | 19 +++ config/config.php | 16 +++ docs/configuration.md | 5 +- docs/tools/generators.md | 46 ++++++- src/Generators/Services/Generator.php | 33 ++++- src/Providers/LacommerceServiceProvider.php | 45 ++----- src/Supports/Identifiers.php | 55 ++++++++ src/Supports/StrMacros.php | 123 ++++++++++++++++++ ...LegacyStrMacrosConfiguredSeparatorTest.php | 46 +++++++ tests/Feature/LegacyStrMacrosOptOutTest.php | 57 ++++++++ tests/Feature/LegacyStrMacrosTest.php | 108 +++++++++++++++ tests/Feature/ScopedStrMacrosTest.php | 103 +++++++++++++++ tests/Unit/IdentifiersTest.php | 59 +++++++++ 15 files changed, 709 insertions(+), 37 deletions(-) create mode 100644 src/Supports/Identifiers.php create mode 100644 src/Supports/StrMacros.php create mode 100644 tests/Feature/LegacyStrMacrosConfiguredSeparatorTest.php create mode 100644 tests/Feature/LegacyStrMacrosOptOutTest.php create mode 100644 tests/Feature/LegacyStrMacrosTest.php create mode 100644 tests/Feature/ScopedStrMacrosTest.php create mode 100644 tests/Unit/IdentifiersTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5b76d..b76bc9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- `Simtabi\Lacommerce\Supports\Identifiers`: `sku()`, `orderNumber()` and `ticketNumber()` as a final class + that goes through no registry. It is now the primary way to generate a value without a model. +- Vendor-scoped `Str` macros `Str::simtabiLacommerceSku()`, `Str::simtabiLacommerceOrderNumber()` and + `Str::simtabiLacommerceTicketNumber()`, taking the same arguments as the bare macros they replace. +- Config key `register_legacy_macros` (default `true`). Set it to `false` to stop registering the bare macros. + +### Changed + +- The `HasSku`, `HasOrderNumber` and `HasTicketNumber` generators call `Identifiers` directly instead of the + bare `Str` macro. A package or application that registers its own `Str::sku()` no longer changes what + your models generate. A custom generator naming another `$strMixin` still has that macro called. + +### Deprecated + +- The bare macros `Str::sku()`, `Str::orderNumber()` and `Str::ticketNumber()`. `Str`'s macro registry is one + flat map, so any other registration of those names silently replaces them. They still work, with the + same arguments and output, forwarding to the scoped macros, and raise one `E_USER_DEPRECATED` per name per + boot. Earliest removal: 0.2.0. + ## [0.1.0] - 2026-10-05 The first tagged release. An entry dated 2022-02-03 used to sit here as `0.1.0`, but no tag was ever diff --git a/README.md b/README.md index 57f78f6..f043f2b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,16 @@ class Order extends Model echo Order::create(['name' => 'Web order'])->order_number; // "ORD-3920571846" ``` +To generate a value without a model, call `Identifiers` directly: + +```php +use Simtabi\Lacommerce\Supports\Identifiers; + +Identifiers::sku('Laravel is Awesome'); // "LAR-8056449213" +Identifiers::orderNumber('INV', '/'); // "INV/3920571846" +Identifiers::ticketNumber('Support'); // "SUP-1749302865" +``` + The full walkthrough is in [docs/getting-started.md](docs/getting-started.md); everything else is in the [Documentation](#documentation) index. diff --git a/UPGRADING.md b/UPGRADING.md index 0d887bc..d2d7c7a 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1 +1,20 @@ # Upgrading + +## From 0.1.0 + +### The bare `Str` macros are deprecated + +`Str::sku()`, `Str::orderNumber()` and `Str::ticketNumber()` still work and are deprecated. Replace them with +`Simtabi\Lacommerce\Supports\Identifiers`, or rename them to the scoped macros, which take the same +arguments: + +| Before | After | +|--------|-------| +| `Str::sku($name)` | `Identifiers::sku($name)` or `Str::simtabiLacommerceSku($name)` | +| `Str::orderNumber(null, '-', 'INV')` | `Identifiers::orderNumber('INV', '-')` or `Str::simtabiLacommerceOrderNumber(null, '-', 'INV')` | +| `Str::ticketNumber($subject)` | `Identifiers::ticketNumber($subject)` or `Str::simtabiLacommerceTicketNumber($subject)` | + +`Identifiers` uses the separator you pass, and `-` when you pass none; the macros fall back to the +configured `generator.default.separator`. Once nothing calls the bare names, set `register_legacy_macros` to +`false` in `config/simtabi/lacommerce.php`. + diff --git a/config/config.php b/config/config.php index b21d2f6..8a2c485 100644 --- a/config/config.php +++ b/config/config.php @@ -7,6 +7,22 @@ return [ + /* + |-------------------------------------------------------------------------- + | Deprecated bare Str macros + |-------------------------------------------------------------------------- + | + | 0.1.0 registered Str::sku(), Str::orderNumber() and Str::ticketNumber(). + | Those names sit in Str's flat macro registry, where another package or + | your application can silently replace them. They still work and are + | deprecated: use Simtabi\Lacommerce\Supports\Identifiers, or the scoped + | Str::simtabiLacommerceSku() family. Set this to false to stop registering + | the bare names and leave them free. Earliest removal: 0.2.0. + | + */ + + 'register_legacy_macros' => true, + /* |-------------------------------------------------------------------------- | Generator settings diff --git a/docs/configuration.md b/docs/configuration.md index a2d3a60..7da4ac8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -5,11 +5,13 @@ and read from the `simtabi.lacommerce` config key. See the [Documentation index] ## Structure -Config lives under `generator`: a shared `default` block plus one block per generator (`sku`, +Config lives under `generator`, plus the top-level `register_legacy_macros` switch. `generator` holds a shared `default` block plus one block per generator (`sku`, `ticket_number`, `order_number`). ```php return [ + 'register_legacy_macros' => true, // register the deprecated bare Str macros + 'generator' => [ 'default' => [ 'separator' => '-', // separator between source parts @@ -43,6 +45,7 @@ return [ | Key | Purpose | |-----|---------| +| `register_legacy_macros` | Register the deprecated bare `Str::sku()`, `Str::orderNumber()` and `Str::ticketNumber()` (default `true`). Set `false` to leave those names free; see [Generators](tools/generators.md#str-macros). | | `generator.default.separator` | Joins multiple source values (default `-`). | | `generator.default.unique` | Enforce the generated value is unique. | | `generator.default.generate_on_create` | Generate when the model is created. | diff --git a/docs/tools/generators.md b/docs/tools/generators.md index 71493d3..d5ca004 100644 --- a/docs/tools/generators.md +++ b/docs/tools/generators.md @@ -33,7 +33,51 @@ echo $product->sku; // "LAR-8056449213" The trait registers model-event listeners for the destination field, generated every time you save the model. If you plan to overwrite values manually, add the destination column to the model's `$fillable`. -`Illuminate\Support\Str::sku()` is also registered, so you can generate a value directly. +## Generating a value directly + +`Simtabi\Lacommerce\Supports\Identifiers` generates a value without a model. It is a final class of three +static methods, and it goes through no registry, so nothing another package or your application registers +can change what it returns: + +```php +use Simtabi\Lacommerce\Supports\Identifiers; + +Identifiers::sku('Laravel is Awesome'); // "LAR-8056449213" +Identifiers::sku('Laravel is Awesome', '_'); // "LAR_8056449213" +Identifiers::orderNumber(); // "ORD-3920571846" +Identifiers::orderNumber('INV', '/'); // "INV/3920571846" +Identifiers::ticketNumber('Support request'); // "SUP-1749302865" +``` + +| Method | Returns | +|--------|---------| +| `sku(string $source, string $separator = '-')` | First three characters of the studly-cased source, the separator, ten random digits, upper-cased. | +| `orderNumber(?string $prefix = null, string $separator = '-')` | The prefix (`ORD` when empty), the separator, ten random digits, upper-cased. | +| `ticketNumber(string $source, string $separator = '-')` | As `sku()`. | + +The separator is used as given. The traits pass the configured `generator.default.separator`. A value from +`Identifiers` is not checked against your table; uniqueness is enforced by the traits' generators. + +### `Str` macros + +The same three are registered on `Illuminate\Support\Str` under vendor-and-package names. They read the +configured separator when you pass none: + +| Macro | Forwards to | +|-------|-------------| +| `Str::simtabiLacommerceSku(string $source, ?string $separator = null)` | `Identifiers::sku()` | +| `Str::simtabiLacommerceOrderNumber(?string $source, ?string $separator = null, ?string $prefix = null)` | `Identifiers::orderNumber($prefix, …)`; `$source` is ignored | +| `Str::simtabiLacommerceTicketNumber(string $source, ?string $separator = null)` | `Identifiers::ticketNumber()` | + +Each takes the same arguments as the bare macro it replaces, so migrating is a rename. + +> The bare `Str::sku()`, `Str::orderNumber()` and `Str::ticketNumber()` from 0.1.0 are deprecated. +> `Str`'s macros are one flat map keyed by name, so another package or your application registering +> `Str::sku()` silently replaces this one, or this one replaces theirs. They still work, forward to the +> scoped macros with the same arguments, and raise one `E_USER_DEPRECATED` per name per boot. Set +> `register_legacy_macros` to `false` to stop registering them and leave the names free. The earliest release +> that could remove them is 0.2.0. Since this release, the traits no longer call any `Str` macro for the three +> shipped generators, so overriding `Str::sku()` does not change the SKUs your models get. ## Per-model configuration diff --git a/src/Generators/Services/Generator.php b/src/Generators/Services/Generator.php index a9df9cf..ea4fce2 100644 --- a/src/Generators/Services/Generator.php +++ b/src/Generators/Services/Generator.php @@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; use Simtabi\Lacommerce\Generators\Services\Contracts\GeneratorInterface; +use Simtabi\Lacommerce\Providers\LacommerceServiceProvider; +use Simtabi\Lacommerce\Supports\Identifiers; class Generator implements Jsonable, Renderable, GeneratorInterface { @@ -89,7 +91,7 @@ protected function getSourceString(): string protected function generate(string $source, string $separator, bool $unique = false): string { // Make - $value = Str::{$this->strMixin}($source, $separator); + $value = $this->makeValue($source, $separator); // if we are forcing uniques, and it already exists, re-try if ($unique and $this->exists($value)) { @@ -99,6 +101,35 @@ protected function generate(string $source, string $separator, bool $unique = fa return $value; } + /** + * Make one candidate value. + * + * The three shipped generators call Identifiers directly rather than the `Str` macro named by + * `$strMixin`: Str's macro registry is a flat, host-owned map, so going through it let any package + * or application that registered its own `Str::sku()` replace what every model generated. A + * subclass naming any other `$strMixin` still has that macro called, as before. + * + * An empty separator falls back to the configured default, as the 0.1.0 macros did. + * + * @param string $source + * @param string $separator + * @return string + */ + protected function makeValue(string $source, string $separator): string + { + $separator = $separator ?: (string) config( + LacommerceServiceProvider::CONFIG_KEY . '.generator.default.separator', + Identifiers::DEFAULT_SEPARATOR, + ); + + return match ($this->strMixin) { + 'sku' => Identifiers::sku($source, $separator), + 'orderNumber' => Identifiers::orderNumber(null, $separator), + 'ticketNumber' => Identifiers::ticketNumber($source, $separator), + default => Str::{$this->strMixin}($source, $separator), + }; + } + /** * True if the value already exists in the DB. * diff --git a/src/Providers/LacommerceServiceProvider.php b/src/Providers/LacommerceServiceProvider.php index 8c26901..8d4d0d3 100644 --- a/src/Providers/LacommerceServiceProvider.php +++ b/src/Providers/LacommerceServiceProvider.php @@ -4,14 +4,13 @@ use Illuminate\Contracts\Foundation\CachesConfiguration; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; use Simtabi\Lacommerce\Generators\Concerns\OrderNumber\OrderNumberConfigs; use Simtabi\Lacommerce\Generators\Concerns\Sku\SkuConfigs; use Simtabi\Lacommerce\Generators\Concerns\TicketNumber\TicketNumberConfigs; use Simtabi\Lacommerce\Generators\Contracts\SkuGeneratorInterface; use Simtabi\Lacommerce\Generators\Contracts\TicketNumberGeneratorInterface; use Simtabi\Lacommerce\Generators\Contracts\OrderNumberGeneratorInterface; -use Simtabi\Lacommerce\Supports\Helpers; +use Simtabi\Lacommerce\Supports\StrMacros; class LacommerceServiceProvider extends ServiceProvider { @@ -206,39 +205,17 @@ protected function bindConfigs() } - private function registerStrMacros() + /** + * Register the vendor-scoped Str macros, and the deprecated bare ones unless the host opted out. + * + * The bodies moved to Supports\StrMacros, which forwards to Supports\Identifiers. + */ + private function registerStrMacros(): void { - - $defSeparator = config(self::CONFIG_KEY . '.generator.default.separator', '-'); - - Str::macro('sku', function (string $source, ?string $separator = null, ?string $prefix = null) use ($defSeparator){ - $separator = $separator ?: $defSeparator; - // Clean up the source - $source = Str::studly($source); - // Limit the source - $source = Str::limit($source, 3, ''); - // Add prefix @todo - - return Helpers::makeRandomString($source, $separator); - }); - - Str::macro('orderNumber', function (?string $source, ?string $separator = null, ?string $prefix = null) use ($defSeparator){ - $separator = $separator ?: $defSeparator; - $source = !empty($prefix) ? $prefix: 'ORD'; - - return Helpers::makeRandomString($source, $separator); - }); - - Str::macro('ticketNumber', function (string $source, ?string $separator = null, ?string $prefix = null) use ($defSeparator){ - $separator = $separator ?: $defSeparator; - // Clean up the source - $source = Str::studly($source); - // Limit the source - $source = Str::limit($source, 3, ''); - - return Helpers::makeRandomString($source, $separator); - }); - + StrMacros::register( + (string) config(self::CONFIG_KEY . '.generator.default.separator', '-'), + (bool) config(self::CONFIG_KEY . '.register_legacy_macros', true), + ); } /** diff --git a/src/Supports/Identifiers.php b/src/Supports/Identifiers.php new file mode 100644 index 0000000..e4e2ee6 --- /dev/null +++ b/src/Supports/Identifiers.php @@ -0,0 +1,55 @@ + the vendor-scoped macro it forwards to. + * + * @deprecated The bare macros shipped in 0.1.0 are kept so their callers keep working. Call + * Identifiers, or the scoped macros, instead. Earliest removal: 0.2.0. + */ + public const DEPRECATED_ALIASES = [ + 'sku' => self::SKU, + 'orderNumber' => self::ORDER_NUMBER, + 'ticketNumber' => self::TICKET_NUMBER, + ]; + + /** + * Bare name => the Identifiers method that replaces it, for the deprecation message. + */ + private const REPLACEMENTS = [ + 'sku' => 'sku', + 'orderNumber' => 'orderNumber', + 'ticketNumber' => 'ticketNumber', + ]; + + /** + * @var array bare names that have already raised their deprecation since the last + * register(), which runs once per application boot + */ + private static array $warned = []; + + /** + * @param string $defaultSeparator used when a caller passes no separator, or an empty one + * @param bool $registerLegacy whether to register the deprecated bare names + */ + public static function register(string $defaultSeparator, bool $registerLegacy = true): void + { + self::$warned = []; + + Str::macro(self::SKU, static function (string $source, ?string $separator = null, ?string $prefix = null) use ($defaultSeparator): string { + // $prefix is accepted and ignored, as it was by the 0.1.0 Str::sku() this replaces. + return Identifiers::sku($source, $separator ?: $defaultSeparator); + }); + + Str::macro(self::ORDER_NUMBER, static function (?string $source, ?string $separator = null, ?string $prefix = null) use ($defaultSeparator): string { + // $source is accepted and ignored, as it was by the 0.1.0 Str::orderNumber() this replaces. + return Identifiers::orderNumber($prefix, $separator ?: $defaultSeparator); + }); + + Str::macro(self::TICKET_NUMBER, static function (string $source, ?string $separator = null, ?string $prefix = null) use ($defaultSeparator): string { + // $prefix is accepted and ignored, as it was by the 0.1.0 Str::ticketNumber() this replaces. + return Identifiers::ticketNumber($source, $separator ?: $defaultSeparator); + }); + + if ($registerLegacy) { + foreach (array_keys(self::DEPRECATED_ALIASES) as $bare) { + Str::macro($bare, self::deprecatedAlias($bare)); + } + } + } + + /** + * Run a deprecated bare macro: raise its deprecation once per boot, then call the scoped macro. + * + * Public only because Macroable rebinds a macro closure to Str's scope, from which a private + * member of this class is unreachable. Not part of the package's API. + * + * @internal + * @deprecated Exists only for the bare macros. Earliest removal: 0.2.0. + * + * @param array $arguments + */ + public static function forwardDeprecated(string $bare, array $arguments): string + { + $scoped = self::DEPRECATED_ALIASES[$bare]; + + if (! isset(self::$warned[$bare])) { + self::$warned[$bare] = true; + + trigger_error(sprintf( + 'Str::%s() from simtabi/lacommerce is deprecated and may be removed in 0.2.0; ' + . 'use %s::%s() or Str::%s() instead.', + $bare, + Identifiers::class, + self::REPLACEMENTS[$bare], + $scoped, + ), E_USER_DEPRECATED); + } + + return Str::{$scoped}(...$arguments); + } + + private static function deprecatedAlias(string $bare): Closure + { + // Macroable binds the closure to Str's scope, so name this class rather than using self::. + return static fn (mixed ...$arguments): string => StrMacros::forwardDeprecated($bare, $arguments); + } +} diff --git a/tests/Feature/LegacyStrMacrosConfiguredSeparatorTest.php b/tests/Feature/LegacyStrMacrosConfiguredSeparatorTest.php new file mode 100644 index 0000000..1e13f91 --- /dev/null +++ b/tests/Feature/LegacyStrMacrosConfiguredSeparatorTest.php @@ -0,0 +1,46 @@ + $scoped]; + } + + #[Test] + public function the_bare_macros_use_the_configured_separator(): void + { + $this->assertMatchesRegularExpression('/^LAR\/\d{10}$/', @Str::sku('laravel')); + $this->assertMatchesRegularExpression('/^ORD\/\d{10}$/', @Str::orderNumber(null)); + $this->assertMatchesRegularExpression('/^SUP\/\d{10}$/', @Str::ticketNumber('support')); + } + + #[Test] + public function the_scoped_macros_use_the_configured_separator(): void + { + $this->assertMatchesRegularExpression('/^LAR\/\d{10}$/', Str::simtabiLacommerceSku('laravel')); + $this->assertMatchesRegularExpression('/^ORD\/\d{10}$/', Str::simtabiLacommerceOrderNumber(null)); + $this->assertMatchesRegularExpression('/^SUP\/\d{10}$/', Str::simtabiLacommerceTicketNumber('support')); + } +} diff --git a/tests/Feature/LegacyStrMacrosOptOutTest.php b/tests/Feature/LegacyStrMacrosOptOutTest.php new file mode 100644 index 0000000..dfc6dd0 --- /dev/null +++ b/tests/Feature/LegacyStrMacrosOptOutTest.php @@ -0,0 +1,57 @@ + false`: the bare names are left free for the host. + */ +final class LegacyStrMacrosOptOutTest extends TestCase +{ + protected function setUp(): void + { + Str::flushMacros(); + + parent::setUp(); + } + + protected function publishedConfig(): array + { + $scoped = self::defaultConfig(); + $scoped['register_legacy_macros'] = false; + + return ['simtabi.lacommerce' => $scoped]; + } + + #[Test] + public function the_bare_macros_are_not_registered(): void + { + $this->assertFalse(Str::hasMacro('sku')); + $this->assertFalse(Str::hasMacro('orderNumber')); + $this->assertFalse(Str::hasMacro('ticketNumber')); + } + + #[Test] + public function the_scoped_macros_are_still_registered(): void + { + $this->assertTrue(Str::hasMacro('simtabiLacommerceSku')); + $this->assertTrue(Str::hasMacro('simtabiLacommerceOrderNumber')); + $this->assertTrue(Str::hasMacro('simtabiLacommerceTicketNumber')); + } + + #[Test] + public function the_traits_still_generate_values_without_the_bare_macros(): void + { + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', DummyProduct::create(['name' => 'Laravel'])->sku); + $this->assertMatchesRegularExpression('/^ORD-\d{10}$/', DummyOrder::create(['name' => 'Blue shirt'])->order_number); + $this->assertMatchesRegularExpression('/^SUP-\d{10}$/', DummyTicket::create(['name' => 'Support'])->ticket_number); + } +} diff --git a/tests/Feature/LegacyStrMacrosTest.php b/tests/Feature/LegacyStrMacrosTest.php new file mode 100644 index 0000000..e37dfe5 --- /dev/null +++ b/tests/Feature/LegacyStrMacrosTest.php @@ -0,0 +1,108 @@ +assertTrue(Str::hasMacro('sku')); + $this->assertTrue(Str::hasMacro('orderNumber')); + $this->assertTrue(Str::hasMacro('ticketNumber')); + } + + #[Test] + public function sku_takes_the_first_three_studly_characters_of_the_source(): void + { + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', @Str::sku('laravel is awesome')); + } + + #[Test] + public function sku_uses_the_separator_it_is_given(): void + { + $this->assertMatchesRegularExpression('/^LAR_\d{10}$/', @Str::sku('laravel', '_')); + } + + #[Test] + public function sku_falls_back_to_the_configured_separator_for_an_empty_one(): void + { + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', @Str::sku('laravel', '')); + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', @Str::sku('laravel', null)); + } + + #[Test] + public function sku_ignores_its_prefix_argument(): void + { + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', @Str::sku('laravel', '-', 'PFX')); + } + + #[Test] + public function order_number_ignores_its_source_and_defaults_to_ord(): void + { + $this->assertMatchesRegularExpression('/^ORD-\d{10}$/', @Str::orderNumber('Blue shirt')); + $this->assertMatchesRegularExpression('/^ORD-\d{10}$/', @Str::orderNumber(null)); + } + + #[Test] + public function order_number_uses_its_prefix_and_separator(): void + { + $this->assertMatchesRegularExpression('/^INV\/\d{10}$/', @Str::orderNumber('ignored', '/', 'inv')); + } + + #[Test] + public function ticket_number_takes_the_first_three_studly_characters_of_the_source(): void + { + $this->assertMatchesRegularExpression('/^SUP-\d{10}$/', @Str::ticketNumber('support request')); + $this->assertMatchesRegularExpression('/^SUP\.\d{10}$/', @Str::ticketNumber('support', '.')); + } + + #[Test] + #[WithoutErrorHandler] + public function each_bare_macro_raises_one_deprecation_per_boot_and_still_returns_a_value(): void + { + $raised = []; + set_error_handler(static function (int $level, string $message) use (&$raised): bool { + $raised[] = [$level, $message]; + + return true; + }, E_USER_DEPRECATED); + + try { + $first = Str::sku('laravel'); + $second = Str::sku('laravel'); + Str::orderNumber(null); + Str::ticketNumber('support'); + } finally { + restore_error_handler(); + } + + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', $first); + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', $second); + $this->assertCount(3, $raised); + $this->assertSame(E_USER_DEPRECATED, $raised[0][0]); + $this->assertStringContainsString('Str::sku()', $raised[0][1]); + $this->assertStringContainsString('Identifiers::sku()', $raised[0][1]); + } +} diff --git a/tests/Feature/ScopedStrMacrosTest.php b/tests/Feature/ScopedStrMacrosTest.php new file mode 100644 index 0000000..12a9527 --- /dev/null +++ b/tests/Feature/ScopedStrMacrosTest.php @@ -0,0 +1,103 @@ +assertSame([ + 'sku' => 'simtabiLacommerceSku', + 'orderNumber' => 'simtabiLacommerceOrderNumber', + 'ticketNumber' => 'simtabiLacommerceTicketNumber', + ], StrMacros::DEPRECATED_ALIASES); + + foreach (StrMacros::DEPRECATED_ALIASES as $scoped) { + $this->assertTrue(Str::hasMacro($scoped), "Str::{$scoped}() is not registered"); + } + } + + #[Test] + public function no_scoped_name_shadows_a_real_str_method(): void + { + // Str::__callStatic only consults the macro map for a name that is not a real method, so a + // macro whose name Laravel later adds as a method would silently stop being called. + foreach ([...array_keys(StrMacros::DEPRECATED_ALIASES), ...StrMacros::DEPRECATED_ALIASES] as $name) { + $this->assertFalse(method_exists(Str::class, $name), "Str::{$name}() is a real method"); + } + } + + #[Test] + public function the_scoped_macros_keep_the_bare_signatures(): void + { + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', Str::simtabiLacommerceSku('laravel is awesome')); + $this->assertMatchesRegularExpression('/^LAR_\d{10}$/', Str::simtabiLacommerceSku('laravel', '_', 'ignored')); + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', Str::simtabiLacommerceSku('laravel', '')); + $this->assertMatchesRegularExpression('/^ORD-\d{10}$/', Str::simtabiLacommerceOrderNumber('ignored')); + $this->assertMatchesRegularExpression('/^INV\/\d{10}$/', Str::simtabiLacommerceOrderNumber(null, '/', 'inv')); + $this->assertMatchesRegularExpression('/^SUP-\d{10}$/', Str::simtabiLacommerceTicketNumber('support request')); + } + + #[Test] + #[WithoutErrorHandler] + public function the_scoped_macros_raise_no_deprecation(): void + { + $raised = 0; + set_error_handler(static function () use (&$raised): bool { + $raised++; + + return true; + }, E_USER_DEPRECATED); + + try { + Str::simtabiLacommerceSku('laravel'); + Str::simtabiLacommerceOrderNumber(null); + Str::simtabiLacommerceTicketNumber('support'); + } finally { + restore_error_handler(); + } + + $this->assertSame(0, $raised); + } + + #[Test] + public function each_bare_alias_delegates_to_its_scoped_macro(): void + { + // Replace the scoped implementations: the bare aliases must follow, which proves they forward + // rather than carry a second copy of the logic. + foreach (StrMacros::DEPRECATED_ALIASES as $bare => $scoped) { + Str::macro($scoped, static fn (...$arguments): string => "{$scoped}:" . implode('|', array_map('strval', $arguments))); + } + + $this->assertSame('simtabiLacommerceSku:laravel|_|P', @Str::sku('laravel', '_', 'P')); + $this->assertSame('simtabiLacommerceOrderNumber:x|/|INV', @Str::orderNumber('x', '/', 'INV')); + $this->assertSame('simtabiLacommerceTicketNumber:support', @Str::ticketNumber('support')); + } + + #[Test] + public function a_host_overriding_a_bare_name_no_longer_changes_what_the_traits_generate(): void + { + Str::macro('sku', static fn (): string => 'HIJACKED'); + + $product = DummyProduct::create(['name' => 'Laravel is Awesome']); + + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', $product->sku); + } +} diff --git a/tests/Unit/IdentifiersTest.php b/tests/Unit/IdentifiersTest.php new file mode 100644 index 0000000..192393d --- /dev/null +++ b/tests/Unit/IdentifiersTest.php @@ -0,0 +1,59 @@ +assertMatchesRegularExpression('/^LAR-\d{10}$/', Identifiers::sku('laravel is awesome')); + $this->assertMatchesRegularExpression('/^LAR_\d{10}$/', Identifiers::sku('laravel', '_')); + } + + #[Test] + public function order_number_defaults_to_the_ord_prefix(): void + { + $this->assertMatchesRegularExpression('/^ORD-\d{10}$/', Identifiers::orderNumber()); + $this->assertMatchesRegularExpression('/^ORD-\d{10}$/', Identifiers::orderNumber(null)); + $this->assertMatchesRegularExpression('/^ORD-\d{10}$/', Identifiers::orderNumber('')); + } + + #[Test] + public function order_number_uses_its_prefix_and_separator(): void + { + $this->assertMatchesRegularExpression('/^INV\/\d{10}$/', Identifiers::orderNumber('inv', '/')); + } + + #[Test] + public function ticket_number_takes_the_first_three_studly_characters_of_the_source(): void + { + $this->assertMatchesRegularExpression('/^SUP-\d{10}$/', Identifiers::ticketNumber('support request')); + $this->assertMatchesRegularExpression('/^SUP\.\d{10}$/', Identifiers::ticketNumber('support', '.')); + } + + #[Test] + public function the_separator_is_used_verbatim(): void + { + // The macros resolve an empty separator to the configured default before they get here; the + // class itself does not second-guess its caller. + $this->assertMatchesRegularExpression('/^LAR\d{10}$/', Identifiers::sku('laravel', '')); + } + + #[Test] + public function the_class_is_final_and_its_constants_name_the_defaults(): void + { + $this->assertTrue((new \ReflectionClass(Identifiers::class))->isFinal()); + $this->assertSame('-', Identifiers::DEFAULT_SEPARATOR); + $this->assertSame('ORD', Identifiers::DEFAULT_ORDER_PREFIX); + } +}