diff --git a/composer.json b/composer.json index 09e3868..806a468 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,6 @@ { "name": "ziffdavis/laravel-eloquent-imagery", + "description": "Image handling per column/attribute for Laravel Eloquent", "type": "library", "authors": [ { @@ -11,7 +12,7 @@ "php": "^7.1" }, "require-dev": { - "laravel/laravel": "^5.6.0", + "orchestra/testbench": "^3.8", "intervention/image": "^2.4", "phpunit/phpunit": "^7.1" }, diff --git a/src/Controller/EloquentImageryController.php b/src/Controller/EloquentImageryController.php index 93dbef9..e32415c 100644 --- a/src/Controller/EloquentImageryController.php +++ b/src/Controller/EloquentImageryController.php @@ -98,6 +98,7 @@ public function render($path) // step 3: no placeholder, no primary FS image, look for fallback image on alternative filesystem if enabled if (!$imageBytes && config('eloquent-imagery.render.fallback.enable')) { + /** @var Filesystem $fallbackFilesystem */ $fallbackFilesystem = app(FilesystemManager::class)->disk(config('eloquent-imagery.render.fallback.filesystem')); try { $imageBytes = $fallbackFilesystem->get($storagePath); diff --git a/src/Eloquent/EloquentImageryObserver.php b/src/Eloquent/EloquentImageryObserver.php index afe03f0..04b8889 100644 --- a/src/Eloquent/EloquentImageryObserver.php +++ b/src/Eloquent/EloquentImageryObserver.php @@ -5,15 +5,28 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use ReflectionProperty; +use RuntimeException; class EloquentImageryObserver { - protected $attributeReflector = null; + /** @var ReflectionProperty */ + protected $eloquentImageryImagesReflector; - public function __construct() + /** @var ReflectionProperty */ + protected $attributesReflector; + + /** + * EloquentImageryObserver constructor. + * @param $modelClassToObserve + * @throws \ReflectionException + */ + public function __construct($modelClassToObserve) { - $this->attributeReflector = new ReflectionProperty(Model::class, 'attributes'); - $this->attributeReflector->setAccessible(true); + $this->eloquentImageryImagesReflector = new ReflectionProperty($modelClassToObserve, 'eloquentImageryImages'); + $this->eloquentImageryImagesReflector->setAccessible(true); + + $this->attributesReflector = new ReflectionProperty($modelClassToObserve, 'attributes'); + $this->attributesReflector->setAccessible(true); } /** @@ -21,31 +34,32 @@ public function __construct() */ public function retrieved(Model $model) { - $attributeImages = $model->getEloquentImageryImages(); + /** @var Image[]|ImageCollection[] $eloquentImageryImages */ + $eloquentImageryImages = $this->eloquentImageryImagesReflector->getValue($model); - $modelAttributes = $this->attributeReflector->getValue($model); + $modelAttributes = $this->attributesReflector->getValue($model); - foreach ($attributeImages as $attribute => $image) { + foreach ($eloquentImageryImages as $attribute => $image) { // in the case a model was retrieved and the image column was not returned if (!array_key_exists($attribute, $modelAttributes)) { continue; } - $properties = $modelAttributes[$attribute]; + $attributeData = $modelAttributes[$attribute]; $modelAttributes[$attribute] = $image; - if ($properties == '') { + if ($attributeData == '') { continue; } - if (is_string($properties)) { - $properties = json_decode($properties, true); + if (is_string($attributeData)) { + $attributeData = json_decode($attributeData, true); } - $image->setStateProperties($properties); + $image->setStateFromAttributeData($attributeData); } - $this->attributeReflector->setValue($model, $modelAttributes); + $this->attributesReflector->setValue($model, $modelAttributes); } /** @@ -53,36 +67,35 @@ public function retrieved(Model $model) */ public function saving(Model $model) { - $attributeImages = $model->getEloquentImageryImages(); + /** @var Image[]|ImageCollection[] $eloquentImageryImages */ + $eloquentImageryImages = $this->eloquentImageryImagesReflector->getValue($model); $casts = $model->getCasts(); - $modelAttributes = $this->attributeReflector->getValue($model); + $modelAttributes = $this->attributesReflector->getValue($model); - foreach ($attributeImages as $attribute => $image) { + foreach ($eloquentImageryImages as $attribute => $image) { if ($image->pathHasReplacements()) { - $image->updatePath($model); + $image->updatePath([], $model); } if ($image instanceof ImageCollection) { $image->purgeRemovedImages(); - } - - if (!$image->exists()) { + } elseif ($image instanceof Image && !$image->exists()) { $modelAttributes[$attribute] = null; continue; } - $imageState = $image->getStateProperties(); + $attributeData = $image->getStateAsAttributeData(); $value = (isset($casts[$attribute]) && $casts[$attribute] === 'json') - ? $imageState - : json_encode($imageState); + ? $attributeData + : json_encode($attributeData); $modelAttributes[$attribute] = $value; } - $this->attributeReflector->setValue($model, $modelAttributes); + $this->attributesReflector->setValue($model, $modelAttributes); } /** @@ -90,24 +103,27 @@ public function saving(Model $model) */ public function saved(Model $model) { - $attributeImages = $model->getEloquentImageryImages(); + /** @var Image[]|ImageCollection[] $eloquentImageryImages */ + $eloquentImageryImages = $this->eloquentImageryImagesReflector->getValue($model); + + $casts = $model->getCasts(); $errors = []; - $modelAttributes = $this->attributeReflector->getValue($model); + $modelAttributes = $this->attributesReflector->getValue($model); - foreach ($attributeImages as $attribute => $image) { + foreach ($eloquentImageryImages as $attribute => $image) { if ($image->pathHasReplacements()) { - $image->updatePath($model); + $image->updatePath([], $model); if ($image->pathHasReplacements()) { $errors[] = "After saving row, image for attribute {$attribute}'s path still contains unresolvable path replacements"; } - $imageState = $image->getStateProperties(); + $imageState = $image->getStateAsAttributeData(); - $value = (isset($this->casts[$attribute]) && $this->casts[$attribute] === 'json') + $value = (isset($casts[$attribute]) && $casts[$attribute] === 'json') ? $imageState : json_encode($imageState); @@ -121,10 +137,10 @@ public function saved(Model $model) $modelAttributes[$attribute] = $image; } - $this->attributeReflector->setValue($model, $modelAttributes); + $this->attributesReflector->setValue($model, $modelAttributes); if ($errors) { - throw new \RuntimeException(implode('; ', $errors)); + throw new RuntimeException(implode('; ', $errors)); } } @@ -137,7 +153,10 @@ public function deleted(Model $model) return; } - foreach ($model->getEloquentImageryImages() as $image) { + /** @var Image[]|ImageCollection[] $eloquentImageryImages */ + $eloquentImageryImages = $this->eloquentImageryImagesReflector->getValue($model); + + foreach ($eloquentImageryImages as $image) { if ($image->exists()) { $image->remove(); $image->flush(); diff --git a/src/Eloquent/HasEloquentImagery.php b/src/Eloquent/HasEloquentImagery.php index acfc2ce..ebc3c5c 100644 --- a/src/Eloquent/HasEloquentImagery.php +++ b/src/Eloquent/HasEloquentImagery.php @@ -2,14 +2,15 @@ namespace ZiffDavis\Laravel\EloquentImagery\Eloquent; -use Illuminate\Support\Str; +use Illuminate\Filesystem\FilesystemManager; +use RuntimeException; /** * @mixin \Illuminate\Database\Eloquent\Model */ trait HasEloquentImagery { - /** @var Image[] */ + /** @var Image[]|ImageCollection[] */ protected static $eloquentImageryPrototypes = []; /** @var Image[]|ImageCollection[] */ @@ -17,17 +18,23 @@ trait HasEloquentImagery public static function bootHasEloquentImagery() { - static::observe(new EloquentImageryObserver()); + $observer = new EloquentImageryObserver(get_called_class()); + + // register directly so that the instance is preserved (not preserved via static::observe()) + static::registerModelEvent('retrieved', [$observer, 'retrieved']); + static::registerModelEvent('saving', [$observer, 'saving']); + static::registerModelEvent('saved', [$observer, 'saved']); + static::registerModelEvent('deleted', [$observer, 'deleted']); } public function initializeHasEloquentImagery() { if (!empty($this->eloquentImageryImages)) { - throw new \RuntimeException('$eloquentImageryImages should be empty, are you sure you have your configuration in the right place?'); + throw new RuntimeException('$eloquentImageryImages should be empty, are you sure you have your configuration in the right place?'); } if (empty($this->eloquentImagery) || !property_exists($this, 'eloquentImagery')) { - throw new \RuntimeException('You are using ' . __TRAIT__ . ' but have not yet configured it through $eloquentImagery, please see the docs'); + throw new RuntimeException('You are using ' . __TRAIT__ . ' but have not yet configured it through $eloquentImagery, please see the docs'); } foreach ($this->eloquentImagery as $attribute => $config) { @@ -35,45 +42,23 @@ public function initializeHasEloquentImagery() $config = ['path' => $config]; } - if (isset($config['collection']) && $config['collection'] === true) { - $this->eloquentImageryCollection($attribute, $config['path']); - } else { - $this->eloquentImagery($attribute, $config['path']); + if (!is_array($config)) { + throw new RuntimeException('configuration must be a string or array'); } - } - } - public function eloquentImagery($attribute, $path = null) - { - $this->eloquentImageryInitializeImage(Image::class, $attribute, $path); - } + if (!isset(static::$eloquentImageryPrototypes[$attribute])) { + $prototype = new Image($config['path']); - public function eloquentImageryCollection($attribute, $path = null) - { - $this->eloquentImageryInitializeImage(ImageCollection::class, $attribute, $path); - } + if (isset($config['collection']) && $config['collection'] === true) { + $prototype = new ImageCollection($prototype); + } - protected function eloquentImageryInitializeImage($class, $attribute, $path) - { - if (!isset(static::$eloquentImageryPrototypes[$attribute])) { - if (!$path) { - $path = ($class === ImageCollection::class) - ? Str::singular($this->getTable()) . '/{' . $this->getKeyName() . "}/{$attribute}-{index}.{extension}" - : Str::singular($this->getTable()) . '/{' . $this->getKeyName() . "}/{$attribute}.{extension}"; + static::$eloquentImageryPrototypes[$attribute] = $prototype; + } else { + $prototype = static::$eloquentImageryPrototypes[$attribute]; } - static::$eloquentImageryPrototypes[$attribute] = new $class($attribute, $path); + $this->attributes[$attribute] = $this->eloquentImageryImages[$attribute] = clone $prototype; } - - // set the image as the attribute so that it can be accessed on new instances via attribute accessor - $this->eloquentImageryImages[$attribute] = $this->attributes[$attribute] = clone static::$eloquentImageryPrototypes[$attribute]; - } - - /** - * @return Image[]|ImageCollection[] - */ - public function getEloquentImageryImages() - { - return $this->eloquentImageryImages; } } diff --git a/src/Eloquent/Image.php b/src/Eloquent/Image.php index 88020dd..a9a9a3b 100644 --- a/src/Eloquent/Image.php +++ b/src/Eloquent/Image.php @@ -2,21 +2,27 @@ namespace ZiffDavis\Laravel\EloquentImagery\Eloquent; -use Illuminate\Database\Eloquent\Model; +use Carbon\Carbon; +use Illuminate\Contracts\Filesystem\Cloud; use Illuminate\Contracts\Filesystem\Filesystem; +use Illuminate\Database\Eloquent\Model; use Illuminate\Filesystem\FilesystemManager; -use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Collection; +use finfo; +use OutOfBoundsException; +use RuntimeException; /** - * @property-read \ArrayObject $metadata + * @property-read string $path + * @property-read Collection $metadata */ class Image implements \JsonSerializable { - /** @var Filesystem */ + /** @var Filesystem|Cloud */ protected static $filesystem = null; + /** @var string */ protected $pathTemplate = null; protected $path = ''; @@ -25,7 +31,7 @@ class Image implements \JsonSerializable protected $height = null; protected $hash = ''; protected $timestamp = 0; - /** @var \ArrayObject */ + /** @var Collection */ protected $metadata = null; protected $exists = false; @@ -35,12 +41,12 @@ class Image implements \JsonSerializable public function __construct($pathTemplate) { - if (!self::$filesystem) { - self::$filesystem = app(FilesystemManager::class)->disk(config('eloquent-imagery.filesystem', config('filesystems.default'))); + if (!static::$filesystem) { + static::$filesystem = app(FilesystemManager::class)->disk(config('eloquent-imagery.filesystem', config('filesystems.default'))); } $this->pathTemplate = $pathTemplate; - $this->metadata = new \ArrayObject([], \ArrayObject::ARRAY_AS_PROPS); + $this->metadata = new Collection; } public function exists() @@ -53,11 +59,11 @@ public function url($modifiers = null) $renderRouteEnabled = config('eloquent-imagery.render.enable'); if ($renderRouteEnabled === false && $modifiers) { - throw new \RuntimeException('Cannot process render options unless the rendering route is enabled'); + throw new RuntimeException('Cannot process render options unless the rendering route is enabled'); } - if ($renderRouteEnabled === false) { - return self::$filesystem->url($this->path); + if ($renderRouteEnabled === false && static::$filesystem instanceof Cloud) { + return static::$filesystem->url($this->path); } if ($modifiers) { @@ -83,40 +89,42 @@ public function url($modifiers = null) return url()->route('eloquent-imagery.render', $pathWithModifiers); } - public function setStateProperties($properties) + public function setStateFromAttributeData($attributeData) { - $this->path = $properties['path']; - $this->extension = $properties['extension']; - $this->width = $properties['width']; - $this->height = $properties['height']; - $this->hash = $properties['hash']; - $this->timestamp = $properties['timestamp']; - $this->metadata->exchangeArray($properties['metadata']); + $this->path = $attributeData['path']; + $this->extension = $attributeData['extension']; + $this->width = $attributeData['width']; + $this->height = $attributeData['height']; + $this->hash = $attributeData['hash']; + $this->timestamp = $attributeData['timestamp']; + + $this->metadata = new Collection($attributeData['metadata']); + $this->exists = true; } - public function getStateProperties() + public function getStateAsAttributeData() { return [ - 'path' => $this->path, + 'path' => $this->path, 'extension' => $this->extension, - 'width' => $this->width, - 'height' => $this->height, - 'hash' => $this->hash, + 'width' => $this->width, + 'height' => $this->height, + 'hash' => $this->hash, 'timestamp' => $this->timestamp, - 'metadata' => $this->metadata->getArrayCopy() + 'metadata' => $this->metadata->toArray() ]; } public function setData($data) { - if ($this->path && self::$filesystem->exists($this->path)) { + if ($this->path && static::$filesystem->exists($this->path)) { $this->removeAtPathOnFlush = $this->path; } static $fInfo = null; if (!$fInfo) { - $fInfo = new \Finfo; + $fInfo = new finfo; } if ($data instanceof UploadedFile) { @@ -131,7 +139,7 @@ public function setData($data) $mimeType = $fInfo->buffer($data, FILEINFO_MIME_TYPE); if (!$mimeType) { - throw new \InvalidArgumentException('Mime type could not be discovered'); + throw new RuntimeException('Mime type could not be discovered'); } $this->path = $this->pathTemplate; @@ -140,7 +148,7 @@ public function setData($data) $this->data = $data; $this->width = $width; $this->height = $height; - $this->timestamp = time(); + $this->timestamp = Carbon::now()->unix(); $this->hash = md5($data); switch ($mimeType) { @@ -154,42 +162,52 @@ public function setData($data) $this->extension = 'gif'; break; default: - throw new \RuntimeException('Unsupported mime-type for expected image: ' . $mimeType); + throw new RuntimeException('Unsupported mime-type for expected image: ' . $mimeType); } } - public function setMetadata($metadata) + public function metadata() { - $this->metadata->exchangeArray($metadata); + return $this->metadata; } - public function updatePath(Model $model = null, $fromTemplate = false) + public function updatePath(array $replacements, Model $model) { + $path = $this->path; + + $updatedPathParts = []; + $pathReplacements = []; - $path = ($fromTemplate) ? $this->pathTemplate : $this->path; preg_match_all('#{(\w+)}#', $path, $pathReplacements); foreach ($pathReplacements[1] as $pathReplacement) { - if (in_array($pathReplacement, ['attribute', 'extension', 'width', 'height', 'hash', 'timestamp'])) { + if (in_array($pathReplacement, ['extension', 'width', 'height', 'hash', 'timestamp'])) { $path = str_replace("{{$pathReplacement}}", $this->{$pathReplacement}, $path); + $updatedPathParts[] = $pathReplacement; continue; } - if (isset($this->metadata[$pathReplacement]) && $this->metadata[$pathReplacement] != '') { - $path = str_replace("{{$pathReplacement}}", $this->metadata[$pathReplacement], $path); + + if ($replacements && isset($replacements[$pathReplacement]) && $replacements[$pathReplacement] != '') { + $path = str_replace("{{$pathReplacement}}", $replacements[$pathReplacement], $path); + $updatedPathParts[] = $pathReplacement; continue; } + if ($model && $model->offsetExists($pathReplacement) && $model->offsetGet($pathReplacement) != '') { $path = str_replace("{{$pathReplacement}}", $model->offsetGet($pathReplacement), $path); + $updatedPathParts[] = $pathReplacement; continue; } } $this->path = $path; + + return $updatedPathParts; } public function pathHasReplacements() { - return (bool)preg_match('#{(\w+)}#', $this->path); + return (bool) preg_match('#{(\w+)}#', $this->path); } public function isFullyRemoved() @@ -200,7 +218,7 @@ public function isFullyRemoved() public function remove() { if ($this->path == '') { - throw new \RuntimeException('Called remove on an image that has no path'); + throw new RuntimeException('Called remove on an image that has no path'); } $this->exists = false; $this->flush = true; @@ -212,7 +230,7 @@ public function remove() $this->height = null; $this->hash = ''; $this->timestamp = 0; - $this->metadata->exchangeArray([]); + $this->metadata = new Collection; } public function flush() @@ -222,15 +240,14 @@ public function flush() } if ($this->removeAtPathOnFlush) { - self::$filesystem->delete($this->removeAtPathOnFlush); - $this->remove = null; + static::$filesystem->delete($this->removeAtPathOnFlush); } if ($this->data) { if ($this->pathHasReplacements()) { - throw new \RuntimeException('The image path still has an unresolved replacement in it ("{...}") and cannot be saved: ' . $this->path); + throw new RuntimeException('The image path still has an unresolved replacement in it ("{...}") and cannot be saved: ' . $this->path); } - self::$filesystem->put($this->path, $this->data); + static::$filesystem->put($this->path, $this->data); } $this->flush = false; @@ -242,32 +259,26 @@ public function __get($name) return $this->metadata; } - if (!in_array($name, ['exists', 'metadata', 'timestamp', 'path'])) { - throw new \OutOfBoundsException("Property $name is not accessible"); + $properties = $this->toArray(); + + if (!array_key_exists($name, $properties)) { + throw new OutOfBoundsException("Property $name is not accessible"); } - return $this->{$name}; + return $properties[$name]; } public function toArray() { - return [ - 'path' => $this->path, - 'extension' => $this->extension, - 'width' => $this->width, - 'height' => $this->height, - 'hash' => $this->hash, - 'timestamp' => $this->timestamp, - 'metadata' => $this->metadata->getArrayCopy() - ]; + return $this->getStateAsAttributeData(); } public function jsonSerialize() { if ($this->exists) { return [ - 'url' => $this->url(), - 'metadata' => $this->metadata + 'previewUrl' => $this->url('v' . $this->timestamp), + 'metadata' => $this->metadata ]; } diff --git a/src/Eloquent/ImageCollection.php b/src/Eloquent/ImageCollection.php index bf2293c..dd6875d 100644 --- a/src/Eloquent/ImageCollection.php +++ b/src/Eloquent/ImageCollection.php @@ -2,133 +2,205 @@ namespace ZiffDavis\Laravel\EloquentImagery\Eloquent; +use ArrayAccess; +use Countable; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Jsonable; use Illuminate\Database\Eloquent\Model; -use Illuminate\Http\Request; - -class ImageCollection implements \ArrayAccess, Arrayable, \Countable, \IteratorAggregate, Jsonable, \JsonSerializable +use Illuminate\Support\Collection; +use Illuminate\Support\Traits\ForwardsCalls; +use IteratorAggregate; +use JsonSerializable; + +/** + * @mixin Collection + */ +class ImageCollection implements Arrayable, ArrayAccess, Countable, IteratorAggregate, JsonSerializable, Jsonable { - protected $attribute = null; - protected $pathTemplate = null; + use ForwardsCalls; + + /** @var Image */ + protected $imagePrototype; + + /** @var Collection|Image[] */ + protected $images; + + /** @var int */ + protected $autoincrement = 1; - /** @var Image[] */ - protected $images = []; - protected $autoinc = 1; - protected $metadata = []; + /** @var Collection */ + protected $metadata; protected $deletedImages = []; - public function __construct($attribute, $pathTemplate) + public function __construct($imagePrototype) { - $this->attribute = $attribute; - $this->pathTemplate = $pathTemplate; + $this->imagePrototype = $imagePrototype; + $this->images = new Collection; + $this->metadata = new Collection; } - public function createImage($incrementAutoinc = true) + public function createImage($imageData) { - $image = new Image($this->pathTemplate); - $image->metadata->index = $this->autoinc++; + $image = clone $this->imagePrototype; + $image->setData($imageData); return $image; } - public function exchangeArray($images) + public function getCollection() { - $array = $this->images; - $this->images = []; - return $array; + return $this->images; } - public function exists() + public function metadata() { - return true; + return $this->metadata; } - public function offsetExists($offset) + /** + * Get an iterator for the items. + * + * @return \ArrayIterator + */ + public function getIterator() { - return isset($this->images[$offset]); + return $this->images->getIterator(); } - public function offsetGet($offset) + /** + * Determine if the given item exists. + * + * @param mixed $key + * @return bool + */ + public function offsetExists($key) { - if (!isset($this->images[$offset])) { - throw new \InvalidArgumentException("There is no image at offset $offset in this collection"); - } - - return $this->images[$offset]; + return $this->images->has($key); } - public function offsetSet($offset, $value) + /** + * Get the item at the given offset. + * + * @param mixed $key + * @return mixed + */ + public function offsetGet($key) { - // get image object - if ($offset && isset($this->images[$offset])) { - $image = $this->images[$offset]; - } elseif ($value instanceof Image) { - $image = $value; - } else { - $image = $this->createImage(); - } + return $this->images->get($key); + } - if ($offset === null) { - $offset = count($this->images); - $this->images[$offset] = $image = $this->createImage(); - } else { - $this->images[$offset] = $image; + /** + * Set the item at the given offset. + * + * @param mixed $key + * @param mixed $value + * @return void + */ + public function offsetSet($key, $value) + { + if (!$value instanceof Image) { + $value = $this->createImage($value); } - if (is_string($value)) { - $image->setData($value); - } + $this->images->put($key, $value); } - public function offsetUnset($offset) + /** + * Unset the item at the given key. + * + * @param mixed $key + * @return void + */ + public function offsetUnset($key) { - if (!isset($this->images[$offset])) { - throw new \RuntimeException("Image does not exist at offset $offset"); - } + $this->deletedImages[] = $this->images[$key]; - // find image at offset, set to remove on flush - $image = $this->images[$offset]; - $image->remove(); + $this->images[$key]->remove(); - $this->deletedImages[] = $image; + $this->images->forget($key); + } - unset($this->images[$offset]); + /** + * Get the number of items for the current page. + * + * @return int + */ + public function count() + { + return $this->images->count(); } + /** + * Get the instance as an array. + * + * @return array + */ public function toArray() { - return $this->images; + return $this->getStateAsAttributeData(); } - public function count() + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + public function jsonSerialize() { - return count($this->images); + return $this->toArray(); } - public function getIterator() + /** + * Convert the object to its JSON representation. + * + * @param int $options + * @return string + */ + public function toJson($options = 0) { - return new \ArrayIterator($this->images); + return json_encode($this->jsonSerialize(), $options); } - public function toJson($options = 0) + public function setStateFromAttributeData($attributeData) { - return json_encode($this->getStateProperties(), $options); + $this->autoincrement = $attributeData['autoinc'] ?? $attributeData['autoincrement'] ?? 1; + + foreach ($attributeData['images'] as $imageState) { + $image = clone $this->imagePrototype; + $image->setStateFromAttributeData($imageState); + + $this->images->push($image); + } + + if ($attributeData['metadata']) { + foreach ($attributeData['metadata'] as $key => $value) { + $this->metadata[$key] = $value; + } + } } - public function jsonSerialize() + public function getStateAsAttributeData() { - return $this->getStateProperties(); + $images = $this->images->map(function (Image $image) { + return $image->getStateAsAttributeData(); + })->toArray(); + + return [ + 'autoincrement' => $this->autoincrement, + 'images' => $images, + 'metadata' => $this->metadata->toArray() + ]; } public function pathHasReplacements() { - foreach ($this->images as $image) { - if ($image->pathHasReplacements()) { - return true; - } + if ($this->images->count() === 0) { + return false; } - return false; + + return $this->images->every(function ($image) { + return $image->pathHasReplacements(); + }); } public function purgeRemovedImages() @@ -141,11 +213,27 @@ public function purgeRemovedImages() } } + public function updatePath(array $replacements, Model $model) + { + $this->images->each(function (Image $image) use ($replacements, $model) { + $updatedPathParts = $image->updatePath(array_merge($replacements, ['index' => $this->autoincrement]), $model); + + if (in_array('index', $updatedPathParts)) { + $this->autoincrement++; + } + }); + } + + /** + * Called to remove all the images from this collection, generally in a workflow to remove an entire entity + */ public function remove() { - foreach ($this->images as $image) { + $this->images = $this->images->filter(function (Image $image) { + $this->deletedImages[] = $image; $image->remove(); - } + return false; + }); } public function flush() @@ -153,55 +241,21 @@ public function flush() foreach ($this->deletedImages as $image) { $image->flush(); } - foreach ($this->images as $image) { - $image->flush(); - } - } - - public function updatePath(Model $model = null, $fromTemplate = false) - { - foreach ($this->images as $image) { - $image->updatePath($model, $fromTemplate); - } - } - - public function setStateProperties(array $properties) - { - $this->autoinc = $properties['autoinc'] ?? 1; - - foreach ($properties['images'] as $imageState) { - $image = new Image($this->attribute, $this->pathTemplate); - $image->setStateProperties($imageState); - $this->images[] = $image; - } - - $this->metadata = $properties['metadata'] ?? []; - } - public function getStateProperties() - { - $imagesState = []; - - foreach ($this->images as $image) { - $imagesState[] = $image->getStateProperties(); - } - - return [ - 'autoinc' => $this->autoinc, - 'images' => $imagesState, - 'metadata' => $this->metadata - ]; + $this->images->each(function (Image $image) { + $image->flush(); + }); } - public function __get($name) + /** + * Make dynamic calls into the collection. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) { - switch ($name) { - case 'autoinc': - return $this->autoinc; - case 'images': - return $this->images; - default: - throw new \InvalidArgumentException($name . ' is not a valid property on ' . __CLASS__); - } + return $this->forwardCallTo($this->getCollection(), $method, $parameters); } } diff --git a/src/EloquentImageryProvider.php b/src/EloquentImageryProvider.php index 56c0a78..8a4651a 100644 --- a/src/EloquentImageryProvider.php +++ b/src/EloquentImageryProvider.php @@ -47,6 +47,5 @@ public function boot(Router $router) Nova::script('eloquent-imagery', __DIR__ . '/../dist/js/nova.js'); }); } - } } diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..1bed868 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +/storage \ No newline at end of file diff --git a/tests/Unit/Eloquent/AbstractTestCase.php b/tests/Unit/Eloquent/AbstractTestCase.php new file mode 100644 index 0000000..f38363c --- /dev/null +++ b/tests/Unit/Eloquent/AbstractTestCase.php @@ -0,0 +1,32 @@ +set('eloquent-imagery.filesystem', 'imagery'); + $application['config']->set('filesystems.disks.imagery', [ + 'driver' => 'local', + 'root' => realpath(__DIR__ . '/../../') . '/storage', + ]); + + Carbon::setTestNow(Carbon::now()); + } + + public function tearDown(): void + { + $disk = Storage::disk('imagery'); + + foreach ($disk->allDirectories() as $directory) { + $disk->deleteDirectory($directory); + } + + parent::tearDown(); + } +} \ No newline at end of file diff --git a/tests/Unit/Eloquent/EloquentImageryObserverTest.php b/tests/Unit/Eloquent/EloquentImageryObserverTest.php index 9d0260d..ff7a6fa 100644 --- a/tests/Unit/Eloquent/EloquentImageryObserverTest.php +++ b/tests/Unit/Eloquent/EloquentImageryObserverTest.php @@ -2,19 +2,11 @@ namespace ZiffDavis\Laravel\EloquentImagery\Test\Unit\Eloquent; -use App\Console\Kernel; -use PHPUnit\Framework\TestCase; use ZiffDavis\Laravel\EloquentImagery\Eloquent\EloquentImageryObserver; use ZiffDavis\Laravel\EloquentImagery\Eloquent\Image; -class EloquentImageryObserverTest extends TestCase +class EloquentImageryObserverTest extends AbstractTestCase { - public function setup() - { - $app = require __DIR__ . '/../../../vendor/laravel/laravel/bootstrap/app.php'; - $app->make(Kernel::class)->bootstrap(); - } - public function testRetrievedSetsStateOnImage() { $foo = new TestAssets\FooModel(); @@ -23,7 +15,7 @@ public function testRetrievedSetsStateOnImage() 'image' => '{"path": "foo/bar.jpg", "extension": "jpg", "width": 1, "height": 1, "hash": "1234", "timestamp": 12345, "metadata": []}' ], true); - $observer = new EloquentImageryObserver(); + $observer = new EloquentImageryObserver(TestAssets\FooModel::class); $observer->retrieved($foo); $this->assertInstanceOf(Image::class, $foo->image); @@ -33,7 +25,7 @@ public function testRetrievedSetsStateOnImage() public function testSavingRestoresModelAttributes() { $foo = new TestAssets\FooModel(); - $foo->image->setStateProperties([ + $foo->image->setStateFromAttributeData([ 'path' => 'foo/bar.jpg', 'extension' => 'jpg', 'width' => 1, @@ -43,7 +35,7 @@ public function testSavingRestoresModelAttributes() 'metadata' => [] ]); - $observer = new EloquentImageryObserver(); + $observer = new EloquentImageryObserver(TestAssets\FooModel::class); $observer->saving($foo); $this->assertEquals('{"path":"foo\/bar.jpg","extension":"jpg","width":1,"height":1,"hash":"1234","timestamp":12345,"metadata":[]}', $foo->image); @@ -57,7 +49,7 @@ public function testSavedRestoresImage() 'image' => '{"path": "foo/bar.jpg", "extension": "jpg", "width": 1, "height": 1, "hash": "1234", "timestamp": 12345, "metadata": []}' ], true); - $observer = new EloquentImageryObserver(); + $observer = new EloquentImageryObserver(TestAssets\FooModel::class); $observer->saved($foo); $this->assertInstanceOf(Image::class, $foo->image); diff --git a/tests/Unit/Eloquent/ImageCollectionTest.php b/tests/Unit/Eloquent/ImageCollectionTest.php new file mode 100644 index 0000000..920e340 --- /dev/null +++ b/tests/Unit/Eloquent/ImageCollectionTest.php @@ -0,0 +1,226 @@ + 10, + 'images' => [ + [ + 'path' => 'foo/bar.jpg', + 'extension' => 'jpg', + 'width' => 1, + 'height' => 1, + 'hash' => '1234567890', + 'timestamp' => 12345, + 'metadata' => [] + ] + ], + 'metadata' => [ + 'foo' => 'bar' + ] + ]; + + $imageCollection->setStateFromAttributeData($state); + + $this->assertCount(1, $imageCollection); + $image = $imageCollection[0]; + + $this->assertEquals('foo/bar.jpg', $image->path); + $this->assertTrue($image->exists()); + $this->assertEquals('http://localhost/imagery/foo/bar.jpg', $image->url()); + $this->assertEquals('bar', $imageCollection->metadata()['foo']); + } + + public function testPathHasReplacements() + { + $imageCollection = new ImageCollection(new Image('foo/{name}-{index}.{extension}')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.jpg')); + + $this->assertTrue($imageCollection->pathHasReplacements()); + + $imageCollection->updatePath(['name' => 'bar'], new TestAssets\FooModel); + + $this->assertFalse($imageCollection->pathHasReplacements()); + } + + public function testOffsetExists() + { + $imageCollection = new ImageCollection(new Image('foo/{name}-{index}.{extension}')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.jpg')); + + $this->assertTrue(isset($imageCollection[0])); + $this->assertFalse(isset($imageCollection[1])); + } + + public function testOffsetSet() + { + $imageCollection = new ImageCollection(new Image('foo/{name}-{index}.{extension}')); + + // add image + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.jpg')); + + // add bytes that will go through createImage() + $imageCollection[] = file_get_contents(__DIR__ . '/TestAssets/30.png'); + + $this->assertCount(2, $imageCollection); + $this->assertInstanceOf(Image::class, $imageCollection[0]); + $this->assertInstanceOf(Image::class, $imageCollection[1]); + } + + public function testOffsetUnset() + { + $imageCollection = new ImageCollection(new Image('foo/{name}-{index}.{extension}')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.jpg')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.png')); + + $this->assertCount(2, $imageCollection); + + unset($imageCollection[1]); + + $this->assertCount(1, $imageCollection); + } + + + public function testFlush() + { + $imageCollection = new ImageCollection(new Image('foo/{slug}-{index}.{extension}')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.jpg')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.png')); + + $model = new TestAssets\FooModel(); + $model->setRawAttributes(['slug' => 'bar-baz'], true); + + $imageCollection->updatePath([], $model); + $imageCollection->flush(); + + $this->assertFileExists(__DIR__ . '/../../storage/foo/bar-baz-1.jpg'); + $this->assertFileExists(__DIR__ . '/../../storage/foo/bar-baz-2.png'); + + $state = $imageCollection->getStateAsAttributeData(); + + // reset collection + + $imageCollection = new ImageCollection(new Image('foo/{slug}-{index}.{extension}')); + $imageCollection->setStateFromAttributeData($state); + + unset($imageCollection[1]); + $imageCollection->flush(); + + $this->assertFileExists(__DIR__ . '/../../storage/foo/bar-baz-1.jpg'); + $this->assertFileNotExists(__DIR__ . '/../../storage/foo/bar-baz-2.png'); + } + + public function testGetStateAsDataAttribute() + { + $imageCollection = new ImageCollection(new Image('foo/{slug}-{index}.{extension}')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.jpg')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.png')); + + $model = new TestAssets\FooModel(); + $model->setRawAttributes(['slug' => 'boom'], true); + + $imageCollection->updatePath([], $model); + + $expected = [ + 'autoincrement' => 3, + 'images' => [ + [ + 'path' => 'foo/boom-1.jpg', + 'extension' => 'jpg', + 'width' => 30, + 'height' => 30, + 'hash' => '809dcbbcd89eb8a275a6c6f4556e1f41', + 'timestamp' => Carbon::getTestNow()->unix(), + 'metadata' => [], + ], + [ + 'path' => 'foo/boom-2.png', + 'extension' => 'png', + 'width' => 30, + 'height' => 30, + 'hash' => '7692f4f945481216e41ce0a8f42f6ed6', + 'timestamp' => Carbon::getTestNow()->unix(), + 'metadata' => [] + ] + ], + 'metadata' => [] + ]; + + $this->assertEquals($expected, $imageCollection->getStateAsAttributeData()); + $this->assertEquals($expected, $imageCollection->toArray()); + } + + public function testRemove() + { + $imageCollection = new ImageCollection(new Image('foo/{slug}-{index}.{extension}')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.jpg')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.png')); + $imageCollection->updatePath(['slug' => 'bar-baz'], new TestAssets\FooModel); + $imageCollection->flush(); + + $this->assertFileExists(__DIR__ . '/../../storage/foo/bar-baz-1.jpg'); + $this->assertFileExists(__DIR__ . '/../../storage/foo/bar-baz-2.png'); + + $state = $imageCollection->getStateAsAttributeData(); + + // reset collection + + $imageCollection = new ImageCollection(new Image('foo/{slug}-{index}.{extension}')); + $imageCollection->setStateFromAttributeData($state); + $imageCollection->remove(); + $imageCollection->flush(); + + $this->assertFileNotExists(__DIR__ . '/../../storage/foo/bar-baz-1.jpg'); + $this->assertFileNotExists(__DIR__ . '/../../storage/foo/bar-baz-2.png'); + } + + public function testPurgeRemovedImages() + { + $imageCollection = new ImageCollection(new Image('foo/{slug}-{index}.{extension}')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.jpg')); + $imageCollection[] = $imageCollection->createImage(file_get_contents(__DIR__ . '/TestAssets/30.png')); + + $this->assertCount(2, $imageCollection); + + // remove image directly + $imageCollection[0]->remove(); + + $this->assertCount(2, $imageCollection); + + // purge will removed these image->remove() images + $imageCollection->purgeRemovedImages(); + + $this->assertCount(1, $imageCollection); + } + + public function testGetCollection() + { + $imageCollection = new ImageCollection(new Image('foo/{slug}-{index}.{extension}')); + $this->assertInstanceOf(Collection::class, $imageCollection->getCollection()); + } + + public function testGetIterator() + { + $imageCollection = new ImageCollection(new Image('foo/{slug}-{index}.{extension}')); + $this->assertInstanceOf(ArrayIterator::class, $imageCollection->getIterator()); + } +} + diff --git a/tests/Unit/Eloquent/ImageTest.php b/tests/Unit/Eloquent/ImageTest.php new file mode 100644 index 0000000..0104ec4 --- /dev/null +++ b/tests/Unit/Eloquent/ImageTest.php @@ -0,0 +1,121 @@ + 'foo/bar.jpg', + 'extension' => 'jpg', + 'width' => 1, + 'height' => 1, + 'hash' => '1234567890', + 'timestamp' => 12345, + 'metadata' => [] + ]; + + $image->setStateFromAttributeData($state); + + $this->assertEquals('foo/bar.jpg', $image->path); + $this->assertTrue($image->exists()); + $this->assertEquals('http://localhost/imagery/foo/bar.jpg', $image->url()); + } + + public function testUpdatePath() + { + $foo = new TestAssets\FooModel(); + $foo->setRawAttributes(['id' => 20], true); + + $pngImageData = file_get_contents(__DIR__ . '/TestAssets/30.png'); + + $image = new Image('foo/{id}.{extension}'); + $image->setData($pngImageData); + $updatedParts = $image->updatePath([], $foo); + + $this->assertEquals('foo/20.png', $image->path); + $this->assertEquals(['id', 'extension'], $updatedParts); + + $image = new Image('foo/{outside_var}.{extension}'); + $image->setData($pngImageData); + $updatedParts = $image->updatePath(['outside_var' => 'foobar'], $foo); + + $this->assertEquals('foo/foobar.png', $image->path); + $this->assertEquals(['outside_var', 'extension'], $updatedParts); + } + + public function testPathHasReplacements() + { + $image = new Image('foo/{id}.{extension}'); + $image->setData(file_get_contents(__DIR__ . '/TestAssets/30.png')); + + $this->assertTrue($image->pathHasReplacements()); + + $image->updatePath(['id' => 5, 'extension' => 'jpg'], new TestAssets\FooModel); + + $this->assertFalse($image->pathHasReplacements()); + } + + public function testMetadata() + { + $image = new Image('foo/{name}.{extension}'); + + $state = [ + 'path' => 'foo/bar.jpg', + 'extension' => 'jpg', + 'width' => 1, + 'height' => 1, + 'hash' => '1234567890', + 'timestamp' => 12345, + 'metadata' => [ + 'one' => 1, + 'two' => 2, + 'negative_three' => -3 + ] + ]; + + $image->setStateFromAttributeData($state); + + $this->assertInstanceOf(Collection::class, $image->metadata()); + $this->assertInstanceOf(Collection::class, $image->metadata); + + // assert size + $this->assertCount(3, $image->metadata); + + // assert a filtered collection can be created + $this->assertCount(2, $image->metadata->filter(function ($value) { return $value > 0; })); + + // assert the previous filter did not alter the initial collection + $this->assertCount(3, $image->metadata); + + // assert you can edit the initial collection + unset($image->metadata['two']); + $this->assertCount(2, $image->metadata); + } + + public function testFlush() + { + $image = new Image('foo/{name}.{extension}'); + + $image->setData(file_get_contents(__DIR__ . '/TestAssets/30.jpg')); + $image->updatePath(['name' => 'bar'], new TestAssets\FooModel); + + $image->flush(); + + $this->assertEquals('foo/bar.jpg', $image->path); + $this->assertFileExists(__DIR__ . '/../../storage/foo/bar.jpg'); + } +} + diff --git a/tests/Unit/Eloquent/TestAssets/30.jpg b/tests/Unit/Eloquent/TestAssets/30.jpg new file mode 100644 index 0000000..f67930b Binary files /dev/null and b/tests/Unit/Eloquent/TestAssets/30.jpg differ diff --git a/tests/Unit/Eloquent/TestAssets/30.png b/tests/Unit/Eloquent/TestAssets/30.png new file mode 100644 index 0000000..278926d Binary files /dev/null and b/tests/Unit/Eloquent/TestAssets/30.png differ diff --git a/tests/Unit/Eloquent/TestAssets/FooModel.php b/tests/Unit/Eloquent/TestAssets/FooModel.php index b5e7f55..a2f3018 100644 --- a/tests/Unit/Eloquent/TestAssets/FooModel.php +++ b/tests/Unit/Eloquent/TestAssets/FooModel.php @@ -4,14 +4,16 @@ use Illuminate\Database\Eloquent\Model; use ZiffDavis\Laravel\EloquentImagery\Eloquent\HasEloquentImagery; +use ZiffDavis\Laravel\EloquentImagery\Eloquent\Image; +/** + * @property Image $image + */ class FooModel extends Model { use HasEloquentImagery; - public function __construct(array $attributes = []) - { - $this->eloquentImagery('image'); - parent::__construct($attributes); - } + protected $eloquentImagery = [ + 'image' => 'images/{id}.{extension}' + ]; }