diff --git a/.php_cs.dist.php b/.php-cs-fixer.dist.php similarity index 100% rename from .php_cs.dist.php rename to .php-cs-fixer.dist.php diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 7660774..73dca7b 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,12 +1,24 @@ checks: php: + type_analyzer_migration_checks: false + argument_type_checks: false code_rating: true duplication: true + fix_doc_comments: false + no_exit: false + unused_parameters: false + unused_properties: false + unused_variables: false + use_statement_alias_conflict: false + verify_property_names: false build: image: default-jammy environment: - php: 8.4.11 + php: + version: 8.4.11 + ini: + memory_limit: "512M" nodes: coverage: services: diff --git a/src/Controllers/Media/Endpoints/Thumbnail.php b/src/Controllers/Media/Endpoints/Thumbnail.php index 85aaaf0..b1f9a14 100644 --- a/src/Controllers/Media/Endpoints/Thumbnail.php +++ b/src/Controllers/Media/Endpoints/Thumbnail.php @@ -41,7 +41,8 @@ public function handle(...$arguments): ResponseInterface return $response; } - if (preg_match('/^(\d+)x(\d+)(x([0-9A-F]{6})?)?$/i', $this->handler->peekPath(), $matches)) { + $size = $this->handler->peekPath(); + if (is_string($size) && preg_match('/^(\d+)x(\d+)(x([0-9A-F]{6})?)?$/i', $size, $matches)) { $this->handler->shiftPath(); $maxWidth = $matches[1]; $maxHeight = $matches[2]; diff --git a/src/Controllers/MediaRequestHandler.php b/src/Controllers/MediaRequestHandler.php index 2314162..658add9 100644 --- a/src/Controllers/MediaRequestHandler.php +++ b/src/Controllers/MediaRequestHandler.php @@ -10,6 +10,7 @@ namespace Divergence\Controllers; +use Exception; use Divergence\Controllers\Media\Endpoints\Browse; use Divergence\Controllers\Media\Endpoints\Caption; use Divergence\Controllers\Media\Endpoints\Create; diff --git a/src/Controllers/Records/Endpoints/Create.php b/src/Controllers/Records/Endpoints/Create.php index 1524cd7..7403cac 100644 --- a/src/Controllers/Records/Endpoints/Create.php +++ b/src/Controllers/Records/Endpoints/Create.php @@ -25,7 +25,7 @@ public function handle(...$arguments): ResponseInterface if (!$Record) { $className = $this->handler::$recordClass; $defaultClass = $className::getDefaultClassName(); - $Record = new $defaultClass(); + $Record = $defaultClass::create(); } $this->handler->onRecordCreatedHook($Record, $_REQUEST); diff --git a/src/Controllers/Records/Endpoints/MultiSave.php b/src/Controllers/Records/Endpoints/MultiSave.php index 2f4f8d8..8316cdc 100644 --- a/src/Controllers/Records/Endpoints/MultiSave.php +++ b/src/Controllers/Records/Endpoints/MultiSave.php @@ -64,7 +64,7 @@ protected function getDatumRecord($datum) if (empty($datum[$PrimaryKey])) { $defaultClass = $className::getDefaultClassName(); - $record = new $defaultClass(); + $record = $defaultClass::create(); $this->handler->onRecordCreatedHook($record, $datum); return $record; diff --git a/src/Controllers/RequestHandler.php b/src/Controllers/RequestHandler.php index ecda377..42c0e83 100644 --- a/src/Controllers/RequestHandler.php +++ b/src/Controllers/RequestHandler.php @@ -12,7 +12,7 @@ use Divergence\App; use Divergence\Responders\Response; -use BadMethodCallException; +use Error; use Exception; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -87,7 +87,7 @@ public function __call(string $name, array $arguments) $endpointName = strtolower($name); if (!isset($this->endpointClasses[$endpointName])) { - throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', static::class, $name)); + throw new Error(sprintf('Call to undefined method %s::%s()', static::class, $name)); } if (!isset($this->endpoints[$endpointName])) { diff --git a/src/Data/Collections/Collection.php b/src/Data/Collections/Collection.php new file mode 100644 index 0000000..bfdab16 --- /dev/null +++ b/src/Data/Collections/Collection.php @@ -0,0 +1,212 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections; + +use Iterator; +use Countable; +use ArrayAccess; + +/** + * @template TRecord of object|array + * @implements Iterator + * @implements ArrayAccess + */ +class Collection implements Iterator, Countable, ArrayAccess, Indexing +{ + use Getters; + + public static $addHandler; + public static $addManyHandler; + public static $removeHandler; + public static $removeManyHandler; + public static $createIndexByFieldHandler; + public static $hasIndexHandler; + public static $updateIndexForModelHandler; + public static $setIndexesHandler; + public static $clearIndexesHandler; + + /** @var array */ + public array $Index = []; + + /** @var array */ + public array $Indexes = []; + + /** @var array */ + public array $HashKeyIndex = []; + + public int $position = 0; + + /** + * @param array $records + * @param array $indexes + */ + public function __construct(array $records = [], array $indexes = []) + { + foreach ($indexes as $field) { + $this->createIndexByField($field); + } + + $this->addMany($records); + } + + public function validate($record) + { + return true; + } + + public function add($record) + { + $handler = static::$addHandler; + $handler::handle($this, $record); + } + + public function addMany(array $records) + { + $handler = static::$addManyHandler; + $handler::handle($this, $records); + } + + public function remove($record) + { + $handler = static::$removeHandler; + $handler::handle($this, $record); + } + + public function removeMany($records) + { + $handler = static::$removeManyHandler; + $handler::handle($this, $records); + } + + public function toArray() + { + return $this->Index; + } + + /* ### Implements IndexedFields Internally in the Collection ### */ + + public function createIndexByField($field) + { + $handler = static::$createIndexByFieldHandler; + $handler::handle($this, $field); + } + + public function hasIndex($field): bool + { + $handler = static::$hasIndexHandler; + return $handler::handle($this, $field); + } + + public function updateIndexForModel($index, &$record) + { + $handler = static::$updateIndexForModelHandler; + $handler::handle($this, $index, $record); + } + + public function setIndexes(&$record) + { + $handler = static::$setIndexesHandler; + $handler::handle($this, $record); + } + + public function clearIndexes(&$record) + { + $handler = static::$clearIndexesHandler; + $handler::handle($this, $record); + } + + /* ### START implements Countable { ### */ + + public function count(): int + { + return count($this->Index); + } + + /* ### } END implements Countable ### */ + + /* ### START implements Iterator { ### */ + + public function current(): mixed + { + return $this->Index[$this->position] ?? null; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->Index[$this->position]); + } + + /* ### } END implements Iterator ### */ + + /* ### START implements ArrayAccess { ### */ + + public function offsetSet(mixed $offset, mixed $value): void + { + if (is_null($offset)) { + $this->add($value); + } elseif ($this->validate($value)) { + if (isset($this->Index[$offset])) { + $position = $this->position; + $this->offsetUnset($offset); + array_splice($this->Index, $offset, 0, [$value]); + $this->position = $position; + } else { + $this->Index[$offset] = $value; + } + + $this->setIndexes($value); + } + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->Index[$offset]); + } + + public function offsetUnset(mixed $offset): void + { + if (isset($this->Index[$offset])) { + $record = $this->Index[$offset]; + $recordKey = RecordKey::get($record); + unset($this->HashKeyIndex[$recordKey]); + $this->clearIndexes($record); + array_splice($this->Index, $offset, 1); + + if ($this->position > $offset) { + --$this->position; + } + } + } + + public function offsetGet(mixed $offset): mixed + { + if ($offset < 0) { + $offset += $this->count(); + } + return $this->Index[$offset] ?? null; + } + + /* ### } END implements ArrayAccess ### */ +} diff --git a/src/Data/Collections/Events/AbstractHandler.php b/src/Data/Collections/Events/AbstractHandler.php new file mode 100644 index 0000000..f2c73d8 --- /dev/null +++ b/src/Data/Collections/Events/AbstractHandler.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Events; + +use Divergence\Data\Collections\Collection; + +abstract class AbstractHandler +{ + abstract public static function handle(Collection $collection); +} diff --git a/src/Data/Collections/Events/Add.php b/src/Data/Collections/Events/Add.php new file mode 100644 index 0000000..f3c75e9 --- /dev/null +++ b/src/Data/Collections/Events/Add.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Events; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Indexing; +use Divergence\Data\Collections\RecordKey; + +class Add extends AbstractHandler +{ + public static function handle(Collection $collection, $record = null): void + { + if ($collection->validate($record)) { + $recordKey = RecordKey::get($record); + + if (isset($collection->HashKeyIndex[$recordKey])) { + return; + } + + array_push($collection->Index, $record); + + if ($collection instanceof Indexing) { + $collection->setIndexes($record); + } + } + } +} diff --git a/src/Data/Collections/Events/AddMany.php b/src/Data/Collections/Events/AddMany.php new file mode 100644 index 0000000..a3e55cb --- /dev/null +++ b/src/Data/Collections/Events/AddMany.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Events; + +use Divergence\Data\Collections\Collection; + +class AddMany extends AbstractHandler +{ + public static function handle(Collection $collection, array $records = []): void + { + foreach ($records as $record) { + $collection->add($record); + } + } +} diff --git a/src/Data/Collections/Events/Remove.php b/src/Data/Collections/Events/Remove.php new file mode 100644 index 0000000..9b1803c --- /dev/null +++ b/src/Data/Collections/Events/Remove.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Events; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Indexing; +use Divergence\Data\Collections\RecordKey; + +class Remove extends AbstractHandler +{ + public static function handle(Collection $collection, $record = null): void + { + $recordKey = RecordKey::get($record); + + foreach ($collection->Index as $key => $existing) { + $existingKey = RecordKey::get($existing); + + if ($existingKey === $recordKey) { + array_splice($collection->Index, $key, 1); + + if ($collection instanceof Indexing) { + unset($collection->HashKeyIndex[$existingKey]); + $collection->clearIndexes($existing); + } + + if ($collection->position > $key) { + --$collection->position; + } + + return; + } + } + } +} diff --git a/src/Data/Collections/Events/RemoveMany.php b/src/Data/Collections/Events/RemoveMany.php new file mode 100644 index 0000000..f551e04 --- /dev/null +++ b/src/Data/Collections/Events/RemoveMany.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Events; + +use Divergence\Data\Collections\Collection; + +class RemoveMany extends AbstractHandler +{ + public static function handle(Collection $collection, $records = []): void + { + foreach ($records as $record) { + $collection->remove($record); + } + } +} diff --git a/src/Data/Collections/Factory/Factory.php b/src/Data/Collections/Factory/Factory.php new file mode 100644 index 0000000..888245e --- /dev/null +++ b/src/Data/Collections/Factory/Factory.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Factory; + +use Exception; +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Events\Add; +use Divergence\Data\Collections\Events\AddMany; +use Divergence\Data\Collections\Events\Remove; +use Divergence\Data\Collections\Events\RemoveMany; +use Divergence\Data\Collections\Factory\Getters\GetByField; +use Divergence\Data\Collections\Factory\Getters\GetAllByField; +use Divergence\Data\Collections\Factory\Getters\GetByCriteria; +use Divergence\Data\Collections\Factory\Getters\GetAllByCriteria; +use Divergence\Data\Collections\Indexing\CreateIndexByField; +use Divergence\Data\Collections\Indexing\HasIndex; +use Divergence\Data\Collections\Indexing\UpdateIndexForModel; +use Divergence\Data\Collections\Indexing\SetIndexes; +use Divergence\Data\Collections\Indexing\ClearIndexes; + +class Factory +{ + protected $getterClasses = []; + + public function __construct() + { + $this->registerGetterClasses(); + } + + protected function registerGetterClasses(): void + { + $this->getterClasses = []; + + foreach ([ + GetByField::class, + GetAllByField::class, + GetByCriteria::class, + GetAllByCriteria::class, + ] as $className) { + $this->registerGetterClass($className); + } + } + + protected function registerGetterClass(string $className): void + { + $parts = explode('\\', $className); + $getterName = strtolower(lcfirst(end($parts))); + + if (isset($this->getterClasses[$getterName])) { + throw new Exception(sprintf('Getter method collision for %s', $getterName)); + } + + $this->getterClasses[$getterName] = $className; + } + + public function getGetterClasses(): array + { + return $this->getterClasses; + } + + public function create(array $records = [], array $indexes = []): Collection + { + Collection::$addHandler = Add::class; + Collection::$addManyHandler = AddMany::class; + Collection::$removeHandler = Remove::class; + Collection::$removeManyHandler = RemoveMany::class; + Collection::$createIndexByFieldHandler = CreateIndexByField::class; + Collection::$hasIndexHandler = HasIndex::class; + Collection::$updateIndexForModelHandler = UpdateIndexForModel::class; + Collection::$setIndexesHandler = SetIndexes::class; + Collection::$clearIndexesHandler = ClearIndexes::class; + + return new Collection($records, $indexes); + } +} diff --git a/src/Data/Collections/Factory/Getters/AbstractGetter.php b/src/Data/Collections/Factory/Getters/AbstractGetter.php new file mode 100644 index 0000000..5e2d687 --- /dev/null +++ b/src/Data/Collections/Factory/Getters/AbstractGetter.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Factory\Getters; + +use Divergence\Data\Collections\Collection; + +abstract class AbstractGetter +{ + abstract public static function handle(Collection $collection); +} diff --git a/src/Data/Collections/Factory/Getters/GetAllByCriteria.php b/src/Data/Collections/Factory/Getters/GetAllByCriteria.php new file mode 100644 index 0000000..86c7245 --- /dev/null +++ b/src/Data/Collections/Factory/Getters/GetAllByCriteria.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Factory\Getters; + +use Divergence\Data\Collections\Collection; +use Divergence\Models\Expr\Conjunction; +use Divergence\Models\Expr\Criteria; +use Divergence\Models\Expr\CriteriaGroup; +use Divergence\Models\Expr\CriteriaType; + +class GetAllByCriteria extends AbstractGetter +{ + private const FIELD_OPERATORS = [ + CriteriaType::FieldEqual => CriteriaType::Equal, + CriteriaType::FieldNotEqual => CriteriaType::NotEqual, + CriteriaType::FieldGreaterThan => CriteriaType::GreaterThan, + CriteriaType::FieldGreaterThanOrEqual => CriteriaType::GreaterThanOrEqual, + CriteriaType::FieldLessThan => CriteriaType::LessThan, + CriteriaType::FieldLessThanOrEqual => CriteriaType::LessThanOrEqual, + ]; + + /** + * @param Collection $collection + * @param Criteria|Criteria[]|CriteriaGroup $CriteriaGroup + * @return array + */ + public static function handle(Collection $collection, $CriteriaGroup=[]) + { + if (is_a($CriteriaGroup, Criteria::class)) { + $CriteriaGroup = [$CriteriaGroup]; + } + + $output = []; + if ($found = static::searchByCriteria($collection, $CriteriaGroup)) { + if (is_array($found)) { + foreach ($found as $key=>$value) { + if (isset($collection->HashKeyIndex[$key])) { + $output[] = $collection->HashKeyIndex[$key]; + } + } + } + } + return $output; + } + + /** + * @param Collection $collection + * @param Criteria[]|CriteriaGroup $CriteriaGroup + * @return array + */ + private static function searchByCriteria(Collection $collection, $CriteriaGroup) + { + $CriteriaGroup = self::normalizeCriteriaGroup($CriteriaGroup); + + $results = []; + foreach ($CriteriaGroup->criteria as $crit) { + if (is_a($crit, Criteria::class)) { + $result = self::searchByCriterion($collection, $crit); + $results[] = $result; + + // when processing a Group Conjunction::GroupAnd must be found in all indexes to match the operation + if ($CriteriaGroup->conjunction == Conjunction::GroupAnd && !$result) { + return []; + } + } + + if (is_a($crit, CriteriaGroup::class)) { + $results[] = static::searchByCriteria($collection, $crit) ?: []; + } + } + + // no criteria in the group found anything. + // we return an empty array immediately. + if (count($results) === 0) { + return []; + } + + // if one thing is found use that one thing + if (count($results) === 1) { + $found = array_shift($results); + } + + if (count($results)>1) { + $found = self::combineResults($results, $CriteriaGroup->conjunction); + } + + switch ($CriteriaGroup->conjunction) { + case Conjunction::GroupNotAnd: + case Conjunction::GroupNotOr: + $found = array_diff_key(array_fill_keys(array_keys($collection->HashKeyIndex), 1), $found); + break; + } + + return $found ?: []; + } + + private static function normalizeCriteriaGroup($CriteriaGroup) + { + if (!is_array($CriteriaGroup) && !is_a($CriteriaGroup, CriteriaGroup::class)) { + throw new \Exception('Collection->GetAllByCriteria($CriteriaGroup) expects CriteriaGroup[].'); + } + + if (is_array($CriteriaGroup)) { + return new CriteriaGroup($CriteriaGroup); + } + + return $CriteriaGroup; + } + + private static function searchByCriterion(Collection $collection, Criteria $crit) + { + // just-in-time create the index if needed + // this is obviously slower than pre-indexing + if (!isset($collection->Indexes[$crit->key])) { + $collection->createIndexByField($crit->key); + } + // fetch index + $operator = self::FIELD_OPERATORS[$crit->operator] ?? null; + + if ($operator) { + if (!isset($collection->Indexes[$crit->value])) { + $collection->createIndexByField($crit->value); + } + + return $collection->Indexes[$crit->key]->findByIndex($collection->Indexes[$crit->value], $operator); + } + + return $collection->Indexes[$crit->key]->find($crit->value, $crit->operator); + } + + private static function combineResults(array $results, int $conjunction) + { + switch ($conjunction) { + case Conjunction::GroupAnd: + case Conjunction::GroupNotAnd: + return call_user_func_array('array_intersect_key', $results); + + case Conjunction::GroupOr: + case Conjunction::GroupNotOr: + $orKeys = []; + foreach ($results as $orResults) { + if (is_array($orResults)) { + $orKeys = array_merge($orKeys, array_keys($orResults)); + } + } + $results = array_unique($orKeys); + return array_fill_keys($results, 1); + } + } +} diff --git a/src/Data/Collections/Factory/Getters/GetAllByField.php b/src/Data/Collections/Factory/Getters/GetAllByField.php new file mode 100644 index 0000000..8725ca6 --- /dev/null +++ b/src/Data/Collections/Factory/Getters/GetAllByField.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Factory\Getters; + +use Divergence\Data\Collections\Collection; + +class GetAllByField extends AbstractGetter +{ + public static function handle(Collection $collection, $field = null, $value = null) + { + $records = []; + + if (isset($collection->Indexes[$field])) { + $results = $collection->Indexes[$field]->find($value); + + if ($results) { + foreach ($results as $key => $_found) { + if (isset($collection->HashKeyIndex[$key])) { + $records[] = $collection->HashKeyIndex[$key]; + } + } + } + } + + $className = get_class($collection); + + return new $className($records, array_keys($collection->Indexes)); + } +} diff --git a/src/Data/Collections/Factory/Getters/GetByCriteria.php b/src/Data/Collections/Factory/Getters/GetByCriteria.php new file mode 100644 index 0000000..2cc4d65 --- /dev/null +++ b/src/Data/Collections/Factory/Getters/GetByCriteria.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Factory\Getters; + +use Divergence\Data\Collections\Collection; + +class GetByCriteria extends AbstractGetter +{ + public static function handle(Collection $collection, $criteria = null) + { + $results = GetAllByCriteria::handle($collection, $criteria); + return $results ? reset($results) : null; + } +} diff --git a/src/Data/Collections/Factory/Getters/GetByField.php b/src/Data/Collections/Factory/Getters/GetByField.php new file mode 100644 index 0000000..941acb2 --- /dev/null +++ b/src/Data/Collections/Factory/Getters/GetByField.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Factory\Getters; + +use Divergence\Data\Collections\Collection; + +class GetByField extends AbstractGetter +{ + public static function handle(Collection $collection, $field = null, $value = null) + { + if (!isset($collection->Indexes[$field])) { + return null; + } + + $results = $collection->Indexes[$field]->find($value); + + if ($results) { + $key = array_key_first($results); + + return $collection->HashKeyIndex[$key] ?? null; + } + + return null; + } +} diff --git a/src/Data/Collections/Getters.php b/src/Data/Collections/Getters.php new file mode 100644 index 0000000..4287c32 --- /dev/null +++ b/src/Data/Collections/Getters.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections; + +use Error; +use Divergence\Data\Collections\Factory\Factory; + +trait Getters +{ + protected static $_registeredGetterMethods = []; + + public static function Factory(): Factory + { + return new Factory(); + } + + protected static function registerGetterMethods(): void + { + $factory = static::Factory(); + + static::$_registeredGetterMethods[static::class] = $factory->getGetterClasses(); + } + + public static function __callStatic(string $name, array $arguments) + { + $factory = static::Factory(); + + if (method_exists($factory, $name)) { + return $factory->$name(...$arguments); + } + + throw new Error(sprintf('Call to undefined method %s::%s()', static::class, $name)); + } + + public function __call(string $name, array $arguments) + { + if (!isset(static::$_registeredGetterMethods[static::class])) { + static::registerGetterMethods(); + } + + $methodName = strtolower($name); + $getterClass = static::$_registeredGetterMethods[static::class][$methodName] ?? null; + + if ($getterClass === null) { + throw new Error(sprintf('Call to undefined method %s::%s()', static::class, $name)); + } + + return $getterClass::handle($this, ...$arguments); + } +} diff --git a/src/Data/Collections/IndexedField.php b/src/Data/Collections/IndexedField.php new file mode 100644 index 0000000..2d9a33e --- /dev/null +++ b/src/Data/Collections/IndexedField.php @@ -0,0 +1,237 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections; + +use Divergence\Models\Expr\CriteriaType; +use RuntimeException; + +class IndexedField +{ + public $field; + public $type; + + protected $cardinality = []; + + protected $index = []; + + protected $values = []; + + protected ?array $orderedValues = null; + + protected ?array $orderedRecordKeys = null; + + protected IndexedFieldFinder $finder; + + private const FINDER_METHODS = [ + 'find' => 'find', + 'findByIndex' => 'findByIndex', + ]; + + public function __construct($field, $type = null) + { + $this->field = $field; + $this->type = $type; + $this->finder = new IndexedFieldFinder($this); + } + + public function __call(string $method, array $arguments) + { + return $this->finder->{self::FINDER_METHODS[$method]}(...$arguments); + } + + public function getCardinality(): array + { + return $this->cardinality; + } + + public function getIndex(): array + { + return $this->index; + } + + public function getValues(): array + { + return $this->values; + } + + public function getOrderedValues(): ?array + { + return $this->orderedValues; + } + + public function setOrderedValues(array $orderedValues): void + { + $this->orderedValues = $orderedValues; + } + + public function getOrderedRecordKeys(): ?array + { + return $this->orderedRecordKeys; + } + + public function setOrderedRecordKeys(array $orderedRecordKeys): void + { + $this->orderedRecordKeys = $orderedRecordKeys; + } + + public function getCardinalityKey($value) + { + return $this->cardinalityKey($value); + } + + public function doesValueMatch($indexedValue, $value, int $operator): bool + { + return $this->matchesValue($indexedValue, $value, $operator); + } + + public function rebuildIndex(&$records) + { + if ($records) { + foreach ($records as $record) { + $this->set($record); + } + } + } + + public function clearExistingIndexForValue($record) + { + $recordKey = RecordKey::get($record); + + if (isset($this->values[$recordKey])) { + $cardinality = $this->values[$recordKey]; + unset($this->index[$cardinality][$recordKey]); + unset($this->values[$recordKey]); + + // if a cardinality becomes unused completely remove it from the known cardinalities + if (!$this->index[$cardinality]) { + unset($this->index[$cardinality], $this->cardinality[$cardinality]); + } + + $this->invalidateOrdering(); + } + } + + public function set($record) + { + $fieldValue = is_array($record) ? ($record[$this->field] ?? null) : ($record->{$this->field} ?? null); + $cardinalityValue = $this->indexableValue($fieldValue); + + if (!$this->cardinality_exists($cardinalityValue)) { + $this->bootstrapCardinality($cardinalityValue); + } + + $this->clearExistingIndexForValue($record); + + $cardinality = $this->cardinalityKey($cardinalityValue); + $recordKey = RecordKey::get($record); + + $this->index[$cardinality][$recordKey] = true; + $this->values[$recordKey] = $cardinality; + $this->invalidateOrdering(); + } + + /** + * @param mixed $value + * @return mixed + */ + public function indexableValue($value) + { + switch ($this->type) { + case 'DateString': + case 'timestamp': + $timestamp = strtotime($value); + return $timestamp === false ? $value : $timestamp; + + default: + $type = gettype($value); + if ($type === 'float' || $type === 'double') { + $value = (string) $value; + } + return $value; + } + } + + /** + * @param mixed $cardinality + * @return boolean + */ + public function cardinality_exists($cardinality) + { + $key = $this->cardinalityKey($cardinality); + + return array_key_exists($key, $this->cardinality) + && $this->cardinality[$key] === $cardinality; + } + + /** + * @param mixed $cardinality + * @return void + */ + public function bootstrapCardinality($cardinality) + { + $hash = $this->cardinalityKey($cardinality); + + $this->cardinality[$hash] = $cardinality; + $this->index[$hash] = []; + } + + protected function cardinalityKey($value) + { + return serialize($value); + } + + protected function invalidateOrdering(): void + { + $this->orderedValues = null; + $this->orderedRecordKeys = null; + } + + protected function matchesValue($indexedValue, $value, int $operator): bool + { + switch ($operator) { + case CriteriaType::NotEqual: + return $indexedValue != $value; + case CriteriaType::GreaterThan: + return $indexedValue > $value; + case CriteriaType::GreaterThanOrEqual: + return $indexedValue >= $value; + case CriteriaType::LessThan: + return $indexedValue < $value; + case CriteriaType::LessThanOrEqual: + return $indexedValue <= $value; + case CriteriaType::Like: + return $this->matchesLike($indexedValue, $value); + case CriteriaType::NotLike: + return !$this->matchesLike($indexedValue, $value); + case CriteriaType::In: + return in_array($indexedValue, (array) $value); + case CriteriaType::NotIn: + return !in_array($indexedValue, (array) $value); + case CriteriaType::Nulled: + case CriteriaType::NotExists: + return $indexedValue === null; + case CriteriaType::NotNulled: + case CriteriaType::Exists: + return $indexedValue !== null; + case CriteriaType::Equal: + return $indexedValue == $value; + default: + throw new RuntimeException(sprintf('Criteria operator "%s" cannot be evaluated in-memory', $operator)); + } + } + + private function matchesLike($value, $pattern): bool + { + $quoted = preg_quote((string) $pattern, '/'); + $regex = '/^' . str_replace(['%', '_'], ['.*', '.'], $quoted) . '$/i'; + + return (bool) preg_match($regex, (string) $value); + } +} diff --git a/src/Data/Collections/IndexedFieldFinder.php b/src/Data/Collections/IndexedFieldFinder.php new file mode 100644 index 0000000..0bc7e59 --- /dev/null +++ b/src/Data/Collections/IndexedFieldFinder.php @@ -0,0 +1,247 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections; + +use Divergence\Models\Expr\CriteriaType; +use RuntimeException; + +class IndexedFieldFinder +{ + private const FINDERS = [ + CriteriaType::Equal => 'findEquality', + CriteriaType::NotEqual => 'findEquality', + CriteriaType::GreaterThan => 'findOrderedComparison', + CriteriaType::GreaterThanOrEqual => 'findOrderedComparison', + CriteriaType::LessThan => 'findOrderedComparison', + CriteriaType::LessThanOrEqual => 'findOrderedComparison', + CriteriaType::Like => 'findPattern', + CriteriaType::NotLike => 'findPattern', + CriteriaType::In => 'findMembership', + CriteriaType::NotIn => 'findMembership', + CriteriaType::Nulled => 'findNullComparison', + CriteriaType::NotNulled => 'findNullComparison', + CriteriaType::Exists => 'findNullComparison', + CriteriaType::NotExists => 'findNullComparison', + ]; + + private const ORDERED_COMPARISONS = [ + CriteriaType::GreaterThan => [false, false], + CriteriaType::GreaterThanOrEqual => [false, true], + CriteriaType::LessThan => [true, false], + CriteriaType::LessThanOrEqual => [true, true], + ]; + + private IndexedField $IndexedField; + + public function __construct(IndexedField $IndexedField) + { + $this->IndexedField = $IndexedField; + } + + /** + * @param mixed $value + */ + public function find($value = [], int $operator = CriteriaType::Equal): array + { + if (!isset(self::FINDERS[$operator])) { + throw new RuntimeException(sprintf('Criteria operator "%s" cannot be evaluated in-memory', $operator)); + } + + $finder = self::FINDERS[$operator]; + + return $this->{$finder}($value, $operator); + } + + public function findByIndex(IndexedField $Index, int $operator): array + { + $matches = []; + + foreach ($this->IndexedField->getValues() as $recordKey => $leftCardinality) { + if (!array_key_exists($recordKey, $Index->getValues())) { + continue; + } + + $rightCardinality = $Index->getValues()[$recordKey]; + + if ($this->IndexedField->doesValueMatch( + $this->IndexedField->getCardinality()[$leftCardinality], + $Index->getCardinality()[$rightCardinality], + $operator + )) { + $matches[$recordKey] = true; + } + } + + return $matches; + } + + private function findEquality($value, int $operator): array + { + $matches = $this->findEqual($this->IndexedField->indexableValue($value)); + + if ($operator === CriteriaType::Equal) { + return $matches; + } + + return array_diff_key($this->allKeys(), $matches); + } + + private function findOrderedComparison($value, int $operator): array + { + [$lessThan, $inclusive] = self::ORDERED_COMPARISONS[$operator]; + + return $this->findOrdered($this->IndexedField->indexableValue($value), $lessThan, $inclusive); + } + + private function findPattern($value, int $operator): array + { + $value = $this->IndexedField->indexableValue($value); + $matches = []; + + foreach ($this->IndexedField->getCardinality() as $cardinality => $indexedValue) { + if ($this->IndexedField->doesValueMatch($indexedValue, $value, $operator)) { + $matches += $this->IndexedField->getIndex()[$cardinality]; + } + } + + return $matches; + } + + private function findMembership($value, int $operator): array + { + $matches = $this->findIn((array) $value); + + if ($operator === CriteriaType::In) { + return $matches; + } + + return array_diff_key($this->allKeys(), $matches); + } + + private function findNullComparison($_value, int $operator): array + { + $matches = $this->findEqual(null); + + if ($operator === CriteriaType::Nulled || $operator === CriteriaType::NotExists) { + return $matches; + } + + return array_diff_key($this->allKeys(), $matches); + } + + private function findEqual($value): array + { + $cardinality = $this->IndexedField->getCardinalityKey($value); + + if (array_key_exists($cardinality, $this->IndexedField->getCardinality()) + && $this->IndexedField->getCardinality()[$cardinality] === $value) { + return $this->IndexedField->getIndex()[$cardinality]; + } + + return []; + } + + private function findIn(array $values): array + { + $matches = []; + + foreach ($values as $value) { + $matches += $this->findEqual($this->IndexedField->indexableValue($value)); + } + + return $matches; + } + + private function findOrdered($value, bool $lessThan, bool $inclusive): array + { + $this->buildOrdering(); + + if ($lessThan) { + $end = $this->lowerBoundary($value, $inclusive); + $keys = array_slice($this->IndexedField->getOrderedRecordKeys(), 0, $end); + } else { + $start = $this->upperBoundary($value, $inclusive); + $keys = array_slice($this->IndexedField->getOrderedRecordKeys(), $start); + } + + return $keys ? array_fill_keys($keys, true) : []; + } + + private function buildOrdering(): void + { + if ($this->IndexedField->getOrderedValues() !== null) { + return; + } + + $values = []; + $recordKeys = []; + + foreach ($this->IndexedField->getCardinality() as $cardinality => $value) { + foreach ($this->IndexedField->getIndex()[$cardinality] as $recordKey => $_found) { + $values[] = $value; + $recordKeys[] = $recordKey; + } + } + + if ($values) { + array_multisort($values, SORT_ASC, SORT_REGULAR, $recordKeys, SORT_ASC, SORT_REGULAR); + } + + $this->IndexedField->setOrderedValues($values); + $this->IndexedField->setOrderedRecordKeys($recordKeys); + } + + private function lowerBoundary($value, bool $inclusive): int + { + $low = 0; + $high = count($this->IndexedField->getOrderedValues()); + + while ($low < $high) { + $middle = intdiv($low + $high, 2); + $matches = $inclusive + ? $this->IndexedField->getOrderedValues()[$middle] <= $value + : $this->IndexedField->getOrderedValues()[$middle] < $value; + + if ($matches) { + $low = $middle + 1; + } else { + $high = $middle; + } + } + + return $low; + } + + private function upperBoundary($value, bool $inclusive): int + { + $low = 0; + $high = count($this->IndexedField->getOrderedValues()); + + while ($low < $high) { + $middle = intdiv($low + $high, 2); + $matches = $inclusive + ? $this->IndexedField->getOrderedValues()[$middle] < $value + : $this->IndexedField->getOrderedValues()[$middle] <= $value; + + if ($matches) { + $low = $middle + 1; + } else { + $high = $middle; + } + } + + return $low; + } + + private function allKeys(): array + { + return array_fill_keys(array_keys($this->IndexedField->getValues()), true); + } +} diff --git a/src/Data/Collections/Indexing.php b/src/Data/Collections/Indexing.php new file mode 100644 index 0000000..4bae66f --- /dev/null +++ b/src/Data/Collections/Indexing.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections; + +interface Indexing +{ + public function createIndexByField($field); + + public function hasIndex($field): bool; + + public function updateIndexForModel($index, &$record); + + public function setIndexes(&$record); + + public function clearIndexes(&$record); +} diff --git a/src/Data/Collections/Indexing/AbstractHandler.php b/src/Data/Collections/Indexing/AbstractHandler.php new file mode 100644 index 0000000..578f19e --- /dev/null +++ b/src/Data/Collections/Indexing/AbstractHandler.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Indexing; + +use Divergence\Data\Collections\Collection; + +abstract class AbstractHandler +{ + abstract public static function handle(Collection $collection); +} diff --git a/src/Data/Collections/Indexing/ClearIndexes.php b/src/Data/Collections/Indexing/ClearIndexes.php new file mode 100644 index 0000000..1801da2 --- /dev/null +++ b/src/Data/Collections/Indexing/ClearIndexes.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Indexing; + +use Divergence\Data\Collections\Collection; + +class ClearIndexes extends AbstractHandler +{ + public static function handle(Collection $collection, &$record = null): void + { + foreach ($collection->Indexes as $index) { + $index->clearExistingIndexForValue($record); + } + } +} diff --git a/src/Data/Collections/Indexing/CreateIndexByField.php b/src/Data/Collections/Indexing/CreateIndexByField.php new file mode 100644 index 0000000..e98c1eb --- /dev/null +++ b/src/Data/Collections/Indexing/CreateIndexByField.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Indexing; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\IndexedField; + +class CreateIndexByField extends AbstractHandler +{ + public static function handle(Collection $collection, $field = null): void + { + $index = new IndexedField($field); + $index->rebuildIndex($collection->Index); + $collection->Indexes[$field] = $index; + } +} diff --git a/src/Data/Collections/Indexing/HasIndex.php b/src/Data/Collections/Indexing/HasIndex.php new file mode 100644 index 0000000..21730a3 --- /dev/null +++ b/src/Data/Collections/Indexing/HasIndex.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Indexing; + +use Divergence\Data\Collections\Collection; + +class HasIndex extends AbstractHandler +{ + public static function handle(Collection $collection, $field = null): bool + { + return isset($collection->Indexes[$field]); + } +} diff --git a/src/Data/Collections/Indexing/SetIndexes.php b/src/Data/Collections/Indexing/SetIndexes.php new file mode 100644 index 0000000..59f9b5f --- /dev/null +++ b/src/Data/Collections/Indexing/SetIndexes.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Indexing; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\RecordKey; + +class SetIndexes extends AbstractHandler +{ + public static function handle(Collection $collection, &$record = null): void + { + $recordKey = RecordKey::get($record); + $collection->HashKeyIndex[$recordKey] = $record; + + foreach ($collection->Indexes as $index) { + $index->set($record); + } + } +} diff --git a/src/Data/Collections/Indexing/UpdateIndexForModel.php b/src/Data/Collections/Indexing/UpdateIndexForModel.php new file mode 100644 index 0000000..2985ef3 --- /dev/null +++ b/src/Data/Collections/Indexing/UpdateIndexForModel.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Indexing; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\RecordKey; + +class UpdateIndexForModel extends AbstractHandler +{ + public static function handle(Collection $collection, $index = null, &$record = null): void + { + if (isset($collection->Indexes[$index])) { + $recordKey = RecordKey::get($record); + $collection->HashKeyIndex[$recordKey] = $record; + $collection->Indexes[$index]->set($record); + } + } +} diff --git a/src/Data/Collections/RecordKey.php b/src/Data/Collections/RecordKey.php new file mode 100644 index 0000000..96e6c0c --- /dev/null +++ b/src/Data/Collections/RecordKey.php @@ -0,0 +1,28 @@ +getPrimaryKeyValue(); + + if ($primaryKey !== null) { + // if we get a hash we return right away + return KeyToHashInt::hashForKeys([$primaryKey]); + } + } + + // this is phantoms and all non ORM objects that are indexed + // after save phantoms will run ->remove() then ->add() on + // themselves in the collection indexes + + // detect other ORMs here for indexing support + return spl_object_id($record); + } +} diff --git a/src/Data/KeyToHashInt.php b/src/Data/KeyToHashInt.php new file mode 100644 index 0000000..6f913ee --- /dev/null +++ b/src/Data/KeyToHashInt.php @@ -0,0 +1,90 @@ +keys = $keys; + $this->hash = null; + } + + public function getSingular() + { + // If a single key PK is already an int, return it as-is + if (is_int($this->keys[0])) { + $this->hash = $this->keys[0]; + return $this->hash; + } + + if (is_string($this->keys[0])) { + // if for some reason it's a string we're gonna convert it to an int + if (ctype_digit($this->keys[0])) { + $this->hash = (int)($this->keys[0]); + return $this->hash; + // if it's a non-numeric string but still being used as a PK then we'll hash it for a 64 bit int + } else { + $this->hash = intval(hexdec(hash('xxh64', $this->keys[0]))); + return $this->hash; + } + } + + return $this->hash; + } + + // Pack two 32-bit component hashes into one 64-bit integer. + public function getDouble() + { + $k1 = crc32((string) $this->keys[0]); + $k2 = crc32((string) $this->keys[1]); + // shift first hash left 32 bits and OR with second + $this->hash = $k1 << 32 | $k2; + return $this->hash; + } + + // Three or more dimensions use one delimited xxHash64 input. + public function getMany() + { + $this->hash = intval(hexdec(hash('xxh64', implode('|', $this->keys)))); + return $this->hash; + } + + public function get() + { + if ($this->hash !== null) { + return $this->hash; + } + + switch (count($this->keys)) { + case 1: + return $this->getSingular(); + + case 2: + return $this->getDouble(); + + default: + return $this->getMany(); + } + } + + public static function hashForKeys($keys) + { + if (self::$singleton === null) { + self::$singleton = new self($keys); + } else { + self::$singleton->keys = $keys; + self::$singleton->hash = null; + } + + return self::$singleton->get(); + } +} diff --git a/src/Helpers/Util.php b/src/Helpers/Util.php index ae7adb4..d1ca983 100644 --- a/src/Helpers/Util.php +++ b/src/Helpers/Util.php @@ -21,7 +21,7 @@ class Util /** * Prepares options. * - * @param string|array $value Option. If provided a string will be assumed to be json and it will attempt to json_decode it and merge it with defaults. Or provide the array yourself. + * @param string|array|false|null $value Option. If provided a string will be assumed to be json and it will attempt to json_decode it and merge it with defaults. Or provide the array yourself. * @param array $defaults Defaults for the options array * @return array Merged array from $defaults and $value */ diff --git a/src/IO/Database/Writer/MySQL.php b/src/IO/Database/Writer/MySQL.php index 85b6223..b31f387 100644 --- a/src/IO/Database/Writer/MySQL.php +++ b/src/IO/Database/Writer/MySQL.php @@ -118,7 +118,7 @@ public static function getCreateTable($recordClass, $historyVariant = false) static::appendMySqlIndexes($queryString, $fulltextColumns, $indexes); $createSQL = sprintf( - "CREATE TABLE IF NOT EXISTS `%s` (\n\t%s\n) ENGINE=MyISAM DEFAULT CHARSET=utf8;", + "CREATE TABLE IF NOT EXISTS `%s` (\n\t%s\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;", static::getTargetTableName($recordClass, $historyVariant), join("\n\t,", $queryString) ); diff --git a/src/Models/ActiveRecord.php b/src/Models/ActiveRecord.php index 8b1555f..afc3459 100644 --- a/src/Models/ActiveRecord.php +++ b/src/Models/ActiveRecord.php @@ -33,6 +33,7 @@ use Divergence\IO\Database\Query\Update; use Divergence\Models\Mapping\DefaultGetMapper; use Divergence\Models\Mapping\DefaultSetMapper; +use Divergence\Models\Factory as ModelFactory; /** * ActiveRecord @@ -729,12 +730,8 @@ public static function isRelational() */ public static function create($values = [], $save = false) { - $className = get_called_class(); - - // create class /** @var ActiveRecord */ - $ActiveRecord = new $className(); - $ActiveRecord->setFields($values); + $ActiveRecord = ModelFactory::get(static::class)->instantiatePhantomRecord($values); if ($save) { $ActiveRecord->save(); @@ -1897,6 +1894,15 @@ public function finalizeSave(): void $this->_isDirty = false; } + public function restoreState(self $state): void + { + foreach (get_object_vars($state) as $property => $value) { + $this->$property = $value; + } + + $this->initializeAttributeFields(); + } + /** * @param array $set * @return void diff --git a/src/Models/Collections/Events/Add.php b/src/Models/Collections/Events/Add.php new file mode 100644 index 0000000..3c64a00 --- /dev/null +++ b/src/Models/Collections/Events/Add.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections\Events; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Events\AbstractHandler; +use Divergence\Data\Collections\RecordKey; + +class Add extends AbstractHandler +{ + public static function handle(Collection $collection, $record = null): void + { + if ($collection->validate($record)) { + $primaryKey = $record->getPrimaryKeyValue(); + $modelKey = RecordKey::get($record); + + if (isset($collection->HashKeyIndex[$modelKey]) + && ($primaryKey !== null || $collection->HashKeyIndex[$modelKey] === $record)) { + return; + } + + array_push($collection->Index, $record); + $collection->setIndexes($record); + } + } +} diff --git a/src/Models/Collections/Events/Remove.php b/src/Models/Collections/Events/Remove.php new file mode 100644 index 0000000..5ea5771 --- /dev/null +++ b/src/Models/Collections/Events/Remove.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections\Events; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Events\AbstractHandler; +use Divergence\Data\Collections\RecordKey; + +class Remove extends AbstractHandler +{ + public static function handle(Collection $collection, $record = null): void + { + $recordKey = RecordKey::get($record); + + foreach ($collection->Index as $key => $Model) { + $modelKey = RecordKey::get($Model); + + if ($modelKey === $recordKey) { + array_splice($collection->Index, $key, 1); + unset($collection->HashKeyIndex[$modelKey]); + $collection->clearIndexes($Model); + + if ($collection->position > $key) { + --$collection->position; + } + + return; + } + } + } +} diff --git a/src/Models/Collections/Factory/Factory.php b/src/Models/Collections/Factory/Factory.php new file mode 100644 index 0000000..1c9ab61 --- /dev/null +++ b/src/Models/Collections/Factory/Factory.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections\Factory; + +use Divergence\Data\Collections\Factory\Factory as BaseFactory; +use Divergence\Data\Collections\Factory\Getters\GetByField; +use Divergence\Data\Collections\Factory\Getters\GetByCriteria; +use Divergence\Models\Collections\Factory\Getters\GetAllByField; +use Divergence\Models\Collections\Factory\Getters\GetAllByCriteria; + +class Factory extends BaseFactory +{ + protected function registerGetterClasses(): void + { + $this->getterClasses = []; + + foreach ([ + GetByField::class, + GetAllByField::class, + GetByCriteria::class, + GetAllByCriteria::class, + ] as $className) { + $this->registerGetterClass($className); + } + } +} diff --git a/src/Models/Collections/Factory/Getters/GetAllByCriteria.php b/src/Models/Collections/Factory/Getters/GetAllByCriteria.php new file mode 100644 index 0000000..52c9a1a --- /dev/null +++ b/src/Models/Collections/Factory/Getters/GetAllByCriteria.php @@ -0,0 +1,14 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections\Factory\Getters; + +class GetAllByCriteria extends \Divergence\Data\Collections\Factory\Getters\GetAllByCriteria +{ +} diff --git a/src/Models/Collections/Factory/Getters/GetAllByField.php b/src/Models/Collections/Factory/Getters/GetAllByField.php new file mode 100644 index 0000000..d804b37 --- /dev/null +++ b/src/Models/Collections/Factory/Getters/GetAllByField.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections\Factory\Getters; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Factory\Getters\AbstractGetter; + +class GetAllByField extends AbstractGetter +{ + public static function handle(Collection $collection, $field = null, $value = null) + { + $Models = []; + + if (isset($collection->Indexes[$field])) { + $results = $collection->Indexes[$field]->find($value); + + if ($results) { + foreach ($results as $key => $_found) { + if (isset($collection->HashKeyIndex[$key])) { + $Models[] = $collection->HashKeyIndex[$key]; + } + } + } + } + + $className = get_class($collection); + + return new $className($Models, array_keys($collection->Indexes), $collection->recordClassName); + } +} diff --git a/src/Models/Collections/IndexedRecordField.php b/src/Models/Collections/IndexedRecordField.php new file mode 100644 index 0000000..706450e --- /dev/null +++ b/src/Models/Collections/IndexedRecordField.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections; + +use Divergence\Data\Collections\IndexedField; +use Divergence\Data\Collections\RecordKey; + +class IndexedRecordField extends IndexedField +{ + public function clearExistingIndexForValue($record) + { + $modelKey = RecordKey::get($record); + + if (isset($this->values[$modelKey])) { + $cardinality = $this->values[$modelKey]; + unset($this->index[$cardinality][$modelKey]); + unset($this->values[$modelKey]); + $this->invalidateOrdering(); + } + } + + public function set($record) + { + $cardinalityValue = $this->indexableValue($record->getValue($this->field)); + + if (!$this->cardinality_exists($cardinalityValue)) { + $this->bootstrapCardinality($cardinalityValue); + } + + $this->clearExistingIndexForValue($record); + + $cardinality = $this->cardinalityKey($cardinalityValue); + $modelKey = RecordKey::get($record); + + $this->index[$cardinality][$modelKey] = true; + $this->values[$modelKey] = $cardinality; + $this->invalidateOrdering(); + } + + public function indexableValue($value) + { + switch ($this->type) { + case 'DateString': + case 'timestamp': + return strtotime($value) ?: $value; + + default: + $type = gettype($value); + if ($type === 'float' || $type === 'double') { + $value = (string) $value; + } + return $value; + } + } +} diff --git a/src/Models/Collections/Indexing/ClearIndexes.php b/src/Models/Collections/Indexing/ClearIndexes.php new file mode 100644 index 0000000..2ff15bf --- /dev/null +++ b/src/Models/Collections/Indexing/ClearIndexes.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections\Indexing; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Indexing\AbstractHandler; + +class ClearIndexes extends AbstractHandler +{ + public static function handle(Collection $collection, &$record = null): void + { + foreach ($collection->Indexes as $index) { + $index->clearExistingIndexForValue($record); + } + } +} diff --git a/src/Models/Collections/Indexing/CreateIndexByField.php b/src/Models/Collections/Indexing/CreateIndexByField.php new file mode 100644 index 0000000..2f0d641 --- /dev/null +++ b/src/Models/Collections/Indexing/CreateIndexByField.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections\Indexing; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Indexing\AbstractHandler; +use Divergence\Models\Collections\IndexedRecordField; + +class CreateIndexByField extends AbstractHandler +{ + public static function handle(Collection $collection, $field = null): void + { + $fieldOptions = $collection->recordClassName::getClassFields()[$field] ?? []; + $type = $fieldOptions['type'] ?? null; + $index = new IndexedRecordField($field, $type); + $index->rebuildIndex($collection->Index); + $collection->Indexes[$field] = $index; + } +} diff --git a/src/Models/Collections/Indexing/SetIndexes.php b/src/Models/Collections/Indexing/SetIndexes.php new file mode 100644 index 0000000..39b1170 --- /dev/null +++ b/src/Models/Collections/Indexing/SetIndexes.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections\Indexing; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\RecordKey; +use Divergence\Data\Collections\Indexing\AbstractHandler; + +class SetIndexes extends AbstractHandler +{ + public static function handle(Collection $collection, &$record = null): void + { + $modelKey = RecordKey::get($record); + $collection->HashKeyIndex[$modelKey] = $record; + + foreach ($collection->Indexes as $index) { + $index->set($record); + } + } +} diff --git a/src/Models/Collections/Indexing/UpdateIndexForModel.php b/src/Models/Collections/Indexing/UpdateIndexForModel.php new file mode 100644 index 0000000..ce8c06f --- /dev/null +++ b/src/Models/Collections/Indexing/UpdateIndexForModel.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections\Indexing; + +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\RecordKey; +use Divergence\Data\Collections\Indexing\AbstractHandler; + +class UpdateIndexForModel extends AbstractHandler +{ + public static function handle(Collection $collection, $index = null, &$record = null): void + { + if (isset($collection->Indexes[$index])) { + $modelKey = RecordKey::get($record); + $collection->HashKeyIndex[$modelKey] = $record; + $collection->Indexes[$index]->set($record); + } + } +} diff --git a/src/Models/Collections/RecordCollection.php b/src/Models/Collections/RecordCollection.php new file mode 100644 index 0000000..2df879a --- /dev/null +++ b/src/Models/Collections/RecordCollection.php @@ -0,0 +1,170 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Collections; + +use Exception; +use Divergence\Models\ActiveRecord; +use Divergence\Data\Collections\RecordKey; +use Divergence\IO\Database\Connections; +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Events\AddMany; +use Divergence\Data\Collections\Events\RemoveMany; +use Divergence\Data\Collections\Indexing\HasIndex; +use Divergence\Models\Collections\Events\Add; +use Divergence\Models\Collections\Events\Remove; +use Divergence\Models\Collections\Indexing\CreateIndexByField; +use Divergence\Models\Collections\Indexing\UpdateIndexForModel; +use Divergence\Models\Collections\Indexing\SetIndexes; +use Divergence\Models\Collections\Indexing\ClearIndexes; +use Divergence\Models\Collections\Factory\Factory; + +/** + * @template TModel of ActiveRecord + */ +class RecordCollection extends Collection +{ + public static $addHandler = Add::class; + public static $addManyHandler = AddMany::class; + public static $removeHandler = Remove::class; + public static $removeManyHandler = RemoveMany::class; + public static $createIndexByFieldHandler = CreateIndexByField::class; + public static $hasIndexHandler = HasIndex::class; + public static $updateIndexForModelHandler = UpdateIndexForModel::class; + public static $setIndexesHandler = SetIndexes::class; + public static $clearIndexesHandler = ClearIndexes::class; + + public static function Factory(): Factory + { + return new Factory(); + } + + /** @var class-string|null */ + public $recordClassName; + + /** + * @param array $records + * @param array $indexes + * @param class-string|null $recordClassName + */ + public function __construct(array $records = [], array $indexes = [], $recordClassName = null) + { + $this->recordClassName = $recordClassName; + + if (!$this->recordClassName && count($records)) { + $this->recordClassName = get_class(reset($records)); + } + + foreach ($indexes as $field) { + $this->createIndexByField($field); + } + + $this->addMany($records); + } + + public function validate($record) + { + if (!$this->recordClassName) { + $this->recordClassName = get_class($record); + } + + return is_a($record, $this->recordClassName); + } + + public function isDirty() + { + if (count($this->Index)) { + foreach ($this->Index as $Model) { + if ($Model->isDirty) { + return true; + } + } + } + return false; + } + + public function saveWithTransaction(bool $deep = true) + { + if (count($this->Index) === 0) { + return; + } + + $connection = Connections::getConnection(); + $models = $this->Index; + $states = array_map(fn ($Model) => clone $Model, $models); + + try { + $connection->beginTransaction(); + + foreach ($this->Index as $Model) { + if ($Model->isDirty || $Model->isPhantom) { + $this->remove($Model); + $Model->save($deep); + $this->add($Model); + } + } + + return $connection->commit(); + } catch (Exception $exception) { + $connection->rollBack(); + + foreach ($this->Index as $Model) { + $this->clearIndexes($Model); + } + + $this->Index = $this->HashKeyIndex = []; + + foreach ($models as $key => $Model) { + $Model->restoreState($states[$key]); + } + + $this->addMany($models); + throw $exception; + } + } + + public function save(bool $deep = true): void + { + foreach ($this->Index as $Model) { + if ($Model->isDirty || $Model->isPhantom) { + $this->remove($Model); + $Model->save($deep); + $this->add($Model); + } + } + } + + public function current(): ?ActiveRecord + { + return $this->Index[$this->position] ?? null; + } + + public function offsetUnset(mixed $offset): void + { + if (isset($this->Index[$offset])) { + $Model = $this->Index[$offset]; + $modelKey = RecordKey::get($Model); + unset($this->HashKeyIndex[$modelKey]); + $this->clearIndexes($Model); + array_splice($this->Index, $offset, 1); + + if ($this->position > $offset) { + --$this->position; + } + } + } + + public function offsetGet(mixed $offset): ?ActiveRecord + { + if ($offset < 0) { + $offset += $this->count(); + } + return $this->Index[$offset] ?? null; + } +} diff --git a/src/Models/Events/HandleException.php b/src/Models/Events/HandleException.php index 488dabe..5c49531 100644 --- a/src/Models/Events/HandleException.php +++ b/src/Models/Events/HandleException.php @@ -15,6 +15,12 @@ public static function handle(string $className, Exception $e, $query = null, $q $errorMessage = strtolower($errorInfo[2] ?? $e->getMessage()); if (static::isMissingTableError($errorCode, $errorMessage) && $className::$autoCreateTables) { + $transactionStarted = $connection->inTransaction(); + + if ($transactionStarted && $connection->getAttribute(\PDO::ATTR_DRIVER_NAME) === 'pgsql') { + $connection->rollBack(); + } + $writerClass = static::getWriterClass(); $rootClass = $className::getRootClassName(); $statements = [$writerClass::getCreateTable($rootClass)]; @@ -40,6 +46,10 @@ public static function handle(string $className, Exception $e, $query = null, $q } } + if ($transactionStarted && !$connection->inTransaction()) { + $connection->beginTransaction(); + } + return $connection->query((string) $query); } diff --git a/src/Models/Expr/Conjunction.php b/src/Models/Expr/Conjunction.php new file mode 100644 index 0000000..2de0e69 --- /dev/null +++ b/src/Models/Expr/Conjunction.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Expr; + +class Conjunction +{ + const GroupAnd = 1; + const GroupOr = 2; + const GroupNotAnd = 3; + const GroupNotOr = 4; +} diff --git a/src/Models/Expr/Criteria.php b/src/Models/Expr/Criteria.php new file mode 100644 index 0000000..4612eda --- /dev/null +++ b/src/Models/Expr/Criteria.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Expr; + +class Criteria +{ + public $key; + public $value; + public $operator; + public $rawOperator; + + public function __construct(string $key, $value = null, int $operator = CriteriaType::Equal, ?string $rawOperator = null) + { + $this->key = $key; + $this->value = $value; + $this->operator = $operator; + $this->rawOperator = $rawOperator; + } +} diff --git a/src/Models/Expr/CriteriaGroup.php b/src/Models/Expr/CriteriaGroup.php new file mode 100644 index 0000000..95c8f7e --- /dev/null +++ b/src/Models/Expr/CriteriaGroup.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Expr; + +class CriteriaGroup +{ + public $criteria; + public $conjunction; + + /** + * @param array $criteria + */ + public function __construct(array $criteria, int $conjunction = Conjunction::GroupAnd) + { + $this->criteria = $criteria; + $this->conjunction = $conjunction; + } +} diff --git a/src/Models/Expr/CriteriaType.php b/src/Models/Expr/CriteriaType.php new file mode 100644 index 0000000..d3efb68 --- /dev/null +++ b/src/Models/Expr/CriteriaType.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Expr; + +class CriteriaType +{ + const Equal = 1; + const NotEqual = 2; + const GreaterThan = 3; + const GreaterThanOrEqual = 4; + const LessThan = 5; + const LessThanOrEqual = 6; + const Like = 7; + const NotLike = 8; + const In = 9; + const NotIn = 10; + const Nulled = 11; + const NotNulled = 12; + const Exists = 13; + const NotExists = 14; + const Raw = 15; + const FieldEqual = 16; + const FieldNotEqual = 17; + const FieldGreaterThan = 18; + const FieldGreaterThanOrEqual = 19; + const FieldLessThan = 20; + const FieldLessThanOrEqual = 21; +} diff --git a/src/Models/Factory.php b/src/Models/Factory.php index f5dc3c4..abc708d 100644 --- a/src/Models/Factory.php +++ b/src/Models/Factory.php @@ -10,7 +10,7 @@ namespace Divergence\Models; -use BadMethodCallException; +use Error; use Exception; use Divergence\Models\Factory\Instantiator; use Divergence\Models\Factory\Getters\GetAll; @@ -64,6 +64,11 @@ */ class Factory { + /** + * @var array + */ + protected static $InstanceRegistry = []; + /** * @var array */ @@ -121,6 +126,15 @@ class Factory */ protected $modelMetadata; + public static function get(string $modelClass): static + { + if (!isset(static::$InstanceRegistry[$modelClass])) { + static::$InstanceRegistry[$modelClass] = new static($modelClass); + } + + return static::$InstanceRegistry[$modelClass]; + } + /** * @param string $modelClass */ @@ -181,7 +195,7 @@ public function __call(string $name, array $arguments) $getterName = strtolower($name); if (!isset($this->getterClasses[$getterName])) { - throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', static::class, $name)); + throw new Error(sprintf('Call to undefined method %s::%s()', static::class, $name)); } if (!isset($this->getters[$getterName])) { @@ -264,11 +278,22 @@ public function instantiateRecord($record) return $this->instantiator->instantiateRecord($record); } + /** + * Creates a new phantom model from the provided values. + * + * @param array $record + * @return Model + */ + public function instantiatePhantomRecord($record = []) + { + return $this->instantiator->instantiatePhantomRecord($record); + } + /** * Converts an array of database records to a model corresponding to each record. Will attempt to use the record's Class field value to as the class to instantiate as or the name of this class if none is provided. * * @param array $record An array of database rows. - * @return array|null An array of instantiated ActiveRecord models from the provided data. + * @return array|\Divergence\Models\Collections\RecordCollection An array or collection of instantiated ActiveRecord models from the provided data. */ public function instantiateRecords($records) { diff --git a/src/Models/Factory/EventBinder.php b/src/Models/Factory/EventBinder.php index 229245d..3fe6270 100644 --- a/src/Models/Factory/EventBinder.php +++ b/src/Models/Factory/EventBinder.php @@ -12,6 +12,9 @@ use ReflectionProperty; +/** + * @template TModel of \Divergence\Models\Model + */ class EventBinder { /** @@ -42,7 +45,13 @@ protected function synchronizeMappedProperties($model): void } } - public function bindPrototype($model) + /** + * Main instantiator + * + * @param TModel $model + * @return TModel + */ + public function initPrototype($model) { $className = get_class($model); @@ -69,10 +78,18 @@ public function bindPrototype($model) return $model; } - public function bindRecord($model, array $record = [], bool $isDirty = false, ?bool $isPhantom = null) + /** + * Configures meta data fields + * + * @param TModel $model + * @param array $record + * @param boolean $isDirty + * @param boolean $isPhantom + * @return TModel + */ + public function bindRecord($model, array $record = [], bool $isDirty = false, bool $isPhantom = false) { $className = get_class($model); - $isPhantom = isset($isPhantom) ? $isPhantom : empty($record); if ($className::fieldExists('Class')) { $columnName = $className::getColumnName('Class'); diff --git a/src/Models/Factory/Getters/ModelGetter.php b/src/Models/Factory/Getters/ModelGetter.php index 852b983..183e3d1 100644 --- a/src/Models/Factory/Getters/ModelGetter.php +++ b/src/Models/Factory/Getters/ModelGetter.php @@ -59,7 +59,7 @@ protected function instantiateRecord($record) /** * @param array>|array> $records - * @return array|array + * @return array|array|\Divergence\Models\Collections\RecordCollection */ protected function instantiateRecords($records) { diff --git a/src/Models/Factory/Instantiator.php b/src/Models/Factory/Instantiator.php index 4f769a4..e1470df 100644 --- a/src/Models/Factory/Instantiator.php +++ b/src/Models/Factory/Instantiator.php @@ -12,6 +12,8 @@ use ReflectionClass; use Divergence\Models\Model; +use Divergence\Models\Mapping\InMemoryIndexing; +use Divergence\Models\Collections\RecordCollection; /** * @template TModel of Model @@ -29,9 +31,14 @@ class Instantiator protected $eventBinder; /** - * @var PrototypeRegistry + * @var RecordCollection|null */ - protected $prototypeRegistry; + protected $Collection; + + /** + * @var InMemoryIndexing|null + */ + protected $indexingConfig; /** * @param string $modelClass @@ -43,7 +50,14 @@ public function __construct(ModelMetadata $metadata) { $this->metadata = $metadata; $this->eventBinder = new EventBinder(); - $this->prototypeRegistry = new PrototypeRegistry(); + + $modelClass = $this->metadata->getModelClass(); + $attributes = (new ReflectionClass($modelClass))->getAttributes(InMemoryIndexing::class); + + if ($attributes) { + $this->indexingConfig = $attributes[0]->newInstance(); + $this->instantiateCollection(); + } } /** @@ -67,48 +81,83 @@ protected function getRecordClass($record) return $className; } + /** + * @param array $record + * @return TModel + */ + public function instantiatePhantomRecord($record = []) + { + $className = $this->getRecordClass($record); + $prototype = $this->createPrototype($className); + $model = clone $prototype; + + $model = $this->eventBinder->bindRecord($model, [], false, true); + $model->setFields($record); + + return $model; + } + /** * @param array|null $record * @return TModel|null */ public function instantiateRecord($record) { - return $this->instantiateModel($record); + if ($record === false || $record === null) { + return null; + } + + return $this->instantiateModel($record, false); } /** * @param array>|array> $records - * @return array|array + * @return array|array|RecordCollection */ public function instantiateRecords($records) { + $Collection = $this->Collection; + + if ($Collection) { + $this->instantiateCollection(); + } + foreach ($records as &$record) { $record = $this->instantiateModel($record); + + if ($Collection) { + $Collection->add($record); + } } - return $records; + return $Collection ?: $records; + } + + protected function instantiateCollection(): void + { + $modelClass = $this->metadata->getModelClass(); + $this->Collection = new RecordCollection([], $this->indexingConfig->indexes, $modelClass); } /** - * @param array|null $record - * @return TModel|null + * @param array $record + * @return TModel */ - protected function instantiateModel($record) + protected function instantiateModel(array $record, bool $phantom = false) { $className = $this->getRecordClass($record); - if (!$record) { - return null; - } + $prototype = $this->createPrototype($className); - $prototype = $this->prototypeRegistry->get($className, function () use ($className) { - $model = (new ReflectionClass($className))->newInstanceWithoutConstructor(); + $model = clone $prototype; - return $this->eventBinder->bindPrototype($model); - }); + return $this->eventBinder->bindRecord($model, $record, false, $phantom); + } - $model = clone $prototype; + protected function createPrototype(string $className) + { + $model = (new ReflectionClass($className))->newInstanceWithoutConstructor(); - return $this->eventBinder->bindRecord($model, $record); + return $this->eventBinder->initPrototype($model); } } diff --git a/src/Models/Factory/PrototypeRegistry.php b/src/Models/Factory/PrototypeRegistry.php deleted file mode 100644 index c55bb03..0000000 --- a/src/Models/Factory/PrototypeRegistry.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Divergence\Models\Factory; - -class PrototypeRegistry -{ - /** - * @var array - */ - protected static $prototypes = []; - - public function get(string $className, callable $factory) - { - if (!isset(static::$prototypes[$className])) { - static::$prototypes[$className] = $factory(); - } - - return static::$prototypes[$className]; - } -} diff --git a/src/Models/Getters.php b/src/Models/Getters.php index ba65172..1e4661f 100644 --- a/src/Models/Getters.php +++ b/src/Models/Getters.php @@ -10,7 +10,7 @@ namespace Divergence\Models; -use BadMethodCallException; +use Error; /** * @property string $handleField Defined in the model @@ -29,7 +29,7 @@ trait Getters */ public static function Factory(?string $modelClass = null): Factory { - return new Factory($modelClass ?: static::class); + return Factory::get($modelClass ?: static::class); } protected static function registerGetterMethods(): void @@ -55,6 +55,6 @@ public static function __callStatic(string $name, array $arguments) return $factory->$name(...$arguments); } - throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', static::class, $name)); + throw new Error(sprintf('Call to undefined method %s::%s()', static::class, $name)); } } diff --git a/src/Models/Mapping/InMemoryIndexing.php b/src/Models/Mapping/InMemoryIndexing.php new file mode 100644 index 0000000..dd53d73 --- /dev/null +++ b/src/Models/Mapping/InMemoryIndexing.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Models\Mapping; + +use Attribute; + +#[Attribute(Attribute::TARGET_CLASS)] +final class InMemoryIndexing implements MappingAttribute +{ + /** @var array */ + public $indexes = []; + + public function __construct(array $indexes = []) + { + $this->indexes = $indexes; + } +} diff --git a/src/Models/Media/Audio.php b/src/Models/Media/Audio.php index 8950b2b..bdedd16 100644 --- a/src/Models/Media/Audio.php +++ b/src/Models/Media/Audio.php @@ -53,7 +53,7 @@ public function getValue($name) } } - public function getImage($sourceFile = null) + public function getImage($sourceFile = null): \GdImage|false { if (!isset($sourceFile)) { $sourceFile = $this->BlankPath; @@ -72,10 +72,10 @@ public function createPreview() $startTime = 0; } - $previewPath = tempnam('/tmp', 'mediaPreview'); + $previewPath = tempnam(sys_get_temp_dir(), 'mediaPreview'); // generate preview - $cmd = sprintf(static::$previewExtractCommand, $this->FilesystemPath, $previewPath, $startTime, static::$previewDuration); + $cmd = sprintf(static::$previewExtractCommand, escapeshellarg($this->FilesystemPath), escapeshellarg($previewPath), $startTime, static::$previewDuration); shell_exec($cmd); if (!filesize($previewPath)) { diff --git a/src/Models/Media/Media.php b/src/Models/Media/Media.php index aed7576..253ccdf 100644 --- a/src/Models/Media/Media.php +++ b/src/Models/Media/Media.php @@ -221,14 +221,21 @@ public function getImage($sourceFile = null): \GdImage|false case 'image/tiff': //Converts PSD to PNG temporarily on the real file system. - $tempFile = tempnam('/tmp', 'media_convert'); - exec("convert -density 100 ".$this->getValue('FilesystemPath')."[0] -flatten $tempFile.png"); + $tempFile = tempnam(sys_get_temp_dir(), 'media_convert'); + $cmd = 'convert -density 100 ' . escapeshellarg($this->getValue('FilesystemPath') . '[0]') . ' -flatten ' . escapeshellarg($tempFile . '.png'); + exec($cmd); - return imagecreatefrompng("$tempFile.png"); + return imagecreatefrompng($tempFile . '.png'); case 'application/postscript': - return imagecreatefromstring(shell_exec("gs -r150 -dEPSCrop -dNOPAUSE -dBATCH -sDEVICE=png48 -sOutputFile=- -q $this->getValue('FilesystemPath')")); + $cmd = 'gs -r150 -dEPSCrop -dNOPAUSE -dBATCH -sDEVICE=png48 -sOutputFile=- -q ' . escapeshellarg($this->getValue('FilesystemPath')); + + if (!$imageData = shell_exec($cmd)) { + throw new Exception('Failed to convert postscript file with gs, ensure ghostscript is installed'); + } + + return imagecreatefromstring($imageData); default: @@ -468,10 +475,12 @@ public static function createFromUpload($uploadedFile, $fieldValues = []): stati public static function createFromFile($file, $fieldValues = []): static | false { + $Media = null; + try { // handle url input if (filter_var($file, FILTER_VALIDATE_URL)) { - $tempName = tempnam('/tmp', 'remote_media'); + $tempName = tempnam(sys_get_temp_dir(), 'remote_media'); copy($file, $tempName); $file = $tempName; } @@ -496,15 +505,13 @@ public static function createFromFile($file, $fieldValues = []): static | false return $Media; } catch (Exception $e) { - throw $e; - } + // remove partially-created media record + if ($Media) { + $Media->destroy(); + } - // remove photo record - if ($Media) { - $Media->destroy(); + throw $e; } - - return false; } public function initializeFromAnalysis($mediaInfo) @@ -599,6 +606,13 @@ public function getMIMEType(): string return $this->getValue('MIMEType'); } + public function isVariantAvailable($variant): bool + { + $path = $this->getFilesystemPath($variant); + + return $path !== null && is_readable($path); + } + public function writeFile($sourceFile): bool { $targetDirectory = dirname($this->getValue('FilesystemPath')); diff --git a/src/Models/Media/PDF.php b/src/Models/Media/PDF.php index cdb8df7..8995e0e 100644 --- a/src/Models/Media/PDF.php +++ b/src/Models/Media/PDF.php @@ -21,8 +21,7 @@ */ class PDF extends Media { - // configurables - public static $extractPageCommand = 'convert \'%1$s[%2$u]\' JPEG:- 2>/dev/null'; // 1=pdf path, 2=page + public static $extractPageCommand = 'convert %1$s JPEG:- 2>/dev/null'; // 1=escaped 'pdf path[page]' argument public static $extractPageIndex = 0; public function getValue($name) @@ -44,31 +43,35 @@ public function getValue($name) throw new Exception('Unable to find document extension for mime-type: '.$this->getValue('MIMEType')); } - // no break default: return parent::getValue($name); } } - - // public methods public function getImage($sourceFile = null): false|\GdImage { if (!isset($sourceFile)) { $sourceFile = $this->FilesystemPath ? $this->FilesystemPath : $this->BlankPath; } - $cmd = sprintf(static::$extractPageCommand, $sourceFile, static::$extractPageIndex); - $fileImage = imagecreatefromstring(shell_exec($cmd)); + $cmd = sprintf(static::$extractPageCommand, escapeshellarg($sourceFile . '[' . static::$extractPageIndex . ']')); + + if (!$imageData = shell_exec($cmd)) { + return false; + } - return $fileImage; + return imagecreatefromstring($imageData); } - // static methods public static function analyzeFile($filename, $mediaInfo = []) { - $cmd = sprintf(static::$extractPageCommand, $filename, static::$extractPageIndex); - $pageIm = @imagecreatefromstring(shell_exec($cmd)); + $cmd = sprintf(static::$extractPageCommand, escapeshellarg($filename . '[' . static::$extractPageIndex . ']')); + + if (!$imageData = shell_exec($cmd)) { + throw new Exception('Unable to convert PDF, ensure that imagemagick is installed on the server'); + } + + $pageIm = imagecreatefromstring($imageData); if (!$pageIm) { throw new Exception('Unable to convert PDF, ensure that imagemagick is installed on the server'); diff --git a/src/Models/Media/Video.php b/src/Models/Media/Video.php index 22572c3..55ed448 100644 --- a/src/Models/Media/Video.php +++ b/src/Models/Media/Video.php @@ -22,45 +22,59 @@ class Video extends Media { // configurables - public static $ExtractFrameCommand = 'avconv -ss %2$u -i %1$s -an -vframes 1 -f mjpeg -'; // 1=video path, 2=position + public static $ExtractFrameCommand = 'ffmpeg -ss %2$u -i %1$s -an -vframes 1 -f mjpeg pipe:1 2>/dev/null'; public static $ExtractFramePosition = 3; + public static $encodingProfiles = [ - // from https://www.virag.si/2012/01/web-video-encoding-tutorial-with-ffmpeg-0-9/ 'h264-high-480p' => [ 'enabled' => true, 'extension' => 'mp4', 'mimeType' => 'video/mp4', 'inputOptions' => [], - 'videoCodec' => 'h264', + 'videoCodec' => 'libx264', 'videoOptions' => [ 'profile:v' => 'high', 'preset' => 'slow', 'b:v' => '500k', 'maxrate' => '500k', 'bufsize' => '1000k', - 'vf' => 'scale="trunc(oh*a/2)*2:480"', // http://superuser.com/questions/571141/ffmpeg-avconv-force-scaled-output-to-be-divisible-by-2 + 'vf' => 'scale=trunc(oh*a/2)*2:480', ], 'audioCodec' => 'aac', - 'audioOptions' => [ - 'strict' => 'experimental', - ], + 'audioOptions' => [], ], - // from http://superuser.com/questions/556463/converting-video-to-webm-with-ffmpeg-avconv 'webm-480p' => [ 'enabled' => true, 'extension' => 'webm', 'mimeType' => 'video/webm', 'inputOptions' => [], - 'videoCodec' => 'libvpx', + 'videoCodec' => 'libvpx-vp9', 'videoOptions' => [ - 'vf' => 'scale=-1:480', + 'vf' => 'scale=-2:480', + 'b:v' => '500k', + 'deadline' => 'good', + 'cpu-used' => '2', ], - 'audioCodec' => 'libvorbis', + 'audioCodec' => 'libopus', + 'audioOptions' => [], ], ]; + public static $mimeTypeExtensions = [ + 'video/mp4' => 'mp4', + 'video/webm' => 'webm', + 'video/ogg' => 'ogv', + 'video/x-matroska' => 'mkv', + 'video/x-msvideo' => 'avi', + 'video/quicktime' => 'mov', + 'video/x-flv' => 'flv', + 'video/3gpp' => '3gp', + 'video/x-ms-wmv' => 'wmv', + 'video/mpeg' => 'mpg', + 'video/x-m4v' => 'm4v', + ]; public function getValue($name) { @@ -69,78 +83,79 @@ public function getValue($name) return 'image/jpeg'; case 'Extension': - - switch ($this->getValue('MIMEType')) { - case 'video/x-flv': - return 'flv'; - - case 'video/mp4': - return 'mp4'; - - case 'video/quicktime': - return 'mov'; - - default: - throw new Exception('Unable to find video extension for mime-type: '.$this->getValue('MIMEType')); + $mime = $this->getValue('MIMEType'); + if (isset(static::$mimeTypeExtensions[$mime])) { + return static::$mimeTypeExtensions[$mime]; + } + if (str_starts_with($mime, 'video/')) { + return substr($mime, 6); } + throw new Exception('Unable to find video extension for mime-type: ' . $mime); - // no break default: return parent::getValue($name); } } - - // public methods public function getImage($sourceFile = null): false|\GdImage { if (!isset($sourceFile)) { - $sourceFile = $this->getValue('FilesystemPath') ? $this->getValue('FilesystemPath') : $this->getValue('BlankPath'); + $sourceFile = $this->getValue('FilesystemPath') ?: $this->getValue('BlankPath'); } - $cmd = sprintf(self::$ExtractFrameCommand, $sourceFile, min(self::$ExtractFramePosition, floor($this->getValue('Duration')))); + $duration = (float)$this->getValue('Duration'); + $position = min(static::$ExtractFramePosition, max(0, (int)floor($duration))); + + $cmd = sprintf(static::$ExtractFrameCommand, escapeshellarg($sourceFile), $position); if ($imageData = shell_exec($cmd)) { return imagecreatefromstring($imageData); - } elseif ($sourceFile != $this->getValue('BlankPath')) { + } elseif ($sourceFile !== $this->getValue('BlankPath')) { return static::getImage($this->getValue('BlankPath')); } - return null; + return false; } /** - * Uses ffprobe to analyze the given file and returns meta data from the first video stream found - * * @param string $filename * @param array $mediaInfo * @return array */ public static function analyzeFile($filename, $mediaInfo = []) { - // examine media with ffprobe - $output = shell_exec("ffprobe -of json -show_streams -v quiet $filename"); + $output = shell_exec('ffprobe -of json -show_streams -show_format -v quiet ' . escapeshellarg($filename)); if (!$output || !($json = json_decode($output, true)) || empty($json['streams'])) { - throw new \Exception('Unable to examine video with ffprobe, ensure ffmpeg with ffprobe is installed'); + throw new Exception('Unable to examine video with ffprobe, ensure ffmpeg (with ffprobe) is installed'); } - // extract video streams - $videoStreams = array_filter($json['streams'], function ($streamInfo) { - return $streamInfo['codec_type'] == 'video'; - }); + $videoStreams = array_values(array_filter($json['streams'], fn ($s) => $s['codec_type'] === 'video')); if (!count($videoStreams)) { - throw new Exception('avprobe did not detect any video streams'); + throw new Exception('ffprobe did not detect any video streams'); } - // convert and write interesting information to mediaInfo - $mediaInfo['streams'] = $json['streams']; - $mediaInfo['videoStream'] = array_shift($videoStreams); + $mediaInfo['streams'] = $json['streams']; + $mediaInfo['videoStream'] = $videoStreams[0]; + + $mediaInfo['width'] = (int)$mediaInfo['videoStream']['width']; + $mediaInfo['height'] = (int)$mediaInfo['videoStream']['height']; + + $mediaInfo['duration'] = (float)( + $mediaInfo['videoStream']['duration'] + ?? $json['format']['duration'] + ?? 0 + ); - $mediaInfo['width'] = (int)$mediaInfo['videoStream']['width']; - $mediaInfo['height'] = (int)$mediaInfo['videoStream']['height']; - $mediaInfo['duration'] = (float)$mediaInfo['videoStream']['duration']; + $rotation = 0; + foreach ($mediaInfo['videoStream']['side_data_list'] ?? [] as $sideData) { + if (($sideData['side_data_type'] ?? '') === 'Display Matrix') { + $rotation = (int)abs($sideData['rotation'] ?? 0); + break; + } + } + $mediaInfo['rotation'] = $rotation; return $mediaInfo; } @@ -149,16 +164,8 @@ public function writeFile($sourceFile): bool { parent::writeFile($sourceFile); - - // determine rotation metadata with exiftool - $exifToolOutput = exec("exiftool -S -Rotation $this->FilesystemPath"); - - if (!$exifToolOutput || !preg_match('/Rotation\s*:\s*(?\d+)/', $exifToolOutput, $matches)) { - throw new Exception('Unable to examine video with exiftool, ensure libimage-exiftool-perl is installed on the host system'); - } - - $sourceRotation = intval($matches['rotation']); - + $mediaInfo = static::analyzeFile($this->FilesystemPath); + $sourceRotation = (int)($mediaInfo['rotation'] ?? 0); // fork encoding job with each configured profile foreach (static::$encodingProfiles as $profileName => $profile) { @@ -166,76 +173,72 @@ public function writeFile($sourceFile): bool continue; } - // build paths and create directories if needed $outputPath = $this->getFilesystemPath($profileName); if (!is_dir($outputDir = dirname($outputPath))) { mkdir($outputDir, static::$newDirectoryPermissions, true); } - $tmpOutputPath = $outputDir.'/'.'tmp-'.basename($outputPath); - ; - + $tmpOutputPath = $outputDir . '/tmp-' . basename($outputPath); - // build avconv command - $cmd = ['avconv', '-loglevel quiet']; + $cmd = ['ffmpeg', '-loglevel quiet', '-y']; // -- input options if (!empty($profile['inputOptions'])) { - static::_appendAvconvOptions($cmd, $profile['inputOptions']); + static::_appendFfmpegOptions($cmd, $profile['inputOptions']); } $cmd[] = '-i'; - $cmd[] = $this->FilesystemPath; + $cmd[] = escapeshellarg($this->FilesystemPath); - // -- video output options $cmd[] = '-codec:v'; $cmd[] = $profile['videoCodec']; - if (!empty($profile['videoOptions'])) { - static::_appendAvconvOptions($cmd, $profile['videoOptions']); + + $videoOptions = $profile['videoOptions'] ?? []; + + if ($sourceRotation !== 0) { + $transpose = match($sourceRotation) { + 90 => 'transpose=1', + 180 => 'transpose=1,transpose=1', + 270 => 'transpose=2', + default => null, + }; + if ($transpose) { + $videoOptions['vf'] = isset($videoOptions['vf']) + ? $videoOptions['vf'] . ',' . $transpose + : $transpose; + } } - // -- audio output options - $cmd[] = '-codec:a'; - $cmd[] = $profile['audioCodec']; - if (!empty($profile['audioOptions'])) { - static::_appendAvconvOptions($cmd, $profile['audioOptions']); + if (!empty($videoOptions)) { + static::_appendFfmpegOptions($cmd, $videoOptions); } - // -- normalize smartphone rotation - $cmd[] = '-metadata:s:v rotate="0"'; + $cmd[] = '-metadata:s:v:0'; + $cmd[] = 'rotate=0'; - if ($sourceRotation == 90) { - $cmd[] = '-vf "transpose=1"'; - } elseif ($sourceRotation == 180) { - $cmd[] = '-vf "transpose=1,transpose=1"'; - } elseif ($sourceRotation == 270) { - $cmd[] = '-vf "transpose=1,transpose=1,transpose=1"'; + $cmd[] = '-codec:a'; + $cmd[] = $profile['audioCodec']; + if (!empty($profile['audioOptions'])) { + static::_appendFfmpegOptions($cmd, $profile['audioOptions']); } // -- general output options if (!empty($profile['outputOptions'])) { - static::_appendAvconvOptions($cmd, $profile['outputOptions']); + static::_appendFfmpegOptions($cmd, $profile['outputOptions']); } - $cmd[] = $tmpOutputPath; + $cmd[] = escapeshellarg($tmpOutputPath); + $cmd[] = '&& mv ' . escapeshellarg($tmpOutputPath) . ' ' . escapeshellarg($outputPath); - // move to final path after it finished - $cmd[] = "&& mv $tmpOutputPath $outputPath"; + $fullCmd = '(nohup ' . implode(' ', $cmd) . ') > /dev/null 2>/dev/null & echo $!'; - - // convert command to string and decorate for process control - $cmd = '(nohup '.implode(' ', $cmd).') > /dev/null 2>/dev/null & echo $! &'; - - - // execute command and retrieve the spawned PID - $pid = exec($cmd); - // TODO: store PID somewhere in APCU cache so we can do something smarter when a video is requested before it's done encoding + $pid = exec($fullCmd); } return true; } - public function getFilesystemPath($variant = 'original', $filename = null): string + public function getFilesystemPath($variant = 'original', $filename = null): ?string { if (!$filename && array_key_exists($variant, static::$encodingProfiles)) { $filename = $this->ID.'.'.static::$encodingProfiles[$variant]['extension']; @@ -254,7 +257,7 @@ public function getMIMEType($variant = 'original'): string return parent::getMIMEType($variant); } - public function isVariantAvailable($variant) + public function isVariantAvailable($variant): bool { if ( array_key_exists($variant, static::$encodingProfiles) && @@ -267,15 +270,14 @@ public function isVariantAvailable($variant) return parent::isVariantAvailable($variant); } - protected static function _appendAvconvOptions(array &$cmd, array $options) + protected static function _appendFfmpegOptions(array &$cmd, array $options): void { foreach ($options as $key => $value) { if (!is_int($key)) { - $cmd[] = '-'.$key; + $cmd[] = '-' . $key; } - - if ($value) { - $cmd[] = $value; + if ($value !== null && $value !== false) { + $cmd[] = escapeshellarg((string) $value); } } } diff --git a/src/Models/Versioning.php b/src/Models/Versioning.php index 1feb960..bcba2ac 100644 --- a/src/Models/Versioning.php +++ b/src/Models/Versioning.php @@ -28,6 +28,8 @@ * @property static[] $History All revisions for this object. This is hooked in the Relations trait. * @property string $historyTable * @property callable $createRevisionOnSave + * @method array|null getPreparedPersistedSet() + * @method mixed getPrimaryKeyValue() */ trait Versioning { diff --git a/src/Responders/Response.php b/src/Responders/Response.php index ebb2822..15ca436 100644 --- a/src/Responders/Response.php +++ b/src/Responders/Response.php @@ -106,7 +106,7 @@ public function __construct(ResponseBuilder $responseBuilder) /** * @param int $status Status code * @param array $headers Response headers - * @param string|resource|StreamInterface|null $body Response body + * @param string|resource|\Psr\Http\Message\StreamInterface|null $body Response body * @param string $version Protocol version * @param string|null $reason Reason phrase (when empty a default will be used based on the status code) * @return static diff --git a/src/Routing/Path.php b/src/Routing/Path.php index eb48496..520da48 100644 --- a/src/Routing/Path.php +++ b/src/Routing/Path.php @@ -79,6 +79,6 @@ protected function setPath($requestURI = null) $this->pathStack = $this->requestPath = explode('/', ltrim($parsedURL['path'], '/')); } - $this->_path = isset($path) ? $path : $this->pathStack; + $this->_path = $this->pathStack; } } diff --git a/tests/Divergence/Controllers/RecordsRequestHandlerTest.php b/tests/Divergence/Controllers/RecordsRequestHandlerTest.php index ba4f09e..508a8b6 100644 --- a/tests/Divergence/Controllers/RecordsRequestHandlerTest.php +++ b/tests/Divergence/Controllers/RecordsRequestHandlerTest.php @@ -11,6 +11,7 @@ namespace Divergence\Tests\Controllers; use Divergence\App; +use Error; use ReflectionClass; use Twig\Error\LoaderError; use Divergence\Helpers\JSON; @@ -920,7 +921,7 @@ public function testNoWriteAccessDelete() // write access denied public function testProcessDatumSaveNoWriteAccess() { - $this->expectException('Exception'); + $this->expectException(Error::class); $controller = new SecureCanaryRequestHandler(); $controller->processDatumSave([ 'ID' => '1', @@ -931,7 +932,7 @@ public function testProcessDatumSaveNoWriteAccess() // database error public function testProcessDatumSaveDatabaseError() { - $this->expectException('Exception'); + $this->expectException(Error::class); $controller = new CanaryRequestHandler(); $controller->processDatumSave([ 'Created' => 'fake', @@ -941,7 +942,7 @@ public function testProcessDatumSaveDatabaseError() // write access denied public function testProcessDatumDestroyNoWriteAccess() { - $this->expectException('Exception'); + $this->expectException(Error::class); $controller = new SecureCanaryRequestHandler(); $controller->processDatumDestroy([ 'ID' => '1', @@ -951,7 +952,7 @@ public function testProcessDatumDestroyNoWriteAccess() // missing key public function testProcessDatumDestroyNoKey() { - $this->expectException('Exception'); + $this->expectException(Error::class); $controller = new CanaryRequestHandler(); $controller->processDatumDestroy([ 'fake' => 'fake', @@ -962,7 +963,7 @@ public function testProcessDatumDestroyNoKey() public function testProcessDatumDestroyFailed() { DB::nonQuery('LOCK TABLES `canaries` READ'); - $this->expectException('Exception'); + $this->expectException(Error::class); $controller = new CanaryRequestHandler(); $controller->processDatumDestroy([ 'ID' => '1', diff --git a/tests/Divergence/Data/Collections/CollectionCriteriaTest.php b/tests/Divergence/Data/Collections/CollectionCriteriaTest.php new file mode 100644 index 0000000..9f86b89 --- /dev/null +++ b/tests/Divergence/Data/Collections/CollectionCriteriaTest.php @@ -0,0 +1,626 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Tests\Data\Collections; + +use stdClass; +use Error; +use RuntimeException; +use Exception; +use PHPUnit\Framework\TestCase; +use Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\IndexedField; +use Divergence\Data\Collections\Factory\Factory; +use Divergence\Data\Collections\Factory\Getters\GetByField; +use Divergence\Models\Expr\Criteria; +use Divergence\Models\Expr\CriteriaGroup; +use Divergence\Models\Expr\CriteriaType; +use Divergence\Models\Expr\Conjunction; + +class CollectionCriteriaTest extends TestCase +{ + private Collection $Collection; + + protected function setUp(): void + { + $this->Collection = (new Factory())->create(static::buildRecords(), ['Status', 'Team', 'Score']); + } + + /** + * @return array + */ + private static function buildRecords(): array + { + $records = []; + + for ($i = 1; $i <= 100; $i++) { + $record = new stdClass(); + $record->ID = $i; + $record->Name = sprintf('Record %03d', $i); + $record->Status = $i % 4; + $record->Team = $i % 10; + $record->Score = $i * 10; + $record->ScoreCopy = $i * 10; + $record->Threshold = 500; + $record->Tag = ['alpha', 'beta', 'gamma', 'delta'][$i % 4]; + $record->Note = ($i % 5 === 0) ? null : sprintf('note-%d', $i); + $records[] = $record; + } + + return $records; + } + + private function assertOperatorCount(int $operator, string $field, $value, int $expectedCount): void + { + $matches = $this->Collection->getAllByCriteria(new Criteria($field, $value, $operator)); + + $this->assertCount($expectedCount, $matches); + } + + public function testEqualOperator(): void + { + $this->assertOperatorCount(CriteriaType::Equal, 'Score', 500, 1); + } + + public function testNotEqualOperator(): void + { + $this->assertOperatorCount(CriteriaType::NotEqual, 'Team', 0, 90); + } + + public function testGreaterThanOperator(): void + { + $this->assertOperatorCount(CriteriaType::GreaterThan, 'Score', 950, 5); + } + + public function testGreaterThanOrEqualOperator(): void + { + $this->assertOperatorCount(CriteriaType::GreaterThanOrEqual, 'Score', 950, 6); + } + + public function testLessThanOperator(): void + { + $this->assertOperatorCount(CriteriaType::LessThan, 'Score', 50, 4); + } + + public function testLessThanOrEqualOperator(): void + { + $this->assertOperatorCount(CriteriaType::LessThanOrEqual, 'Score', 50, 5); + } + + public function testLikeOperator(): void + { + $this->assertOperatorCount(CriteriaType::Like, 'Tag', 'al%', 25); + } + + public function testNotLikeOperator(): void + { + $this->assertOperatorCount(CriteriaType::NotLike, 'Tag', 'al%', 75); + } + + public function testInOperator(): void + { + $this->assertOperatorCount(CriteriaType::In, 'Team', [1, 2, 3], 30); + } + + public function testNotInOperator(): void + { + $this->assertOperatorCount(CriteriaType::NotIn, 'Team', [1, 2, 3], 70); + } + + public function testNulledOperator(): void + { + $this->assertOperatorCount(CriteriaType::Nulled, 'Note', null, 20); + } + + public function testNotNulledOperator(): void + { + $this->assertOperatorCount(CriteriaType::NotNulled, 'Note', null, 80); + } + + public function testFieldEqualOperator(): void + { + $this->assertOperatorCount(CriteriaType::FieldEqual, 'Score', 'ScoreCopy', 100); + } + + public function testFieldNotEqualOperator(): void + { + $this->assertOperatorCount(CriteriaType::FieldNotEqual, 'Score', 'Threshold', 99); + } + + public function testFieldGreaterThanOperator(): void + { + $this->assertOperatorCount(CriteriaType::FieldGreaterThan, 'Score', 'Threshold', 50); + } + + public function testFieldGreaterThanOrEqualOperator(): void + { + $this->assertOperatorCount(CriteriaType::FieldGreaterThanOrEqual, 'Score', 'Threshold', 51); + } + + public function testFieldLessThanOperator(): void + { + $this->assertOperatorCount(CriteriaType::FieldLessThan, 'Score', 'Threshold', 49); + } + + public function testFieldLessThanOrEqualOperator(): void + { + $this->assertOperatorCount(CriteriaType::FieldLessThanOrEqual, 'Score', 'Threshold', 50); + } + + public function testGetByCriteriaReturnsFirstMatch(): void + { + $found = $this->Collection->getByCriteria(new Criteria('Score', 500, CriteriaType::Equal)); + + $this->assertNotNull($found); + $this->assertSame('Record 050', $found->Name); + } + + public function testGetByCriteriaReturnsNullWhenNoMatch(): void + { + $found = $this->Collection->getByCriteria(new Criteria('Score', 999999, CriteriaType::Equal)); + + $this->assertNull($found); + } + + public function testNestedAndOr(): void + { + $tree = new CriteriaGroup([ + new CriteriaGroup([ + new Criteria('Team', 2, CriteriaType::Equal), + new Criteria('Score', 20, CriteriaType::Equal), + ], Conjunction::GroupAnd), + new CriteriaGroup([ + new Criteria('Team', 0, CriteriaType::Equal), + new Criteria('Score', 1000, CriteriaType::Equal), + ], Conjunction::GroupAnd), + ], Conjunction::GroupOr); + + $names = []; + foreach ($this->Collection->getAllByCriteria($tree) as $record) { + $names[] = $record->Name; + } + sort($names); + + $this->assertSame(['Record 002', 'Record 100'], $names); + } + + public function testNotAndIsNegationOfAnd(): void + { + $and = new CriteriaGroup([ + new Criteria('Team', 1, CriteriaType::Equal), + new Criteria('Status', 1, CriteriaType::Equal), + ], Conjunction::GroupAnd); + + $notAnd = new CriteriaGroup($and->criteria, Conjunction::GroupNotAnd); + + $andCount = count($this->Collection->getAllByCriteria($and)); + $notAndCount = count($this->Collection->getAllByCriteria($notAnd)); + + $this->assertSame(100, $andCount + $notAndCount); + } + + public function testNotOrIsNegationOfOr(): void + { + $or = new CriteriaGroup([ + new Criteria('Team', 1, CriteriaType::Equal), + new Criteria('Status', 1, CriteriaType::Equal), + ], Conjunction::GroupOr); + + $notOr = new CriteriaGroup($or->criteria, Conjunction::GroupNotOr); + + $orCount = count($this->Collection->getAllByCriteria($or)); + $notOrCount = count($this->Collection->getAllByCriteria($notOr)); + + $this->assertSame(100, $orCount + $notOrCount); + } + + public function testSingleCriterionNotAndGroupReturnsComplement(): void + { + $matches = $this->Collection->getAllByCriteria(new CriteriaGroup([ + new Criteria('Team', 1, CriteriaType::Equal), + ], Conjunction::GroupNotAnd)); + + $this->assertCount(90, $matches); + } + + public function testSingleCriterionNotOrGroupReturnsComplement(): void + { + $matches = $this->Collection->getAllByCriteria(new CriteriaGroup([ + new Criteria('Team', 1, CriteriaType::Equal), + ], Conjunction::GroupNotOr)); + + $this->assertCount(90, $matches); + } + + public function testEmptyAndGroupMatchesNothing(): void + { + $matches = $this->Collection->getAllByCriteria(new CriteriaGroup([], Conjunction::GroupAnd)); + + $this->assertCount(0, $matches); + } + + public function testEmptyOrGroupMatchesNothing(): void + { + $matches = $this->Collection->getAllByCriteria(new CriteriaGroup([], Conjunction::GroupOr)); + + $this->assertCount(0, $matches); + } + + public function testGetAllByCriteriaWithAndGroup(): void + { + $matches = $this->Collection->getAllByCriteria(new CriteriaGroup([ + new Criteria('Team', 1, CriteriaType::Equal), + new Criteria('Status', 1, CriteriaType::Equal), + ], Conjunction::GroupAnd)); + + $this->assertCount(5, $matches); + $this->assertSame('Record 001', $matches[0]->Name); + } + + public function testGetAllByCriteriaReturnsArray(): void + { + $matches = $this->Collection->getAllByCriteria(new Criteria('Team', 1, CriteriaType::Equal)); + + $this->assertIsArray($matches); + $this->assertCount(10, $matches); + } + + public function testValidateAlwaysReturnsTrue(): void + { + $this->assertTrue($this->Collection->validate('anything')); + $this->assertTrue($this->Collection->validate(null)); + } + + public function testRemoveDeletesRecordAndClearsIndex(): void + { + $record = $this->Collection[0]; + + $this->Collection->remove($record); + + $this->assertCount(99, $this->Collection); + $this->assertNull($this->Collection->getByField('Score', $record->Score)); + } + + public function testRemoveManyDeletesMultipleRecords(): void + { + $records = [$this->Collection[0], $this->Collection[1], $this->Collection[2]]; + + $this->Collection->removeMany($records); + + $this->assertCount(97, $this->Collection); + } + + public function testToArrayReturnsIndexArray(): void + { + $array = $this->Collection->toArray(); + + $this->assertIsArray($array); + $this->assertCount(100, $array); + $this->assertSame($this->Collection->Index, $array); + } + + public function testHasIndexReturnsTrueForConfiguredFieldAndFalseOtherwise(): void + { + $this->assertTrue($this->Collection->hasIndex('Team')); + $this->assertFalse($this->Collection->hasIndex('NotAnIndexedField')); + } + + public function testUpdateIndexForModelDirectCall(): void + { + $record = $this->Collection[0]; + $record->Team = 99; + + $this->Collection->updateIndexForModel('Team', $record); + + $this->assertSame($record, $this->Collection->getByField('Team', 99)); + } + + public function testClearIndexesDirectCall(): void + { + $record = $this->Collection[0]; + + $this->Collection->clearIndexes($record); + + $this->assertNull($this->Collection->getByField('Score', $record->Score)); + } + + public function testKeyReturnsCurrentPosition(): void + { + $this->Collection->rewind(); + $this->assertSame(0, $this->Collection->key()); + + $this->Collection->next(); + $this->assertSame(1, $this->Collection->key()); + } + + public function testOffsetSetAddsRecordWithNullOffset(): void + { + $record = new stdClass(); + $record->ID = 101; + $record->Status = 0; + $record->Team = 0; + $record->Score = 1234; + + $this->Collection[] = $record; + + $this->assertCount(101, $this->Collection); + $this->assertSame($record, $this->Collection[100]); + } + + public function testDuplicateIdentityDoesNotDivergeCollectionAndIndexCounts(): void + { + $record = new stdClass(); + $record->Status = 1; + $collection = (new Factory())->create([$record], ['Status']); + + $collection->add($record); + + $this->assertCount($collection->count(), $collection->getAllByField('Status', 1)); + } + + public function testObjectRecordsHaveDistinctIdentities(): void + { + $first = new stdClass(); + $first->ID = 1; + $first->Status = 1; + + $second = new stdClass(); + $second->ID = 2; + $second->Status = 1; + + $collection = (new Factory())->create([ + $first, + $second, + ], ['Status']); + + $this->assertCount(2, $collection); + $this->assertCount(2, $collection->getAllByField('Status', 1)); + } + + public function testOffsetSetReplacesRecordAtExistingOffset(): void + { + $original = $this->Collection[0]; + $replacement = new stdClass(); + $replacement->ID = $original->ID; + $replacement->Status = $original->Status; + $replacement->Team = $original->Team; + $replacement->Score = 98765; + + $this->Collection[0] = $replacement; + + $this->assertSame($replacement, $this->Collection[0]); + $this->assertNull($this->Collection->getByField('Score', $original->Score)); + $this->assertSame($replacement, $this->Collection->getByField('Score', 98765)); + } + + public function testOffsetSetRemovesReplacedRecordFromHashKeyIndex(): void + { + $original = $this->Collection[0]; + $replacement = clone $original; + + $this->Collection[0] = $replacement; + + $this->assertArrayNotHasKey(spl_object_id($original), $this->Collection->HashKeyIndex); + } + + public function testOffsetExists(): void + { + $this->assertTrue(isset($this->Collection[0])); + $this->assertFalse(isset($this->Collection[999])); + } + + public function testOffsetUnset(): void + { + unset($this->Collection[0]); + + $this->assertCount(99, $this->Collection); + $this->assertSame(2, $this->Collection[0]->ID); + } + + public function testOffsetUnsetKeepsIterationAndNegativeOffsetsConsistent(): void + { + $last = $this->Collection[-1]; + + unset($this->Collection[0]); + + $this->assertSame( + [99, $last], + [count(iterator_to_array($this->Collection, false)), $this->Collection[-1]] + ); + } + + public function testOffsetGetNegativeIndex(): void + { + $this->assertSame($this->Collection[99], $this->Collection[-1]); + } + + public function testGetterMagicCallThrowsForUndefinedMethod(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessage(sprintf( + 'Call to undefined method %s::bogusGetterMethod()', + Collection::class + )); + + $this->Collection->bogusGetterMethod(); + } + + public function testGetterMagicCallStaticDelegatesToFactory(): void + { + $fresh = Collection::create([], []); + + $this->assertInstanceOf(Collection::class, $fresh); + $this->assertNotSame($this->Collection, $fresh); + } + + public function testGetterMagicCallStaticThrowsForUndefinedMethod(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessage(sprintf( + 'Call to undefined method %s::bogusStaticMethod()', + Collection::class + )); + + Collection::bogusStaticMethod(); + } + + public function testCriteriaRawOperatorDoesNotOverrideTypedOperator(): void + { + $criteria = new Criteria('Score', 500); + $criteria->rawOperator = '= 500'; + + $matches = $this->Collection->getAllByCriteria($criteria); + + $this->assertCount(1, $matches); + $this->assertSame('Record 050', $matches[0]->Name); + } + + public function testUnsupportedCriteriaOperatorCannotBeEvaluatedInMemory(): void + { + $this->expectException(RuntimeException::class); + + $this->Collection->getAllByCriteria(new Criteria('Score', 500, CriteriaType::Raw)); + } + + public function testFactoryThrowsOnGetterMethodCollision(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Getter method collision for getbyfield'); + + new class extends Factory { + protected function registerGetterClasses(): void + { + $this->registerGetterClass(GetByField::class); + $this->registerGetterClass(GetByField::class); + } + }; + } + + public function testCreateIndexByFieldRebuildsIndexFromExistingRecords(): void + { + $this->Collection->createIndexByField('ID'); + + $this->assertTrue($this->Collection->hasIndex('ID')); + $this->assertSame(50, $this->Collection->getByField('ID', 50)->ID); + } + + public function testGetByFieldReturnsNullWhenValueNotIndexed(): void + { + $this->assertNull($this->Collection->getByField('Team', 99999)); + } + + public function testGetByFieldReturnsNullForUnindexedField(): void + { + $this->assertNull($this->Collection->getByField('NotAnIndexedField', 'anything')); + } + + public function testGetAllByFieldReturnsMatchingRecords(): void + { + $matches = $this->Collection->getAllByField('Team', 5); + + $this->assertInstanceOf(Collection::class, $matches); + $this->assertCount(10, $matches); + } + + public function testIndexedFieldIndexableValueHandlesDateStringType(): void + { + $index = new IndexedField('CreatedAt', 'DateString'); + + $this->assertSame(strtotime('2024-01-01'), $index->indexableValue('2024-01-01')); + } + + private function buildTimestampIndex(): IndexedField + { + $first = new stdClass(); + $first->CreatedAt = '2024-01-01 00:00:00'; + $second = new stdClass(); + $second->CreatedAt = '2024-01-02 00:00:00'; + $records = [$first, $second]; + $index = new IndexedField('CreatedAt', 'timestamp'); + $index->rebuildIndex($records); + + return $index; + } + + public function testTimestampInOperatorAcceptsArrays(): void + { + $matches = $this->buildTimestampIndex()->find( + ['2024-01-01 00:00:00'], + CriteriaType::In + ); + + $this->assertCount(1, $matches); + } + + public function testTimestampNotInOperatorAcceptsArrays(): void + { + $matches = $this->buildTimestampIndex()->find( + ['2024-01-01 00:00:00'], + CriteriaType::NotIn + ); + + $this->assertCount(1, $matches); + } + + public function testIndexedFieldFindDefaultsToAnEmptyArray(): void + { + $this->assertSame([], (new IndexedField('Value'))->find()); + } + + public function testIndexedFieldFindReturnsAnEmptyArrayWhenNothingMatches(): void + { + $this->assertSame([], $this->buildTimestampIndex()->find('2030-01-01 00:00:00')); + } + + public function testTimestampIndexNormalizesUnixEpoch(): void + { + $index = new IndexedField('CreatedAt', 'timestamp'); + + $this->assertSame(0, $index->indexableValue('1970-01-01 00:00:00 UTC')); + } + + public function testIndexedFieldRemovesUnusedCardinalities(): void + { + $index = new class('Status') extends IndexedField { + public function countCardinalities(): int + { + return count($this->cardinality); + } + }; + $record = new stdClass(); + $record->Status = 'first'; + $index->set($record); + + $record->Status = 'second'; + $index->set($record); + + $this->assertSame(1, $index->countCardinalities()); + } + + public function testIndexedFieldIndexableValueCastsFloatToString(): void + { + $index = new IndexedField('Score'); + + $this->assertSame((string) 1.5, $index->indexableValue(1.5)); + } + + public function testRemoveDecrementsPositionWhenRemovingRecordBeforeCurrentPosition(): void + { + $this->Collection->rewind(); + $this->Collection->next(); + $this->Collection->next(); + $this->Collection->next(); + + $record = $this->Collection[0]; + $this->Collection->remove($record); + + $this->assertSame(2, $this->Collection->key()); + } +} diff --git a/tests/Divergence/Data/Collections/IndexedFieldTest.php b/tests/Divergence/Data/Collections/IndexedFieldTest.php new file mode 100644 index 0000000..c9f0edd --- /dev/null +++ b/tests/Divergence/Data/Collections/IndexedFieldTest.php @@ -0,0 +1,54 @@ +assertCardinalitiesRemainDistinct([true, false]); + } + + public function testBooleanCardinalitiesDoNotCollideWithStringValues(): void + { + $this->assertCardinalitiesRemainDistinct([true, '1', 'true', false, '0', 'false', '']); + } + + public function testBooleanCardinalitiesDoNotCollideWithIntegerValues(): void + { + $this->assertCardinalitiesRemainDistinct([true, 1]); + $this->assertCardinalitiesRemainDistinct([false, 0]); + } + + public function testNullCardinalityDoesNotCollideWithBasicValues(): void + { + $this->assertCardinalitiesRemainDistinct([null, true]); + $this->assertCardinalitiesRemainDistinct([null, false]); + $this->assertCardinalitiesRemainDistinct([null, 0]); + $this->assertCardinalitiesRemainDistinct([null, '']); + } + + private function assertCardinalitiesRemainDistinct(array $values): void + { + $index = new IndexedField('Value'); + $records = []; + + foreach ($values as $value) { + $record = new stdClass(); + $record->Value = $value; + $records[] = $record; + $index->set($record); + } + + foreach ($values as $position => $value) { + $this->assertSame( + [spl_object_id($records[$position]) => true], + $index->find($value) + ); + } + } +} diff --git a/tests/Divergence/Data/Collections/RecordKeyTest.php b/tests/Divergence/Data/Collections/RecordKeyTest.php new file mode 100644 index 0000000..295f06a --- /dev/null +++ b/tests/Divergence/Data/Collections/RecordKeyTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Tests\Data\Collections; + +use stdClass; +use PHPUnit\Framework\TestCase; +use Divergence\Data\Collections\RecordKey; +use Divergence\Tests\MockSite\Models\FixtureItem; + +class RecordKeyTest extends TestCase +{ + public function testUsesPrimaryKeyForActiveRecord(): void + { + $record = new FixtureItem([ + 'ID' => 123, + 'Name' => 'Record', + 'Team' => 1, + 'Score' => 10, + ], false, false); + + $this->assertSame(123, RecordKey::get($record)); + } + + public function testUsesObjectIdentityForPlainObject(): void + { + $record = new stdClass(); + + $this->assertSame(spl_object_id($record), RecordKey::get($record)); + } + + public function testUsesObjectIdentityForNonModelWithPrimaryKeyMethod(): void + { + $record = new class { + public function getPrimaryKeyValue(): int + { + return 123; + } + }; + + $this->assertSame(spl_object_id($record), RecordKey::get($record)); + } +} diff --git a/tests/Divergence/Data/KeyToHashIntTest.php b/tests/Divergence/Data/KeyToHashIntTest.php new file mode 100644 index 0000000..72ef3c8 --- /dev/null +++ b/tests/Divergence/Data/KeyToHashIntTest.php @@ -0,0 +1,93 @@ +assertSame(123, KeyToHashInt::hashForKeys([123])); + $singleton = KeyToHashInt::$singleton; + + $this->assertSame(456, KeyToHashInt::hashForKeys([456])); + $this->assertSame($singleton, KeyToHashInt::$singleton); + } + + public function testConstructorStoresKeysWithoutCalculatingHash(): void + { + $hasher = new KeyToHashInt(['key']); + + $this->assertSame(['key'], $hasher->keys); + $this->assertNull($hasher->hash); + } + + public function testSingularIntegerIsReturnedAsIs(): void + { + $hasher = new KeyToHashInt([123]); + + $this->assertSame(123, $hasher->getSingular()); + $this->assertSame(123, $hasher->hash); + } + + public function testSingularNumericStringIsConvertedToInteger(): void + { + $hasher = new KeyToHashInt(['123']); + + $this->assertSame(123, $hasher->getSingular()); + $this->assertSame(123, $hasher->hash); + } + + public function testSingularNonNumericStringIsHashed(): void + { + $hasher = new KeyToHashInt(['hello']); + $expected = intval(hexdec(hash('xxh64', 'hello'))); + + $this->assertSame($expected, $hasher->getSingular()); + $this->assertSame($expected, $hasher->hash); + } + + public function testUnsupportedSingularKeyReturnsNull(): void + { + $hasher = new KeyToHashInt([null]); + + $this->assertNull($hasher->getSingular()); + $this->assertNull($hasher->hash); + } + + public function testGetReturnsPreviouslyCalculatedHash(): void + { + $hasher = new KeyToHashInt([123]); + + $this->assertSame(123, $hasher->get()); + $hasher->keys = [456]; + $this->assertSame(123, $hasher->get()); + } + + public function testGetPacksTwoKeysIntoOneInteger(): void + { + $keys = ['left', 'right']; + $expected = crc32($keys[0]) << 32 | crc32($keys[1]); + $hasher = new KeyToHashInt($keys); + + $this->assertSame($expected, $hasher->get()); + $this->assertSame($expected, $hasher->hash); + } + + public function testGetHashesThreeOrMoreKeysTogether(): void + { + $keys = ['tenant', 'record', 'locale']; + $expected = intval(hexdec(hash('xxh64', implode('|', $keys)))); + $hasher = new KeyToHashInt($keys); + + $this->assertSame($expected, $hasher->get()); + $this->assertSame($expected, $hasher->hash); + } +} diff --git a/tests/Divergence/IO/Database/SQLTest.php b/tests/Divergence/IO/Database/SQLTest.php index ac74e67..8b60867 100644 --- a/tests/Divergence/IO/Database/SQLTest.php +++ b/tests/Divergence/IO/Database/SQLTest.php @@ -42,8 +42,8 @@ public function testEscape() public function testGetCreateTable() { - $Expected[Tag::class] = 'ae3e735ba26bdd70332877d0458a5ff98a6580dc'; - $Expected[Canary::class] = '9aca8005cf7bf72f3873de36c14dbf121c4bca35'; + $Expected[Tag::class] = '2a6459dc1f43846be657a77347c221e76a66f88a'; + $Expected[Canary::class] = 'c80a4195924a505ef974aa43c6e91d7c688fc460'; foreach ($Expected as $Class=>$Hash) { $this->assertEquals($Hash, sha1(SQL::getCreateTable($Class))); @@ -52,7 +52,7 @@ public function testGetCreateTable() public function testGetCreateTableVersioned() { - $Expected[Canary::class] = '492078c2af3848f4b4d4448b8bdf1086310e2bd5'; + $Expected[Canary::class] = '950d3081e5c841834cda565bc1e70e4e3420a65a'; foreach ($Expected as $Class=>$Hash) { $this->assertEquals($Hash, sha1(SQL::getCreateTable($Class, true))); } diff --git a/tests/Divergence/Models/Collections/CanaryCollectionTest.php b/tests/Divergence/Models/Collections/CanaryCollectionTest.php new file mode 100644 index 0000000..2a615c6 --- /dev/null +++ b/tests/Divergence/Models/Collections/CanaryCollectionTest.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Tests\Models\Collections; + +use PHPUnit\Framework\TestCase; +use Divergence\Models\Collections\IndexedRecordField; +use Divergence\Models\Collections\RecordCollection; +use Divergence\Models\Expr\Conjunction; +use Divergence\Models\Expr\Criteria; +use Divergence\Models\Expr\CriteriaGroup; +use Divergence\Tests\TestUtils; +use Divergence\Tests\MockSite\Collections\CanaryCollection; +use Divergence\Tests\MockSite\Models\Canary; +use Divergence\Tests\MockSite\Models\IndexedCanary; +use Divergence\Tests\MockSite\Models\Tag; + +class CanaryCollectionTest extends TestCase +{ + private const FIELD_TYPES = [ + 'ID' => 'integer', + 'Class' => 'enum', + 'Created' => 'timestamp', + 'CreatorID' => 'integer', + 'ContextID' => 'int', + 'ContextClass' => 'enum', + 'DNA' => 'clob', + 'Name' => 'string', + 'Handle' => 'string', + 'isAlive' => 'boolean', + 'DNAHash' => 'password', + 'StatusCheckedLast' => 'timestamp', + 'SerializedData' => 'serialized', + 'Colors' => 'set', + 'EyeColors' => 'list', + 'Height' => 'float', + 'LongestFlightTime' => 'int', + 'HighestRecordedAltitude' => 'uint', + 'ObservationCount' => 'integer', + 'DateOfBirth' => 'date', + 'Weight' => 'decimal', + 'RevisionID' => 'integer', + ]; + + private CanaryCollection $Collection; + + protected function setUp(): void + { + $this->Collection = new CanaryCollection([ + new Canary(static::record(1), false, false), + new Canary(static::record(2), false, false), + ]); + } + + private static function record(int $id): array + { + return [ + 'ID' => $id, + 'Class' => Canary::class, + 'Created' => sprintf('2024-01-%02d 03:04:05', $id), + 'CreatorID' => 100 + $id, + 'ContextID' => 200 + $id, + 'ContextClass' => Tag::class, + 'DNA' => str_repeat($id === 1 ? 'ATGC' : 'CGTA', 250), + 'Name' => sprintf('Canary %d', $id), + 'Handle' => sprintf('canary-%d', $id), + 'isAlive' => $id === 1, + 'DNAHash' => hash('sha256', sprintf('canary-%d', $id)), + 'StatusCheckedLast' => sprintf('2024-02-%02d 04:05:06', $id), + 'SerializedData' => serialize(['canary' => $id, 'nested' => ['alive' => $id === 1]]), + 'Colors' => $id === 1 ? ['red', 'purple'] : ['blue', 'green'], + 'EyeColors' => $id === 1 ? ['amber', 'brown'] : ['cyan', 'teal'], + 'Height' => 10.5 + $id, + 'LongestFlightTime' => 1000 + $id, + 'HighestRecordedAltitude' => 2000 + $id, + 'ObservationCount' => 3000 + $id, + 'DateOfBirth' => sprintf('2020-03-%02d', $id), + 'Weight' => sprintf('1%d.2%d', $id, $id), + 'RevisionID' => 4000 + $id, + ]; + } + + public function testIndexesEveryCanaryField(): void + { + $this->assertEqualsCanonicalizing( + array_keys(Canary::getClassFields()), + array_keys(static::FIELD_TYPES) + ); + $this->assertEqualsCanonicalizing( + array_keys(static::FIELD_TYPES), + array_keys($this->Collection->Indexes) + ); + } + + public function testIndexedCanaryAttributeCreatesCollectionWithEveryFieldIndex(): void + { + TestUtils::requireDB($this); + + $Collection = IndexedCanary::getAll(['order' => ['ID' => 'ASC']]); + + $this->assertInstanceOf(RecordCollection::class, $Collection); + $this->assertSame(IndexedCanary::class, $Collection->recordClassName); + $this->assertNotEmpty($Collection); + $this->assertEqualsCanonicalizing( + array_keys(IndexedCanary::getClassFields()), + array_keys($Collection->Indexes) + ); + + $Canary = $Collection[0]; + + foreach (static::FIELD_TYPES as $field => $type) { + $this->assertInstanceOf(IndexedRecordField::class, $Collection->Indexes[$field]); + $this->assertSame($type, $Collection->Indexes[$field]->type); + $this->assertSame($Canary, $Collection->getByField($field, $Canary->getValue($field))); + } + } + + public function testIndexedCanaryAttributeReturnsFreshCollections(): void + { + TestUtils::requireDB($this); + + $FirstCollection = IndexedCanary::getAll(['limit' => 1]); + $SecondCollection = IndexedCanary::getAll(['limit' => 1]); + + $this->assertNotSame($FirstCollection, $SecondCollection); + $this->assertNotSame($FirstCollection[0], $SecondCollection[0]); + $this->assertSame($FirstCollection[0]->ID, $SecondCollection[0]->ID); + } + + public function testIndexedCanaryOrCriteriaReturnsKnownLiveRecords(): void + { + TestUtils::requireDB($this); + + $Collection = IndexedCanary::getAll(['order' => ['ID' => 'ASC']]); + + $this->assertGreaterThanOrEqual(2, count($Collection)); + + $FirstCanary = $Collection[0]; + $SecondCanary = $Collection[1]; + + $this->assertNotSame($FirstCanary->ID, $SecondCanary->ID); + $this->assertNotSame($FirstCanary->Handle, $SecondCanary->Handle); + + $matches = $Collection->getAllByCriteria(new CriteriaGroup([ + new Criteria('ID', $FirstCanary->ID), + new Criteria('Handle', $SecondCanary->Handle), + ], Conjunction::GroupOr)); + + $this->assertSame([$FirstCanary, $SecondCanary], $matches); + } + + public function testIndexedCanaryNotOrCriteriaReturnsKnownLiveComplement(): void + { + TestUtils::requireDB($this); + + $Collection = IndexedCanary::getAll(['order' => ['ID' => 'ASC']]); + + $this->assertGreaterThanOrEqual(2, count($Collection)); + + $matches = $Collection->getAllByCriteria(new CriteriaGroup([ + new Criteria('ID', $Collection[0]->ID), + new Criteria('Handle', $Collection[1]->Handle), + ], Conjunction::GroupNotOr)); + + $this->assertSame(array_slice($Collection->toArray(), 2), $matches); + } + + public function testIndexedCanaryAttributeReturnsIndexedEmptyOrmResult(): void + { + TestUtils::requireDB($this); + + $Collection = IndexedCanary::getAll(); + + $this->assertNotEmpty($Collection); + + $IDs = array_map(function ($Canary) { + return $Canary->ID; + }, $Collection->toArray()); + $EmptyCollection = IndexedCanary::getAllByWhere([ + 'ID' => max($IDs) + 1, + ]); + + $this->assertInstanceOf(RecordCollection::class, $EmptyCollection); + $this->assertSame(IndexedCanary::class, $EmptyCollection->recordClassName); + $this->assertCount(0, $EmptyCollection); + $this->assertEqualsCanonicalizing( + array_keys(IndexedCanary::getClassFields()), + array_keys($EmptyCollection->Indexes) + ); + } + + public function testCanaryWithoutAttributeStillReturnsArray(): void + { + TestUtils::requireDB($this); + + $records = Canary::getAll(['limit' => 1]); + + $this->assertIsArray($records); + $this->assertInstanceOf(Canary::class, $records[0]); + } + + public function testEveryCanaryFieldTypeCanBeIndexed(): void + { + $Canary = $this->Collection[0]; + + foreach (static::FIELD_TYPES as $field => $type) { + $this->assertInstanceOf(IndexedRecordField::class, $this->Collection->Indexes[$field]); + $this->assertSame($type, $this->Collection->Indexes[$field]->type); + $this->assertSame($Canary, $this->Collection->getByField($field, $Canary->getValue($field))); + } + } +} diff --git a/tests/Divergence/Models/Collections/RecordCollectionSaveTest.php b/tests/Divergence/Models/Collections/RecordCollectionSaveTest.php new file mode 100644 index 0000000..5fbe4ef --- /dev/null +++ b/tests/Divergence/Models/Collections/RecordCollectionSaveTest.php @@ -0,0 +1,225 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Tests\Models\Collections; + +use Exception; +use PHPUnit\Framework\TestCase; +use Divergence\Models\Collections\RecordCollection; +use Divergence\Tests\MockSite\Models\FixtureItem; + +class RecordCollectionSaveTest extends TestCase +{ + /** + * @return array + */ + private static function buildPhantomRecords(int $count, string $namePrefix): array + { + $records = []; + + for ($i = 1; $i <= $count; $i++) { + $records[] = new FixtureItem([ + 'Name' => sprintf('%s Item %d', $namePrefix, $i), + 'Team' => $i % 2, + 'Score' => $i * 10, + ], true, true); + } + + return $records; + } + + public function testSaveWithTransactionPersistsPhantomRecords(): void + { + $collection = new RecordCollection( + static::buildPhantomRecords(5, 'Transaction Save'), + [], + FixtureItem::class + ); + + $collection->saveWithTransaction(); + + foreach ($collection as $Model) { + $this->assertFalse($Model->isPhantom); + $this->assertIsInt($Model->ID); + $this->assertSame($Model->Name, FixtureItem::getByID($Model->ID)->Name); + } + } + + public function testSaveWithTransactionPropagatesPrimaryKeysAcrossIndexes(): void + { + $records = static::buildPhantomRecords(2, 'Transaction Primary Key'); + $collection = new RecordCollection($records, ['Team'], FixtureItem::class); + + $collection->saveWithTransaction(); + + foreach ($records as $record) { + $this->assertNotNull($record->getPrimaryKeyValue()); + $this->assertSame($record, $collection->HashKeyIndex[$record->ID] ?? null); + $this->assertSame($record->Name, FixtureItem::getByID($record->ID)->Name); + } + + $this->assertSame([$records[1]], $collection->getAllByField('Team', 0)->toArray()); + $this->assertSame([$records[0]], $collection->getAllByField('Team', 1)->toArray()); + + $records[0]->Team = 0; + $collection->saveWithTransaction(); + + $this->assertSame([], $collection->getAllByField('Team', 1)->toArray()); + $this->assertEqualsCanonicalizing($records, $collection->getAllByField('Team', 0)->toArray()); + } + + public function testPhantomRecordsHaveDistinctCollectionIdentities(): void + { + $collection = new RecordCollection( + static::buildPhantomRecords(2, 'Phantom Identity'), + ['Team'], + FixtureItem::class + ); + + $this->assertSame([ + 2, + 1, + 1, + ], [ + count($collection->HashKeyIndex), + count($collection->getAllByField('Team', 0)), + count($collection->getAllByField('Team', 1)), + ]); + } + + public function testSaveRekeysPhantomRecordsByPrimaryKey(): void + { + $records = static::buildPhantomRecords(2, 'Phantom Rekey'); + $collection = new RecordCollection($records, ['Team'], FixtureItem::class); + + $collection->save(); + + $indexedRecords = []; + foreach ($records as $record) { + $indexedRecords[] = $collection->HashKeyIndex[$record->ID] ?? null; + } + + $this->assertSame($records, $indexedRecords); + } + + public function testSaveWithTransactionIsNoOpForEmptyCollection(): void + { + $recordsBefore = FixtureItem::getAll(); + $collection = new RecordCollection([], [], FixtureItem::class); + + $collection->saveWithTransaction(); + + $this->assertCount(count($recordsBefore), FixtureItem::getAll()); + } + + public function testSavePersistsDirtyRecordsWithoutTransaction(): void + { + $records = static::buildPhantomRecords(1, 'Save'); + $collection = new RecordCollection($records, [], FixtureItem::class); + + $collection->save(); + + $this->assertFalse($collection->isDirty()); + $this->assertSame($records[0]->Name, FixtureItem::getByID($records[0]->ID)->Name); + } + + public function testIsDirtyReflectsUnsavedRecords(): void + { + $collection = new RecordCollection( + static::buildPhantomRecords(1, 'Dirty'), + [], + FixtureItem::class + ); + + $this->assertTrue($collection->isDirty()); + + $collection->saveWithTransaction(); + + $this->assertFalse($collection->isDirty()); + } + + public function testSaveWithTransactionRollsBackOnFailure(): void + { + $duplicateName = 'Rollback Collision'; + $duplicateRecord = new FixtureItem([ + 'Name' => $duplicateName, + 'Team' => 0, + 'Score' => 1, + ], true, true); + + $records = static::buildPhantomRecords(2, 'Rollback'); + $records[] = $duplicateRecord; + $records[] = new FixtureItem([ + 'Name' => $duplicateName, + 'Team' => 1, + 'Score' => 2, + ], true, true); + + $collection = new RecordCollection($records, [], FixtureItem::class); + + $this->expectException(Exception::class); + + try { + $collection->saveWithTransaction(); + } finally { + foreach (['Rollback Item 1', 'Rollback Item 2', $duplicateName] as $name) { + $this->assertSame([], FixtureItem::getAllByField('Name', $name)); + } + } + } + + public function testSaveWithTransactionRestoresCollectionStateAfterRollback(): void + { + $duplicateName = 'Rollback State Collision'; + $records = static::buildPhantomRecords(2, 'Rollback State'); + $records[] = new FixtureItem([ + 'Name' => $duplicateName, + 'Team' => 0, + 'Score' => 1, + ], true, true); + $records[] = new FixtureItem([ + 'Name' => $duplicateName, + 'Team' => 1, + 'Score' => 2, + ], true, true); + + $collection = new RecordCollection($records, ['Team'], FixtureItem::class); + $recordOrderBefore = $collection->toArray(); + $hashKeysBefore = array_keys($collection->HashKeyIndex); + $teamZeroBefore = $collection->getAllByField('Team', 0)->toArray(); + $teamOneBefore = $collection->getAllByField('Team', 1)->toArray(); + $exception = null; + + try { + $collection->saveWithTransaction(); + } catch (Exception $caught) { + $exception = $caught; + } + + $this->assertNotNull($exception); + $this->assertSame( + array_fill(0, count($records), true), + array_map(function (FixtureItem $record) { + return $record->isPhantom; + }, $records) + ); + $this->assertSame( + array_fill(0, count($records), null), + array_map(function (FixtureItem $record) { + return $record->getPrimaryKeyValue(); + }, $records) + ); + $this->assertTrue($collection->isDirty()); + $this->assertSame($recordOrderBefore, $collection->toArray()); + $this->assertSame($hashKeysBefore, array_keys($collection->HashKeyIndex)); + $this->assertSame($teamZeroBefore, $collection->getAllByField('Team', 0)->toArray()); + $this->assertSame($teamOneBefore, $collection->getAllByField('Team', 1)->toArray()); + } +} diff --git a/tests/Divergence/Models/Collections/RecordCollectionTest.php b/tests/Divergence/Models/Collections/RecordCollectionTest.php new file mode 100644 index 0000000..07607d6 --- /dev/null +++ b/tests/Divergence/Models/Collections/RecordCollectionTest.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Tests\Models\Collections; + +use stdClass; +use PHPUnit\Framework\TestCase; +use Divergence\Models\Collections\RecordCollection; +use Divergence\Models\Collections\IndexedRecordField; +use Divergence\Models\Expr\Criteria; +use Divergence\Models\Expr\CriteriaType; +use Divergence\Tests\MockSite\Models\FixtureItem; + +class RecordCollectionTest extends TestCase +{ + private RecordCollection $Collection; + + protected function setUp(): void + { + $this->Collection = new RecordCollection(static::buildRecords(), ['Team', 'Score'], FixtureItem::class); + } + + /** + * @return array + */ + private static function buildRecords(): array + { + $records = []; + + for ($i = 1; $i <= 20; $i++) { + $records[] = new FixtureItem([ + 'ID' => $i, + 'Name' => sprintf('Item %02d', $i), + 'Team' => $i % 4, + 'Score' => $i * 10, + ], false, false); + } + + return $records; + } + + public function testConstructorInfersRecordClassNameFromFirstRecord(): void + { + $collection = new RecordCollection(static::buildRecords()); + + $this->assertSame(FixtureItem::class, $collection->recordClassName); + } + + public function testValidateInfersRecordClassNameWhenUnset(): void + { + $collection = new RecordCollection(); + $item = new FixtureItem(['ID' => 1, 'Name' => 'Solo', 'Team' => 0, 'Score' => 10], false, false); + + $collection->add($item); + + $this->assertSame(FixtureItem::class, $collection->recordClassName); + $this->assertCount(1, $collection); + } + + public function testValidateRejectsRecordsOfTheWrongClass(): void + { + $collection = new RecordCollection([], [], FixtureItem::class); + + $collection->add(new stdClass()); + + $this->assertCount(0, $collection); + } + + public function testGetByFieldReturnsMatchingRecord(): void + { + $found = $this->Collection->getByField('Score', 100); + + $this->assertInstanceOf(FixtureItem::class, $found); + $this->assertSame(10, $found->ID); + } + + public function testGetAllByFieldReturnsMatchingRecords(): void + { + $matches = $this->Collection->getAllByField('Team', 1); + + $this->assertInstanceOf(RecordCollection::class, $matches); + $this->assertCount(5, $matches); + } + + public function testGetAllByCriteriaReturnsMatchingRecords(): void + { + $matches = $this->Collection->getAllByCriteria(new Criteria('Team', 2, CriteriaType::Equal)); + + $this->assertIsArray($matches); + $this->assertCount(5, $matches); + $this->assertInstanceOf(FixtureItem::class, $matches[0]); + } + + public function testUpdateIndexForModelDirectCall(): void + { + $item = $this->Collection[0]; + $item->Team = 99; + + $this->Collection->updateIndexForModel('Team', $item); + + $this->assertSame($item, $this->Collection->getByField('Team', 99)); + } + + public function testClearIndexesDirectCall(): void + { + $item = $this->Collection[0]; + + $this->Collection->clearIndexes($item); + + $this->assertNull($this->Collection->getByField('Score', $item->Score)); + } + + public function testCurrentReturnsActiveRecordInstance(): void + { + $this->Collection->rewind(); + + $this->assertInstanceOf(FixtureItem::class, $this->Collection->current()); + } + + public function testOffsetGetNegativeIndex(): void + { + $this->assertSame($this->Collection[19], $this->Collection[-1]); + } + + public function testOffsetUnsetRemovesRecord(): void + { + $item = $this->Collection[0]; + + unset($this->Collection[0]); + + $this->assertCount(19, $this->Collection); + $this->assertSame(2, $this->Collection[0]->ID); + $this->assertNull($this->Collection->getByField('Score', $item->Score)); + } + + public function testRemoveDeletesRecordAndClearsIndex(): void + { + $item = $this->Collection[0]; + + $this->Collection->remove($item); + + $this->assertCount(19, $this->Collection); + $this->assertNull($this->Collection->getByField('Score', $item->Score)); + } + + public function testRemoveManyDeletesMultipleRecords(): void + { + $items = [$this->Collection[0], $this->Collection[1]]; + + $this->Collection->removeMany($items); + + $this->assertCount(18, $this->Collection); + } + + public function testRemoveDecrementsPositionWhenRemovingRecordBeforeCurrentPosition(): void + { + $this->Collection->rewind(); + $this->Collection->next(); + $this->Collection->next(); + $this->Collection->next(); + + $item = $this->Collection[0]; + $this->Collection->remove($item); + + $this->assertSame(2, $this->Collection->key()); + } + + public function testIndexedRecordFieldIndexableValueHandlesDateStringType(): void + { + $index = new IndexedRecordField('CreatedAt', 'DateString'); + + $this->assertSame(strtotime('2024-01-01'), $index->indexableValue('2024-01-01')); + } + + public function testIndexedRecordFieldIndexableValueCastsFloatToString(): void + { + $index = new IndexedRecordField('Score'); + + $this->assertSame((string) 1.5, $index->indexableValue(1.5)); + } +} diff --git a/tests/Divergence/Models/Media/ImageTest.php b/tests/Divergence/Models/Media/ImageTest.php new file mode 100644 index 0000000..919c9c1 --- /dev/null +++ b/tests/Divergence/Models/Media/ImageTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Tests\Models\Media; + +use Exception; +use PHPUnit\Framework\TestCase; +use Divergence\App; +use Divergence\Models\Media\Image; + +class ImageTest extends TestCase +{ + private static string $jpegPath; + + public static function setUpBeforeClass(): void + { + static::$jpegPath = dirname(__DIR__, 3) . '/assets/20210211232214_IMG_0570.JPG'; + + if (!isset(App::$App)) { + new App(dirname(__DIR__, 4)); + } + } + + protected function tearDown(): void + { + $mediaPath = App::$App->ApplicationPath . '/media'; + + if (is_dir($mediaPath)) { + exec('rm -rf ' . escapeshellarg($mediaPath)); + } + } + + private static function makeImage(array $record = [], bool $phantom = false): Image + { + return new Image($record, false, $phantom); + } + + public function testGetValueMapsMimeTypeToExtension(): void + { + $this->assertSame('jpg', static::makeImage(['MIMEType' => 'image/jpeg'])->getValue('Extension')); + $this->assertSame('png', static::makeImage(['MIMEType' => 'image/png'])->getValue('Extension')); + $this->assertSame('gif', static::makeImage(['MIMEType' => 'image/gif'])->getValue('Extension')); + $this->assertSame('psd', static::makeImage(['MIMEType' => 'application/psd'])->getValue('Extension')); + $this->assertSame('tif', static::makeImage(['MIMEType' => 'image/tiff'])->getValue('Extension')); + } + + public function testGetValueThrowsForUnknownMimeType(): void + { + $image = static::makeImage(['MIMEType' => 'image/x-nonexistent']); + + $this->expectException(Exception::class); + + $image->getValue('Extension'); + } + + public function testGetValueThumbnailMimeTypeMapsPsdAndTiffToDifferentFormats(): void + { + $this->assertSame('image/png', static::makeImage(['MIMEType' => 'application/psd'])->getValue('ThumbnailMIMEType')); + $this->assertSame('image/jpeg', static::makeImage(['MIMEType' => 'image/tiff'])->getValue('ThumbnailMIMEType')); + $this->assertSame('image/jpeg', static::makeImage(['MIMEType' => 'image/jpeg'])->getValue('ThumbnailMIMEType')); + } + + public function testAnalyzeFileReturnsRealImageDimensions(): void + { + $mediaInfo = Image::analyzeFile(static::$jpegPath); + + $this->assertSame(6960, $mediaInfo['width']); + $this->assertSame(4640, $mediaInfo['height']); + $this->assertSame(0, $mediaInfo['duration']); + } + + public function testAnalyzeFileThrowsForInvalidImageFile(): void + { + $notAnImage = tempnam(sys_get_temp_dir(), 'not_an_image_'); + file_put_contents($notAnImage, 'this is definitely not a jpeg'); + + $this->expectException(Exception::class); + + try { + Image::analyzeFile($notAnImage); + } finally { + unlink($notAnImage); + } + } + + public function testGetImageLoadsRealJpegAndAppliesExifOrientation(): void + { + $image = static::makeImage(['MIMEType' => 'image/jpeg']); + + $gdImage = $image->getImage(static::$jpegPath); + + $this->assertInstanceOf(\GdImage::class, $gdImage); + $this->assertSame(6960, imagesx($gdImage)); + $this->assertSame(4640, imagesy($gdImage)); + } + + public function testCreateThumbnailImageProducesRealThumbnailFile(): void + { + $image = static::makeImage(['ID' => 601, 'MIMEType' => 'image/jpeg']); + + $sourcePath = $image->getFilesystemPath(); + if (!is_dir($dir = dirname($sourcePath))) { + mkdir($dir, 0775, true); + } + copy(static::$jpegPath, $sourcePath); + + $thumbPath = tempnam(sys_get_temp_dir(), 'thumb_test_') . '.jpg'; + + $image->createThumbnailImage($thumbPath, 200, 200); + + $this->assertFileExists($thumbPath); + $size = getimagesize($thumbPath); + $this->assertLessThanOrEqual(200, $size[0]); + $this->assertLessThanOrEqual(200, $size[1]); + + unlink($thumbPath); + } + + public function testGetThumbnailCreatesAndCachesThumbnailFile(): void + { + $image = static::makeImage(['ID' => 602, 'MIMEType' => 'image/jpeg']); + + $sourcePath = $image->getFilesystemPath(); + if (!is_dir($dir = dirname($sourcePath))) { + mkdir($dir, 0775, true); + } + copy(static::$jpegPath, $sourcePath); + + $thumbPath = $image->getThumbnail(150, 150); + + $this->assertFileExists($thumbPath); + + $secondCallPath = $image->getThumbnail(150, 150); + $this->assertSame($thumbPath, $secondCallPath); + } +} diff --git a/tests/Divergence/Models/Media/VideoTest.php b/tests/Divergence/Models/Media/VideoTest.php new file mode 100644 index 0000000..e016c7a --- /dev/null +++ b/tests/Divergence/Models/Media/VideoTest.php @@ -0,0 +1,175 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Tests\Models\Media; + +use Exception; +use PHPUnit\Framework\TestCase; +use Divergence\App; +use Divergence\Models\Media\Video; + +class VideoTest extends TestCase +{ + private static string $bunnyPath; + + public static function setUpBeforeClass(): void + { + static::$bunnyPath = dirname(__DIR__, 3) . '/assets/bunny.mp4'; + + if (!isset(App::$App)) { + new App(dirname(__DIR__, 4)); + } + } + + protected function tearDown(): void + { + $mediaPath = App::$App->ApplicationPath . '/media'; + + if (is_dir($mediaPath)) { + exec('rm -rf ' . escapeshellarg($mediaPath)); + } + } + + private static function makeVideo(array $record = [], bool $phantom = false): Video + { + return new Video($record, false, $phantom); + } + + public function testGetValueMapsKnownMimeTypeToExtension(): void + { + $video = static::makeVideo(['MIMEType' => 'video/webm']); + + $this->assertSame('webm', $video->getValue('Extension')); + } + + public function testGetValueFallsBackToSubtypeForUnknownVideoMimeType(): void + { + $video = static::makeVideo(['MIMEType' => 'video/x-newformat']); + + $this->assertSame('x-newformat', $video->getValue('Extension')); + } + + public function testGetValueThrowsForNonVideoMimeType(): void + { + $video = static::makeVideo(['MIMEType' => 'application/octet-stream']); + + $this->expectException(Exception::class); + + $video->getValue('Extension'); + } + + public function testGetValueReturnsThumbnailMimeType(): void + { + $video = static::makeVideo(['MIMEType' => 'video/mp4']); + + $this->assertSame('image/jpeg', $video->getValue('ThumbnailMIMEType')); + } + + public function testAnalyzeFileReturnsRealFfprobeMetadata(): void + { + $mediaInfo = Video::analyzeFile(static::$bunnyPath); + + $this->assertSame(480, $mediaInfo['width']); + $this->assertSame(270, $mediaInfo['height']); + $this->assertEqualsWithDelta(12.16, $mediaInfo['duration'], 0.5); + $this->assertSame(0, $mediaInfo['rotation']); + } + + public function testAnalyzeFileThrowsForUnreadableFile(): void + { + $this->expectException(Exception::class); + + Video::analyzeFile('/nonexistent/path/to/nothing.mp4'); + } + + public function testGetImageExtractsRealFrameFromVideo(): void + { + $video = static::makeVideo(['MIMEType' => 'video/mp4', 'Duration' => 12.16]); + + $image = $video->getImage(static::$bunnyPath); + + $this->assertInstanceOf(\GdImage::class, $image); + $this->assertSame(480, imagesx($image)); + $this->assertSame(270, imagesy($image)); + } + + public function testGetFilesystemPathReturnsNullForPhantomRecord(): void + { + $video = static::makeVideo([], true); + + $this->assertNull($video->getFilesystemPath()); + } + + public function testGetFilesystemPathUsesEncodingProfileExtensionForKnownVariant(): void + { + $video = static::makeVideo(['ID' => 501, 'MIMEType' => 'video/mp4']); + + $path = $video->getFilesystemPath('h264-high-480p'); + + $this->assertStringContainsString('/video-h264-high-480p/', $path); + $this->assertStringEndsWith('501.mp4', $path); + } + + public function testGetMIMETypeReturnsEncodingProfileMimeTypeForKnownVariant(): void + { + $video = static::makeVideo(['MIMEType' => 'video/mp4']); + + $this->assertSame('video/webm', $video->getMIMEType('webm-480p')); + } + + public function testGetMIMETypeFallsBackToParentForUnknownVariant(): void + { + $video = static::makeVideo(['MIMEType' => 'video/mp4']); + + $this->assertSame('video/mp4', $video->getMIMEType('original')); + } + + public function testIsVariantAvailableReturnsTrueWhenEncodedFileExists(): void + { + $video = static::makeVideo(['ID' => 502, 'MIMEType' => 'video/mp4']); + + $path = $video->getFilesystemPath('h264-high-480p'); + $dir = dirname($path); + + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + file_put_contents($path, 'fake-encoded-output'); + + $this->assertTrue($video->isVariantAvailable('h264-high-480p')); + } + + public function testIsVariantAvailableReturnsFalseWhenEncodedFileMissing(): void + { + $video = static::makeVideo(['ID' => 503, 'MIMEType' => 'video/mp4']); + + $this->assertFalse($video->isVariantAvailable('h264-high-480p')); + } + + public function testWriteFileMovesSourceAndLaunchesEncodingJobs(): void + { + $video = static::makeVideo(['ID' => 504, 'MIMEType' => 'video/mp4']); + + $tempCopy = tempnam(sys_get_temp_dir(), 'bunny_test_'); + copy(static::$bunnyPath, $tempCopy); + + $video->writeFile($tempCopy); + + $originalPath = $video->getFilesystemPath(); + + $this->assertFileExists($originalPath); + $this->assertGreaterThan(0, filesize($originalPath)); + $this->assertFileDoesNotExist($tempCopy); + + foreach (['h264-high-480p', 'webm-480p'] as $profileName) { + $this->assertDirectoryExists(dirname($video->getFilesystemPath($profileName))); + } + } +} diff --git a/tests/MockSite/Collections/CanaryCollection.php b/tests/MockSite/Collections/CanaryCollection.php new file mode 100644 index 0000000..82fe34d --- /dev/null +++ b/tests/MockSite/Collections/CanaryCollection.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Tests\MockSite\Collections; + +use Divergence\Models\Collections\RecordCollection; +use Divergence\Tests\MockSite\Models\Canary; + +class CanaryCollection extends RecordCollection +{ + public function __construct(array $records = []) + { + parent::__construct($records, array_keys(Canary::getClassFields()), Canary::class); + } +} diff --git a/tests/MockSite/Models/FixtureItem.php b/tests/MockSite/Models/FixtureItem.php new file mode 100644 index 0000000..3652ab9 --- /dev/null +++ b/tests/MockSite/Models/FixtureItem.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Tests\MockSite\Models; + +use Divergence\Models\Mapping\Column; +use Divergence\Models\Model; + +class FixtureItem extends Model +{ + public static $tableName = 'fixture_items'; + + #[Column(type: 'integer')] + private int $Team; + + #[Column(type: 'integer')] + private int $Score; + + #[Column(type: 'string', unique: true)] + private string $Name; +} diff --git a/tests/MockSite/Models/IndexedCanary.php b/tests/MockSite/Models/IndexedCanary.php new file mode 100644 index 0000000..eb1fd73 --- /dev/null +++ b/tests/MockSite/Models/IndexedCanary.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Divergence\Tests\MockSite\Models; + +use Divergence\Models\Mapping\InMemoryIndexing; +/** + * This test demonstrates a technique where you have the + * indexed one piggyback on an existing Model definition. + */ +#[InMemoryIndexing(indexes: [ + 'ID', + 'Class', + 'Created', + 'CreatorID', + 'ContextID', + 'ContextClass', + 'DNA', + 'Name', + 'Handle', + 'isAlive', + 'DNAHash', + 'StatusCheckedLast', + 'SerializedData', + 'Colors', + 'EyeColors', + 'Height', + 'LongestFlightTime', + 'HighestRecordedAltitude', + 'ObservationCount', + 'DateOfBirth', + 'Weight', + 'RevisionID', +])] +class IndexedCanary extends Canary +{ +} diff --git a/tests/assets/20210211232214_IMG_0570.JPG b/tests/assets/20210211232214_IMG_0570.JPG new file mode 100644 index 0000000..cc8cc54 Binary files /dev/null and b/tests/assets/20210211232214_IMG_0570.JPG differ