diff --git a/CHANGELOG.md b/CHANGELOG.md index db652f5e..03d89dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `FleetbaseResource::resolve()` applies registered transformers to every resource, nested resource and collection item; `FleetbaseResourceCollection` resolves items (instead of calling `toArray()`), sharing one `prepare()` pass per collection. A hand-built collection with a manually set `preserveKeys` now filters item arrays with the item's flag. - `ResourceLifecycleEvent::getEventData()` and `broadcastWith()`, chat participant broadcast events, `Utils::serializeJsonResource()` and the cached internal user payload serialize through `resolve()`, so transformers apply to webhook and broadcast payloads and conditional `MissingValue`s are no longer emitted as `{}`. - `Find::httpResourceForModel()` caches internal and public resolutions separately. +- Untyped relation methods can no longer be requested by a client (see Security). Give the method a return type (`public function driver(): BelongsTo`) or list its name in `protected $withable = [...]`. +- A relation requested in another case (`subfleets`) loads under the declared method name (`subFleets`), the key resources read. +- `without` on create, update and public list responses adds to the model's hidden attributes (`makeHidden`) instead of replacing them (`setHidden`), so it can no longer un-hide the model's defaults. + +### Security +- Request `with` / `expand` / `with_count` / `count` names, and a template query's stored `with` list, reach Eloquent only when they name a real relation. That means a method or Expandable closure that declares an Eloquent Relation return type (a union counts when one member is a Relation), or a name the model lists in a new `$withable` array. Each segment of a nested path is checked against the related model, and the path is cut at the first segment that fails. Nothing after a `morphTo` is kept, and a `morphTo` is never counted. Anything else is dropped without an error. Previously Eloquent called any requested public method with no required arguments on a blank model: `with[]=saveInstance` inserted an empty row, and fleetops' `Order::dispatch()` saved an order and queued its events. See `Fleetbase\Support\RequestRelations`. ### Removed +- `HasApiModelBehavior::normalizeRequestRelations()` (protected, no callers outside the trait). - Legacy duck-typed transformers (`$target` property + static `output($model, $data)`), `ResourceTransformerRegistry::transform(Model, array)`, `resolveByTarget()`, `fixClassName()` and the static `$transformers` array. The `User` resource no longer calls the registry directly. - Adds first version diff --git a/src/Models/TemplateQuery.php b/src/Models/TemplateQuery.php index 6c1641b9..225de5c3 100644 --- a/src/Models/TemplateQuery.php +++ b/src/Models/TemplateQuery.php @@ -4,6 +4,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Services\TemplateRenderService; +use Fleetbase\Support\RequestRelations; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -180,7 +181,8 @@ public function execute(): \Illuminate\Support\Collection } // Eager-load relationships - $with = $this->getAttribute('with'); + // The stored list is user-editable, so it is held to the same relation check as a request's `with` + $with = RequestRelations::eagerLoads($model, $this->getAttribute('with')); if (!empty($with)) { $query->with($with); } diff --git a/src/Support/RequestRelations.php b/src/Support/RequestRelations.php new file mode 100644 index 00000000..dce3f2ca --- /dev/null +++ b/src/Support/RequestRelations.php @@ -0,0 +1,205 @@ +newInstance()->{$name}()`, so an + * unchecked name runs any public method that needs no arguments on a blank model: `with[]=saveInstance` + * inserts an empty row, and fleetops' `Order::dispatch()` saves an order and queues its events. + * + * A name is accepted when the method (or the Expandable closure registered under that name) declares an + * Eloquent Relation return type, or when the model lists it in a `$withable` array. Each segment of a + * nested path is checked against the model the previous segment relates to, and the path is cut at the + * first segment that fails. Nothing after a morphTo is kept, since its related model is only known per + * result. Rejected names are dropped without an error. + */ +class RequestRelations +{ + /** + * Most paths read from one request. + */ + public const MAX_PATHS = 50; + + /** + * Most segments kept in one nested path. + */ + public const MAX_DEPTH = 5; + + /** + * Get the eager load paths a request may apply to a model. + * + * @param mixed $relations a comma-separated string or a list of dot paths + * + * @return string[] the accepted paths, with each segment named as the model's method + */ + public static function eagerLoads(Model $model, mixed $relations): array + { + $paths = []; + foreach (static::split($relations) as $path) { + $current = $model; + $segments = []; + foreach (explode('.', $path) as $segment) { + $method = static::relationMethod($current, $segment); + $relation = $method ? static::relation($current, $method) : null; + if (!$relation) { + break; + } + + $segments[] = $method; + if ($relation instanceof MorphTo || count($segments) >= static::MAX_DEPTH) { + break; + } + + $current = $relation->getRelated(); + } + + if ($segments) { + $paths[] = implode('.', $segments); + } + } + + return array_values(array_unique($paths)); + } + + /** + * Get the relation counts a request may apply to a model. + * + * @param mixed $relations a comma-separated string or a list of relation names + * + * @return string[] the accepted relation names, named as the model's methods + */ + public static function counts(Model $model, mixed $relations): array + { + $names = []; + foreach (static::split($relations) as $name) { + $method = str_contains($name, '.') ? null : static::relationMethod($model, $name); + if ($method === null) { + continue; + } + + $relation = static::relation($model, $method); + if ($relation && !$relation instanceof MorphTo) { + $names[] = $method; + } + } + + return array_values(array_unique($names)); + } + + /** + * Resolve a requested segment to the name of a relation method on the model. + * + * The segment is tried camel-cased, as given, then snake-cased. A method is returned as declared, so + * `subfleets` resolves to `subFleets` and the relation is loaded under the key resources read. + */ + public static function relationMethod(Model $model, string $segment): ?string + { + $segment = trim($segment); + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $segment)) { + return null; + } + + $withable = array_map('strtolower', static::withable($model)); + foreach (array_unique([Str::camel($segment), $segment, Str::snake($segment)]) as $name) { + $callable = static::callable($model, $name); + if (!$callable) { + continue; + } + + $name = $callable instanceof \ReflectionMethod ? $callable->getName() : $name; + if (in_array(strtolower($name), $withable, true) || static::declaresRelation($callable->getReturnType())) { + return $name; + } + } + + return null; + } + + /** + * Flatten every accepted input shape to a list of trimmed, non-empty paths. + * + * @return string[] + */ + protected static function split(mixed $relations): array + { + if (is_string($relations)) { + $relations = explode(',', $relations); + } + if (!is_array($relations)) { + return []; + } + + $paths = array_map('trim', array_filter($relations, 'is_string')); + + return array_slice(array_values(array_unique(array_filter($paths, fn (string $path) => $path !== ''))), 0, static::MAX_PATHS); + } + + /** + * Get the method, or the Expandable closure, a relation name calls, when it can be called with no arguments. + */ + protected static function callable(Model $model, string $name): ?\ReflectionFunctionAbstract + { + if (method_exists($model, $name)) { + $method = new \ReflectionMethod($model, $name); + + return $method->isPublic() && !$method->isStatic() && $method->getNumberOfRequiredParameters() === 0 ? $method : null; + } + + // isExpansion() only holds for a registered Closure + if (method_exists($model, 'isExpansion') && $model::isExpansion($name)) { + $closure = new \ReflectionFunction($model::getExpansionClosure($name)); + + return $closure->getNumberOfRequiredParameters() === 0 ? $closure : null; + } + + return null; + } + + /** + * Whether a declared return type names an Eloquent relation (`BelongsTo`, `?HasMany`, `BelongsTo|Builder`). + */ + protected static function declaresRelation(?\ReflectionType $type): bool + { + $types = $type instanceof \ReflectionUnionType ? $type->getTypes() : [$type]; + foreach ($types as $member) { + if ($member instanceof \ReflectionNamedType && !$member->isBuiltin() && is_a($member->getName(), Relation::class, true)) { + return true; + } + } + + return false; + } + + /** + * Get the relation names a model allows beyond those with a declared Relation return type. + * + * @return string[] + */ + protected static function withable(Model $model): array + { + $withable = (fn () => property_exists($this, 'withable') ? $this->withable : [])->call($model); + + return is_array($withable) ? array_values(array_filter($withable, 'is_string')) : []; + } + + /** + * Build the relation the same way Eloquent does for an eager load, or null when it is not one. + */ + protected static function relation(Model $model, string $method): ?Relation + { + try { + $relation = Relation::noConstraints(fn () => $model->newInstance()->{$method}()); + } catch (\Throwable) { + return null; + } + + return $relation instanceof Relation ? $relation : null; + } +} diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 89c29cd3..29c8545a 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -7,6 +7,7 @@ use Fleetbase\Support\Auth; use Fleetbase\Support\Http; use Fleetbase\Support\QueryOptimizer; +use Fleetbase\Support\RequestRelations; use Fleetbase\Support\Resolve; use Fleetbase\Support\Utils; use Illuminate\Http\JsonResponse; @@ -353,13 +354,13 @@ public function createRecordFromRequest($request, ?callable $onBefore = null, ?c // PERFORMANCE OPTIMIZATION: Use load() instead of re-querying the database // This avoids an unnecessary second database query - $with = static::normalizeRequestRelations($request->or(['with', 'expand'], [])); + $with = RequestRelations::eagerLoads($record, $request->or(['with', 'expand'], [])); if (!empty($with)) { $record->load($with); } // Load counts if requested - $withCount = $request->array('with_count', []); + $withCount = RequestRelations::counts($record, $request->input('with_count', [])); if (!empty($withCount)) { $record->loadCount($withCount); } @@ -456,13 +457,13 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo // PERFORMANCE OPTIMIZATION: Use load() instead of re-querying the database // This avoids an unnecessary second database query - $with = static::normalizeRequestRelations($request->or(['with', 'expand'], [])); + $with = RequestRelations::eagerLoads($record, $request->or(['with', 'expand'], [])); if (!empty($with)) { $record->load($with); } // Load counts if requested - $withCount = $request->array('with_count', []); + $withCount = RequestRelations::counts($record, $request->input('with_count', [])); if (!empty($withCount)) { $record->loadCount($withCount); } @@ -548,9 +549,6 @@ public function bulkRemove($ids = []) */ public static function mutateModelWithRequest(Request $request, $result) { - $with = static::normalizeRequestRelations($request->or(['with', 'expand'], [])); - $without = $request->array('without'); - // handle collection or array of results if (is_array($result) || $result instanceof \Illuminate\Support\Collection) { return collect($result)->map( @@ -560,30 +558,25 @@ function ($model) use ($request) { ); } + if (!$result instanceof \Illuminate\Database\Eloquent\Model) { + return $result; + } + + $with = RequestRelations::eagerLoads($result, $request->or(['with', 'expand'], [])); + $without = array_filter($request->array('without'), 'is_string'); + if ($with) { $result->load($with); } + // makeHidden adds to the model's hidden attributes; setHidden would replace them and expose the defaults if ($without) { - $result->setHidden($without); + $result->makeHidden($without); } return $result; } - protected static function normalizeRequestRelations($relations) - { - if (empty($relations)) { - return []; - } - - $relations = is_array($relations) ? $relations : explode(',', $relations); - - return array_map(function ($relation) { - return implode('.', array_map(fn ($part) => Str::camel(trim($part)), explode('.', $relation))); - }, $relations); - } - /** * Fills the target array with session attributes based on the specified rules. * Allows to apply exceptions and only specific attributes to be filled. @@ -640,33 +633,9 @@ public function withRelationships(Request $request, $builder) return $builder; } - $contains = is_array($with) ? $with : explode(',', $with); - - foreach ($contains as $contain) { - $camelVersion = Str::camel(trim($contain)); - if (\method_exists($this, $camelVersion)) { - $builder->with($camelVersion); - continue; - } - - $snakeCase = Str::snake(trim($contain)); - if (\method_exists($this, $snakeCase)) { - $builder->with(trim($snakeCase)); - continue; - } - - if (strpos($contain, '.') !== false) { - $parts = array_map( - function ($part) { - return Str::camel($part); - }, - explode('.', $contain) - ); - $contain = implode('.', $parts); - - $builder->with($contain); - continue; - } + $contains = RequestRelations::eagerLoads($this, $with); + if ($contains) { + $builder->with($contains); } if ($without) { @@ -692,19 +661,9 @@ public function withCounts($request, $builder) return $builder; } - $counters = explode(',', $count); - - foreach ($counters as $counter) { - if (\method_exists($this, $counter)) { - $builder->withCount($counter); - continue; - } - - $camelVersion = Str::camel($counter); - if (\method_exists($this, $camelVersion)) { - $builder->withCount($camelVersion); - continue; - } + $counters = RequestRelations::counts($this, $count); + if ($counters) { + $builder->withCount($counters); } return $builder; @@ -965,16 +924,9 @@ public function applyCustomFilters(Request $request, $builder) $without = $request->array('without'); $withoutRelations = $request->boolean('without_relations'); - // camelcase all params in with and apply + // apply only the requested names that are relations of this model if (is_array($with)) { - $with = array_map( - function ($relationship) { - return Str::camel($relationship); - }, - $with - ); - - $builder = $builder->with($with); + $builder = $builder->with(RequestRelations::eagerLoads($this, $with)); } // camelcase all params in with and apply diff --git a/tests/Unit/Models/TemplateModelsTest.php b/tests/Unit/Models/TemplateModelsTest.php index d0a221fb..1c3a940e 100644 --- a/tests/Unit/Models/TemplateModelsTest.php +++ b/tests/Unit/Models/TemplateModelsTest.php @@ -5,6 +5,7 @@ use Fleetbase\Traits\Searchable; use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Events\Dispatcher; use Illuminate\Support\Facades\Facade; @@ -15,10 +16,19 @@ class TemplateQueryTenantFixture extends Model protected $guarded = []; public $timestamps = false; - public function notes() + public static int $archived = 0; + + public function notes(): HasMany { return $this->hasMany(TemplateQueryTenantNoteFixture::class, 'fixture_id'); } + + public function archive() + { + static::$archived++; + + return $this; + } } class TemplateQueryTenantNoteFixture extends Model @@ -411,16 +421,18 @@ function template_models_database(): Capsule ['field' => 'archived_at', 'operator' => 'null'], ['field' => null, 'operator' => '=', 'value' => 'ignored'], ], - 'with' => ['notes'], + 'with' => ['notes', 'archive'], 'sort' => [ ['field' => 'score', 'direction' => 'desc'], ], 'limit' => 1, ]); - $results = $query->execute(); + TemplateQueryTenantFixture::$archived = 0; + $results = $query->execute(); expect($results)->toHaveCount(1) + ->and(TemplateQueryTenantFixture::$archived)->toBe(0) ->and($results->first()->name)->toBe('Alpha order') ->and($results->first()->company_uuid)->toBe('company-1') ->and($results->first()->relationLoaded('notes'))->toBeTrue() diff --git a/tests/Unit/Support/RequestRelationsTest.php b/tests/Unit/Support/RequestRelationsTest.php new file mode 100644 index 00000000..f0c817bc --- /dev/null +++ b/tests/Unit/Support/RequestRelationsTest.php @@ -0,0 +1,214 @@ +hasMany(RequestRelationsChild::class, 'parent_id'); + } + + public function nullableOwner(): ?BelongsTo + { + return $this->belongsTo(RequestRelationsChild::class, 'owner_id'); + } + + public function scopedOwner(): BelongsTo|Builder + { + return $this->belongsTo(RequestRelationsChild::class, 'owner_id'); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } + + public function brokenChildren(): HasMany + { + throw new RuntimeException('relation cannot be built on a blank model'); + } + + public function untypedChildren() + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + } + + public function legacyChildren() + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + } + + public function dispatch(bool $save = true) + { + static::$calls++; + + return $this; + } + + public function typedChildrenFor(string $type): HasMany + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id')->where('type', $type); + } + + public static function staticChildren(): HasMany + { + return (new static())->hasMany(RequestRelationsChild::class, 'parent_id'); + } + + protected function hiddenChildren(): HasMany + { + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + } +} + +class RequestRelationsChild extends EloquentModel +{ + public static int $calls = 0; + + protected $table = 'request_relations_children'; + + public function parent(): BelongsTo + { + return $this->belongsTo(RequestRelationsParent::class, 'parent_id'); + } + + public function untypedParent() + { + return $this->belongsTo(RequestRelationsParent::class, 'parent_id'); + } + + public function cancel() + { + static::$calls++; + + return $this; + } +} + +class RequestRelationsExpansion +{ + public static function target(): string + { + return RequestRelationsParent::class; + } + + public static function expandedChildren() + { + return function (): HasMany { + /* @var RequestRelationsParent $this */ + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + }; + } + + public static function untypedExpandedChildren() + { + return function () { + /* @var RequestRelationsParent $this */ + return $this->hasMany(RequestRelationsChild::class, 'parent_id'); + }; + } +} + +beforeEach(function () { + bind_test_container(); + + $capsule = new Capsule(app()); + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + RequestRelationsParent::expand(new RequestRelationsExpansion()); + RequestRelationsParent::$calls = 0; + RequestRelationsChild::$calls = 0; +}); + +afterEach(function () { + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); +}); + +test('request relations accept methods that declare a relation return type', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['children', 'nullable_owner', 'scopedOwner', 'subject'])) + ->toBe(['children', 'nullableOwner', 'scopedOwner', 'subject']) + ->and(RequestRelations::eagerLoads($model, 'children, nullable_owner'))->toBe(['children', 'nullableOwner']) + ->and(RequestRelations::eagerLoads($model, ['nullableowner', 'legacychildren']))->toBe(['nullableOwner', 'legacyChildren']) + ->and(RequestRelations::counts($model, ['children', 'scoped_owner']))->toBe(['children', 'scopedOwner']); +}); + +test('request relations drop untyped methods unless the model lists them as withable', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['untypedChildren', 'legacy_children']))->toBe(['legacyChildren']) + ->and(RequestRelations::counts($model, ['untypedChildren', 'legacyChildren']))->toBe(['legacyChildren']); +}); + +test('request relations never call a method that is not a relation', function () { + $model = new RequestRelationsParent(); + $requested = ['dispatch', 'save', 'push', 'touch', 'delete', 'fresh', 'replicate', 'toArray', 'getKey', 'children.cancel', 'children.parent.dispatch']; + + expect(RequestRelations::eagerLoads($model, $requested))->toBe(['children', 'children.parent']) + ->and(RequestRelations::counts($model, $requested))->toBe([]) + ->and(RequestRelationsParent::$calls)->toBe(0) + ->and(RequestRelationsChild::$calls)->toBe(0); +}); + +test('request relations drop static protected and argument taking methods', function () { + expect(RequestRelations::eagerLoads(new RequestRelationsParent(), ['staticChildren', 'hiddenChildren', 'typedChildrenFor']))->toBe([]); +}); + +test('request relations drop a typed relation that cannot be built instead of failing the request', function () { + expect(RequestRelations::eagerLoads(new RequestRelationsParent(), ['brokenChildren', 'children']))->toBe(['children']) + ->and(RequestRelations::counts(new RequestRelationsParent(), ['brokenChildren']))->toBe([]); +}); + +test('request relations check each nested segment against the related model', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['children.parent.children', 'children.untyped_parent', 'children.missing.parent'])) + ->toBe(['children.parent.children', 'children']) + ->and(RequestRelations::eagerLoads($model, ['children.parent.children.parent.children.parent.children'])) + ->toBe(['children.parent.children.parent.children']) + ->and(RequestRelations::counts($model, ['children.parent']))->toBe([]); +}); + +test('request relations stop a path at a morph to and never count one', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['subject.dispatch', 'subject.children']))->toBe(['subject']) + ->and(RequestRelations::counts($model, ['subject']))->toBe([]); +}); + +test('request relations accept expansion closures by the same rule', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['expandedChildren', 'untypedExpandedChildren', 'expanded_children.parent'])) + ->toBe(['expandedChildren', 'expandedChildren.parent']); +}); + +test('request relations ignore malformed input and cap the number of paths', function () { + $model = new RequestRelationsParent(); + + expect(RequestRelations::eagerLoads($model, ['children:id,name', 'children items', '', ' ', ['children'], 42, null, 'children..parent'])) + ->toBe(['children']) + ->and(RequestRelations::eagerLoads($model, null))->toBe([]) + ->and(RequestRelations::eagerLoads($model, 42))->toBe([]) + ->and(RequestRelations::eagerLoads($model, array_merge(array_map(fn ($i) => "missing{$i}", range(1, RequestRelations::MAX_PATHS)), ['children'])))->toBe([]); +}); diff --git a/tests/Unit/Traits/HasApiModelBehaviorTest.php b/tests/Unit/Traits/HasApiModelBehaviorTest.php index 96f1f75b..a8dcba73 100644 --- a/tests/Unit/Traits/HasApiModelBehaviorTest.php +++ b/tests/Unit/Traits/HasApiModelBehaviorTest.php @@ -8,6 +8,7 @@ use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Events\Dispatcher; use Illuminate\Http\JsonResponse; @@ -172,7 +173,7 @@ class HasApiModelBehaviorRecord extends Model 'updated_at', ]; - public function childItems() + public function childItems(): HasMany { return $this->hasMany(HasApiModelBehaviorChild::class, 'record_uuid', 'uuid'); } @@ -348,7 +349,7 @@ class HasApiModelBehaviorSnakeRelationRecord extends Model 'name', ]; - public function child_items() + public function child_items(): HasMany { return $this->hasMany(HasApiModelBehaviorChild::class, 'record_uuid', 'uuid'); } @@ -363,6 +364,29 @@ class HasApiModelBehaviorChild extends Model public $timestamps = false; } +class HasApiModelBehaviorHiddenRecord extends HasApiModelBehaviorRecord +{ + protected $hidden = ['amount']; +} + +/** + * Mirrors fleetops' Order::dispatch(): a public method with no required arguments + * that writes a row and would fire events. A request naming it must never run it. + */ +class HasApiModelBehaviorSideEffectRecord extends HasApiModelBehaviorRecord +{ + public static int $dispatched = 0; + + public function dispatch(bool $save = true) + { + static::$dispatched++; + + $this->forceFill(['uuid' => 'junk-' . static::$dispatched, 'name' => 'Junk dispatch'])->saveQuietly(); + + return $this; + } +} + function has_api_model_behavior_database(): Capsule { EloquentModel::clearBootedModels(); @@ -1041,12 +1065,67 @@ public function total(): int ->and($nullStatuses)->toBe(['record-3']) ->and($notNullStatuses)->toBe(['record-1', 'record-2']) ->and($likeNames)->toBe(['record-1']) - ->and(array_keys($relationshipBuilder->getEagerLoads()))->toBe(['childItems', 'childItems.grandChildren']) + ->and(array_keys($relationshipBuilder->getEagerLoads()))->toBe(['childItems']) ->and($relationshipBuilder->getQuery()->columns)->toBeNull() ->and(array_keys($snakeRelationBuilder->getEagerLoads()))->toBe(['child_items']) ->and($countBuilder->toSql())->toContain('api_model_behavior_children', 'child_items_count'); }); +test('api model behavior without adds to the hidden attributes instead of replacing them', function () { + $capsule = has_api_model_behavior_database(); + has_api_model_behavior_seed_records($capsule); + + $results = (new HasApiModelBehaviorHiddenRecord())->queryFromRequest(has_api_model_behavior_request([ + 'company_uuid' => 'company-a', + 'without' => ['slug'], + ])); + + expect($results->first()->getHidden())->toContain('amount', 'slug') + ->and($results->first()->toArray())->not->toHaveKeys(['amount', 'slug']) + ->and(HasApiModelBehaviorHiddenRecord::mutateModelWithRequest(has_api_model_behavior_request(['with' => 'childItems']), null))->toBeNull(); +}); + +test('api model behavior never calls a requested method that is not a relation', function (array $input) { + $capsule = has_api_model_behavior_database(); + has_api_model_behavior_seed_records($capsule); + session(['user' => 'session-user', 'company' => 'company-a']); + HasApiModelBehaviorSideEffectRecord::$dispatched = 0; + + $model = new HasApiModelBehaviorSideEffectRecord(); + $errors = []; + $attempt = function (string $path, Closure $call) use (&$errors) { + try { + return $call(); + } catch (Throwable $e) { + $errors[$path] = get_class($e); + } + }; + + $results = $attempt('list', fn () => $model->queryFromRequest(has_api_model_behavior_request(array_merge(['company_uuid' => 'company-a'], $input)))); + $found = $attempt('find', fn () => $model->getById('record_alpha', null, has_api_model_behavior_request($input))); + $created = $attempt('create', fn () => $model->createRecordFromRequest(has_api_model_behavior_request(array_merge([ + 'api_model_behavior_record' => ['uuid' => 'record-created', 'public_id' => 'record_created', 'name' => 'Created'], + ], $input), method: 'POST'))); + + $records = $capsule->getConnection('mysql')->table('api_model_behavior_records'); + + expect(HasApiModelBehaviorSideEffectRecord::$dispatched)->toBe(0) + ->and((clone $records)->where('name', 'like', 'Junk%')->orWhereNull('name')->count())->toBe(0) + ->and($errors)->toBe([]) + ->and($results)->toHaveCount(2) + ->and($found->uuid)->toBe('record-1') + ->and($created->uuid)->toBe('record-created'); +})->with([ + 'with array' => [['with' => ['dispatch']]], + 'with string' => [['with' => 'dispatch']], + 'expand' => [['expand' => ['dispatch']]], + 'nested' => [['with' => ['childItems', 'child_items.dispatch']]], + 'with_count' => [['with_count' => 'dispatch']], + 'with_count array' => [['with_count' => ['dispatch']]], + 'count' => [['count' => 'dispatch']], + 'base model method' => [['with' => ['saveInstance']]], +]); + test('api model behavior covers cached queries and create update response relation loading', function () { $capsule = has_api_model_behavior_database(); has_api_model_behavior_seed_records($capsule);