|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +namespace App\Doctrine\Functions; |
| 6 | + |
| 7 | +use Doctrine\ORM\Query\AST\Functions\FunctionNode; |
| 8 | +use Doctrine\ORM\Query\AST\Node; |
| 9 | +use Doctrine\ORM\Query\Parser; |
| 10 | +use Doctrine\ORM\Query\SqlWalker; |
| 11 | +use Doctrine\ORM\Query\TokenType; |
| 12 | + |
| 13 | +/** |
| 14 | + * SEMVER_NUMERIC(versionString) — MariaDB DQL function returning a sortable |
| 15 | + * BIGINT for a dotted-version string. Padding the string with ".0.0.0" lets |
| 16 | + * us treat shorter versions ("10", "10.5") as if all four segments were |
| 17 | + * present. Each segment is given 10^4 of room (max 9999 per segment), |
| 18 | + * packed left-to-right, so "10.5.9" becomes |
| 19 | + * 10·10^12 + 5·10^8 + 9·10^4 = 10_000_500_090_000. |
| 20 | + * |
| 21 | + * The 10^4 cap keeps the maximum value (~10^16) well inside PHP's |
| 22 | + * 64-bit PHP_INT_MAX (~9.22·10^18), so callers can mirror this arithmetic |
| 23 | + * in PHP to bind a single BIGINT parameter rather than feeding the |
| 24 | + * raw string back through the DQL function (which would expand the |
| 25 | + * argument-placeholder several times — see SemverFilter::toSemverNumeric()). |
| 26 | + * |
| 27 | + * Non-semver inputs (anything that doesn't match the dotted-number shape) |
| 28 | + * collapse to NULL via the outer CASE so they're excluded from any |
| 29 | + * comparison. |
| 30 | + */ |
| 31 | +class SemverNumeric extends FunctionNode |
| 32 | +{ |
| 33 | + private Node $versionExpression; |
| 34 | + |
| 35 | + #[\Override] |
| 36 | + public function parse(Parser $parser): void |
| 37 | + { |
| 38 | + $parser->match(TokenType::T_IDENTIFIER); |
| 39 | + $parser->match(TokenType::T_OPEN_PARENTHESIS); |
| 40 | + $this->versionExpression = $parser->StringPrimary(); |
| 41 | + $parser->match(TokenType::T_CLOSE_PARENTHESIS); |
| 42 | + } |
| 43 | + |
| 44 | + #[\Override] |
| 45 | + public function getSql(SqlWalker $sqlWalker): string |
| 46 | + { |
| 47 | + $expr = $this->versionExpression->dispatch($sqlWalker); |
| 48 | + // Strip an optional leading "v" or "V" so "v5.5.40" parses identically to "5.5.40". |
| 49 | + $stripped = sprintf("TRIM(LEADING 'v' FROM TRIM(LEADING 'V' FROM %s))", $expr); |
| 50 | + $padded = sprintf("CONCAT(%s, '.0.0.0')", $stripped); |
| 51 | + $segment = static fn (int $n): string => sprintf( |
| 52 | + "CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(%s, '.', %d), '.', -1) AS UNSIGNED)", |
| 53 | + $padded, |
| 54 | + $n, |
| 55 | + ); |
| 56 | + |
| 57 | + // Return NULL for anything that isn't a dotted-number sequence so non-semver |
| 58 | + // rows ("?", "unknown", "main", "release-1.0", ...) get NULL comparisons |
| 59 | + // and are excluded from the result set regardless of operator. |
| 60 | + return sprintf( |
| 61 | + "(CASE WHEN %s REGEXP '^[vV]?[0-9]+(\\\\.[0-9]+){0,3}\$' " |
| 62 | + .'THEN (%s * 1000000000000 + %s * 100000000 + %s * 10000 + %s) ' |
| 63 | + .'ELSE NULL END)', |
| 64 | + $expr, |
| 65 | + $segment(1), |
| 66 | + $segment(2), |
| 67 | + $segment(3), |
| 68 | + $segment(4), |
| 69 | + ); |
| 70 | + } |
| 71 | +} |
0 commit comments