diff --git a/CHANGELOG.md b/CHANGELOG.md index b76bc9d..09d9005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - 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. +- Config key `generator..prefix` (default `null`) on the `sku`, `ticket_number` and `order_number` blocks. + When set, it leads every value the trait generates: `ACME-BLU-8056449213`. For order numbers it replaces the + default `ORD`. A published config without the key behaves as `null`. +- An optional `$prefix` third argument on `Identifiers::sku()` and `Identifiers::ticketNumber()`, and on + `Supports\Helpers::makeRandomString()`. +- The shipped `SkuGenerator`, `OrderNumberGenerator` and `TicketNumberGenerator` implement the per-type + interface (`SkuGeneratorInterface`, …) the container resolves them through. They extended only the base + `GeneratorInterface` before. ### 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. +- The observer calls `render()` on the generator instead of casting it to a string. Output is unchanged for the + shipped generators, whose `__toString()` returned `render()`. +- A `generator` config key naming a class that does not implement `GeneratorInterface` now throws + `InvalidOptionException` naming the key on the first save. It threw a `TypeError` from the observer before. +- `InvalidOptionException::invalidArgument()` takes an optional `$code` (default `500`), and `render()` takes + `Illuminate\Http\Request`. It used to drop the code its one caller passed, leaving `0`, and type-hint the + `Request` facade, which a real request never is. ### Deprecated @@ -28,6 +43,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), same arguments and output, forwarding to the scoped macros, and raise one `E_USER_DEPRECATED` per name per boot. Earliest removal: 0.2.0. +### Fixed + +- `Supports\Helpers::makeRandomString()` read a `$prefix` variable it never declared. The body was moved out of + the 0.1.0 `Str` macros in 2022, where `$prefix` was a closure parameter, and the parameter was left behind. + `empty()` of an undefined variable raises nothing, so the prefix branch could never run. It is now a third + parameter, `?string $prefix = null`. Output for every existing call is unchanged: no caller passed a prefix. + A call that does gets `PREFIX-SOURCE-DIGITS`, upper-cased. A prefix of `'0'` is kept; `null` and `''` add + nothing. +- `docs/tools/generators.md` told you to extend `SkuGenerator`, which is final, so its custom-generator example + could not compile. It now documents the two seams that work, extending the non-final + `Generators\Services\Generator` base or implementing the interface, and both examples are test fixtures run + against a model. A test fails if the documented example stops matching its fixture. +- A custom generator that implemented `GeneratorInterface` without a `__toString()` threw + `Object ... could not be converted to string` on every save. +- `Configs::getPrefix()`, and `skuConfig('prefix')` and its siblings, threw "must not be accessed before + initialization" unless `setPrefix()` had been called. The prefix now defaults to `null`. + ## [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/UPGRADING.md b/UPGRADING.md index d2d7c7a..2795e42 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -18,3 +18,23 @@ arguments: configured `generator.default.separator`. Once nothing calls the bare names, set `register_legacy_macros` to `false` in `config/simtabi/lacommerce.php`. +### Custom generators + +The documentation used to tell you to extend `SkuGenerator`. It is final, so no working code did that. Extend +`Simtabi\Lacommerce\Generators\Services\Generator` instead, or implement the interface; see +[Custom generators](docs/tools/generators.md#custom-generators). + +Two things changed for a custom generator that already works: + +- The observer calls `render()` rather than casting the generator to a string. A class whose `__toString()` + returned something other than `render()` now has `render()` used. +- A `generator` key naming a class that does not implement `GeneratorInterface` throws + `InvalidOptionException` naming the key, instead of a `TypeError`. + +### Prefixes + +Each generator block takes a new `prefix` key. A config published before this release does not have it, which +is the same as `null`: values are generated exactly as before. Add it to the block to prefix the values. +`Identifiers::sku()` and `Identifiers::ticketNumber()` take the prefix as an optional third argument. The `Str` +macros still ignore their `$prefix` argument for SKUs and ticket numbers, as 0.1.0 did. + diff --git a/config/config.php b/config/config.php index 8a2c485..fec9b83 100644 --- a/config/config.php +++ b/config/config.php @@ -54,7 +54,10 @@ | */ 'sku' => [ - /** Generator and must @implements GeneratorInterface */ + /** + * Generator class. Built as `new $class($model)`, it must implement GeneratorInterface; + * extend Generators\Services\Generator to customise one. See docs/tools/generators.md. + */ 'generator' => SkuGenerator::class, /** Source field(column) */ @@ -62,6 +65,9 @@ /** Destination field(column) */ 'destination_column' => 'sku', + + /** Optional leading part, e.g. 'ACME' gives ACME-LAR-8056449213 */ + 'prefix' => null, ], /* @@ -71,7 +77,10 @@ | */ 'ticket_number' => [ - /** Generator and must @implements GeneratorInterface */ + /** + * Generator class. Built as `new $class($model)`, it must implement GeneratorInterface; + * extend Generators\Services\Generator to customise one. See docs/tools/generators.md. + */ 'generator' => TicketNumberGenerator::class, /** Source field(column) */ @@ -79,6 +88,9 @@ /** Destination field(column) */ 'destination_column' => 'ticket_number', + + /** Optional leading part, e.g. 'HD' gives HD-SUP-1749302865 */ + 'prefix' => null, ], /* @@ -88,7 +100,10 @@ | */ 'order_number' => [ - /** Generator and must @implements GeneratorInterface */ + /** + * Generator class. Built as `new $class($model)`, it must implement GeneratorInterface; + * extend Generators\Services\Generator to customise one. See docs/tools/generators.md. + */ 'generator' => OrderNumberGenerator::class, /** Source field(column) */ @@ -96,6 +111,9 @@ /** Destination field(column) */ 'destination_column' => 'order_number', + + /** Replaces the default ORD prefix when set, e.g. 'INV' gives INV-3920571846 */ + 'prefix' => null, ], ], diff --git a/docs/architecture.md b/docs/architecture.md index d999e4b..a871c34 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,18 +9,23 @@ How a generated value is produced on save. See the [Documentation index](../READ - **Observer** (`Generators/Services/Observer`) — hooks the model's create/update events and asks the generator for a value. - **Generator** (`Generators/Services/Generator` + per-type `Concerns/*Generator`) — builds the value from - the configured source column(s) + separator; enforces uniqueness when required. Custom generators extend - these (see [Generators](tools/generators.md)). + the configured prefix, source column(s) and separator; enforces uniqueness when required. The per-type + generators are final; a custom generator extends the non-final `Generator` base or implements the + interface (see [Generators](tools/generators.md#custom-generators)). - **Configs** (`Generators/Services/Configs`) — merges the `generator.default` block with the per-generator block and any per-model overrides. -- **Contracts** (`Generators/Contracts/*GeneratorInterface`) — the interface a custom generator implements. +- **Contracts** (`Generators/Contracts/*GeneratorInterface`) — the per-type interfaces the container resolves + a generator through. Each extends `Generators/Services/Contracts/GeneratorInterface`, whose `render()` the + observer calls. The provider builds the class a config block names as `new $class($model)` and rejects one + that does not implement `GeneratorInterface`. ## Flow 1. A model using a generator trait is saved. 2. The observer fires on create (and on update when `refresh_on_update` is set). -3. The generator reads the source column(s), joins them with the separator, and — if `unique` — ensures no - collision, writing the result to the destination column. +3. The observer calls the generator's `render()`. The shipped generators read the source column(s), join them + with the separator, lead with the prefix when one is configured, and — if `unique` — retry until the + value is not already in the destination column. The observer writes the result there. --- diff --git a/docs/configuration.md b/docs/configuration.md index 7da4ac8..87ebc19 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -24,18 +24,21 @@ return [ 'generator' => SkuGenerator::class, // must implement GeneratorInterface 'source_column' => 'name', // source column(s) 'destination_column' => 'sku', // destination column + 'prefix' => null, // optional leading part ], 'ticket_number' => [ 'generator' => TicketNumberGenerator::class, 'source_column' => 'name', 'destination_column' => 'ticket_number', + 'prefix' => null, ], 'order_number' => [ 'generator' => OrderNumberGenerator::class, 'source_column' => 'name', 'destination_column' => 'order_number', + 'prefix' => null, // replaces the default ORD ], ], ]; @@ -50,9 +53,10 @@ return [ | `generator.default.unique` | Enforce the generated value is unique. | | `generator.default.generate_on_create` | Generate when the model is created. | | `generator.default.refresh_on_update` | Regenerate when the model is updated. | -| `generator..generator` | The generator class (must implement its `GeneratorInterface`). | +| `generator..generator` | The generator class. Built as `new $class($model)`; it must implement `GeneratorInterface`, or the first save throws `InvalidOptionException`. See [Custom generators](tools/generators.md#custom-generators). | | `generator..source_column` | Source column(s) the value is derived from. | | `generator..destination_column` | Column the generated value is written to. | +| `generator..prefix` | Optional leading part of the value (default `null`, none). For `order_number` it replaces `ORD`. A published file without the key behaves as `null`. See [Prefixes](tools/generators.md#prefixes). | Override any of these per model via the trait's config method — see [Generators](tools/generators.md). diff --git a/docs/tools/generators.md b/docs/tools/generators.md index d5ca004..d6c3300 100644 --- a/docs/tools/generators.md +++ b/docs/tools/generators.md @@ -44,6 +44,7 @@ use Simtabi\Lacommerce\Supports\Identifiers; Identifiers::sku('Laravel is Awesome'); // "LAR-8056449213" Identifiers::sku('Laravel is Awesome', '_'); // "LAR_8056449213" +Identifiers::sku('Laravel is Awesome', '-', 'acme'); // "ACME-LAR-8056449213" Identifiers::orderNumber(); // "ORD-3920571846" Identifiers::orderNumber('INV', '/'); // "INV/3920571846" Identifiers::ticketNumber('Support request'); // "SUP-1749302865" @@ -51,11 +52,12 @@ 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. | +| `sku(string $source, string $separator = '-', ?string $prefix = null)` | The prefix when given, then the first three characters of the studly-cased source, then ten random digits, joined by the separator and 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()`. | +| `ticketNumber(string $source, string $separator = '-', ?string $prefix = null)` | As `sku()`. | -The separator is used as given. The traits pass the configured `generator.default.separator`. A value from +The separator is used as given. The traits pass the configured `generator.default.separator`, and the +`prefix` from the generator's config block (see [Prefixes](#prefixes)). A value from `Identifiers` is not checked against your table; uniqueness is enforced by the traits' generators. ### `Str` macros @@ -69,7 +71,9 @@ configured separator when you pass none: | `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. +Each takes the same arguments as the bare macro it replaces, so migrating is a rename. The `sku` and +`ticketNumber` macros accept a third `$prefix` argument and ignore it, as the 0.1.0 macros did; to prefix a +SKU or ticket number, call `Identifiers` or set `prefix` in the config. > 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 @@ -96,7 +100,8 @@ class Product extends Model { return SkuConfigs::make() ->setSourceColumn(['id', 'user_id']) - ->setDestinationColumn('order_number') + ->setDestinationColumn('sku') + ->setPrefix('ACME') ->setSeparator('-') ->forceUnique(true) ->generateOnCreate(true) @@ -105,40 +110,117 @@ class Product extends Model } ``` +## Prefixes + +Each generator's config block takes a `prefix`, `null` by default. When set, it leads the value: + +| Block | `prefix` | Value | +|-------|----------|-------| +| `sku` | `'acme'` | `ACME-BLU-8056449213` | +| `ticket_number` | `'HD'` | `HD-PRI-1749302865` | +| `order_number` | `'INV'` | `INV-3920571846`; the prefix replaces the default `ORD` | + +`setPrefix()` on a model's configs sets it per model, as in the example above. `getPrefix()` returns `null` +when none is set. + ## Custom generators -For extra logic (a default value, a prefix, …) extend the base generator and override `getSourceString()`: +The shipped `SkuGenerator`, `OrderNumberGenerator` and `TicketNumberGenerator` are final, so they are not the +thing to extend. The provider builds whichever class a config block's `generator` key names as +`new $class($model)`, so a custom generator needs two things: + +- a constructor that takes the model as its only argument, and +- `Simtabi\Lacommerce\Generators\Services\Contracts\GeneratorInterface`, whose one method, `render()`, + returns the value. Implement the per-type interface beside it in `Generators\Contracts` + (`SkuGeneratorInterface`, `OrderNumberGeneratorInterface`, `TicketNumberGeneratorInterface`), which extends + it, so the class is what the container says it resolves. + +A class naming anything else fails on the first save with an `InvalidOptionException` that names the config +key. + +### Extend the base generator + +`Simtabi\Lacommerce\Generators\Services\Generator` is the base the shipped generators extend, and it is +built to be extended. Its parent constructor takes the model, the trait's config method (`skuConfigs`, +`orderNumberConfigs` or `ticketNumberConfigs`) and the identifier kind (`sku`, `orderNumber` or +`ticketNumber`). `render()` builds the source with `getSourceString()`, makes a candidate with `makeValue()`, +and, when `unique` is on, retries while `exists()` finds the candidate in the destination column. Override +whichever of those protected methods you need, and the rest, uniqueness included, keeps working. + +This one falls back to a fixed source when the model's source columns are empty: ```php -namespace App\Components\SkuGenerator; +namespace App\Generators; -use Simtabi\Lacommerce\Generators\Concerns\Sku\SkuGenerator; +use Illuminate\Database\Eloquent\Model; +use Simtabi\Lacommerce\Generators\Contracts\SkuGeneratorInterface; +use Simtabi\Lacommerce\Generators\Services\Generator; -class CustomSkuGenerator extends SkuGenerator +final class FallbackSkuGenerator extends Generator implements SkuGeneratorInterface { + public function __construct(Model $model) + { + parent::__construct($model, 'skuConfigs', 'sku'); + } + protected function getSourceString(): string { - $source = $this->modelConfig->sourceColumn; - $fields = array_filter($this->model->only($source)); + $fields = array_filter($this->model->only($this->modelConfig->getSourceColumn())); - if (empty($fields)) { - return 'some-random-value-logic'; + if ($fields === []) { + return 'item'; } - return implode($this->modelConfig->separator, $fields); + return implode($this->modelConfig->getSeparator(), $fields); + } +} +``` + +A product named `Blue shirt` still gets `BLU-8056449213`; one with an empty name gets `ITE-8056449213`. + +### Implement the interface + +When the value has nothing to do with the shipped format, implement the interface directly. You then own +uniqueness: + +```php +namespace App\Generators; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; +use Simtabi\Lacommerce\Generators\Contracts\TicketNumberGeneratorInterface; + +final class SlugTicketNumberGenerator implements TicketNumberGeneratorInterface +{ + public function __construct(private readonly Model $model) + { + } + + public function render(): string + { + return 'TKT-' . Str::upper(Str::slug((string) $this->model->getAttribute('name'))); } } ``` -Then point the config at it: +### Configure it + +Name the class in the published `config/simtabi/lacommerce.php`: ```php -'generator' => \App\Components\SkuGenerator\CustomSkuGenerator::class, +'generator' => [ + // ... + 'sku' => [ + 'generator' => \App\Generators\FallbackSkuGenerator::class, + 'source_column' => 'name', + 'destination_column' => 'sku', + 'prefix' => null, + ], +], ``` -A custom generator must implement `Simtabi\Lacommerce\Generators\Contracts\SkuGeneratorInterface` (or the -`OrderNumberGeneratorInterface` / `TicketNumberGeneratorInterface` beside it); extending the shipped generator -does that for you. +Both examples are the package's own test fixtures, run against a model on every build, and a test fails if +the first one here stops matching its fixture. ## About SKUs diff --git a/src/Generators/Concerns/OrderNumber/OrderNumberGenerator.php b/src/Generators/Concerns/OrderNumber/OrderNumberGenerator.php index 2c7a706..27503d7 100644 --- a/src/Generators/Concerns/OrderNumber/OrderNumberGenerator.php +++ b/src/Generators/Concerns/OrderNumber/OrderNumberGenerator.php @@ -3,9 +3,14 @@ namespace Simtabi\Lacommerce\Generators\Concerns\OrderNumber; use Illuminate\Database\Eloquent\Model; +use Simtabi\Lacommerce\Generators\Contracts\OrderNumberGeneratorInterface; use Simtabi\Lacommerce\Generators\Services\Generator; -final class OrderNumberGenerator extends Generator +/** + * The shipped OrderNumber generator. Final: to customise it, extend Generators\Services\Generator or implement + * OrderNumberGeneratorInterface, and name your class in the config. See docs/tools/generators.md. + */ +final class OrderNumberGenerator extends Generator implements OrderNumberGeneratorInterface { public function __construct(Model $model) diff --git a/src/Generators/Concerns/Sku/SkuGenerator.php b/src/Generators/Concerns/Sku/SkuGenerator.php index 0d999f0..35a261a 100644 --- a/src/Generators/Concerns/Sku/SkuGenerator.php +++ b/src/Generators/Concerns/Sku/SkuGenerator.php @@ -3,9 +3,14 @@ namespace Simtabi\Lacommerce\Generators\Concerns\Sku; use Illuminate\Database\Eloquent\Model; +use Simtabi\Lacommerce\Generators\Contracts\SkuGeneratorInterface; use Simtabi\Lacommerce\Generators\Services\Generator; -final class SkuGenerator extends Generator +/** + * The shipped Sku generator. Final: to customise it, extend Generators\Services\Generator or implement + * SkuGeneratorInterface, and name your class in the config. See docs/tools/generators.md. + */ +final class SkuGenerator extends Generator implements SkuGeneratorInterface { public function __construct(Model $model) diff --git a/src/Generators/Concerns/TicketNumber/TicketNumberGenerator.php b/src/Generators/Concerns/TicketNumber/TicketNumberGenerator.php index 8feef7b..e8290cf 100644 --- a/src/Generators/Concerns/TicketNumber/TicketNumberGenerator.php +++ b/src/Generators/Concerns/TicketNumber/TicketNumberGenerator.php @@ -3,9 +3,14 @@ namespace Simtabi\Lacommerce\Generators\Concerns\TicketNumber; use Illuminate\Database\Eloquent\Model; +use Simtabi\Lacommerce\Generators\Contracts\TicketNumberGeneratorInterface; use Simtabi\Lacommerce\Generators\Services\Generator; -final class TicketNumberGenerator extends Generator +/** + * The shipped TicketNumber generator. Final: to customise it, extend Generators\Services\Generator or implement + * TicketNumberGeneratorInterface, and name your class in the config. See docs/tools/generators.md. + */ +final class TicketNumberGenerator extends Generator implements TicketNumberGeneratorInterface { public function __construct(Model $model) diff --git a/src/Generators/Exceptions/InvalidOptionException.php b/src/Generators/Exceptions/InvalidOptionException.php index 7db7a62..be074e9 100644 --- a/src/Generators/Exceptions/InvalidOptionException.php +++ b/src/Generators/Exceptions/InvalidOptionException.php @@ -6,7 +6,7 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Request; +use Illuminate\Http\Request; class InvalidOptionException extends Exception { @@ -15,11 +15,12 @@ class InvalidOptionException extends Exception * Invalid Argument. * * @param string $message - * @return self [type] + * @param int $code HTTP status render() responds with; 500 when not given + * @return self */ - public static function invalidArgument(string $message): self + public static function invalidArgument(string $message, int $code = 500): self { - return new static($message); + return new static($message, $code); } /** diff --git a/src/Generators/Services/Configs.php b/src/Generators/Services/Configs.php index 7883488..da40f5f 100644 --- a/src/Generators/Services/Configs.php +++ b/src/Generators/Services/Configs.php @@ -24,11 +24,11 @@ class Configs implements ConfigsInterface protected string $destinationColumn; /** - * Define prefix. + * Leading part of the generated value; null for none. * * @var ?string */ - protected ?string $prefix; + protected ?string $prefix = null; /** * True if generated value is to be unique. @@ -68,6 +68,7 @@ public function __construct(array $config, string $key) $this->setSourceColumn($config[$key]['source_column']) ->setDestinationColumn($config[$key]['destination_column']) + ->setPrefix($config[$key]['prefix'] ?? null) ->setSeparator($default['separator']) ->forceUnique($default['unique']) ->generateOnCreate($default['generate_on_create']) @@ -105,7 +106,8 @@ public function getSourceColumn(): array|string } /** - * Set the prefix. + * Set the prefix: a leading part of the generated value. Null or an empty string for none. For an + * order number it replaces the default `ORD`. * * @param mixed $prefix * @return $this @@ -128,7 +130,7 @@ public function getPrefix(): ?string /** * Set the destination column. * - * @param mixed $destinationColumn + * @param string $destinationColumn * @return $this */ public function setDestinationColumn(string $destinationColumn): self diff --git a/src/Generators/Services/Contracts/ConfigsInterface.php b/src/Generators/Services/Contracts/ConfigsInterface.php index 44af829..20c40ee 100644 --- a/src/Generators/Services/Contracts/ConfigsInterface.php +++ b/src/Generators/Services/Contracts/ConfigsInterface.php @@ -5,6 +5,7 @@ /** * @property-read string[] $sourceColumn * @property-read string $destinationColumn + * @property-read ?string $prefix * @property-read bool $status * @property-read string $separator * @property-read bool $generateOnCreate @@ -36,7 +37,7 @@ public function getSourceColumn(): array|string; /** * Set the prefix. * - * @param mixed $prefix + * @param string $prefix * @return $this */ public function setPrefix(string $prefix): self; @@ -49,7 +50,7 @@ public function getPrefix(): ?string; /** * Set the destination column. * - * @param mixed $destinationColumn + * @param string $destinationColumn * @return $this */ public function setDestinationColumn(string $destinationColumn): self; diff --git a/src/Generators/Services/Generator.php b/src/Generators/Services/Generator.php index ea4fce2..2892636 100644 --- a/src/Generators/Services/Generator.php +++ b/src/Generators/Services/Generator.php @@ -10,6 +10,16 @@ use Simtabi\Lacommerce\Providers\LacommerceServiceProvider; use Simtabi\Lacommerce\Supports\Identifiers; +/** + * The base every shipped generator extends, and the one a custom generator should extend. + * + * The provider builds a generator as `new $class($model)`, so a subclass declares a one-argument + * constructor and passes the model, the trait's config method (`skuConfigs`, `orderNumberConfigs`, + * `ticketNumberConfigs`) and the identifier kind (`sku`, `orderNumber`, `ticketNumber`) to this one. + * render() then builds the source with getSourceString(), makes a candidate with makeValue(), and, when + * the config forces uniqueness, retries while exists() finds the candidate in the destination column. + * Override any of those protected hooks; the shipped subclasses are final and are not the seam. + */ class Generator implements Jsonable, Renderable, GeneratorInterface { /** @@ -60,7 +70,7 @@ public function render(): string $source = $this->getSourceString(); // now, generate the value - return $this->generate($source, $this->modelConfig->separator, $this->modelConfig->forceUnique); + return $this->generate($source, $this->modelConfig->getSeparator(), $this->modelConfig->isForceUnique()); } /** @@ -71,13 +81,13 @@ public function render(): string protected function getSourceString(): string { // fetch the source fields - $source = $this->modelConfig->sourceColumn; + $source = $this->modelConfig->getSourceColumn(); // Fetch fields from model, skip empty $fields = array_filter($this->model->only($source)); // Implode with a separator - return implode($this->modelConfig->separator, $fields); + return implode($this->modelConfig->getSeparator(), $fields); } /** @@ -109,7 +119,8 @@ protected function generate(string $source, string $separator, bool $unique = fa * 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. + * An empty separator falls back to the configured default, as the 0.1.0 macros did. The configured + * prefix, when there is one, leads the value; for an order number it replaces `ORD`. * * @param string $source * @param string $separator @@ -122,10 +133,12 @@ protected function makeValue(string $source, string $separator): string Identifiers::DEFAULT_SEPARATOR, ); + $prefix = $this->modelConfig->getPrefix(); + return match ($this->strMixin) { - 'sku' => Identifiers::sku($source, $separator), - 'orderNumber' => Identifiers::orderNumber(null, $separator), - 'ticketNumber' => Identifiers::ticketNumber($source, $separator), + 'sku' => Identifiers::sku($source, $separator, $prefix), + 'orderNumber' => Identifiers::orderNumber($prefix, $separator), + 'ticketNumber' => Identifiers::ticketNumber($source, $separator, $prefix), default => Str::{$this->strMixin}($source, $separator), }; } @@ -140,7 +153,7 @@ protected function exists(string $value): bool { return $this->model ->whereKeyNot($this->model->getKey()) - ->where($this->modelConfig->destinationColumn, $value) + ->where($this->modelConfig->getDestinationColumn(), $value) ->withoutGlobalScopes() ->exists(); } diff --git a/src/Generators/Services/Observer.php b/src/Generators/Services/Observer.php index 0294f2a..6d9cf49 100644 --- a/src/Generators/Services/Observer.php +++ b/src/Generators/Services/Observer.php @@ -53,7 +53,7 @@ public function creating(Model $model): void // Set the value if ($model->{$this->configMethod}('generateOnCreate')) { - $model->setAttribute($destination, (string) $this->generator($model)); + $model->setAttribute($destination, $this->generator($model)->render()); } } @@ -78,7 +78,7 @@ public function updating(Model $model): void // if we are requested to generate and those fields that are dirty if ($model->{$this->configMethod}('refreshOnUpdate') and $model->isDirty($source)) { - $model->setAttribute($destination, (string) $this->generator($model)); + $model->setAttribute($destination, $this->generator($model)->render()); } } diff --git a/src/Providers/LacommerceServiceProvider.php b/src/Providers/LacommerceServiceProvider.php index 8d4d0d3..5115cc3 100644 --- a/src/Providers/LacommerceServiceProvider.php +++ b/src/Providers/LacommerceServiceProvider.php @@ -3,6 +3,7 @@ namespace Simtabi\Lacommerce\Providers; use Illuminate\Contracts\Foundation\CachesConfiguration; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\ServiceProvider; use Simtabi\Lacommerce\Generators\Concerns\OrderNumber\OrderNumberConfigs; use Simtabi\Lacommerce\Generators\Concerns\Sku\SkuConfigs; @@ -10,6 +11,8 @@ use Simtabi\Lacommerce\Generators\Contracts\SkuGeneratorInterface; use Simtabi\Lacommerce\Generators\Contracts\TicketNumberGeneratorInterface; use Simtabi\Lacommerce\Generators\Contracts\OrderNumberGeneratorInterface; +use Simtabi\Lacommerce\Generators\Exceptions\InvalidOptionException; +use Simtabi\Lacommerce\Generators\Services\Contracts\GeneratorInterface; use Simtabi\Lacommerce\Supports\StrMacros; class LacommerceServiceProvider extends ServiceProvider @@ -146,7 +149,11 @@ private function registerConsoles(): static } /** - * Bind the Generator. + * Bind each generator interface to the class its config block names. + * + * The class is built as `new $class($model)`, so it takes the model as its only constructor + * argument, and it must implement GeneratorInterface: the observer calls render() on it. Extending + * Generators\Services\Generator satisfies both. See docs/tools/generators.md. * * @return void */ @@ -155,23 +162,37 @@ protected function bindGenerator() $config = $this->getConfig(); - $this->app->bind(SkuGeneratorInterface::class, function ($app, array $parameters) use ($config) { - $generator = $config['sku']['generator']; - - return new $generator(head($parameters)); - }); - - $this->app->bind(OrderNumberGeneratorInterface::class, function ($app, array $parameters) use ($config) { - $generator = $config['order_number']['generator']; + $bindings = [ + SkuGeneratorInterface::class => 'sku', + OrderNumberGeneratorInterface::class => 'order_number', + TicketNumberGeneratorInterface::class => 'ticket_number', + ]; - return new $generator(head($parameters)); - }); + foreach ($bindings as $interface => $key) { + $this->app->bind($interface, function ($app, array $parameters) use ($config, $key) { + return self::makeGenerator($config[$key]['generator'] ?? null, $key, head($parameters)); + }); + } + } - $this->app->bind(TicketNumberGeneratorInterface::class, function ($app, array $parameters) use ($config) { - $generator = $config['ticket_number']['generator']; + /** + * Build the configured generator, or say which config key names something that is not one. + * + * @throws InvalidOptionException + */ + private static function makeGenerator(mixed $class, string $key, Model $model): GeneratorInterface + { + if (! is_string($class) || ! is_a($class, GeneratorInterface::class, true)) { + throw InvalidOptionException::invalidArgument(sprintf( + '%s.generator.%s.generator must name a class implementing %s; %s given.', + self::CONFIG_KEY, + $key, + GeneratorInterface::class, + is_string($class) ? $class : get_debug_type($class), + )); + } - return new $generator(head($parameters)); - }); + return new $class($model); } private function getConfig(): array diff --git a/src/Supports/Helpers.php b/src/Supports/Helpers.php index 3226465..2a0793c 100644 --- a/src/Supports/Helpers.php +++ b/src/Supports/Helpers.php @@ -7,7 +7,18 @@ class Helpers { - public static function makeRandomString(string $source, string $separator = '-'): string + /** + * Join an optional prefix, the source and ten random digits with the separator, upper-cased. + * + * `makeRandomString('lar')` returns e.g. `LAR-8056449213`; `makeRandomString('lar', '-', 'acme')` + * returns e.g. `ACME-LAR-8056449213`. A null or empty-string prefix adds no segment. + * + * @param string $source the part after the prefix + * @param string $separator joins the parts, used as given + * @param string|null $prefix an optional leading part + * @return string + */ + public static function makeRandomString(string $source, string $separator = '-', ?string $prefix = null): string { // signature $signature = str_shuffle(str_repeat(str_pad('0123456789', 10, rand(0, 9).rand(0, 9), STR_PAD_LEFT), 2)); @@ -16,7 +27,8 @@ public static function makeRandomString(string $source, string $separator = '-') $signature = substr($signature, 0, 10); // Implode with random - $result = !empty($prefix) ? implode($separator, [$prefix, $source, $signature]) : implode($separator, [$source, $signature]); + $parts = $prefix !== null && $prefix !== '' ? [$prefix, $source, $signature] : [$source, $signature]; + $result = implode($separator, $parts); // Uppercase it return Str::upper($result); diff --git a/src/Supports/Identifiers.php b/src/Supports/Identifiers.php index e4e2ee6..ad0efef 100644 --- a/src/Supports/Identifiers.php +++ b/src/Supports/Identifiers.php @@ -14,9 +14,10 @@ * `Str::simtabiLacommerce*()` macros forward to it. Calling it directly means another package or the * host application registering a `Str` macro of the same name cannot change what it returns. * - * Each value is a source part, the separator, and ten random digits, upper-cased: - * `Identifiers::sku('laravel is awesome')` returns e.g. `LAR-8056449213`. Uniqueness against a table - * is the generator's job, not this class's. + * Each value is an optional prefix, a source part and ten random digits, joined by the separator and + * upper-cased: `Identifiers::sku('laravel is awesome')` returns e.g. `LAR-8056449213`, and + * `Identifiers::sku('laravel is awesome', '-', 'acme')` returns e.g. `ACME-LAR-8056449213`. Uniqueness + * against a table is the generator's job, not this class's. */ final class Identifiers { @@ -25,11 +26,11 @@ final class Identifiers public const DEFAULT_ORDER_PREFIX = 'ORD'; /** - * A SKU from the first three characters of the studly-cased source. + * A SKU from the first three characters of the studly-cased source, led by the prefix when one is given. */ - public static function sku(string $source, string $separator = self::DEFAULT_SEPARATOR): string + public static function sku(string $source, string $separator = self::DEFAULT_SEPARATOR, ?string $prefix = null): string { - return Helpers::makeRandomString(self::sourcePart($source), $separator); + return Helpers::makeRandomString(self::sourcePart($source), $separator, $prefix); } /** @@ -41,11 +42,12 @@ public static function orderNumber(?string $prefix = null, string $separator = s } /** - * A ticket number from the first three characters of the studly-cased source. + * A ticket number from the first three characters of the studly-cased source, led by the prefix when + * one is given. */ - public static function ticketNumber(string $source, string $separator = self::DEFAULT_SEPARATOR): string + public static function ticketNumber(string $source, string $separator = self::DEFAULT_SEPARATOR, ?string $prefix = null): string { - return Helpers::makeRandomString(self::sourcePart($source), $separator); + return Helpers::makeRandomString(self::sourcePart($source), $separator, $prefix); } private static function sourcePart(string $source): string diff --git a/tests/Feature/CustomGeneratorTest.php b/tests/Feature/CustomGeneratorTest.php new file mode 100644 index 0000000..4b75bd2 --- /dev/null +++ b/tests/Feature/CustomGeneratorTest.php @@ -0,0 +1,92 @@ + $config]; + } + + #[Test] + public function the_container_resolves_the_configured_generators(): void + { + $product = new DummyProduct(['name' => 'Blue shirt']); + $ticket = new DummyTicket(['name' => 'Printer jam']); + + $this->assertInstanceOf(FallbackSkuGenerator::class, resolve(SkuGeneratorInterface::class, ['model' => $product])); + $this->assertInstanceOf(SlugTicketNumberGenerator::class, resolve(TicketNumberGeneratorInterface::class, ['model' => $ticket])); + } + + #[Test] + public function a_generator_extending_the_base_keeps_the_shipped_format_for_a_normal_source(): void + { + $product = DummyProduct::create(['name' => 'Blue shirt']); + + $this->assertMatchesRegularExpression('/^BLU-\d{10}$/', $product->sku); + } + + #[Test] + public function a_generator_extending_the_base_applies_its_own_source_logic(): void + { + $product = DummyProduct::create(['name' => '']); + + $this->assertMatchesRegularExpression('/^ITE-\d{10}$/', $product->sku); + } + + #[Test] + public function a_generator_extending_the_base_still_regenerates_on_update(): void + { + $product = DummyProduct::create(['name' => '']); + + $product->update(['name' => 'Red hat']); + + $this->assertMatchesRegularExpression('/^RED-\d{10}$/', $product->fresh()->sku); + } + + #[Test] + public function a_generator_implementing_only_the_interface_is_used_through_render(): void + { + // SlugTicketNumberGenerator has render() and no __toString(): the observer used to cast the + // generator to a string, which threw for any generator that only implemented the interface. + $this->assertFalse(method_exists(SlugTicketNumberGenerator::class, '__toString')); + + $ticket = DummyTicket::create(['name' => 'Printer jam']); + + $this->assertSame('TKT-PRINTER-JAM', $ticket->ticket_number); + } + + #[Test] + public function the_documented_example_is_this_fixture(): void + { + $docs = (string) file_get_contents(__DIR__ . '/../../docs/tools/generators.md'); + $fixture = (string) file_get_contents(__DIR__ . '/../Fixtures/Generators/FallbackSkuGenerator.php'); + + $example = str_replace( + "namespace Simtabi\\Lacommerce\\Tests\\Fixtures\\Generators;", + "namespace App\\Generators;", + trim(preg_replace('/^<\?php\s+declare\(strict_types=1\);\s+/', '', $fixture)), + ); + + $this->assertStringContainsString($example, $docs, 'docs/tools/generators.md no longer shows tests/Fixtures/Generators/FallbackSkuGenerator.php'); + } +} diff --git a/tests/Feature/GeneratorPrefixTest.php b/tests/Feature/GeneratorPrefixTest.php new file mode 100644 index 0000000..c2dc4ac --- /dev/null +++ b/tests/Feature/GeneratorPrefixTest.php @@ -0,0 +1,41 @@ + $config]; + } + + #[Test] + public function the_configured_prefix_leads_each_generated_value(): void + { + $this->assertMatchesRegularExpression('/^ACME-BLU-\d{10}$/', DummyProduct::create(['name' => 'Blue shirt'])->sku); + $this->assertMatchesRegularExpression('/^HD-PRI-\d{10}$/', DummyTicket::create(['name' => 'Printer jam'])->ticket_number); + $this->assertMatchesRegularExpression('/^INV-\d{10}$/', DummyOrder::create(['name' => 'Blue shirt'])->order_number); + } + + #[Test] + public function the_configs_expose_the_prefix(): void + { + $this->assertSame('acme', (new DummyProduct())->skuConfigs()->getPrefix()); + $this->assertSame('acme', (new DummyProduct())->skuConfig('prefix')); + $this->assertSame('x', SkuConfigs::make()->setPrefix('x')->getPrefix()); + } +} diff --git a/tests/Feature/InvalidGeneratorConfigTest.php b/tests/Feature/InvalidGeneratorConfigTest.php new file mode 100644 index 0000000..305a5ad --- /dev/null +++ b/tests/Feature/InvalidGeneratorConfigTest.php @@ -0,0 +1,30 @@ + $config]; + } + + #[Test] + public function a_class_that_is_not_a_generator_is_rejected_with_the_config_key_named(): void + { + $this->expectException(InvalidOptionException::class); + $this->expectExceptionMessage('simtabi.lacommerce.generator.sku.generator'); + + DummyProduct::create(['name' => 'Blue shirt']); + } +} diff --git a/tests/Feature/PublishedConfigWithoutPrefixTest.php b/tests/Feature/PublishedConfigWithoutPrefixTest.php new file mode 100644 index 0000000..693b42a --- /dev/null +++ b/tests/Feature/PublishedConfigWithoutPrefixTest.php @@ -0,0 +1,35 @@ + $config]; + } + + #[Test] + public function a_missing_prefix_key_means_no_prefix(): void + { + $this->assertNull((new DummyProduct())->skuConfigs()->getPrefix()); + $this->assertMatchesRegularExpression('/^BLU-\d{10}$/', DummyProduct::create(['name' => 'Blue shirt'])->sku); + $this->assertMatchesRegularExpression('/^ORD-\d{10}$/', DummyOrder::create(['name' => 'Blue shirt'])->order_number); + } +} diff --git a/tests/Feature/ShippedGeneratorsTest.php b/tests/Feature/ShippedGeneratorsTest.php new file mode 100644 index 0000000..2e3fcd6 --- /dev/null +++ b/tests/Feature/ShippedGeneratorsTest.php @@ -0,0 +1,56 @@ +assertTrue((new \ReflectionClass($class))->isFinal(), "{$class} is not final"); + } + + $base = new \ReflectionClass(Generator::class); + $this->assertFalse($base->isFinal()); + + foreach (['getSourceString', 'makeValue', 'exists', 'generate'] as $hook) { + $this->assertTrue($base->getMethod($hook)->isProtected(), "Generator::{$hook}() is not a protected hook"); + } + } + + #[Test] + public function each_shipped_generator_implements_the_interface_it_is_resolved_through(): void + { + $this->assertInstanceOf(SkuGeneratorInterface::class, resolve(SkuGeneratorInterface::class, ['model' => new DummyProduct()])); + $this->assertInstanceOf(OrderNumberGeneratorInterface::class, resolve(OrderNumberGeneratorInterface::class, ['model' => new DummyOrder()])); + $this->assertInstanceOf(TicketNumberGeneratorInterface::class, resolve(TicketNumberGeneratorInterface::class, ['model' => new DummyTicket()])); + } + + #[Test] + public function the_prefix_defaults_to_none(): void + { + // Configs::$prefix was declared without a default, so getPrefix() threw before setPrefix(). + $this->assertNull((new DummyProduct())->skuConfigs()->getPrefix()); + $this->assertNull((new DummyProduct())->skuConfig('prefix')); + } +} diff --git a/tests/Fixtures/Generators/FallbackSkuGenerator.php b/tests/Fixtures/Generators/FallbackSkuGenerator.php new file mode 100644 index 0000000..7bd88da --- /dev/null +++ b/tests/Fixtures/Generators/FallbackSkuGenerator.php @@ -0,0 +1,28 @@ +model->only($this->modelConfig->getSourceColumn())); + + if ($fields === []) { + return 'item'; + } + + return implode($this->modelConfig->getSeparator(), $fields); + } +} diff --git a/tests/Fixtures/Generators/SlugTicketNumberGenerator.php b/tests/Fixtures/Generators/SlugTicketNumberGenerator.php new file mode 100644 index 0000000..d70b336 --- /dev/null +++ b/tests/Fixtures/Generators/SlugTicketNumberGenerator.php @@ -0,0 +1,21 @@ +model->getAttribute('name'))); + } +} diff --git a/tests/Unit/HelpersTest.php b/tests/Unit/HelpersTest.php new file mode 100644 index 0000000..51e7006 --- /dev/null +++ b/tests/Unit/HelpersTest.php @@ -0,0 +1,45 @@ +assertMatchesRegularExpression('/^SRC-\d{10}$/', Helpers::makeRandomString('src')); + $this->assertMatchesRegularExpression('/^SRC_\d{10}$/', Helpers::makeRandomString('src', '_')); + $this->assertMatchesRegularExpression('/^SRC-\d{10}$/', Helpers::makeRandomString('src', '-', null)); + } + + #[Test] + public function an_empty_prefix_is_the_same_as_none(): void + { + $this->assertMatchesRegularExpression('/^SRC-\d{10}$/', Helpers::makeRandomString('src', '-', '')); + } + + #[Test] + public function a_prefix_leads_the_value_and_is_joined_with_the_separator(): void + { + $this->assertMatchesRegularExpression('/^PFX-SRC-\d{10}$/', Helpers::makeRandomString('src', '-', 'pfx')); + $this->assertMatchesRegularExpression('/^PFX\/SRC\/\d{10}$/', Helpers::makeRandomString('src', '/', 'PFX')); + } + + #[Test] + public function a_prefix_of_zero_is_kept(): void + { + // empty('0') is true, so the original check would have dropped it. + $this->assertMatchesRegularExpression('/^0-SRC-\d{10}$/', Helpers::makeRandomString('src', '-', '0')); + } +} diff --git a/tests/Unit/IdentifiersTest.php b/tests/Unit/IdentifiersTest.php index 192393d..7f99e5f 100644 --- a/tests/Unit/IdentifiersTest.php +++ b/tests/Unit/IdentifiersTest.php @@ -56,4 +56,13 @@ public function the_class_is_final_and_its_constants_name_the_defaults(): void $this->assertSame('-', Identifiers::DEFAULT_SEPARATOR); $this->assertSame('ORD', Identifiers::DEFAULT_ORDER_PREFIX); } + + #[Test] + public function sku_and_ticket_number_take_an_optional_prefix(): void + { + $this->assertMatchesRegularExpression('/^ACME-LAR-\d{10}$/', Identifiers::sku('laravel', '-', 'acme')); + $this->assertMatchesRegularExpression('/^HD_SUP_\d{10}$/', Identifiers::ticketNumber('support', '_', 'hd')); + $this->assertMatchesRegularExpression('/^LAR-\d{10}$/', Identifiers::sku('laravel', '-', null)); + $this->assertMatchesRegularExpression('/^SUP-\d{10}$/', Identifiers::ticketNumber('support', '-', '')); + } }