diff --git a/.github/workflows/phan.yml b/.github/workflows/phan.yml new file mode 100644 index 0000000..b3e752a --- /dev/null +++ b/.github/workflows/phan.yml @@ -0,0 +1,32 @@ +name: Phan + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + +permissions: + contents: read + +jobs: + phan: + name: Phan + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Run Phan + run: composer analyze diff --git a/.phan/config.php b/.phan/config.php new file mode 100644 index 0000000..35d1ffc --- /dev/null +++ b/.phan/config.php @@ -0,0 +1,20 @@ + '8.4', + 'directory_list' => [ + 'src', + 'vendor/filp/whoops/src', + 'vendor/guzzlehttp/psr7/src', + 'vendor/psr/http-message/src', + 'vendor/psr/http-server-handler/src', + 'vendor/twig/twig/src', + ], + 'exclude_analysis_directory_list' => [ + 'vendor', + ], + 'exclude_file_regex' => '@^vendor/.*/(?:tests?|Tests?)/@', + 'suppress_issue_types' => [ + 'PhanUnreferencedUseNormal', + ], +]; diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..64cc505 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,98 @@ + + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +EOF; + +$headerRule = [ + 'header' => $header, + 'validator' => '/' . preg_quote($header, '/') . '(?P.*)??/s', + 'comment_type' => 'PHPDoc', + 'location' => 'after_open', + 'separate' => 'none', +]; + +$headerPolicy = new class($headerRule) implements PhpCsFixer\Config\RuleCustomisationPolicyInterface { + private array $headerRule; + private string $headerPrefix; + + public function __construct(array $headerRule) + { + $this->headerRule = $headerRule; + $this->headerPrefix = " rtrim(' * ' . $line), + explode("\n", $headerRule['header']) + )) . "\n"; + } + + public function getPolicyVersionForCache(): string + { + return 'preserve-header-separation-v3'; + } + + public function getRuleCustomisers(): array + { + return [ + 'header_comment' => $this->customizeHeaderComment(...), + ]; + } + + private function customizeHeaderComment(SplFileInfo $file): bool|PhpCsFixer\Fixer\FixerInterface + { + $contents = file_get_contents($file->getPathname()); + + if (false !== $contents && str_starts_with(str_replace("\r", '', $contents), $this->headerPrefix)) { + return false; + } + + $headerRule = $this->headerRule; + + if (false !== $contents && preg_match('/\A<\?php\R(?\/\*\*.*?\*\/)(?\R*)/s', $contents, $matches)) { + $lines = array_slice(explode("\n", str_replace("\r", '', $matches['doc'])), 1, -1); + $extra = implode("\n", array_map( + static fn (string $line): string => ' *' === $line ? '' : (str_starts_with($line, ' * ') ? substr($line, 3) : $line), + $lines + )); + + if ('' !== trim($extra)) { + $headerRule['header'] .= "\n\n" . $extra; + } + + if (1 < substr_count(str_replace("\r", '', $matches['separator']), "\n")) { + $headerRule['separate'] = 'bottom'; + } + } + + $fixer = new PhpCsFixer\Fixer\Comment\HeaderCommentFixer(); + $fixer->configure($headerRule); + + return $fixer; + } +}; + +$finder = (new PhpCsFixer\Finder()) + //->exclude('somedir') + ->in(__DIR__) +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR1' => true, + '@PSR2' => true, + 'no_break_comment' => false, + 'array_syntax' => ['syntax' => 'short'], + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + 'no_trailing_comma_in_singleline' => true, + 'ternary_operator_spaces' => true, + 'trim_array_spaces' => true, + 'indentation_type' => true, + 'header_comment' => $headerRule, + ]) + ->setRuleCustomisationPolicy($headerPolicy) + ->setFinder($finder) +; diff --git a/.php_cs.dist.php b/.php_cs.dist.php deleted file mode 100644 index fe4bf01..0000000 --- a/.php_cs.dist.php +++ /dev/null @@ -1,39 +0,0 @@ - - -For the full copyright and license information, please view the LICENSE -file that was distributed with this source code. -EOF; - -$finder = (new PhpCsFixer\Finder()) - //->exclude('somedir') - ->in(__DIR__) -; - -return (new PhpCsFixer\Config()) - ->setRules([ - '@PSR1' => true, - '@PSR2' => true, - 'array_syntax' => ['syntax' => 'short'], - 'trailing_comma_in_multiline_array' => true, - 'no_trailing_comma_in_singleline_array' => true, - 'ternary_operator_spaces' => true, - 'trim_array_spaces' => true, - 'ordered_imports' => [ - 'sortAlgorithm' => 'length' - ], - 'ordered_class_elements' => true, - 'indentation_type' => true, - 'header_comment' => [ - 'header' => $header, - 'comment_type' => 'PHPDoc', - 'location' => 'after_open', - 'separate' => 'none', - ] - ]) - ->setFinder($finder) -; \ No newline at end of file diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 7660774..a68af55 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,12 +1,23 @@ checks: php: + 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/composer.json b/composer.json index 226b6b0..c19b241 100644 --- a/composer.json +++ b/composer.json @@ -43,12 +43,14 @@ "phpunit/phpcov": "^12.0", "friendsofphp/php-cs-fixer": "*", "mikey179/vfsstream": "^1.6", - "fakerphp/faker": "^1.20" + "fakerphp/faker": "^1.20", + "phan/phan": "^6.0" }, "suggest": { "divergence/cli": "Lets you initialize a new project as well as create, edit, and test database configurations via CLI." }, "scripts": { + "analyze": "phan --allow-polyfill-parser --no-progress-bar", "fix-code": "php-cs-fixer fix", "test": [ "@test:mysql", diff --git a/src/Controllers/Media/Endpoints/Browse.php b/src/Controllers/Media/Endpoints/Browse.php index 4e41040..66dd13b 100644 --- a/src/Controllers/Media/Endpoints/Browse.php +++ b/src/Controllers/Media/Endpoints/Browse.php @@ -1,4 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * @phan-file-suppress PhanUndeclaredClassMethod + */ namespace Divergence\Controllers\Media\Endpoints; diff --git a/src/Controllers/Media/Endpoints/Caption.php b/src/Controllers/Media/Endpoints/Caption.php index a789eef..4cf430a 100644 --- a/src/Controllers/Media/Endpoints/Caption.php +++ b/src/Controllers/Media/Endpoints/Caption.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Media\Endpoints; use Divergence\Controllers\Media\AbstractMediaEndpoint; diff --git a/src/Controllers/Media/Endpoints/Delete.php b/src/Controllers/Media/Endpoints/Delete.php index c4d4c77..265c0d0 100644 --- a/src/Controllers/Media/Endpoints/Delete.php +++ b/src/Controllers/Media/Endpoints/Delete.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Media\Endpoints; use Divergence\Controllers\Media\AbstractMediaEndpoint; diff --git a/src/Controllers/Media/Endpoints/Download.php b/src/Controllers/Media/Endpoints/Download.php index ddb1fa5..b355ab7 100644 --- a/src/Controllers/Media/Endpoints/Download.php +++ b/src/Controllers/Media/Endpoints/Download.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Media\Endpoints; use Divergence\Controllers\Media\AbstractMediaEndpoint; @@ -41,6 +48,9 @@ public function handle(...$arguments): ResponseInterface } $filePath = $Media->getFilesystemPath('original'); + if ($filePath === null) { + return $this->handler->throwNotFoundError(); + } $this->handler->responseBuilder = MediaBuilder::class; $response = $this->handler->respondWithMedia($Media, 'original', $filePath); diff --git a/src/Controllers/Media/Endpoints/Info.php b/src/Controllers/Media/Endpoints/Info.php index 25bacc5..466e193 100644 --- a/src/Controllers/Media/Endpoints/Info.php +++ b/src/Controllers/Media/Endpoints/Info.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Media\Endpoints; use Divergence\Controllers\Media\AbstractMediaEndpoint; diff --git a/src/Controllers/Media/Endpoints/Media.php b/src/Controllers/Media/Endpoints/Media.php index 6fb0f68..1458191 100644 --- a/src/Controllers/Media/Endpoints/Media.php +++ b/src/Controllers/Media/Endpoints/Media.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Media\Endpoints; use Divergence\Controllers\Media\AbstractMediaEndpoint; @@ -66,6 +73,10 @@ public function handle(...$arguments): ResponseInterface set_time_limit(0); $filePath = $Media->getFilesystemPath($variant); + if ($filePath === null) { + return $this->handler->throwNotFoundError(); + } + if (!empty($_server['HTTP_IF_NONE_MATCH']) || !empty($_server['HTTP_IF_MODIFIED_SINCE'])) { $this->handler->responseBuilder = EmptyBuilder::class; $response = $this->handler->respondEmpty($filePath); diff --git a/src/Controllers/Media/Endpoints/MediaDelete.php b/src/Controllers/Media/Endpoints/MediaDelete.php index 1db415b..277c871 100644 --- a/src/Controllers/Media/Endpoints/MediaDelete.php +++ b/src/Controllers/Media/Endpoints/MediaDelete.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Media\Endpoints; use Divergence\Controllers\Media\AbstractMediaEndpoint; @@ -39,7 +46,7 @@ public function handle(...$arguments): ResponseInterface $deleted = []; foreach ($mediaArray as $mediaId => $Media) { - if ($Media->delete()) { + if ($Media->destroy()) { $deleted[] = $mediaId; } } diff --git a/src/Controllers/Media/Endpoints/Thumbnail.php b/src/Controllers/Media/Endpoints/Thumbnail.php index 85aaaf0..73880d3 100644 --- a/src/Controllers/Media/Endpoints/Thumbnail.php +++ b/src/Controllers/Media/Endpoints/Thumbnail.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Media\Endpoints; use Divergence\Controllers\Media\AbstractMediaEndpoint; @@ -41,7 +48,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]; @@ -62,7 +70,7 @@ public function handle(...$arguments): ResponseInterface $thumbPath = $Media->getThumbnail($maxWidth, $maxHeight, $fillColor, $cropped); $this->handler->responseBuilder = MediaBuilder::class; - return $this->handler->respondWithThumbnail($Media, "$maxWidth-$maxHeight-$fillColor-$cropped", $thumbPath); + return $this->handler->respondWithThumbnail($Media, "$maxWidth-$maxHeight-$fillColor-".(int)$cropped, $thumbPath); } catch (Exception $e) { return $this->handler->throwNotFoundError(); } diff --git a/src/Controllers/Media/Endpoints/Upload.php b/src/Controllers/Media/Endpoints/Upload.php index 0b38ffb..ff2a232 100644 --- a/src/Controllers/Media/Endpoints/Upload.php +++ b/src/Controllers/Media/Endpoints/Upload.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Media\Endpoints; use Divergence\Controllers\Media\AbstractMediaEndpoint; diff --git a/src/Controllers/MediaRequestHandler.php b/src/Controllers/MediaRequestHandler.php index 2314162..0ec7662 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; @@ -33,7 +34,6 @@ use Divergence\Responders\MediaBuilder; use Psr\Http\Message\ResponseInterface; use Divergence\Responders\MediaResponse; -use GuzzleHttp\Psr7\ServerRequest; use Psr\Http\Message\ServerRequestInterface; /** @@ -89,7 +89,7 @@ class MediaRequestHandler extends RecordsRequestHandler ], ]; - private ?ServerRequest $request; + private ?ServerRequestInterface $request; public function __construct() { @@ -119,7 +119,7 @@ protected function registerMediaEndpointClasses(): void } } - public function getRequest(): ?ServerRequest + public function getRequest(): ?ServerRequestInterface { return $this->request; } @@ -229,6 +229,11 @@ public function setCache(Response $response): Response ->withHeader('Pragma', 'public'); } + /** + * @param string $variant + * @param string $responseID + * @param array $responseData + */ public function respondWithMedia(Media $Media, $variant, $responseID, $responseData = []): ResponseInterface { if ($this->responseBuilder != MediaBuilder::class) { @@ -241,6 +246,9 @@ public function respondWithMedia(Media $Media, $variant, $responseID, $responseD $size = filesize($responseID); + if ($size === false) { + throw new Exception('Unable to determine media file size.'); + } $length = $size; $start = 0; $end = $size - 1; @@ -296,18 +304,23 @@ public function respondWithMedia(Media $Media, $variant, $responseID, $responseD $response = $response->withStatus(206); } $response = $response->withHeader('Content-Range', "bytes $start-$end/$size") - ->withHeader('Content-Length', $length); + ->withHeader('Content-Length', (string)$length); } else { // range - $filesize = filesize($Media->getFilesystemPath($variant)); + $filesize = $size; $end = $filesize - 1; $response = $response->withHeader('Content-Range', 'bytes 0-'.$end.'/'.$filesize) - ->withHeader('Content-Length', $filesize); + ->withHeader('Content-Length', (string)$filesize); } return $response; } + /** + * @param string $variant + * @param string $responseID + * @param array $responseData + */ public function respondWithThumbnail(Media $Media, $variant, $responseID, $responseData = []): ResponseInterface { if ($this->responseBuilder != MediaBuilder::class) { @@ -321,8 +334,13 @@ public function respondWithThumbnail(Media $Media, $variant, $responseID, $respo $response = new MediaResponse($responseBuilder); $response = $this->setCache($response); + $filesize = filesize($responseID); + if ($filesize === false) { + throw new Exception('Unable to determine thumbnail file size.'); + } + $response = $response->withHeader('ETag', "media-$Media->ID-$variant") - ->withHeader('Content-Length', filesize($responseID)); + ->withHeader('Content-Length', (string)$filesize); return $response; } diff --git a/src/Controllers/Records/Endpoints/Create.php b/src/Controllers/Records/Endpoints/Create.php index 1524cd7..83f1efa 100644 --- a/src/Controllers/Records/Endpoints/Create.php +++ b/src/Controllers/Records/Endpoints/Create.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Records\Endpoints; use Divergence\Controllers\Records\AbstractRecordsEndpoint; @@ -25,7 +32,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/Delete.php b/src/Controllers/Records/Endpoints/Delete.php index d575a32..ceaf7a1 100644 --- a/src/Controllers/Records/Endpoints/Delete.php +++ b/src/Controllers/Records/Endpoints/Delete.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Records\Endpoints; use Divergence\Controllers\Records\AbstractRecordsEndpoint; diff --git a/src/Controllers/Records/Endpoints/Edit.php b/src/Controllers/Records/Endpoints/Edit.php index 8b7c581..9519a18 100644 --- a/src/Controllers/Records/Endpoints/Edit.php +++ b/src/Controllers/Records/Endpoints/Edit.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Records\Endpoints; use Divergence\Controllers\Records\AbstractRecordsEndpoint; diff --git a/src/Controllers/Records/Endpoints/MultiDestroy.php b/src/Controllers/Records/Endpoints/MultiDestroy.php index 0842c9b..b4483a1 100644 --- a/src/Controllers/Records/Endpoints/MultiDestroy.php +++ b/src/Controllers/Records/Endpoints/MultiDestroy.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Records\Endpoints; use Divergence\Controllers\Records\AbstractRecordsEndpoint; diff --git a/src/Controllers/Records/Endpoints/MultiSave.php b/src/Controllers/Records/Endpoints/MultiSave.php index 2f4f8d8..db908ad 100644 --- a/src/Controllers/Records/Endpoints/MultiSave.php +++ b/src/Controllers/Records/Endpoints/MultiSave.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Records\Endpoints; use Divergence\Controllers\Records\AbstractRecordsEndpoint; @@ -64,7 +71,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/Records/Endpoints/Record.php b/src/Controllers/Records/Endpoints/Record.php index 7a74bcf..272814a 100644 --- a/src/Controllers/Records/Endpoints/Record.php +++ b/src/Controllers/Records/Endpoints/Record.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Controllers\Records\Endpoints; use Divergence\Controllers\Records\AbstractRecordsEndpoint; @@ -21,6 +28,10 @@ public function handle(...$arguments): ResponseInterface { [$Record, $action] = array_pad($arguments, 2, false); + if (!$Record instanceof ActiveRecord) { + return $this->handler->throwNotFoundError(); + } + if (!$this->handler->checkReadAccess($Record)) { return $this->handler->throwUnauthorizedError(); } 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..267c6e8 --- /dev/null +++ b/src/Data/Collections/Collection.php @@ -0,0 +1,385 @@ + + * + * 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; + } + + /** + * @param callable(TRecord): (int|float) $selector + */ + public function sum(callable $selector): int|float + { + return Math\Aggregates::sum($this, $selector); + } + + /** + * @param callable(TRecord): (int|float) $selector + */ + public function median(callable $selector): int|float|null + { + return Math\Aggregates::median($this, $selector); + } + + /** + * @param callable(TRecord): (int|float) $selector + */ + public function percentile(callable $selector, float $percentile): int|float|null + { + return Math\Aggregates::percentile($this, $selector, $percentile); + } + + /** + * Uses the nearest-rank definition. + * Rank products within floating-point epsilon of an integer are treated as exact boundaries. + * + * @param callable(TRecord): (int|float) $selector + */ + public function quantile(callable $selector, float $quantile): int|float|null + { + return Math\Aggregates::quantile($this, $selector, $quantile); + } + + /** + * Calculates population variance. + * + * @param callable(TRecord): (int|float) $selector + */ + public function variance(callable $selector): ?float + { + return Math\Deviations::variance($this, $selector); + } + + /** + * Calculates population standard deviation. + * + * @param callable(TRecord): (int|float) $selector + */ + public function stddev(callable $selector): ?float + { + return Math\Deviations::stddev($this, $selector); + } + + /** + * Buckets are half-open except for the final bucket, which includes its maximum. + * A constant distribution returns one bucket. + * + * @param callable(TRecord): (int|float) $selector + * @return list + */ + public function histogram(callable $selector, int $bucketCount = 10): array + { + return Math\Distributions::histogram($this, $selector, $bucketCount); + } + + /** + * Returns every tied mode in first-seen order. + * + * @param callable(TRecord): mixed $selector + * @return list + */ + public function mode(callable $selector): array + { + return Math\Distributions::mode($this, $selector); + } + + /** + * Calculates population covariance. + * + * @param callable(TRecord): (int|float) $firstSelector + * @param callable(TRecord): (int|float) $secondSelector + */ + public function covariance(callable $firstSelector, callable $secondSelector): ?float + { + return Math\Relationships::covariance($this, $firstSelector, $secondSelector); + } + + /** + * @param callable(TRecord): (int|float) $firstSelector + * @param callable(TRecord): (int|float) $secondSelector + */ + public function correlation(callable $firstSelector, callable $secondSelector): ?float + { + return Math\Relationships::correlation($this, $firstSelector, $secondSelector); + } + + /** + * @param callable(TRecord): (int|float) $selector + * @return list + */ + public function topK(callable $selector, int $count): array + { + return Math\Rankings::topK($this, $selector, $count); + } + + /** + * @param callable(TRecord): (int|float) $selector + * @return list + */ + public function bottomK(callable $selector, int $count): array + { + return Math\Rankings::bottomK($this, $selector, $count); + } + + /** + * Values are grouped by type and value so PHP array-key coercion cannot merge distinct values. + * + * @param callable(TRecord): mixed $selector + * @return list + */ + public function frequency(callable $selector): array + { + return Math\Distributions::frequency($this, $selector); + } + + /** + * @param callable(TRecord): mixed $selector + * @return list + */ + public function countBy(callable $selector): array + { + return Math\Distributions::countBy($this, $selector); + } + + /** + * @param callable(TRecord): (int|float) $selector + * @return list + */ + public function movingAverage(callable $selector, int $windowSize): array + { + return Math\Windows::movingAverage($this, $selector, $windowSize); + } + + /** + * @template TResult + * @param callable(list): TResult $callback + * @return list + */ + public function rolling(int $windowSize, callable $callback): array + { + return Math\Windows::rolling($this, $windowSize, $callback); + } + + /** + * @param callable(TRecord): (int|float) $selector + * @return list + */ + public function zScore(callable $selector): array + { + return Math\Deviations::zScore($this, $selector); + } + + /** + * @param callable(TRecord): (int|float) $selector + * @return list + */ + public function outliers(callable $selector, float $threshold = 3): array + { + return Math\Deviations::outliers($this, $selector, $threshold); + } + + /* ### 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..fc82286 --- /dev/null +++ b/src/Data/Collections/Factory/Getters/GetAllByCriteria.php @@ -0,0 +1,158 @@ + + * + * 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 + $found = []; + 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..6adc00b --- /dev/null +++ b/src/Data/Collections/IndexedFieldFinder.php @@ -0,0 +1,264 @@ + + * + * 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(); + $recordKeys = $this->IndexedField->getOrderedRecordKeys(); + + if ($recordKeys === null) { + throw new RuntimeException('Ordered record keys were not initialized.'); + } + + if ($lessThan) { + $end = $this->lowerBoundary($value, $inclusive); + $keys = array_slice($recordKeys, 0, $end); + } else { + $start = $this->upperBoundary($value, $inclusive); + $keys = array_slice($recordKeys, $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 + { + $orderedValues = $this->IndexedField->getOrderedValues(); + + if ($orderedValues === null) { + throw new RuntimeException('Ordered values were not initialized.'); + } + + $low = 0; + $high = count($orderedValues); + + while ($low < $high) { + $middle = intdiv($low + $high, 2); + $matches = $inclusive + ? $orderedValues[$middle] <= $value + : $orderedValues[$middle] < $value; + + if ($matches) { + $low = $middle + 1; + } else { + $high = $middle; + } + } + + return $low; + } + + private function upperBoundary($value, bool $inclusive): int + { + $orderedValues = $this->IndexedField->getOrderedValues(); + + if ($orderedValues === null) { + throw new RuntimeException('Ordered values were not initialized.'); + } + + $low = 0; + $high = count($orderedValues); + + while ($low < $high) { + $middle = intdiv($low + $high, 2); + $matches = $inclusive + ? $orderedValues[$middle] < $value + : $orderedValues[$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/Math/AbstractOperation.php b/src/Data/Collections/Math/AbstractOperation.php new file mode 100644 index 0000000..b9591c3 --- /dev/null +++ b/src/Data/Collections/Math/AbstractOperation.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Math; + +use Divergence\Data\Collections\Collection; +use InvalidArgumentException; +use OverflowException; + +abstract class AbstractOperation +{ + /** + * @param callable(object|array): (int|float) $selector + * @return list + */ + protected static function numericValues(Collection $collection, callable $selector): array + { + return array_map( + static fn ($record): int|float => static::numericValue($selector($record)), + array_values($collection->toArray()) + ); + } + + protected static function numericValue($value): int|float + { + if ((!is_int($value) && !is_float($value)) || (is_float($value) && !is_finite($value))) { + throw new InvalidArgumentException('Math selectors must return finite integers or floats.'); + } + + return $value; + } + + /** + * @param list $values + * @return list + */ + protected static function centeredValues(array $values): array + { + if (!$values) { + return []; + } + + $origin = $values[0]; + + return array_map( + static fn (int|float $value): int|float => $value - $origin, + $values + ); + } + + /** + * @param list $values + */ + protected static function varianceFromValues(array $values): ?float + { + if (!$values) { + return null; + } + + $values = static::centeredValues($values); + $average = array_sum($values) / count($values); + $variance = array_sum(array_map( + static fn (int|float $value): int|float => ($value - $average) ** 2, + $values + )) / count($values); + + if (!is_finite($variance)) { + throw new OverflowException('Variance exceeded floating-point range.'); + } + + return $variance; + } +} diff --git a/src/Data/Collections/Math/Aggregates.php b/src/Data/Collections/Math/Aggregates.php new file mode 100644 index 0000000..a467219 --- /dev/null +++ b/src/Data/Collections/Math/Aggregates.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Math; + +use Divergence\Data\Collections\Collection; +use InvalidArgumentException; +use OverflowException; + +class Aggregates extends AbstractOperation +{ + public static function sum(Collection $collection, callable $selector): int|float + { + $sum = array_sum(static::numericValues($collection, $selector)); + + if (is_float($sum) && !is_finite($sum)) { + throw new OverflowException('Sum exceeded floating-point range.'); + } + + return $sum; + } + + public static function median(Collection $collection, callable $selector): int|float|null + { + $values = static::numericValues($collection, $selector); + + if (!$values) { + return null; + } + + sort($values, SORT_NUMERIC); + $count = count($values); + $middle = intdiv($count, 2); + + if ($count % 2) { + return $values[$middle]; + } + + $lower = $values[$middle - 1]; + $upper = $values[$middle]; + $sum = $lower + $upper; + + if (!is_float($sum) || is_finite($sum)) { + return $sum / 2; + } + + return ($lower / 2) + ($upper / 2); + } + + public static function percentile( + Collection $collection, + callable $selector, + float $percentile + ): int|float|null { + return static::quantile($collection, $selector, $percentile / 100); + } + + public static function quantile( + Collection $collection, + callable $selector, + float $quantile + ): int|float|null { + if (!is_finite($quantile) || $quantile < 0 || $quantile > 1) { + throw new InvalidArgumentException('Quantile must be between 0 and 1.'); + } + + $values = static::numericValues($collection, $selector); + + if (!$values) { + return null; + } + + sort($values, SORT_NUMERIC); + $rank = $quantile * count($values); + $tolerance = PHP_FLOAT_EPSILON * max(1, abs($rank)); + $index = max(0, (int) ceil($rank - $tolerance) - 1); + + return $values[$index]; + } +} diff --git a/src/Data/Collections/Math/Deviations.php b/src/Data/Collections/Math/Deviations.php new file mode 100644 index 0000000..a31ae1a --- /dev/null +++ b/src/Data/Collections/Math/Deviations.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Math; + +use Divergence\Data\Collections\Collection; +use InvalidArgumentException; + +class Deviations extends AbstractOperation +{ + public static function variance(Collection $collection, callable $selector): ?float + { + return static::varianceFromValues(static::numericValues($collection, $selector)); + } + + public static function stddev(Collection $collection, callable $selector): ?float + { + $variance = static::variance($collection, $selector); + + return $variance === null ? null : sqrt($variance); + } + + /** + * @return list + */ + public static function zScore(Collection $collection, callable $selector): array + { + return static::zScoresFromValues(static::numericValues($collection, $selector)); + } + + /** + * @return list + */ + public static function outliers(Collection $collection, callable $selector, float $threshold = 3): array + { + if (!is_finite($threshold) || $threshold < 0) { + throw new InvalidArgumentException('Outlier threshold cannot be negative.'); + } + + $records = array_values($collection->toArray()); + $values = array_map( + static fn ($record): int|float => static::numericValue($selector($record)), + $records + ); + $scores = static::zScoresFromValues($values); + $outliers = []; + + foreach ($scores as $index => $score) { + if (abs($score) >= $threshold) { + $outliers[] = $records[$index]; + } + } + + return $outliers; + } + + /** + * @param list $values + * @return list + */ + protected static function zScoresFromValues(array $values): array + { + $variance = static::varianceFromValues($values); + + if ($variance === null) { + return []; + } + + if ($variance === 0.0) { + return array_fill(0, count($values), 0.0); + } + + $values = static::centeredValues($values); + $average = array_sum($values) / count($values); + $standardDeviation = sqrt($variance); + + return array_map( + static fn (int|float $value): float => ($value - $average) / $standardDeviation, + $values + ); + } +} diff --git a/src/Data/Collections/Math/Distributions.php b/src/Data/Collections/Math/Distributions.php new file mode 100644 index 0000000..26c31fc --- /dev/null +++ b/src/Data/Collections/Math/Distributions.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\Data\Collections\Math; + +use Divergence\Data\Collections\Collection; +use InvalidArgumentException; + +class Distributions extends AbstractOperation +{ + /** + * @return list + */ + public static function histogram(Collection $collection, callable $selector, int $bucketCount = 10): array + { + if ($bucketCount < 1) { + throw new InvalidArgumentException('Histogram bucket count must be greater than zero.'); + } + + $values = static::numericValues($collection, $selector); + + if (!$values) { + return []; + } + + $minimumValue = min($values); + $maximumValue = max($values); + $minimum = (float) $minimumValue; + $maximum = (float) $maximumValue; + + if ($minimumValue == $maximumValue) { + return [['min' => $minimum, 'max' => $maximum, 'count' => count($values)]]; + } + + if ($minimum === $maximum) { + throw new InvalidArgumentException('Histogram values exceed floating-point resolution.'); + } + + $range = $maximum - $minimum; + $width = $range / $bucketCount; + + if (!is_finite($width) || $width <= 0) { + throw new InvalidArgumentException('Histogram range must be finite.'); + } + + $histogram = []; + + for ($index = 0; $index < $bucketCount; $index++) { + $bucket = [ + 'min' => $minimum + ($range * ($index / $bucketCount)), + 'max' => $index === $bucketCount - 1 + ? $maximum + : $minimum + ($range * (($index + 1) / $bucketCount)), + 'count' => 0, + ]; + + if ($bucket['min'] >= $bucket['max']) { + throw new InvalidArgumentException('Histogram buckets exceed floating-point resolution.'); + } + + $histogram[] = $bucket; + } + + foreach ($values as $value) { + $index = min( + $bucketCount - 1, + (int) floor((($value - $minimum) / $range) * $bucketCount) + ); + $histogram[$index]['count']++; + } + + return $histogram; + } + + /** + * @return list + */ + public static function mode(Collection $collection, callable $selector): array + { + $frequencies = static::frequency($collection, $selector); + + if (!$frequencies) { + return []; + } + + $maximum = max(array_column($frequencies, 'count')); + $modes = []; + + foreach ($frequencies as $frequency) { + if ($frequency['count'] === $maximum) { + $modes[] = $frequency['value']; + } + } + + return $modes; + } + + /** + * @return list + */ + public static function frequency(Collection $collection, callable $selector): array + { + $frequencies = []; + $frequencyIndexes = []; + + foreach ($collection->toArray() as $record) { + $value = $selector($record); + $lookupKey = serialize($value); + + if (isset($frequencyIndexes[$lookupKey])) { + static::incrementFrequency($frequencies, $frequencyIndexes[$lookupKey]); + continue; + } + + $frequencyIndexes[$lookupKey] = count($frequencies); + $frequencies[] = ['value' => $value, 'count' => 1]; + } + + return $frequencies; + } + + /** + * @return list + */ + public static function countBy(Collection $collection, callable $selector): array + { + return static::frequency($collection, $selector); + } + + /** + * @param list $frequencies + */ + protected static function incrementFrequency(array &$frequencies, int $index): void + { + $frequencies[$index]['count']++; + } +} diff --git a/src/Data/Collections/Math/Rankings.php b/src/Data/Collections/Math/Rankings.php new file mode 100644 index 0000000..edebcef --- /dev/null +++ b/src/Data/Collections/Math/Rankings.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Math; + +use Divergence\Data\Collections\Collection; +use InvalidArgumentException; + +class Rankings extends AbstractOperation +{ + /** + * @return list + */ + public static function topK(Collection $collection, callable $selector, int $count): array + { + return static::rankedRecords($collection, $selector, $count, true); + } + + /** + * @return list + */ + public static function bottomK(Collection $collection, callable $selector, int $count): array + { + return static::rankedRecords($collection, $selector, $count, false); + } + + /** + * @return list + */ + protected static function rankedRecords( + Collection $collection, + callable $selector, + int $count, + bool $descending + ): array { + if ($count < 0) { + throw new InvalidArgumentException('Ranked result count cannot be negative.'); + } + + if ($count === 0) { + return []; + } + + $ranked = []; + + $position = 0; + + foreach ($collection->toArray() as $record) { + $ranked[] = [ + 'record' => $record, + 'value' => static::numericValue($selector($record)), + 'position' => $position, + ]; + $position++; + } + + usort($ranked, static function (array $first, array $second) use ($descending): int { + $comparison = $first['value'] <=> $second['value']; + + if ($comparison === 0) { + return $first['position'] <=> $second['position']; + } + + return $descending ? -$comparison : $comparison; + }); + + return array_column(array_slice($ranked, 0, $count), 'record'); + } +} diff --git a/src/Data/Collections/Math/Relationships.php b/src/Data/Collections/Math/Relationships.php new file mode 100644 index 0000000..5744643 --- /dev/null +++ b/src/Data/Collections/Math/Relationships.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Math; + +use Divergence\Data\Collections\Collection; +use OverflowException; + +class Relationships extends AbstractOperation +{ + public static function covariance( + Collection $collection, + callable $firstSelector, + callable $secondSelector + ): ?float { + [$firstValues, $secondValues] = static::pairedValues($collection, $firstSelector, $secondSelector); + + return static::covarianceFromValues($firstValues, $secondValues); + } + + public static function correlation( + Collection $collection, + callable $firstSelector, + callable $secondSelector + ): ?float { + [$firstValues, $secondValues] = static::pairedValues($collection, $firstSelector, $secondSelector); + $covariance = static::covarianceFromValues($firstValues, $secondValues); + $firstVariance = static::varianceFromValues($firstValues); + $secondVariance = static::varianceFromValues($secondValues); + + if ($covariance === null || !$firstVariance || !$secondVariance) { + return null; + } + + $correlation = $covariance / (sqrt($firstVariance) * sqrt($secondVariance)); + + return max(-1.0, min(1.0, $correlation)); + } + + /** + * @return array{list, list} + */ + protected static function pairedValues( + Collection $collection, + callable $firstSelector, + callable $secondSelector + ): array { + $firstValues = []; + $secondValues = []; + + foreach ($collection->toArray() as $record) { + $firstValues[] = static::numericValue($firstSelector($record)); + $secondValues[] = static::numericValue($secondSelector($record)); + } + + return [$firstValues, $secondValues]; + } + + /** + * @param list $firstValues + * @param list $secondValues + */ + protected static function covarianceFromValues(array $firstValues, array $secondValues): ?float + { + if (!$firstValues) { + return null; + } + + $firstValues = static::centeredValues($firstValues); + $secondValues = static::centeredValues($secondValues); + $count = count($firstValues); + $firstAverage = array_sum($firstValues) / $count; + $secondAverage = array_sum($secondValues) / $count; + $covariance = array_sum(array_map( + static fn (int|float $first, int|float $second): int|float => + ($first - $firstAverage) * ($second - $secondAverage), + $firstValues, + $secondValues + )) / $count; + + if (!is_finite($covariance)) { + throw new OverflowException('Covariance exceeded floating-point range.'); + } + + return $covariance; + } +} diff --git a/src/Data/Collections/Math/Windows.php b/src/Data/Collections/Math/Windows.php new file mode 100644 index 0000000..54bd25b --- /dev/null +++ b/src/Data/Collections/Math/Windows.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data\Collections\Math; + +use Divergence\Data\Collections\Collection; +use InvalidArgumentException; +use OverflowException; + +class Windows extends AbstractOperation +{ + /** + * @return list + */ + public static function movingAverage(Collection $collection, callable $selector, int $windowSize): array + { + if ($windowSize < 1) { + throw new InvalidArgumentException('Moving-average window size must be greater than zero.'); + } + + $values = static::numericValues($collection, $selector); + + if ($windowSize > count($values)) { + return []; + } + + $averages = []; + $sum = array_sum(array_slice($values, 0, $windowSize)); + + if (is_float($sum) && !is_finite($sum)) { + throw new OverflowException('Moving average exceeded floating-point range.'); + } + + $averages[] = $sum / $windowSize; + + for ($index = $windowSize; $index < count($values); $index++) { + $sum -= $values[$index - $windowSize]; + $sum += $values[$index]; + + if (is_float($sum) && !is_finite($sum)) { + throw new OverflowException('Moving average exceeded floating-point range.'); + } + + $averages[] = $sum / $windowSize; + } + + return $averages; + } + + /** + * @template TResult + * @param callable(list): TResult $callback + * @return list + */ + public static function rolling(Collection $collection, int $windowSize, callable $callback): array + { + if ($windowSize < 1) { + throw new InvalidArgumentException('Rolling window size must be greater than zero.'); + } + + $records = array_values($collection->toArray()); + $results = []; + + for ($index = 0; $index + $windowSize <= count($records); $index++) { + $results[] = $callback(array_slice($records, $index, $windowSize)); + } + + return $results; + } +} diff --git a/src/Data/Collections/RecordKey.php b/src/Data/Collections/RecordKey.php new file mode 100644 index 0000000..c89ebec --- /dev/null +++ b/src/Data/Collections/RecordKey.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; + +use Divergence\Data\KeyToHashInt; +use Divergence\Models\ActiveRecord; + +class RecordKey +{ + public static function get($record) + { + // for our models we can rely on the primary key once it's been set + if ($record instanceof ActiveRecord) { + $primaryKey = $record->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..5b5f30a --- /dev/null +++ b/src/Data/KeyToHashInt.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Data; + +/** + * Best effort Primary Key bit packed into a PHP INT + */ +class KeyToHashInt +{ + public static ?self $singleton = null; + + public array $keys; + public ?int $hash = null; + + public function __construct(array $keys) + { + $this->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/Connections.php b/src/IO/Database/Connections.php index 87df1fd..a0c950a 100644 --- a/src/IO/Database/Connections.php +++ b/src/IO/Database/Connections.php @@ -10,7 +10,7 @@ namespace Divergence\IO\Database; -Use \Divergence\App; +use \Divergence\App; use Exception; use PDO; @@ -64,7 +64,7 @@ class Connections /** * Current resolved storage class for the active connection label. * - * @var class-string|null + * @var class-string|null */ protected static $currentConnectionType = null; @@ -106,7 +106,7 @@ class Connections /** * Sets the connection that should be returned by getConnection when $label is null * - * @param string $label + * @param string|null $label * @return void */ public static function setConnection(?string $label = null) @@ -143,6 +143,10 @@ public static function getConnection($label = null) $label = static::$currentConnection; } + if ($label === null) { + throw new Exception('No database connection label could be resolved.'); + } + if (!isset(static::$Connections[$label])) { $config = static::config(); @@ -164,7 +168,7 @@ public static function getConnection($label = null) /** * Gets the concrete storage class for the current connection config. * - * @return class-string + * @return class-string */ public static function getConnectionType(): string { @@ -201,7 +205,7 @@ public static function getQueryClass(string $queryClass): string * Gets the concrete storage class for a specific connection label. * * @param string|null $label - * @return class-string + * @return class-string */ protected static function getConnectionTypeForLabel(?string $label): string { @@ -262,7 +266,7 @@ protected static function createConnection(array $config, string $label): PDO /** * Create a PDO connection for the resolved backend without relying on caller-side late static binding. * - * @param class-string $driverClass + * @param class-string $driverClass * @param array $config * @param string $label * @return PDO @@ -289,7 +293,7 @@ protected static function configureConnection(PDO $connection): void /** * Apply backend-specific post-connect configuration for a resolved backend. * - * @param class-string $driverClass + * @param class-string $driverClass * @param PDO $connection * @return void */ diff --git a/src/IO/Database/MySQL.php b/src/IO/Database/MySQL.php index 739b3f7..105379f 100644 --- a/src/IO/Database/MySQL.php +++ b/src/IO/Database/MySQL.php @@ -12,6 +12,7 @@ use PDO; use Divergence\IO\Database\Writer\MySQL as StorageWriter; + /** * MySQL. * @@ -39,7 +40,7 @@ public static function foundRows() } /** - * @param array $config + * @param array{database:string, username:string, password:string, socket?:string, host?:string, port?:int} $config * @param string $label * @return PDO */ diff --git a/src/IO/Database/PostgreSQL.php b/src/IO/Database/PostgreSQL.php index b8bc5d9..d36376c 100644 --- a/src/IO/Database/PostgreSQL.php +++ b/src/IO/Database/PostgreSQL.php @@ -62,6 +62,9 @@ public static function insertID() return static::oneValue('SELECT LASTVAL()'); } + /** + * @param array{database:string, username:string, password:string, host?:string, port?:int, sslmode?:string} $config + */ protected static function createConnection(array $config, string $label): PDO { $config = array_merge([ diff --git a/src/IO/Database/Query/AbstractQuery.php b/src/IO/Database/Query/AbstractQuery.php index fd6a266..b3ff39e 100644 --- a/src/IO/Database/Query/AbstractQuery.php +++ b/src/IO/Database/Query/AbstractQuery.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query; use Divergence\IO\Database\Connections; diff --git a/src/IO/Database/Query/Delete.php b/src/IO/Database/Query/Delete.php index 5d1d11c..5f53d4d 100644 --- a/src/IO/Database/Query/Delete.php +++ b/src/IO/Database/Query/Delete.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query; class Delete extends AbstractQuery diff --git a/src/IO/Database/Query/Insert.php b/src/IO/Database/Query/Insert.php index f62f1a6..551bb82 100644 --- a/src/IO/Database/Query/Insert.php +++ b/src/IO/Database/Query/Insert.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query; class Insert extends AbstractQuery diff --git a/src/IO/Database/Query/MySQL/Insert.php b/src/IO/Database/Query/MySQL/Insert.php index f36aca6..aa96857 100644 --- a/src/IO/Database/Query/MySQL/Insert.php +++ b/src/IO/Database/Query/MySQL/Insert.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query\MySQL; use Divergence\IO\Database\Query\Insert as BaseInsert; diff --git a/src/IO/Database/Query/MySQL/Select.php b/src/IO/Database/Query/MySQL/Select.php index 49f39ce..61823a6 100644 --- a/src/IO/Database/Query/MySQL/Select.php +++ b/src/IO/Database/Query/MySQL/Select.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query\MySQL; use Divergence\IO\Database\Query\Select as BaseSelect; diff --git a/src/IO/Database/Query/MySQL/Update.php b/src/IO/Database/Query/MySQL/Update.php index 96ff19a..6015f8f 100644 --- a/src/IO/Database/Query/MySQL/Update.php +++ b/src/IO/Database/Query/MySQL/Update.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query\MySQL; use Divergence\IO\Database\Query\Update as BaseUpdate; diff --git a/src/IO/Database/Query/PostgreSQL/Insert.php b/src/IO/Database/Query/PostgreSQL/Insert.php index 51735f5..e6084a4 100644 --- a/src/IO/Database/Query/PostgreSQL/Insert.php +++ b/src/IO/Database/Query/PostgreSQL/Insert.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query\PostgreSQL; use Divergence\IO\Database\Query\Insert as BaseInsert; diff --git a/src/IO/Database/Query/PostgreSQL/Select.php b/src/IO/Database/Query/PostgreSQL/Select.php index eadb0f2..db69eff 100644 --- a/src/IO/Database/Query/PostgreSQL/Select.php +++ b/src/IO/Database/Query/PostgreSQL/Select.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query\PostgreSQL; use Divergence\IO\Database\Query\Select as BaseSelect; diff --git a/src/IO/Database/Query/SQLite/Insert.php b/src/IO/Database/Query/SQLite/Insert.php index 0335e13..c85f29e 100644 --- a/src/IO/Database/Query/SQLite/Insert.php +++ b/src/IO/Database/Query/SQLite/Insert.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query\SQLite; use Divergence\IO\Database\Query\Insert as BaseInsert; diff --git a/src/IO/Database/Query/SQLite/Select.php b/src/IO/Database/Query/SQLite/Select.php index 02ed1d1..a4f9038 100644 --- a/src/IO/Database/Query/SQLite/Select.php +++ b/src/IO/Database/Query/SQLite/Select.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query\SQLite; use Divergence\IO\Database\Query\Select as BaseSelect; diff --git a/src/IO/Database/Query/SQLite/Update.php b/src/IO/Database/Query/SQLite/Update.php index 4cec0f4..0543ef3 100644 --- a/src/IO/Database/Query/SQLite/Update.php +++ b/src/IO/Database/Query/SQLite/Update.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query\SQLite; use Divergence\IO\Database\Query\Update as BaseUpdate; diff --git a/src/IO/Database/Query/Select.php b/src/IO/Database/Query/Select.php index 4ab8e50..e1f5bb3 100644 --- a/src/IO/Database/Query/Select.php +++ b/src/IO/Database/Query/Select.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query; class Select extends AbstractQuery diff --git a/src/IO/Database/Query/Update.php b/src/IO/Database/Query/Update.php index 7a8a9c9..461a091 100644 --- a/src/IO/Database/Query/Update.php +++ b/src/IO/Database/Query/Update.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Query; class Update extends AbstractQuery diff --git a/src/IO/Database/SQLite.php b/src/IO/Database/SQLite.php index 6efd2b0..08e7ffb 100644 --- a/src/IO/Database/SQLite.php +++ b/src/IO/Database/SQLite.php @@ -123,7 +123,7 @@ protected static function preprocessQuery($query, $parameters = []) $query = preg_replace("/\bformat\s*\((.+?),\s*2\s*\)/i", "printf('%.2f', \\1)", $query); - return $query; + return $query; } public static function interceptNonQuery(string $query): ?bool diff --git a/src/IO/Database/StorageType.php b/src/IO/Database/StorageType.php index 9721935..db99021 100644 --- a/src/IO/Database/StorageType.php +++ b/src/IO/Database/StorageType.php @@ -78,8 +78,8 @@ public static function insertID() /** * Formats a query with vsprintf if you pass an array and sprintf if you pass a string. * - * @param string $query A database query. - * @param array|string $parameters Parameter(s) for vsprintf (array) or sprintf (string) + * @param string|\Stringable $query A database query. + * @param array|string|null $parameters Parameter(s) for vsprintf (array) or sprintf (string) * @return string A formatted query. */ public static function prepareQuery($query, $parameters = []) @@ -96,13 +96,14 @@ public static function prepareQuery($query, $parameters = []) /** * Run a query that returns no data (like update or insert) * - * @param string $query A database query - * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. - * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleException + * @param string|\Stringable $query A database query + * @param array|string|null $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. + * @param callable|null $errorHandler A callback that will run in the event of an error instead of static::handleException * @return void */ public static function nonQuery($query, $parameters = [], $errorHandler = null) { + $queryLog = false; try { $resolvedStorageClass = static::getConnectionType(); $query = $resolvedStorageClass::preprocessQuery($query, $parameters); @@ -118,7 +119,7 @@ public static function nonQuery($query, $parameters = [], $errorHandler = null) $queryLog = static::startQueryLog($query); static::$LastAffectedRows = static::getConnection()->exec($query); static::$LastStatement = null; - } catch (\Exception $e) { + } catch (\PDOException $e) { $ErrorInfo = $e->errorInfo; if ($ErrorInfo[0] != '00000') { static::handleException($e, $query, $queryLog, $errorHandler); @@ -133,14 +134,15 @@ public static function nonQuery($query, $parameters = [], $errorHandler = null) /** * Run a query and return a PDO statement * - * @param string $query A database query - * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. - * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleException + * @param string|\Stringable $query A database query + * @param array|string|null $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. + * @param callable|null $errorHandler A callback that will run in the event of an error instead of static::handleException * @throws Exception * @return \PDOStatement */ public static function query($query, $parameters = [], $errorHandler = null) { + $queryLog = false; try { $resolvedStorageClass = static::getConnectionType(); $query = $resolvedStorageClass::preprocessQuery($query, $parameters); @@ -150,7 +152,7 @@ public static function query($query, $parameters = [], $errorHandler = null) static::finishQueryLog($queryLog); return $Statement; - } catch (\Exception $e) { + } catch (\PDOException $e) { $ErrorInfo = $e->errorInfo; if ($ErrorInfo[0] != '00000') { $handledException = static::handleException($e, $query, $queryLog, $errorHandler); @@ -172,10 +174,10 @@ public static function query($query, $parameters = [], $errorHandler = null) * Runs a query and returns all results as an associative array with $tableKey as the index. * * @param string $tableKey A column to use as an index for the returned array. - * @param string $query A database query - * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. - * @param string $nullKey Optional fallback column to use as an index if the $tableKey param isn't found in a returned record. - * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleException + * @param string|\Stringable $query A database query + * @param array|string|null $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. + * @param string|null $nullKey Optional fallback column to use as an index if the $tableKey param isn't found in a returned record. + * @param callable|null $errorHandler A callback that will run in the event of an error instead of static::handleException * @return array Result from query or an empty array if nothing found. */ public static function table($tableKey, $query, $parameters = [], $nullKey = '', $errorHandler = null) @@ -193,9 +195,9 @@ public static function table($tableKey, $query, $parameters = [], $nullKey = '', /** * Runs a query and returns all results as an associative array. * - * @param string $query A database query - * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. - * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleException + * @param string|\Stringable $query A database query + * @param array|string|null $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. + * @param callable|null $errorHandler A callback that will run in the event of an error instead of static::handleException * @return array Result from query or an empty array if nothing found. */ public static function allRecords($query, $parameters = [], $errorHandler = null) @@ -214,9 +216,9 @@ public static function allRecords($query, $parameters = [], $errorHandler = null * Gets one column from every record. * * @param string $valueKey The name of the column you want. - * @param string $query A database query - * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. - * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleException + * @param string|\Stringable $query A database query + * @param array|string|null $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. + * @param callable|null $errorHandler A callback that will run in the event of an error instead of static::handleException * @return array */ public static function allValues($valueKey, $query, $parameters = [], $errorHandler = null) @@ -246,10 +248,10 @@ public static function clearCachedRecord($cacheKey) * Returns the first database record from a query with caching * * @param string $cacheKey A key for the cache to use for this query. - * @param string $query A database query - * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. - * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleException - * @return array Result from query or an empty array if nothing found. + * @param string|\Stringable $query A database query + * @param array|string|null $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. + * @param callable|null $errorHandler A callback that will run in the event of an error instead of static::handleException + * @return array|false Result from query or false if nothing found. */ public static function oneRecordCached($cacheKey, $query, $parameters = [], $errorHandler = null) { @@ -268,10 +270,10 @@ public static function oneRecordCached($cacheKey, $query, $parameters = [], $err /** * Returns the first database record from a query. * - * @param string $query A database query - * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. - * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleException - * @return array Result from query or an empty array if nothing found. + * @param string|\Stringable $query A database query + * @param array|string|null $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. + * @param callable|null $errorHandler A callback that will run in the event of an error instead of static::handleException + * @return array|false Result from query or false if nothing found. */ public static function oneRecord($query, $parameters = [], $errorHandler = null) { @@ -282,9 +284,9 @@ public static function oneRecord($query, $parameters = [], $errorHandler = null) /** * Returns the first value of the first database record from a query. * - * @param string $query A database query - * @param array|string $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. - * @param callable $errorHandler A callback that will run in the event of an error instead of static::handleException + * @param string|\Stringable $query A database query + * @param array|string|null $parameters Optional parameters for vsprintf (array) or sprintf (string) to use for formatting the query. + * @param callable|null $errorHandler A callback that will run in the event of an error instead of static::handleException * @return string|false First field from the first record from a query or false if nothing found. */ public static function oneValue($query, $parameters = [], $errorHandler = null) @@ -325,10 +327,9 @@ public static function handleException(Exception $e, $query = '', $queryLog = fa $message = $error[2]; if (App::$App->Config['environment'] == 'dev') { - /** @var \Whoops\Handler\PrettyPageHandler */ $Handler = \Divergence\App::$App->whoops->popHandler(); - if ($Handler::class === \Whoops\Handler\PrettyPageHandler::class) { + if ($Handler instanceof \Whoops\Handler\PrettyPageHandler) { $Handler->addDataTable('Query Information', [ 'Query' => $query, 'Error' => $message, @@ -338,7 +339,7 @@ public static function handleException(Exception $e, $query = '', $queryLog = fa } } - throw new \RuntimeException(sprintf("Database error: [%s]", static::getConnection()->errorCode()).$message); + throw new \RuntimeException(sprintf("Database error: [%s]", static::getConnection()->errorCode() ?? '').$message); } /** @@ -408,7 +409,7 @@ protected static function startQueryLog($query) return [ 'query' => $query, - 'time_start' => sprintf('%f', microtime(true)), + 'time_start' => microtime(true), ]; } @@ -425,7 +426,7 @@ protected static function finishQueryLog(&$queryLog, $result = false) return false; } - $queryLog['time_finish'] = sprintf('%f', microtime(true)); + $queryLog['time_finish'] = microtime(true); $queryLog['time_duration_ms'] = ($queryLog['time_finish'] - $queryLog['time_start']) * 1000; if ($result) { diff --git a/src/IO/Database/Writer/AbstractSqlWriter.php b/src/IO/Database/Writer/AbstractSqlWriter.php index 6124515..c9d84e9 100644 --- a/src/IO/Database/Writer/AbstractSqlWriter.php +++ b/src/IO/Database/Writer/AbstractSqlWriter.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\IO\Database\Writer; +/** + * @method static string escape(string $value) + * @method static string getContextIndex(string $recordClass) + */ abstract class AbstractSqlWriter { /** diff --git a/src/IO/Database/Writer/MySQL.php b/src/IO/Database/Writer/MySQL.php index 85b6223..d6cd818 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) ); @@ -142,6 +142,11 @@ protected static function getMySqlBaseStatements(string $recordClass, bool $hist return array_merge($queryString, static::compileFields($recordClass, $historyVariant)); } + /** + * @param array $queryString + * @param array $fulltextColumns + * @param array, fulltext?:bool, unique?:bool}> $indexes + */ protected static function appendMySqlIndexes(array &$queryString, array &$fulltextColumns, array $indexes): void { foreach ($indexes as $indexName => $index) { diff --git a/src/IO/Database/Writer/PostgreSQL.php b/src/IO/Database/Writer/PostgreSQL.php index d33d516..c47494c 100644 --- a/src/IO/Database/Writer/PostgreSQL.php +++ b/src/IO/Database/Writer/PostgreSQL.php @@ -90,6 +90,10 @@ protected static function getPostgreSqlBaseStatements(string $recordClass, bool return array_merge($queryString, static::compileFields($recordClass, $historyVariant)); } + /** + * @param array $postCreateStatements + * @param array, fulltext?:bool, unique?:bool}> $indexes + */ protected static function appendPostgreSqlIndexes(array &$postCreateStatements, string $recordClass, array $indexes): void { foreach ($indexes as $indexName => $index) { diff --git a/src/IO/Database/Writer/SQLite.php b/src/IO/Database/Writer/SQLite.php index 2392f58..6f8d5ed 100644 --- a/src/IO/Database/Writer/SQLite.php +++ b/src/IO/Database/Writer/SQLite.php @@ -107,6 +107,10 @@ protected static function getSqliteBaseStatements(string $recordClass, bool $his return array_merge($queryString, static::compileFields($recordClass, $historyVariant)); } + /** + * @param array $postCreateStatements + * @param array, fulltext?:bool, unique?:bool}> $indexes + */ protected static function appendSqliteIndexes(array &$postCreateStatements, string $recordClass, array $indexes): void { foreach ($indexes as $indexName => $index) { diff --git a/src/Models/ActiveRecord.php b/src/Models/ActiveRecord.php index b6ca021..458280a 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 @@ -62,7 +63,7 @@ * @method static void _defineRelationships() * @method static void _initRelationships() * @method static bool _relationshipExists(string $value) - * @method static array|ActiveRecord|null _getRelationshipValue(string $value) + * @method mixed _getRelationshipValue(string $value) * @method void beforeVersionedSave() * @method void afterVersionedSave() * @method static string getHistoryTable() @@ -186,7 +187,7 @@ class ActiveRecord implements JsonSerializable /** * Internal registry of relationships that are part of this class. The setting of this variable of every parent derived from a child model will get merged. * - * @var array $_classFields + * @var array>> $_classRelationships */ protected static $_classRelationships = []; @@ -239,7 +240,7 @@ class ActiveRecord implements JsonSerializable * @var array $_record Raw array data for this model. */ protected array $_record = [] { - set (array $value) { + set(array $value) { $this->_record = $value; if (empty($this->_suppressRecordSynchronization)) { $this->synchronizeAuthoritativePropertiesFromRecord(); @@ -358,7 +359,6 @@ class ActiveRecord implements JsonSerializable * * @uses static::init * - * @return static Instance of the value of $this->Class */ public function __construct($record = [], $isDirty = false, $isPhantom = null) { @@ -402,11 +402,11 @@ public function __get($name) * @param string $name Name of the magic field to set. * @param mixed $value Value to set. * - * @return mixed The return of $this->setValue($name,$value) + * @return void */ public function __set($name, $value) { - return $this->setValue($name, $value); + $this->setValue($name, $value); } /** @@ -422,6 +422,14 @@ public function __isset($name) return isset($value); } + /** + * @return Factory + */ + public static function Factory(?string $modelClass = null): Factory + { + return Factory::get($modelClass ?: static::class); + } + /** * Gets the primary key field for his model. * @@ -539,7 +547,7 @@ public function getValue($name) } // handle relationship elseif (!empty(static::$_classRelationships[$className]) && static::_relationshipExists($name)) { - $value = $this->_getRelationshipValue($name); + $value = $this->_getRelationshipValue($name); } // default Handle to ID if not caught by fieldExists elseif ($name == static::$handleField) { @@ -729,12 +737,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(); @@ -756,8 +760,8 @@ public function isA($class): bool /** * Used to instantiate a new model of a different class with this model's field's. Useful when you have similar classes or subclasses with the same parent. * - * @param string $className If you leave this blank the return will be $this - * @param array $fieldValues Optional. Any field values you want to override. + * @param string|false $className If you leave this blank the return will be $this + * @param array|false $fieldValues Optional. Any field values you want to override. * @return static A new model of a different class with this model's field's. Useful when you have similar classes or subclasses with the same parent. */ public function changeClass($className = false, $fieldValues = false) @@ -1095,7 +1099,7 @@ public function addValidationError($field, $errorMessage) * Get a validation error for a given field. * * @param string $field Name of the field. - * @return string|null A validation error for the field. Null is no validation error found. + * @return array|string|null A validation error for the field. Null is no validation error found. */ public function getValidationError($field) { @@ -1184,10 +1188,10 @@ public function validate($deep = true) * If the error code from MySQL 42S02 (table not found) is thrown this method will attempt to create the table before running the original query and returning. * Other errors will be routed through to DB::handleException * - * @param Exception $exception - * @param string $query - * @param array $queryLog - * @param array|string $parameters + * @param Exception $e + * @param string|null $query + * @param array|null $queryLog + * @param array|string|null $parameters * @return mixed Retried query result or the return from DB::handleException */ public static function handleException(\Exception $e, $query = null, $queryLog = null, $parameters = null) @@ -1293,7 +1297,7 @@ public static function _definedAttributeFields(): array // skip these because they are built in if (in_array($property->getName(), [ '_classFields','_classRelationships','_classBeforeSave','_classAfterSave','_fieldsDefined','_relationshipsDefined','_eventsDefined','_record','_validator','_validatorRecord' - ,'_validationErrors','_isDirty','_isValid','_convertedValues','_originalValues','_isPhantom','_wasPhantom','_isNew','_isUpdated','_relatedObjects','_preparedPersistedSet','_suppressRecordSynchronization' + ,'_validationErrors','_isDirty','_isValid','_convertedValues','_originalValues','_isPhantom','_wasPhantom','_isNew','_isUpdated','_relatedObjects','_preparedPersistedSet','_suppressRecordSynchronization', ])) { continue; } @@ -1328,7 +1332,7 @@ public static function _definedAttributeFields(): array } return [ 'fields' => $fields, - 'relations' => $relations + 'relations' => $relations, ]; } @@ -1511,14 +1515,14 @@ protected function _getFieldValue($field, $useDefault = true) // apply type-dependent transformations switch ($fieldOptions['type']) { case 'timestamp': - return $this->applyNewValue($fieldOptions['type'], $field, $defaultGetMapper::getTimestampValue($value)); + return $this->applyNewValue($fieldOptions['type'], $field, $defaultGetMapper::getTimestampValue($value)); case 'serialized': - return $this->applyNewValue($fieldOptions['type'], $field, $defaultGetMapper::getSerializedValue($value)); + return $this->applyNewValue($fieldOptions['type'], $field, $defaultGetMapper::getSerializedValue($value)); case 'set': case 'list': - return $this->applyNewValue($fieldOptions['type'], $field, $defaultGetMapper::getListValue($value, $fieldOptions['delimiter'] ?? null)); + return $this->applyNewValue($fieldOptions['type'], $field, $defaultGetMapper::getListValue($value, $fieldOptions['delimiter'] ?? null)); case 'int': case 'integer': @@ -1648,7 +1652,11 @@ protected function _setFieldValue($field, $value) } } - if ($forceDirty || (empty($this->_record[$field]) && isset($value)) || ($this->_record[$field] !== $value)) { + $columnName = static::_cn($field); + $recordHasValue = array_key_exists($columnName, $this->_record); + $currentValue = $recordHasValue ? $this->_record[$columnName] : null; + + if ($forceDirty || (!$recordHasValue && isset($value)) || ($recordHasValue && $currentValue !== $value)) { $this->_setValueAndMarkDirty($field, $value, $fieldOptions); return true; } else { @@ -1893,6 +1901,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 @@ -1936,7 +1953,7 @@ protected static function _mapFieldOrder($order) } /** - * @param array $conditions + * @param array $conditions * @return array */ protected static function _mapConditions($conditions) @@ -1949,7 +1966,7 @@ protected static function _mapConditions($conditions) $fieldOptions = static::$_classFields[get_called_class()][$field]; } - if ($condition === null || ($condition == '' && $fieldOptions['blankisnull'])) { + if ($condition === null || ($condition == '' && ($fieldOptions['blankisnull'] ?? false))) { $condition = sprintf('`%s` IS NULL', static::_cn($field)); } elseif (is_array($condition)) { $condition = sprintf('`%s` %s %s', static::_cn($field), $condition['operator'], $storageClass::quote($condition['value'])); diff --git a/src/Models/Auth/Session.php b/src/Models/Auth/Session.php index 4562c68..499b4bf 100644 --- a/src/Models/Auth/Session.php +++ b/src/Models/Auth/Session.php @@ -155,10 +155,8 @@ public static function updateSession(Session $Session, $sessionData) $Session->setFields($sessionData); if (function_exists('fastcgi_finish_request')) { // @codeCoverageIgnoreStart - register_shutdown_function(function ($Session) { - $Session->save(); - }, $Session); - // @codeCoverageIgnoreEnd + register_shutdown_function([$Session, 'save']); + // @codeCoverageIgnoreEnd } else { $Session->save(); } diff --git a/src/Models/Collections/Events/Add.php b/src/Models/Collections/Events/Add.php new file mode 100644 index 0000000..7f13b7c --- /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) && $record instanceof \Divergence\Models\ActiveRecord) { + $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..09fb8f5 --- /dev/null +++ b/src/Models/Collections/Factory/Getters/GetAllByField.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\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) + { + if (!$collection instanceof \Divergence\Models\Collections\RecordCollection) { + throw new \InvalidArgumentException('GetAllByField requires a RecordCollection.'); + } + + $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..5e009e0 --- /dev/null +++ b/src/Models/Collections/Indexing/CreateIndexByField.php @@ -0,0 +1,30 @@ + + * + * 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 + { + if (!$collection instanceof \Divergence\Models\Collections\RecordCollection) { + throw new \InvalidArgumentException('CreateIndexByField requires a RecordCollection.'); + } + + $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..cb5c44a --- /dev/null +++ b/src/Models/Collections/RecordCollection.php @@ -0,0 +1,171 @@ + + * + * 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 + * @extends \Divergence\Data\Collections\Collection + */ +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/AbstractHandler.php b/src/Models/Events/AbstractHandler.php index 9a6cf8e..1f8bab7 100644 --- a/src/Models/Events/AbstractHandler.php +++ b/src/Models/Events/AbstractHandler.php @@ -17,10 +17,13 @@ abstract class AbstractHandler { /** - * @var array + * @var array */ protected static $storageInstances = []; + /** + * @return \Divergence\IO\Database\StorageType + */ protected static function getStorage() { $storageClass = Connections::getConnectionType(); diff --git a/src/Models/Events/AfterSave.php b/src/Models/Events/AfterSave.php index ff7cf9c..9c0d72e 100644 --- a/src/Models/Events/AfterSave.php +++ b/src/Models/Events/AfterSave.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Events; use Divergence\Models\ActiveRecord; diff --git a/src/Models/Events/BeforeSave.php b/src/Models/Events/BeforeSave.php index e5e2061..98fc764 100644 --- a/src/Models/Events/BeforeSave.php +++ b/src/Models/Events/BeforeSave.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Events; use Divergence\Models\ActiveRecord; diff --git a/src/Models/Events/ClearCaches.php b/src/Models/Events/ClearCaches.php index 48e844a..d0f9f44 100644 --- a/src/Models/Events/ClearCaches.php +++ b/src/Models/Events/ClearCaches.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Events; use Divergence\Models\ActiveRecord; diff --git a/src/Models/Events/Delete.php b/src/Models/Events/Delete.php index 8a89480..78981da 100644 --- a/src/Models/Events/Delete.php +++ b/src/Models/Events/Delete.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Events; use Divergence\Models\Factory\ModelMetadata; diff --git a/src/Models/Events/Destroy.php b/src/Models/Events/Destroy.php index 2debbe8..85d0146 100644 --- a/src/Models/Events/Destroy.php +++ b/src/Models/Events/Destroy.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Events; use Divergence\IO\Database\Query\Insert; diff --git a/src/Models/Events/HandleException.php b/src/Models/Events/HandleException.php index 488dabe..98d69ba 100644 --- a/src/Models/Events/HandleException.php +++ b/src/Models/Events/HandleException.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Events; use Divergence\IO\Database\Connections; @@ -15,6 +22,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 +53,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/Events/Save.php b/src/Models/Events/Save.php index a864a6d..64c0a50 100644 --- a/src/Models/Events/Save.php +++ b/src/Models/Events/Save.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Events; use Divergence\IO\Database\Query\Insert; 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..856c1e9 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; @@ -39,16 +39,16 @@ use PDO; /** - * @template TModel of Model + * @template TModel of ActiveRecord * * @method TModel|null getByContextObject(ActiveRecord $Record, $options = []) * @method TModel|null getByContext($contextClass, $contextID, $options = []) * @method TModel|null getByHandle($handle) * @method TModel|null getByID($id) * @method TModel|null getByField($field, $value, $cacheIndex = false) - * @method array|null getRecordByField($field, $value, $cacheIndex = false) + * @method array|false getRecordByField($field, $value, $cacheIndex = false) * @method TModel|null getByWhere($conditions, $options = []) - * @method array|null getRecordByWhere($conditions, $options = []) + * @method array|false getRecordByWhere($conditions, $options = []) * @method TModel|null getByQuery($query, $params = []) * @method array getAllByClass($className = false, $options = []) * @method array getAllByContextObject(ActiveRecord $Record, $options = []) @@ -65,7 +65,12 @@ class Factory { /** - * @var array + * @var array> + */ + protected static $InstanceRegistry = []; + + /** + * @var array */ protected static $storages = []; @@ -75,34 +80,34 @@ class Factory protected static $connections = []; /** - * @var array + * @var array> */ protected static $instantiators = []; /** - * @var array + * @var array> */ protected static $metadata = []; /** - * @var array + * @var array>> */ protected $getterClasses = []; /** - * @var array + * @var array> */ protected $getters = []; /** * Fully-qualified model class name. * - * @var string + * @var class-string */ protected $modelClass; /** - * @var object + * @var \Divergence\IO\Database\StorageType */ protected $storage; @@ -112,17 +117,31 @@ class Factory protected $connection; /** - * @var Instantiator + * @var Instantiator */ protected $instantiator; /** - * @var ModelMetadata + * @var ModelMetadata */ protected $modelMetadata; /** - * @param string $modelClass + * @template TRequestedModel of ActiveRecord + * @param class-string $modelClass + * @return static + */ + public static function get(string $modelClass): static + { + if (!isset(static::$InstanceRegistry[$modelClass])) { + static::$InstanceRegistry[$modelClass] = new static($modelClass); + } + + return static::$InstanceRegistry[$modelClass]; + } + + /** + * @param class-string $modelClass */ public function __construct(string $modelClass) { @@ -181,7 +200,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])) { @@ -236,13 +255,16 @@ protected function setInstantiator(): void } /** - * @return string + * @return class-string */ public function getModelClass(): string { return $this->modelMetadata->getModelClass(); } + /** + * @return \Divergence\IO\Database\StorageType + */ public function getStorage() { return $this->storage; @@ -256,19 +278,30 @@ public function getGetterClasses(): array /** * Converts database record array to a model. 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 Database row as an array. - * @return Model|null An instantiated ActiveRecord model from the provided data. + * @param array|false|null $record Database row as an array. + * @return TModel|null An instantiated ActiveRecord model from the provided data. */ public function instantiateRecord($record) { return $this->instantiator->instantiateRecord($record); } + /** + * Creates a new phantom model from the provided values. + * + * @param array $record + * @return TModel + */ + 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. + * @param array $records An array of database rows. + * @return array|array|\Divergence\Models\Collections\RecordCollection An array or collection of instantiated ActiveRecord models from the provided data. */ public function instantiateRecords($records) { @@ -281,7 +314,7 @@ public function generateRandomHandle($length = 32) $className = $this->modelClass; do { - $handle = substr(md5(mt_rand(0, mt_getrandmax())), 0, $length); + $handle = substr(md5((string)mt_rand(0, mt_getrandmax())), 0, $length); } while ($this->getByField($className::$handleField, $handle)); return $handle; diff --git a/src/Models/Factory/EventBinder.php b/src/Models/Factory/EventBinder.php index 229245d..f11d3c2 100644 --- a/src/Models/Factory/EventBinder.php +++ b/src/Models/Factory/EventBinder.php @@ -42,7 +42,14 @@ protected function synchronizeMappedProperties($model): void } } - public function bindPrototype($model) + /** + * @template TModel of \Divergence\Models\ActiveRecord + * Main instantiator + * + * @param TModel $model + * @return TModel + */ + public function initPrototype($model) { $className = get_class($model); @@ -69,10 +76,19 @@ public function bindPrototype($model) return $model; } - public function bindRecord($model, array $record = [], bool $isDirty = false, ?bool $isPhantom = null) + /** + * @template TModel of \Divergence\Models\ActiveRecord + * 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/GetAll.php b/src/Models/Factory/Getters/GetAll.php index 6bff3cd..5c13bf6 100644 --- a/src/Models/Factory/Getters/GetAll.php +++ b/src/Models/Factory/Getters/GetAll.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetAll extends ModelGetter { public function getAll($options = []) diff --git a/src/Models/Factory/Getters/GetAllByClass.php b/src/Models/Factory/Getters/GetAllByClass.php index 2b46e58..4e03861 100644 --- a/src/Models/Factory/Getters/GetAllByClass.php +++ b/src/Models/Factory/Getters/GetAllByClass.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetAllByClass extends ModelGetter { public function getAllByClass($className = false, $options = []) diff --git a/src/Models/Factory/Getters/GetAllByContext.php b/src/Models/Factory/Getters/GetAllByContext.php index e099062..9c526f8 100644 --- a/src/Models/Factory/Getters/GetAllByContext.php +++ b/src/Models/Factory/Getters/GetAllByContext.php @@ -1,9 +1,20 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; use Exception; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetAllByContext extends ModelGetter { public function getAllByContext($contextClass, $contextID, $options = []) diff --git a/src/Models/Factory/Getters/GetAllByContextObject.php b/src/Models/Factory/Getters/GetAllByContextObject.php index 45286f8..fdb0795 100644 --- a/src/Models/Factory/Getters/GetAllByContextObject.php +++ b/src/Models/Factory/Getters/GetAllByContextObject.php @@ -1,9 +1,20 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; use Divergence\Models\ActiveRecord; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetAllByContextObject extends ModelGetter { public function getAllByContextObject(ActiveRecord $Record, $options = []) diff --git a/src/Models/Factory/Getters/GetAllByField.php b/src/Models/Factory/Getters/GetAllByField.php index 6b88349..dc1a903 100644 --- a/src/Models/Factory/Getters/GetAllByField.php +++ b/src/Models/Factory/Getters/GetAllByField.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetAllByField extends ModelGetter { public function getAllByField($field, $value, $options = []) diff --git a/src/Models/Factory/Getters/GetAllByQuery.php b/src/Models/Factory/Getters/GetAllByQuery.php index 27b498c..9405a6f 100644 --- a/src/Models/Factory/Getters/GetAllByQuery.php +++ b/src/Models/Factory/Getters/GetAllByQuery.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetAllByQuery extends ModelGetter { public function getAllByQuery($query, $params = []) diff --git a/src/Models/Factory/Getters/GetAllByWhere.php b/src/Models/Factory/Getters/GetAllByWhere.php index cf58c11..62cb75f 100644 --- a/src/Models/Factory/Getters/GetAllByWhere.php +++ b/src/Models/Factory/Getters/GetAllByWhere.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetAllByWhere extends ModelGetter { public function getAllByWhere($conditions = [], $options = []) diff --git a/src/Models/Factory/Getters/GetAllRecords.php b/src/Models/Factory/Getters/GetAllRecords.php index aeb1b01..51a2a37 100644 --- a/src/Models/Factory/Getters/GetAllRecords.php +++ b/src/Models/Factory/Getters/GetAllRecords.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetAllRecords extends ModelGetter { public function getAllRecords($options = []) diff --git a/src/Models/Factory/Getters/GetAllRecordsByWhere.php b/src/Models/Factory/Getters/GetAllRecordsByWhere.php index 5f05f3f..cb032c7 100644 --- a/src/Models/Factory/Getters/GetAllRecordsByWhere.php +++ b/src/Models/Factory/Getters/GetAllRecordsByWhere.php @@ -1,10 +1,21 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; use Divergence\IO\Database\Connections; use Divergence\IO\Database\PostgreSQL; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetAllRecordsByWhere extends ModelGetter { public function getAllRecordsByWhere($conditions = [], $options = []) diff --git a/src/Models/Factory/Getters/GetByContext.php b/src/Models/Factory/Getters/GetByContext.php index e873e87..6ade3dd 100644 --- a/src/Models/Factory/Getters/GetByContext.php +++ b/src/Models/Factory/Getters/GetByContext.php @@ -1,9 +1,20 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; use Exception; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetByContext extends ModelGetter { public function getByContext($contextClass, $contextID, $options = []) diff --git a/src/Models/Factory/Getters/GetByContextObject.php b/src/Models/Factory/Getters/GetByContextObject.php index e0c6299..9729942 100644 --- a/src/Models/Factory/Getters/GetByContextObject.php +++ b/src/Models/Factory/Getters/GetByContextObject.php @@ -1,9 +1,20 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; use Divergence\Models\ActiveRecord; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetByContextObject extends ModelGetter { public function getByContextObject(ActiveRecord $Record, $options = []) diff --git a/src/Models/Factory/Getters/GetByField.php b/src/Models/Factory/Getters/GetByField.php index 81e48b7..738a2e5 100644 --- a/src/Models/Factory/Getters/GetByField.php +++ b/src/Models/Factory/Getters/GetByField.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetByField extends ModelGetter { public function getByField($field, $value, $cacheIndex = false) diff --git a/src/Models/Factory/Getters/GetByHandle.php b/src/Models/Factory/Getters/GetByHandle.php index 45ac984..e80fe31 100644 --- a/src/Models/Factory/Getters/GetByHandle.php +++ b/src/Models/Factory/Getters/GetByHandle.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetByHandle extends ModelGetter { public function getByHandle($handle) diff --git a/src/Models/Factory/Getters/GetByID.php b/src/Models/Factory/Getters/GetByID.php index 009a6c6..a6fd27c 100644 --- a/src/Models/Factory/Getters/GetByID.php +++ b/src/Models/Factory/Getters/GetByID.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetByID extends ModelGetter { public function getByID($id) diff --git a/src/Models/Factory/Getters/GetByQuery.php b/src/Models/Factory/Getters/GetByQuery.php index 1abd27b..c5f7121 100644 --- a/src/Models/Factory/Getters/GetByQuery.php +++ b/src/Models/Factory/Getters/GetByQuery.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetByQuery extends ModelGetter { public function getByQuery($query, $params = []) diff --git a/src/Models/Factory/Getters/GetByWhere.php b/src/Models/Factory/Getters/GetByWhere.php index 814c2ad..8134374 100644 --- a/src/Models/Factory/Getters/GetByWhere.php +++ b/src/Models/Factory/Getters/GetByWhere.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetByWhere extends ModelGetter { public function getByWhere($conditions, $options = []) diff --git a/src/Models/Factory/Getters/GetRecordByField.php b/src/Models/Factory/Getters/GetRecordByField.php index a117d46..8b9640d 100644 --- a/src/Models/Factory/Getters/GetRecordByField.php +++ b/src/Models/Factory/Getters/GetRecordByField.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetRecordByField extends ModelGetter { public function getRecordByField($field, $value, $cacheIndex = false) diff --git a/src/Models/Factory/Getters/GetRecordByWhere.php b/src/Models/Factory/Getters/GetRecordByWhere.php index a8f66aa..b29c313 100644 --- a/src/Models/Factory/Getters/GetRecordByWhere.php +++ b/src/Models/Factory/Getters/GetRecordByWhere.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetRecordByWhere extends ModelGetter { public function getRecordByWhere($conditions, $options = []) diff --git a/src/Models/Factory/Getters/GetTableByQuery.php b/src/Models/Factory/Getters/GetTableByQuery.php index 260afcc..9362e31 100644 --- a/src/Models/Factory/Getters/GetTableByQuery.php +++ b/src/Models/Factory/Getters/GetTableByQuery.php @@ -1,11 +1,22 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetTableByQuery extends ModelGetter { public function getTableByQuery($keyField, $query, $params = []) { - return $this->instantiateRecords($this->getStorage()->table($keyField, $query, $params, $this->getHandleExceptionCallback())); + return $this->instantiateRecords($this->getStorage()->table($keyField, $query, $params, null, $this->getHandleExceptionCallback())); } } diff --git a/src/Models/Factory/Getters/GetUniqueHandle.php b/src/Models/Factory/Getters/GetUniqueHandle.php index b8fa013..59467d7 100644 --- a/src/Models/Factory/Getters/GetUniqueHandle.php +++ b/src/Models/Factory/Getters/GetUniqueHandle.php @@ -1,7 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Models\Factory\Getters; +/** + * @template TModel of \Divergence\Models\ActiveRecord + * @extends ModelGetter + */ class GetUniqueHandle extends ModelGetter { public function getUniqueHandle($text, $options = []) diff --git a/src/Models/Factory/Getters/ModelGetter.php b/src/Models/Factory/Getters/ModelGetter.php index 852b983..34e1d1e 100644 --- a/src/Models/Factory/Getters/ModelGetter.php +++ b/src/Models/Factory/Getters/ModelGetter.php @@ -18,7 +18,7 @@ use Divergence\Models\Model; /** - * @template TModel of Model + * @template TModel of \Divergence\Models\ActiveRecord */ abstract class ModelGetter { @@ -41,7 +41,7 @@ protected function getModelClass(): string } /** - * @return object + * @return \Divergence\IO\Database\StorageType */ protected function getStorage() { @@ -49,7 +49,7 @@ protected function getStorage() } /** - * @param array|null $record + * @param array|false|null $record * @return TModel|null */ protected function instantiateRecord($record) @@ -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..663c3b2 100644 --- a/src/Models/Factory/Instantiator.php +++ b/src/Models/Factory/Instantiator.php @@ -6,20 +6,24 @@ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. + * + * @phan-file-suppress PhanTypeMismatchReturn */ namespace Divergence\Models\Factory; use ReflectionClass; use Divergence\Models\Model; +use Divergence\Models\Mapping\InMemoryIndexing; +use Divergence\Models\Collections\RecordCollection; /** - * @template TModel of Model + * @template TModel of \Divergence\Models\ActiveRecord */ class Instantiator { /** - * @var ModelMetadata + * @var ModelMetadata */ protected $metadata; @@ -29,21 +33,33 @@ class Instantiator protected $eventBinder; /** - * @var PrototypeRegistry + * @var RecordCollection|null */ - protected $prototypeRegistry; + protected $Collection; + + /** + * @var InMemoryIndexing|null + */ + protected $indexingConfig; /** * @param string $modelClass */ /** - * @param ModelMetadata $metadata + * @param ModelMetadata $metadata */ 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(); + } } /** @@ -68,47 +84,82 @@ protected function getRecordClass($record) } /** - * @param array|null $record + * @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|false|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) { - foreach ($records as &$record) { - $record = $this->instantiateModel($record); + $Collection = $this->Collection; + + if ($Collection) { + $this->instantiateCollection(); } - return $records; + foreach ($records as $key => $record) { + $records[$key] = $record = $this->instantiateModel($record); + + if ($Collection) { + $Collection->add($record); + } + } + + 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/ModelMetadata.php b/src/Models/Factory/ModelMetadata.php index 2acf4cd..b76d43a 100644 --- a/src/Models/Factory/ModelMetadata.php +++ b/src/Models/Factory/ModelMetadata.php @@ -10,15 +10,18 @@ namespace Divergence\Models\Factory; +/** + * @template TModel of \Divergence\Models\ActiveRecord + */ class ModelMetadata { /** - * @var array + * @var array> */ protected static $instances = []; /** - * @var string + * @var class-string */ protected $modelClass; @@ -97,6 +100,11 @@ class ModelMetadata */ protected $integerPrimaryKey; + /** + * @template TRequestedModel of \Divergence\Models\ActiveRecord + * @param class-string $modelClass + * @return self + */ public static function get(string $modelClass): self { if (!isset(static::$instances[$modelClass])) { @@ -106,6 +114,9 @@ public static function get(string $modelClass): self return static::$instances[$modelClass]; } + /** + * @param class-string $modelClass + */ public function __construct(string $modelClass) { $this->modelClass = $modelClass; @@ -138,6 +149,9 @@ public function __construct(string $modelClass) } } + /** + * @return class-string + */ public function getModelClass(): string { return $this->modelClass; @@ -186,9 +200,9 @@ public function hasClassField(): bool return $this->hasClassField; } - public function getClassColumnName(): ?string + public function getClassColumnName(): string { - return $this->classColumnName; + return $this->classColumnName ?? throw new \LogicException('Class column name requested for a model without a Class field.'); } /** 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..f2a9053 100644 --- a/src/Models/Getters.php +++ b/src/Models/Getters.php @@ -10,9 +10,11 @@ namespace Divergence\Models; -use BadMethodCallException; +use Error; /** + * @require-extends \Divergence\Models\ActiveRecord + * @mixin \Divergence\Models\ActiveRecord * @property string $handleField Defined in the model * @property string $primaryKey Defined in the model * @property string $tableName Defined in the model @@ -24,14 +26,6 @@ trait Getters */ protected static $_registeredGetterMethods = []; - /** - * @return Factory - */ - public static function Factory(?string $modelClass = null): Factory - { - return new Factory($modelClass ?: static::class); - } - protected static function registerGetterMethods(): void { $factory = static::Factory(); @@ -55,6 +49,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/Column.php b/src/Models/Mapping/Column.php index 6a2a187..8e763f4 100644 --- a/src/Models/Mapping/Column.php +++ b/src/Models/Mapping/Column.php @@ -1,5 +1,12 @@ + * + * 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; @@ -70,6 +77,12 @@ final class Column implements MappingAttribute */ public $unsigned; + /** + * @var mixed + * @readonly + */ + public $default; + /** * @var bool * @readonly @@ -133,15 +146,16 @@ public function __construct( bool $unique = false, bool $notnull = true, bool $autoincrement = false, - bool $unsigned = null, + ?bool $unsigned = null, bool $primary = false, bool $insertable = true, bool $updatable = true, - string $delimiter = null, + ?string $delimiter = null, array $values = [], array $options = [], ?string $columnDefinition = null, - ?string $generated = null + ?string $generated = null, + mixed $default = null ) { $this->columnName = $columnName; $this->type = $type; @@ -152,6 +166,7 @@ public function __construct( $this->notnull = $notnull; $this->autoincrement = $autoincrement; $this->unsigned = $unsigned; + $this->default = $default; $this->primary = $primary; $this->insertable = $insertable; $this->updatable = $updatable; diff --git a/src/Models/Mapping/DefaultSetMapper.php b/src/Models/Mapping/DefaultSetMapper.php index f69e4f3..09f4ac9 100644 --- a/src/Models/Mapping/DefaultSetMapper.php +++ b/src/Models/Mapping/DefaultSetMapper.php @@ -89,7 +89,7 @@ public static function setDateValue($value): ?string is_numeric($value['dd']) ? $value['dd'] : 0 ); } else { - if ($value = strtotime($value)) { + if (!is_array($value) && ($value = strtotime($value))) { $value = date('Y-m-d', $value) ?: null; } else { $value = null; 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/Mapping/MappingAttribute.php b/src/Models/Mapping/MappingAttribute.php index e35c43e..c179e4e 100644 --- a/src/Models/Mapping/MappingAttribute.php +++ b/src/Models/Mapping/MappingAttribute.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ declare(strict_types=1); namespace Divergence\Models\Mapping; diff --git a/src/Models/Mapping/Relation.php b/src/Models/Mapping/Relation.php index b7020d2..13b4dca 100644 --- a/src/Models/Mapping/Relation.php +++ b/src/Models/Mapping/Relation.php @@ -1,5 +1,12 @@ + * + * 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; 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..b30d3d1 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: @@ -317,7 +324,7 @@ public function getThumbnail($maxWidth, $maxHeight, $fillColor = false, $cropped * @param string $thumbPath * @param int $maxWidth * @param int $maxHeight - * @param boolean|int $fillColor Background canvas color. See gd documentation. + * @param string|false $fillColor Background canvas color. See gd documentation. * @param boolean $cropped If cropped is enable the image will instead fill the smaller of width or height and cut the edges off. * @return void */ @@ -410,8 +417,8 @@ public function createThumbnailImage($thumbPath, $maxWidth, $maxHeight, $fillCol imagecopyresampled( $image, $srcImage, - round(($thumbWidth - $scaledWidth) / 2), - round(($thumbHeight - $scaledHeight) / 2), + (int)round(($thumbWidth - $scaledWidth) / 2), + (int)round(($thumbHeight - $scaledHeight) / 2), 0, 0, $scaledWidth, @@ -443,6 +450,10 @@ public function createThumbnailImage($thumbPath, $maxWidth, $maxHeight, $fillCol } // static methods + /** + * @param array{error?:int, name?:string, tmp_name:string}|string $uploadedFile + * @param array $fieldValues + */ public static function createFromUpload($uploadedFile, $fieldValues = []): static | false { // handle recieving a field array from $_FILES @@ -466,12 +477,18 @@ public static function createFromUpload($uploadedFile, $fieldValues = []): stati return static::createFromFile($uploadedFile, $fieldValues); } + /** + * @param string $file + * @param array $fieldValues + */ 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 +513,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) @@ -532,8 +547,6 @@ public static function analyzeFile($filename) throw new Exception('Unable to load media file info'); } - finfo_close($finfo); - // dig deeper if only generic mimetype returned if ($mimeType == 'application/octet-stream') { $finfo = finfo_open(FILEINFO_NONE, static::$magicPath); @@ -542,8 +555,6 @@ public static function analyzeFile($filename) throw new Exception('Unable to load media file info'); } - finfo_close($finfo); - // detect EPS if (preg_match('/^DOS EPS/i', $fileInfo)) { $mimeType = 'application/postscript'; @@ -582,7 +593,7 @@ public function getFilesystemPath($variant = 'original', $filename = null): ?str return null; } - return App::$App->ApplicationPath.'/media/'.$variant.'/'.($filename ?: $this->getFilename($variant)); + return App::$App->ApplicationPath.'/media/'.$variant.'/'.($filename ?: $this->getFilename()); } public function getFilename(): string @@ -599,6 +610,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..3f8f745 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['width'] = (int)$mediaInfo['videoStream']['width']; - $mediaInfo['height'] = (int)$mediaInfo['videoStream']['height']; - $mediaInfo['duration'] = (float)$mediaInfo['videoStream']['duration']; + $mediaInfo['duration'] = (float)( + $mediaInfo['videoStream']['duration'] + ?? $json['format']['duration'] + ?? 0 + ); + + $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,75 @@ public function writeFile($sourceFile): bool continue; } - // build paths and create directories if needed $outputPath = $this->getFilesystemPath($profileName); + if ($outputPath === null) { + throw new Exception('Unable to determine encoded video output path.'); + } 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; - - // move to final path after it finished - $cmd[] = "&& mv $tmpOutputPath $outputPath"; + $cmd[] = escapeshellarg($tmpOutputPath); + $cmd[] = '&& mv ' . escapeshellarg($tmpOutputPath) . ' ' . escapeshellarg($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']; @@ -251,15 +257,18 @@ public function getMIMEType($variant = 'original'): string return static::$encodingProfiles[$variant]['mimeType']; } - return parent::getMIMEType($variant); + return parent::getMIMEType(); } - public function isVariantAvailable($variant) + public function isVariantAvailable($variant): bool { + $path = $this->getFilesystemPath($variant); + if ( array_key_exists($variant, static::$encodingProfiles) && !empty(static::$encodingProfiles[$variant]['enabled']) && - is_readable($this->getFilesystemPath($variant)) + $path !== null && + is_readable($path) ) { return true; } @@ -267,15 +276,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/Model.php b/src/Models/Model.php index aa0dab4..de45e2a 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -23,9 +23,9 @@ * @method static static|null getByHandle($handle) * @method static static|null getByID($id) * @method static static|null getByField($field, $value, $cacheIndex = false) - * @method static array|null getRecordByField($field, $value, $cacheIndex = false) + * @method static array|false getRecordByField($field, $value, $cacheIndex = false) * @method static static|null getByWhere($conditions, $options = []) - * @method static array|null getRecordByWhere($conditions, $options = []) + * @method static array|false getRecordByWhere($conditions, $options = []) * @method static static|null getByQuery($query, $params = []) * @method static array getAllByClass($className = false, $options = []) * @method static array getAllByContextObject(ActiveRecord $Record, $options = []) diff --git a/src/Models/Relations.php b/src/Models/Relations.php index 76b865e..6fcaf63 100644 --- a/src/Models/Relations.php +++ b/src/Models/Relations.php @@ -6,6 +6,10 @@ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. + * + * @phan-file-suppress PhanUndeclaredStaticProperty + * @phan-file-suppress PhanUndeclaredStaticMethod + * @phan-file-suppress PhanAccessSignatureMismatch */ namespace Divergence\Models; @@ -18,10 +22,16 @@ * @package Divergence * @author Henry Paradiz * - * @property array $_classRelationships - * @property array $_classFields - * @property string $rootClass - * @property array $contextClasses + * @require-extends \Divergence\Models\ActiveRecord + * @method mixed _getFieldValue($field, $useDefault = true) + * @method mixed getPrimaryKeyValue() + * @phan-type OneOneRelationship = array{type:'one-one', class:class-string<\Divergence\Models\ActiveRecord>, local:string, foreign:string} + * @phan-type OneManyRelationship = array{type:'one-many', class:class-string<\Divergence\Models\ActiveRecord>, local:string, foreign:string, indexField:string|false, conditions:array, order:array|string|false} + * @phan-type ContextChildrenRelationship = array{type:'context-children', class:class-string<\Divergence\Models\ActiveRecord>, local:string, contextClass:class-string<\Divergence\Models\ActiveRecord>, indexField:string|false, conditions:array, order:array|string|false} + * @phan-type ContextParentRelationship = array{type:'context-parent', local:string, foreign:string, classField:string, allowedClasses:array|null} + * @phan-type ManyManyRelationship = array{type:'many-many', class:class-string<\Divergence\Models\ActiveRecord>, linkClass:class-string<\Divergence\Models\ActiveRecord>, linkLocal:string, linkForeign:string, local:string, foreign:string, indexField:string|false, conditions:array, order:array|string|false} + * @phan-type HistoryRelationship = array{type:'history', class:class-string<\Divergence\Models\ActiveRecord>, order:array|string|false} + * @phan-type NormalizedRelationship = OneOneRelationship|OneManyRelationship|ContextChildrenRelationship|ContextParentRelationship|ManyManyRelationship|HistoryRelationship */ trait Relations { @@ -123,6 +133,7 @@ protected static function _prepareContextChildren($options): array $options['contextClass'] = $options['contextClass'] ?? get_called_class(); $options['indexField'] = $options['indexField'] ?? false; $options['conditions'] = $options['conditions'] ?? []; + $options['conditions'] = is_string($options['conditions']) ? [$options['conditions']] : $options['conditions']; $options['order'] = $options['order'] ?? false; return $options; } @@ -161,6 +172,7 @@ protected static function _prepareManyMany($classShortName, $options): array $options['foreign'] = $options['foreign'] ?? 'ID'; $options['indexField'] = $options['indexField'] ?? false; $options['conditions'] = $options['conditions'] ?? []; + $options['conditions'] = is_string($options['conditions']) ? [$options['conditions']] : $options['conditions']; $options['order'] = $options['order'] ?? false; return $options; } @@ -207,6 +219,14 @@ protected static function _initRelationship($relationship, $options) return $options; } + /** + * @return NormalizedRelationship|false + */ + protected static function _getRelationshipDefinition(string $relationship) + { + return static::$_classRelationships[get_called_class()][$relationship]; + } + /** * Retrieves given relationship's value * @param string $relationship Name of relationship @@ -215,9 +235,11 @@ protected static function _initRelationship($relationship, $options) protected function _getRelationshipValue($relationship) { if (!isset($this->_relatedObjects[$relationship])) { - $rel = static::$_classRelationships[get_called_class()][$relationship]; + $rel = static::_getRelationshipDefinition($relationship); - if ($rel['type'] == 'one-one') { + if ($rel === false) { + $this->_relatedObjects[$relationship] = null; + } elseif ($rel['type'] === 'one-one') { if ($value = $this->_getFieldValue($rel['local'])) { $this->_relatedObjects[$relationship] = $rel['class']::getByField($rel['foreign'], $value); @@ -226,7 +248,7 @@ protected function _getRelationshipValue($relationship) } else { $this->_relatedObjects[$relationship] = null; } - } elseif ($rel['type'] == 'one-many') { + } elseif ($rel['type'] === 'one-many') { if (!empty($rel['indexField']) && !$rel['class']::fieldExists($rel['indexField'])) { $rel['indexField'] = false; } @@ -245,7 +267,7 @@ protected function _getRelationshipValue($relationship) // hook relationship for invalidation static::$_classFields[get_called_class()][$rel['local']]['relationships'][$relationship] = true; - } elseif ($rel['type'] == 'context-children') { + } elseif ($rel['type'] === 'context-children') { if (!empty($rel['indexField']) && !$rel['class']::fieldExists($rel['indexField'])) { $rel['indexField'] = false; } @@ -265,14 +287,14 @@ protected function _getRelationshipValue($relationship) // hook relationship for invalidation static::$_classFields[get_called_class()][$rel['local']]['relationships'][$relationship] = true; - } elseif ($rel['type'] == 'context-parent') { + } elseif ($rel['type'] === 'context-parent') { $className = $this->_getFieldValue($rel['classField']); $this->_relatedObjects[$relationship] = $className ? $className::getByID($this->_getFieldValue($rel['local'])) : null; // hook both relationships for invalidation static::$_classFields[get_called_class()][$rel['classField']]['relationships'][$relationship] = true; static::$_classFields[get_called_class()][$rel['local']]['relationships'][$relationship] = true; - } elseif ($rel['type'] == 'many-many') { + } elseif ($rel['type'] === 'many-many') { if (!empty($rel['indexField']) && !$rel['class']::fieldExists($rel['indexField'])) { $rel['indexField'] = false; } @@ -294,7 +316,7 @@ protected function _getRelationshipValue($relationship) // hook relationship for invalidation static::$_classFields[get_called_class()][$rel['local']]['relationships'][$relationship] = true; - } elseif ($rel['type'] == 'history' && static::isVersioned()) { + } elseif ($rel['type'] === 'history' && static::isVersioned()) { $this->_relatedObjects[$relationship] = $rel['class']::getRevisionsByID($this->getPrimaryKeyValue(), $rel); } } diff --git a/src/Models/Versioning.php b/src/Models/Versioning.php index 1feb960..696a05e 100644 --- a/src/Models/Versioning.php +++ b/src/Models/Versioning.php @@ -6,6 +6,8 @@ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. + * + * @phan-file-suppress PhanUndeclaredStaticProperty */ namespace Divergence\Models; @@ -24,10 +26,16 @@ * @package Divergence * @author Henry Paradiz * @inheritDoc + * @require-extends \Divergence\Models\ActiveRecord + * @mixin \Divergence\Models\ActiveRecord * @property int $RevisionID ID of revision in the history table. * @property static[] $History All revisions for this object. This is hooked in the Relations trait. - * @property string $historyTable - * @property callable $createRevisionOnSave + * @method static static[] instantiateRecords(array $records) + * @method static string _cn(string $field) + * @method static array _mapConditions(array $conditions) + * @method static array|null _mapFieldOrder(array|string $order) + * @method array _prepareRecordValues(?array $fields = null) + * @method static array _mapValuesToSet(array $recordValues, ?array $fieldConfigs = null) */ trait Versioning { diff --git a/src/Responders/EmptyResponse.php b/src/Responders/EmptyResponse.php index 2954c42..0c78a9d 100644 --- a/src/Responders/EmptyResponse.php +++ b/src/Responders/EmptyResponse.php @@ -1,11 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Responders; class EmptyResponse extends Response { public function __construct(ResponseBuilder $responseBuilder) { - return $this; + parent::__construct($responseBuilder); } } diff --git a/src/Responders/MediaResponse.php b/src/Responders/MediaResponse.php index 92ce830..2a39ba0 100644 --- a/src/Responders/MediaResponse.php +++ b/src/Responders/MediaResponse.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Responders; class MediaResponse extends Response diff --git a/src/Responders/Response.php b/src/Responders/Response.php index ebb2822..90e17c9 100644 --- a/src/Responders/Response.php +++ b/src/Responders/Response.php @@ -21,6 +21,7 @@ * I would have simply extended it but I didn't like the constructor design so I instead copied the code. * * {@inheritDoc} + * @method self withHeader(string $name, string|string[] $value) */ class Response implements ResponseInterface { @@ -106,7 +107,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 @@ -116,7 +117,7 @@ public function withDefaults( array $headers = [], $body = null, string $version = '1.1', - string $reason = null + ?string $reason = null ) { $this->assertStatusCodeRange($status); @@ -148,6 +149,9 @@ public function getReasonPhrase(): string return $this->reasonPhrase; } + /** + * @return static + */ public function withStatus($code, $reasonPhrase = ''): ResponseInterface { $this->assertStatusCodeIsInteger($code); diff --git a/src/Routing/Path.php b/src/Routing/Path.php index eb48496..d7befeb 100644 --- a/src/Routing/Path.php +++ b/src/Routing/Path.php @@ -76,9 +76,9 @@ protected function setPath($requestURI = null) { if (!isset($this->pathStack)) { $parsedURL = parse_url($requestURI); - $this->pathStack = $this->requestPath = explode('/', ltrim($parsedURL['path'], '/')); + $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/MediaRequestHandlerTest.php b/tests/Divergence/Controllers/MediaRequestHandlerTest.php index 08147ef..2e1bde6 100644 --- a/tests/Divergence/Controllers/MediaRequestHandlerTest.php +++ b/tests/Divergence/Controllers/MediaRequestHandlerTest.php @@ -385,7 +385,7 @@ public function testHTTPRangeZeroDash() $this->assertEquals('bytes 0-1062814/1062815', $response->getHeader('Content-Range')[0]); $this->assertEquals('1062815', $response->getHeader('Content-Length')[0]); - $expectedOutput = $this->file_get_contents_at_seek(0, $mp4); + $expectedOutput = $this->file_get_contents_at_seek(0, $mp4); $this->expectOutputString($expectedOutput); (new Emitter($response))->emit(); } 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/CollectionAccountingTest.php b/tests/Divergence/Data/Collections/CollectionAccountingTest.php new file mode 100644 index 0000000..b80644e --- /dev/null +++ b/tests/Divergence/Data/Collections/CollectionAccountingTest.php @@ -0,0 +1,361 @@ + + * + * 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 Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Factory\Factory; +use PHPUnit\Framework\TestCase; + +class CollectionAccountingTest extends TestCase +{ + private Collection $ledger; + private TaxJurisdiction $newYorkCity; + + protected function setUp(): void + { + $this->newYorkCity = new TaxJurisdiction('NYC', 8875); + $chicago = new TaxJurisdiction('CHI', 10250); + $portland = new TaxJurisdiction('PDX', 0); + $receipts = []; + $basketSubtotals = [ + 499, 799, 1299, 1599, 1999, 2499, 2999, 3499, 3999, 4499, 4999, 5999, + 6999, 7999, 8999, 9999, 12500, 15000, 17500, 20000, 22500, 25000, 30000, + ]; + + for ($index = 0; $index < 200; $index++) { + $jurisdictionPosition = $index % 20; + + if ($jurisdictionPosition < 8) { + $jurisdiction = $this->newYorkCity; + } elseif ($jurisdictionPosition < 15) { + $jurisdiction = $chicago; + } else { + $jurisdiction = $portland; + } + + $subtotalCents = $basketSubtotals[$index % count($basketSubtotals)] + (($index * 137) % 1800); + + if ($index > 0 && $index % 47 === 0) { + $subtotalCents = -$subtotalCents; + } + + if ($index === 73) { + $subtotalCents = 75000; + } elseif ($index === 149) { + $subtotalCents = 125000; + } elseif ($index === 199) { + $subtotalCents = 250000; + } + + $itemCount = 1 + (($index * 7) % 12); + $receipts[] = new Receipt( + sprintf('R-%03d', $index + 1), + $subtotalCents, + $jurisdiction, + $itemCount, + 1704067200 + ($index * 900), + 40 + ($itemCount * 18) + (($index * 11) % 17) + ); + } + + $this->ledger = (new Factory())->create($receipts, ['JurisdictionCode']); + } + + public function testTwoNewYorkCityReceiptsSumExactly(): void + { + $receipts = (new Factory())->create([ + new Receipt('NYC-001', 10000, $this->newYorkCity, 3, 1704067200, 100), + new Receipt('NYC-002', 5000, $this->newYorkCity, 1, 1704068100, 65), + ]); + + $this->assertCount(2, $receipts); + $this->assertSame(888, $receipts[0]->getTaxCents()); + $this->assertSame(444, $receipts[1]->getTaxCents()); + $this->assertSame(1332, $receipts->sum(static fn (Receipt $receipt): int => $receipt->getTaxCents())); + $this->assertSame(16332, $receipts->sum(static fn (Receipt $receipt): int => $receipt->getTotalCents())); + } + + public function testRefundTaxRoundsSymmetrically(): void + { + $refund = new Receipt('NYC-REFUND', -10000, $this->newYorkCity, 2, 1704069000, 82); + + $this->assertSame(-888, $refund->getTaxCents()); + $this->assertSame(-10888, $refund->getTotalCents()); + } + + public function testZeroRateAndZeroSubtotalProduceNoTax(): void + { + $portland = new TaxJurisdiction('PDX', 0); + $sale = new Receipt('PDX-SALE', 10000, $portland, 2, 1704069000, 82); + $refund = new Receipt('PDX-REFUND', -10000, $portland, 2, 1704069900, 82); + $zero = new Receipt('NYC-ZERO', 0, $this->newYorkCity, 0, 1704070800, 40); + + $this->assertSame(0, $sale->getTaxCents()); + $this->assertSame(0, $refund->getTaxCents()); + $this->assertSame(0, $zero->getTaxCents()); + } + + public function testTaxRoundsBelowAtAndAboveHalfCentForSalesAndRefunds(): void + { + $chicago = new TaxJurisdiction('CHI', 10250); + $cases = [ + [$this->newYorkCity, 569, 50], + [$this->newYorkCity, 400, 36], + [$this->newYorkCity, 62, 6], + [$this->newYorkCity, -569, -50], + [$this->newYorkCity, -400, -36], + [$this->newYorkCity, -62, -6], + [$chicago, 239, 24], + [$chicago, 200, 21], + [$chicago, 161, 17], + [$chicago, -239, -24], + [$chicago, -200, -21], + [$chicago, -161, -17], + ]; + + foreach ($cases as [$jurisdiction, $subtotal, $expectedTax]) { + $this->assertSame($expectedTax, $jurisdiction->calculateTax($subtotal)); + } + } + + public function testTwoHundredReceiptLedgerSumsExactly(): void + { + $subtotal = $this->ledger->sum(static fn (Receipt $receipt): int => $receipt->getSubtotalCents()); + $tax = $this->ledger->sum(static fn (Receipt $receipt): int => $receipt->getTaxCents()); + $total = $this->ledger->sum(static fn (Receipt $receipt): int => $receipt->getTotalCents()); + + $this->assertCount(200, $this->ledger); + $this->assertSame(2348010, $subtotal); + $this->assertSame(152281, $tax); + $this->assertSame(2500291, $total); + $this->assertSame($subtotal + $tax, $total); + } + + public function testLedgerMedianAndNearestRankPercentiles(): void + { + $selector = static fn (Receipt $receipt): int => $receipt->getSubtotalCents(); + + $this->assertSame(6492.5, $this->ledger->median($selector)); + $this->assertSame(6377, $this->ledger->quantile($selector, 0.5)); + $this->assertSame(30316, $this->ledger->percentile($selector, 95)); + $this->assertSame(75000, $this->ledger->percentile($selector, 99)); + } + + public function testLedgerPopulationVarianceAndStandardDeviation(): void + { + $selector = static fn (Receipt $receipt): int => $receipt->getSubtotalCents(); + + $this->assertEqualsWithDelta(441097328.9175, $this->ledger->variance($selector), 0.0001); + $this->assertEqualsWithDelta(21002.31722733232, $this->ledger->stddev($selector), 0.0000001); + } + + public function testLedgerHistogramShowsDistributionAndLargeTransactions(): void + { + $selector = static fn (Receipt $receipt): int => $receipt->getSubtotalCents(); + $histogram = $this->ledger->histogram( + $selector, + 5 + ); + $regularReceipts = (new Factory())->create($this->ledger->bottomK($selector, 197)); + $regularHistogram = $regularReceipts->histogram($selector, 5); + + $this->assertSame([197, 1, 1, 0, 1], array_column($histogram, 'count')); + $this->assertSame(200, array_sum(array_column($histogram, 'count'))); + $this->assertSame([58, 79, 22, 22, 16], array_column($regularHistogram, 'count')); + $this->assertSame(197, array_sum(array_column($regularHistogram, 'count'))); + } + + public function testLedgerFrequencyAndMode(): void + { + $selector = static fn (Receipt $receipt): string => $receipt->JurisdictionCode; + $expected = [ + ['value' => 'NYC', 'count' => 80], + ['value' => 'CHI', 'count' => 70], + ['value' => 'PDX', 'count' => 50], + ]; + + $this->assertSame($expected, $this->ledger->frequency($selector)); + $this->assertSame($expected, $this->ledger->countBy($selector)); + $this->assertSame(['NYC'], $this->ledger->mode($selector)); + } + + public function testLedgerJurisdictionTotals(): void + { + $expected = [ + 'NYC' => [80, 759095, 67366, 826461], + 'CHI' => [70, 828461, 84915, 913376], + 'PDX' => [50, 760454, 0, 760454], + ]; + + foreach ($expected as $code => [$count, $subtotal, $tax, $total]) { + $receipts = $this->ledger->getAllByField('JurisdictionCode', $code); + + $this->assertCount($count, $receipts); + $this->assertSame($subtotal, $receipts->sum( + static fn (Receipt $receipt): int => $receipt->getSubtotalCents() + )); + $this->assertSame($tax, $receipts->sum( + static fn (Receipt $receipt): int => $receipt->getTaxCents() + )); + $this->assertSame($total, $receipts->sum( + static fn (Receipt $receipt): int => $receipt->getTotalCents() + )); + } + } + + public function testLedgerCovarianceAndCorrelation(): void + { + $subtotal = static fn (Receipt $receipt): int => $receipt->getSubtotalCents(); + $total = static fn (Receipt $receipt): int => $receipt->getTotalCents(); + $itemCount = static fn (Receipt $receipt): int => $receipt->ItemCount; + $processingTime = static fn (Receipt $receipt): int => $receipt->ProcessingMilliseconds; + + $this->assertEqualsWithDelta( + 454409196.98225, + $this->ledger->covariance($subtotal, $total), + 0.0001 + ); + $this->assertEqualsWithDelta( + 0.9987046282158667, + $this->ledger->correlation($subtotal, $total), + 0.0000000000001 + ); + $this->assertEqualsWithDelta( + 215.6434, + $this->ledger->covariance($itemCount, $processingTime), + 0.0000000001 + ); + $this->assertEqualsWithDelta( + 0.9969041494789462, + $this->ledger->correlation($itemCount, $processingTime), + 0.0000000000001 + ); + } + + public function testLedgerTopAndBottomTransactions(): void + { + $selector = static fn (Receipt $receipt): int => $receipt->getSubtotalCents(); + $top = $this->ledger->topK($selector, 3); + $bottom = $this->ledger->bottomK($selector, 3); + + $this->assertSame( + ['R-200', 'R-150', 'R-074'], + array_map(static fn (Receipt $receipt): string => $receipt->ReceiptNumber, $top) + ); + $this->assertSame( + ['R-142', 'R-189', 'R-048'], + array_map(static fn (Receipt $receipt): string => $receipt->ReceiptNumber, $bottom) + ); + } + + public function testLedgerRollingCalculationsPreserveReceiptOrder(): void + { + $movingAverage = $this->ledger->movingAverage( + static fn (Receipt $receipt): int => $receipt->getSubtotalCents(), + 3 + ); + $rollingTotal = $this->ledger->rolling( + 3, + static fn (array $receipts): int => array_sum(array_map( + static fn (Receipt $receipt): int => $receipt->getTotalCents(), + $receipts + )) + ); + + $this->assertCount(198, $movingAverage); + $this->assertEqualsWithDelta(1002.6666666666666, $movingAverage[0], 0.000000000001); + $this->assertCount(198, $rollingTotal); + $this->assertSame(3275, $rollingTotal[0]); + } + + public function testLedgerZScoresFindTheLargeTransaction(): void + { + $selector = static fn (Receipt $receipt): int => $receipt->getSubtotalCents(); + $scores = $this->ledger->zScore($selector); + $outliers = $this->ledger->outliers($selector, 3); + + $this->assertCount(200, $scores); + $this->assertEqualsWithDelta(11.344460109855381, $scores[199], 0.000000000001); + $this->assertSame( + ['R-074', 'R-150', 'R-200'], + array_map(static fn (Receipt $receipt): string => $receipt->ReceiptNumber, $outliers) + ); + } +} + +class Receipt +{ + public string $ReceiptNumber; + public string $JurisdictionCode; + public int $ItemCount; + public int $OccurredAt; + public int $ProcessingMilliseconds; + + protected int $subtotalCents; + protected int $taxCents; + + public function __construct( + string $receiptNumber, + int $subtotalCents, + TaxJurisdiction $jurisdiction, + int $itemCount, + int $occurredAt, + int $processingMilliseconds + ) { + $this->ReceiptNumber = $receiptNumber; + $this->JurisdictionCode = $jurisdiction->getCode(); + $this->ItemCount = $itemCount; + $this->OccurredAt = $occurredAt; + $this->ProcessingMilliseconds = $processingMilliseconds; + $this->subtotalCents = $subtotalCents; + $this->taxCents = $jurisdiction->calculateTax($subtotalCents); + } + + public function getSubtotalCents(): int + { + return $this->subtotalCents; + } + + public function getTaxCents(): int + { + return $this->taxCents; + } + + public function getTotalCents(): int + { + return $this->subtotalCents + $this->taxCents; + } +} + +class TaxJurisdiction +{ + protected string $code; + protected int $rateInThousandthsOfPercent; + + public function __construct(string $code, int $rateInThousandthsOfPercent) + { + $this->code = $code; + $this->rateInThousandthsOfPercent = $rateInThousandthsOfPercent; + } + + public function getCode(): string + { + return $this->code; + } + + public function calculateTax(int $subtotalCents): int + { + $tax = $subtotalCents * $this->rateInThousandthsOfPercent; + $rounding = $tax < 0 ? -50000 : 50000; + + return intdiv($tax + $rounding, 100000); + } +} 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/CollectionMathTest.php b/tests/Divergence/Data/Collections/CollectionMathTest.php new file mode 100644 index 0000000..a72b90b --- /dev/null +++ b/tests/Divergence/Data/Collections/CollectionMathTest.php @@ -0,0 +1,543 @@ + + * + * 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 Divergence\Data\Collections\Collection; +use Divergence\Data\Collections\Factory\Factory; +use InvalidArgumentException; +use OverflowException; +use PHPUnit\Framework\TestCase; + +class CollectionMathTest extends TestCase +{ + public function testEmptyCollectionResults(): void + { + $collection = $this->collection([]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame(0, $collection->sum($selector)); + $this->assertNull($collection->median($selector)); + $this->assertNull($collection->percentile($selector, 50)); + $this->assertNull($collection->quantile($selector, 0.5)); + $this->assertNull($collection->variance($selector)); + $this->assertNull($collection->stddev($selector)); + $this->assertSame([], $collection->histogram($selector)); + $this->assertSame([], $collection->mode($selector)); + $this->assertNull($collection->covariance($selector, $selector)); + $this->assertNull($collection->correlation($selector, $selector)); + $this->assertSame([], $collection->topK($selector, 3)); + $this->assertSame([], $collection->bottomK($selector, 3)); + $this->assertSame([], $collection->frequency($selector)); + $this->assertSame([], $collection->countBy($selector)); + $this->assertSame([], $collection->movingAverage($selector, 3)); + $this->assertSame([], $collection->rolling(3, static fn (array $records): array => $records)); + $this->assertSame([], $collection->zScore($selector)); + $this->assertSame([], $collection->outliers($selector)); + } + + public function testSingletonAndConstantDistributionResults(): void + { + $collection = $this->collection([5, 5, 5]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame(5, $collection->median($selector)); + $this->assertSame(5, $collection->quantile($selector, 0)); + $this->assertSame(5, $collection->quantile($selector, 1)); + $this->assertSame(0.0, $collection->variance($selector)); + $this->assertSame(0.0, $collection->stddev($selector)); + $this->assertSame( + [['min' => 5.0, 'max' => 5.0, 'count' => 3]], + $collection->histogram($selector, 4) + ); + $this->assertSame([5], $collection->mode($selector)); + $this->assertSame([0.0, 0.0, 0.0], $collection->zScore($selector)); + $this->assertSame([], $collection->outliers($selector)); + $this->assertNull($collection->correlation($selector, $selector)); + } + + public function testQuantileBoundariesAndNearestRanks(): void + { + $collection = $this->collection([40, 10, 30, 20]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame(10, $collection->quantile($selector, 0)); + $this->assertSame(10, $collection->quantile($selector, 0.25)); + $this->assertSame(20, $collection->quantile($selector, 0.5)); + $this->assertSame(30, $collection->quantile($selector, 0.75)); + $this->assertSame(40, $collection->quantile($selector, 1)); + $this->assertSame(25, $collection->median($selector)); + $this->assertSame(10, $collection->percentile($selector, 0)); + $this->assertSame(20, $collection->percentile($selector, 50)); + $this->assertSame(40, $collection->percentile($selector, 100)); + } + + public function testQuantileRejectsInvalidRangesAndNonFiniteValues(): void + { + $collection = $this->collection([1, 2, 3]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + $operations = [ + static fn () => $collection->quantile($selector, -0.01), + static fn () => $collection->quantile($selector, 1.01), + static fn () => $collection->quantile($selector, NAN), + static fn () => $collection->quantile($selector, INF), + static fn () => $collection->percentile($selector, -1), + static fn () => $collection->percentile($selector, 101), + static fn () => $collection->percentile($selector, NAN), + static fn () => $collection->percentile($selector, INF), + ]; + + $this->assertInvalidOperations($operations); + } + + public function testNearestRankCorrectsFloatingPointBoundaryNoise(): void + { + $collection = $this->collection(range(1, 25)); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame(7, $collection->quantile($selector, 0.28)); + $this->assertSame(7, $collection->percentile($selector, 28)); + + $fourValues = $this->collection([1, 2, 3, 4]); + $this->assertSame(2, $fourValues->quantile($selector, 0.250000000000001)); + } + + public function testSumVarianceAndStandardDeviationWithSignedDecimalValues(): void + { + $collection = $this->collection([-1.5, 0.0, 1.5]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame(0.0, $collection->sum($selector)); + $this->assertSame(1.5, $collection->variance($selector)); + $this->assertEqualsWithDelta(sqrt(1.5), $collection->stddev($selector), 0.000000000001); + } + + public function testMedianAndVariancePreserveFiniteNumericExtremes(): void + { + $maximumFloats = $this->collection([PHP_FLOAT_MAX, PHP_FLOAT_MAX]); + $minimumFloats = $this->collection([5e-324, 5e-324]); + $distinctMinimumFloats = $this->collection([5e-324, 1e-323]); + $adjacentIntegers = $this->collection([PHP_INT_MAX - 1, PHP_INT_MAX]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame(PHP_FLOAT_MAX, $maximumFloats->median($selector)); + $this->assertSame(5e-324, $minimumFloats->median($selector)); + $this->assertSame(1e-323, $distinctMinimumFloats->median($selector)); + $this->assertSame(0.0, $maximumFloats->variance($selector)); + $this->assertSame([0.0, 0.0], $maximumFloats->zScore($selector)); + $this->assertSame(0.25, $adjacentIntegers->variance($selector)); + } + + public function testSumUsesPhpNumericPromotionAndRejectsNonFiniteResults(): void + { + $integerOverflow = $this->collection([PHP_INT_MAX, 1]); + $floatOverflow = $this->collection([PHP_FLOAT_MAX, PHP_FLOAT_MAX]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame(array_sum([PHP_INT_MAX, 1]), $integerOverflow->sum($selector)); + $this->assertOverflowOperations([ + static fn () => $floatOverflow->sum($selector), + static fn () => $floatOverflow->movingAverage($selector, 2), + ]); + } + + public function testNumericOperationsRejectNonNumericAndNonFiniteValues(): void + { + $collection = $this->collection(['1']); + $selector = static fn (MathSample $sample) => $sample->Value; + $operations = [ + static fn () => $collection->sum($selector), + static fn () => $collection->median($selector), + static fn () => $collection->percentile($selector, 50), + static fn () => $collection->quantile($selector, 0.5), + static fn () => $collection->variance($selector), + static fn () => $collection->stddev($selector), + static fn () => $collection->histogram($selector), + static fn () => $collection->covariance($selector, $selector), + static fn () => $collection->correlation($selector, $selector), + static fn () => $collection->topK($selector, 1), + static fn () => $collection->bottomK($selector, 1), + static fn () => $collection->movingAverage($selector, 1), + static fn () => $collection->zScore($selector), + static fn () => $collection->outliers($selector), + ]; + + $this->assertInvalidOperations($operations); + + foreach ([NAN, INF, -INF] as $value) { + $invalid = $this->collection([$value]); + + $this->assertInvalidOperations([ + static fn () => $invalid->sum($selector), + ]); + } + } + + public function testHistogramUsesHalfOpenBucketsAndIncludesMaximum(): void + { + $collection = $this->collection([-10, -5, 0, 5, 10]); + $histogram = $collection->histogram( + static fn (MathSample $sample): int|float => $sample->Value, + 4 + ); + + $this->assertSame([1, 1, 1, 2], array_column($histogram, 'count')); + $this->assertSame( + [ + ['min' => -10.0, 'max' => -5.0, 'count' => 1], + ['min' => -5.0, 'max' => 0.0, 'count' => 1], + ['min' => 0.0, 'max' => 5.0, 'count' => 1], + ['min' => 5.0, 'max' => 10.0, 'count' => 2], + ], + $histogram + ); + $this->assertCount( + 10, + $collection->histogram(static fn (MathSample $sample): int|float => $sample->Value) + ); + } + + public function testHistogramRejectsInvalidBucketCounts(): void + { + $collection = $this->collection([1]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertInvalidOperations([ + static fn () => $collection->histogram($selector, 0), + static fn () => $collection->histogram($selector, -1), + ]); + } + + public function testHistogramRejectsRangesThatOverflowFloatingPoint(): void + { + $collection = $this->collection([-PHP_FLOAT_MAX, PHP_FLOAT_MAX]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertInvalidOperations([ + static fn () => $collection->histogram($selector, 2), + ]); + } + + public function testHistogramCorrectsFloatingPointBoundaryNoise(): void + { + $collection = $this->collection([0.0, 0.3, 1.0]); + $histogram = $collection->histogram( + static fn (MathSample $sample): int|float => $sample->Value, + 10 + ); + + $this->assertSame([1, 0, 0, 1, 0, 0, 0, 0, 0, 1], array_column($histogram, 'count')); + $this->assertSame(0.3, $histogram[3]['min']); + } + + public function testHistogramRejectsUnrepresentableAndSubnormalRanges(): void + { + $largeIntegers = $this->collection([PHP_INT_MAX - 1, PHP_INT_MAX]); + $narrowFloats = $this->collection([1e16, 1.0000000000000002e16]); + $subnormalFloats = $this->collection([0.0, 5e-324]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertInvalidOperations([ + static fn () => $largeIntegers->histogram($selector, 2), + static fn () => $narrowFloats->histogram($selector, 10), + static fn () => $subnormalFloats->histogram($selector, 10), + ]); + } + + public function testFrequencyKeepsPhpTypesDistinctAndGroupsSerializedValues(): void + { + $firstObject = new \stdClass(); + $secondObject = new \stdClass(); + $values = [null, false, 0, 0.0, '', true, 1, 1.0, '1', false, 0, $firstObject, $firstObject, $secondObject, [1], [1]]; + $collection = $this->collection($values); + $frequencies = $collection->frequency(static fn (MathSample $sample) => $sample->Value); + + $this->assertCount(11, $frequencies); + $this->assertSame(1, $frequencies[0]['count']); + $this->assertSame(2, $frequencies[1]['count']); + $this->assertSame(2, $frequencies[2]['count']); + $this->assertSame(1, $frequencies[3]['count']); + $this->assertSame(3, $frequencies[9]['count']); + $this->assertSame(2, $frequencies[10]['count']); + $this->assertSame($frequencies, $collection->countBy(static fn (MathSample $sample) => $sample->Value)); + } + + public function testModeReturnsEveryTieInFirstSeenOrder(): void + { + $collection = $this->collection(['B', 'A', 'C', 'A', 'B', 'D']); + $unique = $this->collection(['X', 'Y', 'Z']); + + $this->assertSame( + ['B', 'A'], + $collection->mode(static fn (MathSample $sample): string => $sample->Value) + ); + $this->assertSame( + ['X', 'Y', 'Z'], + $unique->mode(static fn (MathSample $sample): string => $sample->Value) + ); + } + + public function testPopulationCovarianceAndPositiveNegativeCorrelation(): void + { + $positive = $this->collection([1, 2, 3], [2, 4, 6]); + $negative = $this->collection([1, 2, 3], [-2, -4, -6]); + $constant = $this->collection([1, 2, 3], [5, 5, 5]); + $first = static fn (MathSample $sample): int|float => $sample->Value; + $second = static fn (MathSample $sample): int|float => $sample->Other; + + $this->assertEqualsWithDelta(4 / 3, $positive->covariance($first, $second), 0.000000000001); + $this->assertEqualsWithDelta(4 / 3, $positive->covariance($second, $first), 0.000000000001); + $this->assertSame(1.0, $positive->correlation($first, $second)); + $this->assertSame(1.0, $positive->correlation($first, $first)); + $this->assertEqualsWithDelta(-4 / 3, $negative->covariance($first, $second), 0.000000000001); + $this->assertSame(-1.0, $negative->correlation($first, $second)); + $this->assertNull($constant->correlation($first, $second)); + $this->assertSame(0.0, $constant->covariance($first, $second)); + } + + public function testCorrelationHandlesLargeAndSmallFiniteScales(): void + { + $large = $this->collection([0.0, 2e100]); + $small = $this->collection([0.0, 2e-100]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame(1.0, $large->correlation($selector, $selector)); + $this->assertSame(1.0, $small->correlation($selector, $selector)); + } + + public function testTopAndBottomKHandleLimitsAndStableTies(): void + { + $collection = $this->collection([3, 1, 3, 2]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame(['S-001', 'S-003'], $this->sampleIds($collection->topK($selector, 2))); + $this->assertSame(['S-002', 'S-004'], $this->sampleIds($collection->bottomK($selector, 2))); + $this->assertSame([], $collection->topK($selector, 0)); + $this->assertSame(['S-001', 'S-003', 'S-004', 'S-002'], $this->sampleIds($collection->topK($selector, 10))); + } + + public function testZeroRankCountDoesNotEvaluateSelector(): void + { + $collection = $this->collection([1]); + $calls = 0; + $selector = static function (MathSample $sample) use (&$calls): int { + $calls++; + return $sample->Value; + }; + + $this->assertSame([], $collection->topK($selector, 0)); + $this->assertSame([], $collection->bottomK($selector, 0)); + $this->assertSame(0, $calls); + } + + public function testRankingUsesInputOrderInsteadOfArrayKeysForStableTies(): void + { + $first = new MathSample('FIRST', 1, 0); + $second = new MathSample('SECOND', 1, 0); + $collection = (new Factory())->create(); + $collection->Index = [0 => $first, -1 => $second]; + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame(['FIRST', 'SECOND'], $this->sampleIds($collection->topK($selector, 2))); + $this->assertSame(['FIRST', 'SECOND'], $this->sampleIds($collection->bottomK($selector, 2))); + } + + public function testTopAndBottomKRejectNegativeCounts(): void + { + $collection = $this->collection([1]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertInvalidOperations([ + static fn () => $collection->topK($selector, -1), + static fn () => $collection->bottomK($selector, -1), + ]); + } + + public function testMovingAverageAndRollingWindowBoundaries(): void + { + $collection = $this->collection([1, 2, 3, 4]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertSame([1, 2, 3, 4], $collection->movingAverage($selector, 1)); + $this->assertSame([2.5], $collection->movingAverage($selector, 4)); + $this->assertSame([], $collection->movingAverage($selector, 5)); + $this->assertSame( + [3, 5, 7], + $collection->rolling(2, static fn (array $records): int => array_sum(array_map( + static fn (MathSample $sample): int => $sample->Value, + $records + ))) + ); + $this->assertSame([10], $collection->rolling(4, static fn (array $records): int => array_sum(array_map( + static fn (MathSample $sample): int => $sample->Value, + $records + )))); + $this->assertSame([], $collection->rolling(5, static fn (array $records): array => $records)); + } + + public function testMovingAverageAndRollingRejectInvalidWindowsBeforeCallbacks(): void + { + $collection = $this->collection([1, 2, 3]); + $selectorCalls = 0; + $callbackCalls = 0; + $selector = static function (MathSample $sample) use (&$selectorCalls): int { + $selectorCalls++; + return $sample->Value; + }; + $callback = static function (array $records) use (&$callbackCalls): array { + $callbackCalls++; + return $records; + }; + + $this->assertInvalidOperations([ + static fn () => $collection->movingAverage($selector, 0), + static fn () => $collection->movingAverage($selector, -1), + static fn () => $collection->rolling(0, $callback), + static fn () => $collection->rolling(-1, $callback), + ]); + $this->assertSame(0, $selectorCalls); + $this->assertSame(0, $callbackCalls); + } + + public function testZScoresAndInclusiveOutlierThresholds(): void + { + $collection = $this->collection([0, 0, 0, 10]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + $scores = $collection->zScore($selector); + + $this->assertEqualsWithDelta(-0.5773502691896257, $scores[0], 0.000000000001); + $this->assertEqualsWithDelta(1.7320508075688772, $scores[3], 0.000000000001); + $this->assertSame(['S-004'], $this->sampleIds($collection->outliers($selector, $scores[3]))); + $this->assertSame(['S-001', 'S-002', 'S-003', 'S-004'], $this->sampleIds($collection->outliers($selector, 0))); + } + + public function testRankingAndOutlierSelectorsRunOncePerRecord(): void + { + $collection = $this->collection([1, 2, 100]); + $rankCalls = 0; + $outlierCalls = 0; + $rankSelector = static function (MathSample $sample) use (&$rankCalls): int { + $rankCalls++; + return $sample->Value; + }; + $outlierSelector = static function (MathSample $sample) use (&$outlierCalls): int { + $outlierCalls++; + return $sample->Value; + }; + + $collection->topK($rankSelector, 2); + $collection->outliers($outlierSelector, 1); + + $this->assertSame(3, $rankCalls); + $this->assertSame(3, $outlierCalls); + } + + public function testOutliersRejectNegativeThresholds(): void + { + $collection = $this->collection([1]); + $selector = static fn (MathSample $sample): int|float => $sample->Value; + + $this->assertInvalidOperations([ + static fn () => $collection->outliers($selector, -0.01), + static fn () => $collection->outliers($selector, NAN), + static fn () => $collection->outliers($selector, INF), + ]); + } + + public function testMathSelectorsSupportArrayRecords(): void + { + $collection = (new Factory())->create(); + $collection->Index = [ + ['Value' => 3], + ['Value' => 1], + ['Value' => 2], + ]; + $selector = static fn (array $sample): int => $sample['Value']; + + $this->assertSame(6, $collection->sum($selector)); + $this->assertSame(2, $collection->median($selector)); + $this->assertSame( + [['Value' => 3], ['Value' => 2]], + $collection->topK($selector, 2) + ); + } + + /** + * @param list $values + * @param list $otherValues + */ + private function collection(array $values, array $otherValues = []): Collection + { + $records = []; + + foreach ($values as $index => $value) { + $records[] = new MathSample( + sprintf('S-%03d', $index + 1), + $value, + $otherValues[$index] ?? 0 + ); + } + + return (new Factory())->create($records); + } + + /** + * @param list $samples + * @return list + */ + private function sampleIds(array $samples): array + { + return array_map(static fn (MathSample $sample): string => $sample->ID, $samples); + } + + /** + * @param list $operations + */ + private function assertInvalidOperations(array $operations): void + { + foreach ($operations as $operation) { + try { + $operation(); + $this->fail('Expected InvalidArgumentException was not thrown.'); + } catch (InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } + + /** + * @param list $operations + */ + private function assertOverflowOperations(array $operations): void + { + foreach ($operations as $operation) { + try { + $operation(); + $this->fail('Expected OverflowException was not thrown.'); + } catch (OverflowException) { + $this->addToAssertionCount(1); + } + } + } +} + +class MathSample +{ + public string $ID; + public mixed $Value; + public mixed $Other; + + public function __construct(string $id, $value, $other) + { + $this->ID = $id; + $this->Value = $value; + $this->Other = $other; + } +} diff --git a/tests/Divergence/Data/Collections/IndexedFieldTest.php b/tests/Divergence/Data/Collections/IndexedFieldTest.php new file mode 100644 index 0000000..2f8d7b2 --- /dev/null +++ b/tests/Divergence/Data/Collections/IndexedFieldTest.php @@ -0,0 +1,61 @@ + + * + * 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 Divergence\Data\Collections\IndexedField; +use PHPUnit\Framework\TestCase; +use stdClass; + +class IndexedFieldTest extends TestCase +{ + public function testTrueAndFalseCardinalitiesDoNotCollide(): void + { + $this->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..7eab66f --- /dev/null +++ b/tests/Divergence/Data/KeyToHashIntTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Divergence\Tests\Data; + +use Divergence\Data\KeyToHashInt; +use PHPUnit\Framework\TestCase; + +class KeyToHashIntTest extends TestCase +{ + protected function tearDown(): void + { + KeyToHashInt::$singleton = null; + } + + public function testHashForKeysReusesSingletonWithoutReusingHash(): void + { + $this->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/QueryTest.php b/tests/Divergence/IO/Database/QueryTest.php index 69c1ab3..b4ce74f 100644 --- a/tests/Divergence/IO/Database/QueryTest.php +++ b/tests/Divergence/IO/Database/QueryTest.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ namespace Divergence\Tests\IO\Database; use Divergence\IO\Database\MySQL; 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/ActiveRecordTest.php b/tests/Divergence/Models/ActiveRecordTest.php index d6541c1..b18006e 100644 --- a/tests/Divergence/Models/ActiveRecordTest.php +++ b/tests/Divergence/Models/ActiveRecordTest.php @@ -715,6 +715,17 @@ public function testSaveCanaryTimestamps() $this->assertNull($x->DateOfBirth); } + public function testNewCanaryAllowsNullableFieldsToBeSetWithoutWarnings() + { + $Canary = new Canary(); + + $Canary->Handle = null; + $Canary->LongestFlightTime = null; + + $this->assertNull($Canary->Handle); + $this->assertNull($Canary->LongestFlightTime); + } + /** * * 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/Divergence/Support/ArraySubsetAsserts.php b/tests/Divergence/Support/ArraySubsetAsserts.php index 632e9cb..0b2a80a 100644 --- a/tests/Divergence/Support/ArraySubsetAsserts.php +++ b/tests/Divergence/Support/ArraySubsetAsserts.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ declare(strict_types=1); namespace Divergence\Tests\Support; diff --git a/tests/Divergence/TestListener.php b/tests/Divergence/TestListener.php index 6a78ccf..9852801 100644 --- a/tests/Divergence/TestListener.php +++ b/tests/Divergence/TestListener.php @@ -113,9 +113,13 @@ public function addSkippedTest(Test $test, \Throwable $e, float $time): void } } - public function startTest(Test $test): void {} + public function startTest(Test $test): void + { + } - public function endTest(Test $test, float $time): void {} + public function endTest(Test $test, float $time): void + { + } public function startTestSuite(TestSuite $suite): void { 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..9208c75 --- /dev/null +++ b/tests/MockSite/Models/IndexedCanary.php @@ -0,0 +1,45 @@ + + * + * 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 diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 899f24f..1fdd4d0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,5 +1,12 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ declare(strict_types=1); require __DIR__ . '/../vendor/autoload.php';