diff --git a/CHANGELOG.md b/CHANGELOG.md index 438cb3e51..d5989601c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 8.0.2 + +8.0.1 reverted six deliberate 8.0.0 changes as if they were regressions. 8.0.2 brings them back; everything else in +8.0.1 stays. + +### Fixed + +- On PostgreSQL an exact search (`search('title', '"foo bar"')`) matches the adjacent phrase again, as on MariaDB, + MySQL, SQLite, MongoDB, Memory and Redis, and `notSearch()` with an exact term excludes only that phrase. 8.0.1 + reverted it to every word in any order by mistake. Words in a search term are still split on every character + other than a letter, a number or an underscore, as in 7.x (`foo/bar` and `foo,bar` search `foo` and `bar`). +- `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` refuse a fractional change value, `max` or `min` + on an integer attribute with `Exception\Type` again, before anything is written, and a change value of 0 or less + throws `Exception\Type` (`Value must be numeric and greater than 0`) again, which `catch (DatabaseException)` + catches. 8.0.1 passed fractional values to the engine (rounded on MariaDB and MySQL, truncated on MongoDB, an + error on PostgreSQL) and threw `\InvalidArgumentException` by mistake. +- Numeric operators on integer attributes refuse a fractional `max` or `min` limit with `Exception\Structure` again, + as 8.0.0 did. Numeric text such as `power('2')` stays accepted. 8.0.1 accepted fractional limits by mistake. +- The `UID` validator description names every character it accepts again (`a-z, A-Z, 0-9, period, hyphen, and + underscore`, none of the last three leading). 8.0.1 returned 7.x's text, which leaves out periods and hyphens. +- An update does not validate again the stored values it leaves unchanged, whatever their type, so a value stored + before a range, enum or other rule was narrowed never blocks an update of another attribute. 8.0.1 validated them + again (leaving only object values alone) by mistake. +- `document_purge` fires after the outermost transaction commits again, and never for a write that rolled back. + The cache entry is still purged inside the write's transaction, so a cache failure rolls the write back. 8.0.1 + fired the event just before the commit by mistake. + ## 8.0.1 8.0.1 restores the 7.4.1 behaviour that 8.0.0 changed for callers of features 7.x already had. New 8.0 features are @@ -22,19 +49,20 @@ unchanged. It also holds nested relationship links to the permission rule UPGRAD raw query from a string reads `Invalid query method: raw`. - `select('$tenant')` passes query validation again without shared tables, and the read refuses it with `Cannot select attributes: $tenant`, as in 7.x. -- The `UID` validator description is the 7.x text again, also inside `Invalid cursor: …`. +- The `UID` validator description is the 7.x text again, also inside `Invalid cursor: …`. Reverted in 8.0.2. - On PostgreSQL an exact search (`"foo bar"`) matches every word in any order again, not the adjacent phrase. + Reverted in 8.0.2: this was a deliberate 8.0.0 change. - `containsAll()` on an attribute that is not an array matches each value as a whole-value `LIKE` (`ILIKE` on PostgreSQL) pattern, any of them, on MariaDB, MySQL and PostgreSQL, as in 7.x. - A read whose orders include `orderRandom()` ignores its cursor instead of failing with `Order attribute '' is empty`. - `document_purge` fires inside the write's outermost transaction, just before it commits, so a listener that throws - rolls the write back and its failure reaches the caller, as in 7.x. + rolls the write back and its failure reaches the caller, as in 7.x. Reverted in 8.0.2. - `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` pass a fractional change, `max` or `min` on an integer attribute to the engine again and return the exact sum, and refuse a change that is not above zero with - `\InvalidArgumentException`, as in 7.x. + `\InvalidArgumentException`, as in 7.x. Reverted in 8.0.2. - Numeric operators on integer and double attributes are checked as in 7.x: a fractional change or limit, or a - fractional item appended to an integer array, is no longer refused. + fractional item appended to an integer array, is no longer refused. Reverted in 8.0.2. - A mismatched attribute default reads `Default value x does not match given type ` (and `is not a valid integer string for type bigint`) without JSON quoting, as in 7.x. - `Index::fromArray()` refuses an unknown index type with `Exception\Index` (`Unknown index type: …`) instead of @@ -48,7 +76,7 @@ unchanged. It also holds nested relationship links to the permission rule UPGRAD as in 7.x, and `select(['*', '*.*'])` reads every column instead of failing on MariaDB, MySQL and PostgreSQL. - A single read returns `$sequence` ahead of `$id`, in 7.x's key order, on every adapter. - An update validates the stored values it leaves unchanged again, as 7.x did; a stored object value is held to - 7.x's object rules. + 7.x's object rules. Reverted in 8.0.2. - On MongoDB a transaction that lost a write conflict runs again up to 20 times, so concurrent increments of one document all land. - With dates preserved, linking existing children through their one-to-many parent keeps each child's stored diff --git a/UPGRADE.md b/UPGRADE.md index cf03f2ae7..46109389f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -223,8 +223,11 @@ $permissions = [Permission::read(Role::user(Id::unique()))]; - A filter on a path into an object attribute (`meta.address.city`) takes keys of `a-z`, `A-Z`, `0-9`, `_` and `-` only. The query validator refuses any other key with `Utopia\Database\Exception\Query` on every adapter (7.x refused such keys on PostgreSQL only), and on PostgreSQL the query is refused also when validation is skipped. -- On PostgreSQL, an exact search (`search('title', '"foo bar"')`) matches documents holding every word in any order, - as in 7.x, where MariaDB, MySQL and SQLite match the adjacent phrase. +- On PostgreSQL, an exact search (`search('title', '"foo bar"')`) matches the words as an adjacent phrase, as on + MariaDB, MySQL and SQLite, and `notSearch()` with an exact term excludes only that phrase. In 7.x PostgreSQL + matched every word in any order, and so did 8.0.1, which reverted the phrase match by mistake; 8.0.0 and 8.0.2 + match the phrase. To match every word in any order, pass a `search()` for each word. A term without quotes still + matches any of its words. - `containsAll()` on an attribute that is not an array matches as in 7.x: on MariaDB, MySQL and PostgreSQL each value is a `LIKE` (`ILIKE`) pattern on the whole value and a document matching any of them is returned; on MongoDB it is `$all`. Use `containsString()` for substrings. @@ -699,14 +702,17 @@ It fires once per written document from `updateDocument()` (for both the old and `Event\Document\Purged` with the document's `collection` and `id`. As in 7.x, `createDocument()` and `createDocuments()` do not fire it. Attribute schema changes fire it for the collection's metadata document (`$collection` = `_metadata`). -As in 7.x, a write fires it inside its transaction, so a listener that throws rolls the write back and its failure -reaches the caller. The events wait for the end of the outermost transaction and fire just before it commits: inside -`withTransaction()` the events of every write fire together before the outer commit, a rollback before that point -drops them, and every attempt of a retried transaction that reaches its commit announces them. Each event runs under -the tenant and the `silent()` scope in force when its document was written. If the cache invalidation after the -commit fails, the write throws with its data committed. `purgeCachedDocument()` fires it at once. On an adapter -without savepoints (MongoDB), a nested `withTransaction()` that fails is not rolled back on its own: when the caller -catches the failure, the nested writes commit with the caller and their events fire before that commit. +A write fires it after the outermost transaction commits: inside `withTransaction()` the events of every write wait +for the outer commit, a rollback drops them, and a retried attempt announces once. Each event runs under the tenant +and the `silent()` scope in force when its document was written. If the cache invalidation after the commit fails, +the write throws with its data committed, after `document_purge` has fired. `purgeCachedDocument()` fires it at once. The +cache entry itself is purged inside the write's transaction, so a cache failure rolls the write back and no event +fires. +On an adapter without savepoints (MongoDB), a nested `withTransaction()` that fails is not rolled back on its own: +when the caller catches the failure, the nested writes commit with the caller and their events fire after that +commit. + +8.0.1 fired the event just before the commit instead, by mistake; 8.0.2 fires it after the commit again. ### `document_update` for related documents a delete changed @@ -882,20 +888,30 @@ method as a no-op, so a hook overrides only what it needs: counted and emitted it). Of a batch that repeats an id, only the first copy is written. Permissions are written only for inserted documents: in 7.x the skipped copy's permissions were added to the stored document on MariaDB, MySQL and SQLite, so `find()`, `count()` and `sum()` could return it to roles its own permissions do not grant. -- **Fractional numbers on integer attributes.** As in 7.x, `increaseDocumentAttribute()` and - `decreaseDocumentAttribute()` pass a fractional change value, `max` or `min` on an integer attribute to the - engine and return the document with the exact sum: MariaDB and MySQL store it rounded, MongoDB stores it and reads - it back truncated, and PostgreSQL fails. A whole float such as `2.0` is bound as the integer. A whole change value - and whole bounds are compared with exact integer arithmetic, so 64-bit and unsigned values never pass through a - float. A change value that is not a number greater than 0 throws `\InvalidArgumentException` - (`Value must be numeric and greater than 0`), as in 7.x. -- **Operator limits on integer attributes.** As in 7.x, the change and the `max` or `min` limit of - `Operator::increment()`, `decrement()`, `multiply()`, `divide()` and `power()` on an integer or double attribute - only have to be numeric and finite: a fractional change or limit on an integer reaches the engine, which stores - the result as for a fractional counter. Without a limit, a result predicted outside the attribute's range is - refused with `Utopia\Database\Exception\Structure` (`Cannot apply operator: would overflow maximum value - of `). On a big integer attribute the limit has to be a whole number in the attribute's range (`Cannot apply - operator: max/min limit must be a whole number for integer attribute '', got `). +- **Fractional numbers on integer attributes.** `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` + throw `Utopia\Database\Exception\Type` before anything is written, on every adapter and also on schemaless + collections that declare the attribute as an integer: + - for a fractional change value (`Change value must be an integer.`). 7.x passed it to the engine, which rounded + it on MariaDB and MySQL, failed on PostgreSQL, and stored a float in the integer attribute on SQLite, MongoDB, + Memory and Redis. Pass an integer change value, or use a float attribute for fractional counters. + - for a fractional `max` or `min` (`Max must be an integer.`, `Min must be an integer.`). Integer bounds are + compared with exact integer arithmetic, so 64-bit and unsigned values never pass through a float. The bound + accepts the same whole numbers as an operator limit: an integer, an integer string, a string with only zero + decimals such as `'102.0'`, or a float without a fractional part such as `102.0`, each converted exactly. Pass + a whole bound, for example `floor($max)` or `ceil($min)`, which admits the same integer values. + + Change values and bounds on float and double attributes may still be fractional. +- **Operator limits on integer attributes.** The `max` or `min` limit of `Operator::increment()`, `decrement()`, + `multiply()`, `divide()` and `power()` on an integer or big integer attribute has to be a whole number: an + integer, an integer string, or a float without a fractional part such as `9.0e18`. A fractional limit such as + `102.4` is refused before anything is written with `Utopia\Database\Exception\Structure` + (`Cannot apply operator: max/min limit must be a whole number for integer attribute '', got + `). With validation skipped that check does not run, and Memory and Redis refuse such a limit with + `Utopia\Database\Exception\Operator` when the result leaves PHP's integer range. Limits on float and double + attributes are unchanged. Numeric text such as `power('2')` is accepted. +- 8.0.1 passed fractional change values and bounds on integer attributes to the engine, accepted fractional operator + limits on integers, and threw `\InvalidArgumentException` for a change value of 0 or less, by mistake; 8.0.2 + refuses them again as described above. - **Operators on upserts that create a document.** An upsert that creates a document applies every operator to the attribute's default, as it does for an existing document: `dateAddDays()` and `dateSubDays()` shift the date, `arrayFilter()` filters the array, and the maximum or minimum of increment, decrement, multiply, divide and power @@ -2019,10 +2035,10 @@ $validator = new IndexDefinition($attributes, $indexes, $database->profile()); it starts; `setStatus()`, `enable()`, `disable()` and `reset()` change the shared status unless called inside such a scope (see [Coroutines](#coroutines)). `setDefaultStatus()` is a constructor argument, `new Authorization(bool $defaultStatus = true)`. `restore()` is internal. -- `Validator\Structure` takes `array $storedAttributes = []`: the attributes whose values are the stored ones. They - are validated as in 7.x, so an update fails on a stored value a narrowed definition no longer admits, but a stored - object value is held to what 7.x accepted (any JSON string or empty value). `Database::updateDocument()` passes - it. +- `Validator\Structure` takes `array $storedAttributes = []`: the attributes whose values are the stored ones, which + it does not validate again, whatever their type, so a stored value a narrowed definition no longer admits never + blocks an update of another attribute. `Database::updateDocument()` passes it. (8.0.1 validated them again, by + mistake; 8.0.2 does not.) - `Database::convertQueries()` takes an optional `array $joinedCollections` (join alias => collection). With it, filters on `alias.attribute`, the filters of join ON lists and `having()` conditions in the list are converted too; aggregates and selects in the list are left as they are. Without it the method converts as before. diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index e5fc1dab6..071f1fd2c 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -1884,10 +1884,8 @@ public function increaseDocumentAttribute(Document $collection, string $id, stri $previousValue = $this->data[$key]['documents'][$docKey][$column] ?? null; $previousUpdatedAt = $this->data[$key]['documents'][$docKey][Storage::UPDATED_AT] ?? null; $current = $previousValue ?? 0; - $exact = BigInt::isIntegerValue($current) - && BigInt::isIntegerValue($value) - && ($min === null || BigInt::isIntegerValue($min)) - && ($max === null || BigInt::isIntegerValue($max)); + $exact = (\is_int($current) || (\is_string($current) && BigInt::isIntegerString($current))) + && (\is_int($value) || (\is_string($value) && BigInt::isIntegerString($value))); // MariaDB encodes the bound check as part of the WHERE clause against // the current column value (`attr <= :max` / `attr >= :min`); when the diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 2ebd43fd0..3935445bd 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -1589,10 +1589,8 @@ public function increaseDocumentAttribute(Document $collection, string $id, stri $document = $this->decode($payload); $current = $document->getAttribute($attribute); - $exact = BigInt::isIntegerValue($current) - && BigInt::isIntegerValue($value) - && ($min === null || BigInt::isIntegerValue($min)) - && ($max === null || BigInt::isIntegerValue($max)); + $exact = (\is_int($current) || (\is_string($current) && BigInt::isIntegerString($current))) + && (\is_int($value) || (\is_string($value) && BigInt::isIntegerString($value))); if ($exact) { $current = BigInt::toNative($current); $value = BigInt::toNative($value); diff --git a/src/Database/Builder/Postgres.php b/src/Database/Builder/Postgres.php index f5dec25f5..fac26ba8e 100644 --- a/src/Database/Builder/Postgres.php +++ b/src/Database/Builder/Postgres.php @@ -12,9 +12,9 @@ use Utopia\Query\Schema\ColumnType; /** - * The PostgreSQL builder, which also compiles filters on their own, prepares and matches search terms and containsAll() - * as 7.x did, writes a filter on a path into an object attribute only when every key of the path is a plain key, and - * lets a distanceLessThan() filter use the spatial index. + * The PostgreSQL builder, which also compiles filters on their own, prepares search terms and matches containsAll() as + * 7.x did, writes a filter on a path into an object attribute only when every key of the path is a plain key, and lets + * a distanceLessThan() filter use the spatial index. */ class Postgres extends Base implements Filtering, Scoping { @@ -49,29 +49,6 @@ public function deleteUsing(string $table, string $condition, mixed ...$bindings return parent::deleteUsing($table, $condition, ...$bindings); } - /** - * 7.x handed websearch_to_tsquery() an exact term in single quotes, which match every word in any order where - * double quotes would match the adjacent phrase, and any other term with its words joined by `or`. - * - * @param array $values - */ - #[\Override] - protected function compileSearchExpression(string $attribute, array $values, bool $not): string - { - $term = $values[0] ?? ''; - $term = \is_string($term) ? $term : ''; - $words = $this->searchWords($term); - - if ($words === '') { - return $not ? '1 = 1' : '1 = 0'; - } - - $this->addBinding("'".($this->isExactSearch($term) ? $words : \str_replace(' ', ' or ', $words))."'"); - $match = "to_tsvector(regexp_replace({$attribute}, '[^\\w]+', ' ', 'g')) @@ websearch_to_tsquery(?)"; - - return $not ? "NOT ({$match})" : $match; - } - /** * @throws QueryException */ diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index f37c2ae19..85f07e328 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -269,35 +269,11 @@ private function isDeclaredInteger(?Attribute $attribute): bool return $attribute !== null && ! $attribute->array && $attribute->isInteger(); } - /** - * A change or bound that is not a whole number on an integer attribute: 7.x applied it as given, so the - * engine stored the sum its own way (MariaDB and MySQL round it) and the returned document held the sum. - */ - private function isFractionalChange(int|float|string $value, int|float|string|null $bound): bool + private function assertIntegerChange(int|float|string $value): void { - return \is_float($value) - || (\is_string($value) && ! BigInt::isIntegerString($value)) - || ($bound !== null && BigInt::integralValue($bound) === null); - } - - /** - * A whole float as the integer 7.x bound it as, which PostgreSQL takes for an integer column. - */ - private function wholeOrNative(int|float $value): int|float - { - return \is_float($value) && \is_finite($value) && \floor($value) === $value && \abs($value) < \PHP_INT_MAX - ? (int) $value - : $value; - } - - private function getFractionalResult(mixed $current, int|float|string $value, bool $increase): int|float - { - if ($current !== null && ! \is_numeric($current)) { - throw new TypeException('Attribute value must be numeric.'); + if (! \is_int($value) && (! \is_string($value) || ! BigInt::isIntegerString($value))) { + throw new TypeException('Change value must be an integer.'); } - $current = $current === null ? 0 : $this->getNativeNumber($current); - - return $increase ? $current + $this->getNativeNumber($value) : $current - $this->getNativeNumber($value); } private function integerBound(int|float|string $bound, string $name): int|string @@ -2544,18 +2520,18 @@ public function increaseDocumentAttribute( $collection = $this->silent(fn () => $this->getCollection($collection)); $numericAttribute = $this->numericAttribute($collection, $attribute); - $declaredInteger = $this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute)); - $fractional = $declaredInteger && $this->isFractionalChange($value, $max); - if ($declaredInteger && ! $fractional && $max !== null) { - $max = $this->integerBound($max, 'Max'); + if ($this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute))) { + $this->assertIntegerChange($value); + if ($max !== null) { + $max = $this->integerBound($max, 'Max'); + } } - $integerArithmetic = $numericAttribute?->isInteger() === true && ! $fractional; $cacheTarget = $collection->getId() === self::METADATA ? new Document([Document::ID => $id, Document::COLLECTION => self::METADATA]) : $collection->getId(); - $document = $this->withMutation(Event::DocumentIncrease, $cacheTarget, function () use ($collection, $id, $attribute, $value, $max, $numericAttribute, $fractional, $integerArithmetic) { + $document = $this->withMutation(Event::DocumentIncrease, $cacheTarget, function () use ($collection, $id, $attribute, $value, $max, $numericAttribute) { /** @var Document $document */ $document = $this->authorization->skip(fn () => $this->silent(fn () => $this->readDocument($collection, $id, forUpdate: true))); // Skip ensures user does not need read permission for this @@ -2576,9 +2552,7 @@ public function increaseDocumentAttribute( $attributeExists = $document->offsetExists($attribute); $currentVal = $document->getAttribute($attribute); - if ($fractional) { - $result = $this->getFractionalResult($currentVal, $value, true); - } elseif ($numericAttribute instanceof Attribute) { + if ($numericAttribute instanceof Attribute) { $result = $this->getNumericResult($numericAttribute, $currentVal, $value, true); } else { if (! $attributeExists) { @@ -2590,7 +2564,7 @@ public function increaseDocumentAttribute( $result = $currentVal + $this->getNativeNumber($value); } $exceedsMaximum = ! \is_null($max) && ( - $integerArithmetic + $numericAttribute?->isInteger() === true ? BigInt::compare($result, $max) > 0 : $result > $max ); @@ -2602,22 +2576,18 @@ public function increaseDocumentAttribute( $updatedAt = $document->getUpdatedAt(); $updatedAt = (empty($updatedAt) || ! $this->datePreservation()->get()) ? $time : DateTime::setTimezone($updatedAt); if ($max !== null) { - $max = match (true) { - $integerArithmetic => BigInt::subtract($max, $value), - $fractional => $this->wholeOrNative($this->getNativeNumber($max) - $this->getNativeNumber($value)), - default => $this->getNativeNumber($max) - $this->getNativeNumber($value), - }; + $max = $numericAttribute?->isInteger() === true + ? BigInt::subtract($max, $value) + : $this->getNativeNumber($max) - $this->getNativeNumber($value); } $this->adapter->increaseDocumentAttribute( $collection, $id, $attribute, - match (true) { - $integerArithmetic => BigInt::toNative($value), - $fractional => $this->wholeOrNative($this->getNativeNumber($value)), - default => $this->getNativeNumber($value), - }, + $numericAttribute?->isInteger() === true + ? BigInt::toNative($value) + : $this->getNativeNumber($value), $updatedAt, max: $max ); @@ -2637,14 +2607,14 @@ public function increaseDocumentAttribute( } /** - * @throws \InvalidArgumentException + * @throws TypeException */ private function assertPositiveChange(int|float|string $value): void { if (! \is_numeric($value) || (\is_string($value) && BigInt::isIntegerString($value) ? BigInt::compare($value, 0) <= 0 : (float) $value <= 0)) { - throw new \InvalidArgumentException('Value must be numeric and greater than 0'); + throw new TypeException('Value must be numeric and greater than 0'); } } @@ -2660,7 +2630,7 @@ private function assertPositiveChange(int|float|string $value): void * * @throws AuthorizationException * @throws DatabaseException - * @throws \InvalidArgumentException When $value is not a number greater than 0 + * @throws TypeException When $value is not a number greater than 0 */ public function decreaseDocumentAttribute( string $collection, @@ -2674,18 +2644,18 @@ public function decreaseDocumentAttribute( $collection = $this->silent(fn () => $this->getCollection($collection)); $numericAttribute = $this->numericAttribute($collection, $attribute); - $declaredInteger = $this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute)); - $fractional = $declaredInteger && $this->isFractionalChange($value, $min); - if ($declaredInteger && ! $fractional && $min !== null) { - $min = $this->integerBound($min, 'Min'); + if ($this->isDeclaredInteger($numericAttribute ?? $this->declaredAttribute($collection, $attribute))) { + $this->assertIntegerChange($value); + if ($min !== null) { + $min = $this->integerBound($min, 'Min'); + } } - $integerArithmetic = $numericAttribute?->isInteger() === true && ! $fractional; $cacheTarget = $collection->getId() === self::METADATA ? new Document([Document::ID => $id, Document::COLLECTION => self::METADATA]) : $collection->getId(); - $document = $this->withMutation(Event::DocumentDecrease, $cacheTarget, function () use ($collection, $id, $attribute, $value, $min, $numericAttribute, $fractional, $integerArithmetic) { + $document = $this->withMutation(Event::DocumentDecrease, $cacheTarget, function () use ($collection, $id, $attribute, $value, $min, $numericAttribute) { /** @var Document $document */ $document = $this->authorization->skip(fn () => $this->silent(fn () => $this->readDocument($collection, $id, forUpdate: true))); // Skip ensures user does not need read permission for this @@ -2706,9 +2676,7 @@ public function decreaseDocumentAttribute( $attributeExists = $document->offsetExists($attribute); $currentDecVal = $document->getAttribute($attribute); - if ($fractional) { - $result = $this->getFractionalResult($currentDecVal, $value, false); - } elseif ($numericAttribute instanceof Attribute) { + if ($numericAttribute instanceof Attribute) { $result = $this->getNumericResult($numericAttribute, $currentDecVal, $value, false); } else { if (! $attributeExists) { @@ -2720,7 +2688,7 @@ public function decreaseDocumentAttribute( $result = $currentDecVal - $this->getNativeNumber($value); } $belowMinimum = ! \is_null($min) && ( - $integerArithmetic + $numericAttribute?->isInteger() === true ? BigInt::compare($result, $min) < 0 : $result < $min ); @@ -2732,22 +2700,18 @@ public function decreaseDocumentAttribute( $updatedAt = $document->getUpdatedAt(); $updatedAt = (empty($updatedAt) || ! $this->datePreservation()->get()) ? $time : DateTime::setTimezone($updatedAt); if ($min !== null) { - $min = match (true) { - $integerArithmetic => BigInt::add($min, $value), - $fractional => $this->wholeOrNative($this->getNativeNumber($min) + $this->getNativeNumber($value)), - default => $this->getNativeNumber($min) + $this->getNativeNumber($value), - }; + $min = $numericAttribute?->isInteger() === true + ? BigInt::add($min, $value) + : $this->getNativeNumber($min) + $this->getNativeNumber($value); } $this->adapter->increaseDocumentAttribute( $collection, $id, $attribute, - match (true) { - $integerArithmetic => BigInt::negate($value), - $fractional => $this->wholeOrNative($this->getNativeNumber($value) * -1), - default => $this->getNativeNumber($value) * -1, - }, + $numericAttribute?->isInteger() === true + ? BigInt::negate($value) + : $this->getNativeNumber($value) * -1, $updatedAt, min: $min ); diff --git a/src/Database/Trait/Transactions.php b/src/Database/Trait/Transactions.php index 7c218eb66..da8eefdea 100644 --- a/src/Database/Trait/Transactions.php +++ b/src/Database/Trait/Transactions.php @@ -166,17 +166,13 @@ private function withAdapterTransaction(callable $callback): mixed $discard = function () use ($context, $queued): void { \array_splice($this->documentPurgeEvents[$context], $queued); }; - $outermost = ! $this->adapter->inTransaction(); $returned = false; try { - return $this->adapter->withTransaction(function () use ($callback, $discard, $outermost, $context, &$returned): mixed { + return $this->adapter->withTransaction(function () use ($callback, $discard, &$returned): mixed { $returned = false; $discard(); $result = $callback(); - if ($outermost) { - $this->announceBeforeCommit($context); - } $returned = true; return $result; @@ -196,24 +192,6 @@ private function withAdapterTransaction(callable $callback): mixed } } - /** - * Fire the document purge events queued in the outermost transaction before it commits, as 7.x fired them - * from inside the write's transaction: a listener that fails rolls the write back and its failure is thrown, - * rather than the write committing and the request failing after it. - * - * @throws Throwable The first listener failure - */ - private function announceBeforeCommit(int $context): void - { - $events = $this->documentPurgeEvents[$context]; - $this->documentPurgeEvents[$context] = []; - - $failure = $this->announceDocumentPurges($events); - if ($failure !== null) { - throw $failure; - } - } - /** * Fire every queued document purge event, even after one of them fails. * @@ -236,11 +214,10 @@ private function announceDocumentPurges(array $events): ?Throwable /** * Keep all nested mutation tombstones blocked, and purge every written document - * again, once the outer transaction has committed or rolled back. The outermost adapter - * transaction fires the document purge events queued in it before it commits; any queued - * outside one fire after the commit, even when the invalidation after it fails, and after a - * commit that could not be confirmed, whose failure is still the one thrown. A rollback - * drops them, also when its callback threw Exception\Unconfirmed. + * again, once the outer transaction has committed or rolled back. Document purge + * events queued in the scope fire after a commit, even when the invalidation after it + * fails, and after a commit that could not be confirmed, whose failure is still the + * one thrown. A rollback drops them, also when its callback threw Exception\Unconfirmed. * * @template T * diff --git a/src/Database/Validator/ObjectValue.php b/src/Database/Validator/ObjectValue.php index fbfb6edfa..3c335c07d 100644 --- a/src/Database/Validator/ObjectValue.php +++ b/src/Database/Validator/ObjectValue.php @@ -6,14 +6,6 @@ class ObjectValue extends Validator { - /** - * @param bool $stored Whether the value is one already stored, which 7.x accepted as any JSON string or any - * empty value too - */ - public function __construct(private readonly bool $stored = false) - { - } - #[\Override] public function getDescription(): string { @@ -23,10 +15,6 @@ public function getDescription(): string #[\Override] public function isValid(mixed $value): bool { - if ($this->stored && (empty($value) || (\is_string($value) && \json_validate($value)))) { - return true; - } - if (is_string($value)) { $decoded = json_decode($value); diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index ec565483c..082895af4 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -129,93 +129,6 @@ private function isValidLimit(mixed $limit, Attribute $attribute, DatabaseOperat return true; } - /** - * A numeric operator on an integer or double attribute is checked as 7.x checked it: the change and the limit - * only have to be numeric, so a fractional change or limit on an integer reaches the engine, which stores the - * result its own way. Without a limit, the result predicted from the current document has to fit the attribute. - * - * @param array $values - */ - private function isValidNumericChange(DatabaseOperator $operator, Attribute $attribute, array $values): bool - { - $method = $operator->getMethod(); - $methodName = $method->value; - - if (! isset($values[0]) || ! \is_numeric($values[0]) || ! \is_finite((float) $values[0])) { - $this->message = "Cannot apply {$methodName} operator: value must be numeric, got ".\gettype($operator->getValue()); - - return false; - } - - if (($method === OperatorType::Divide || $method === OperatorType::Modulo) && (float) $values[0] === 0.0) { - $this->message = "Cannot apply {$methodName} operator: ".($method === OperatorType::Divide ? 'division' : 'modulo').' by zero'; - - return false; - } - - if (\count($values) > 1 && $values[1] !== null && (! \is_numeric($values[1]) || ! \is_finite((float) $values[1]))) { - $this->message = "Cannot apply {$methodName} operator: max/min limit must be numeric, got ".\gettype($values[1]); - - return false; - } - - if ($this->currentDocument === null || ! $attribute->isInteger() || isset($values[1])) { - return true; - } - - $stored = $this->getIntegerValue($this->currentDocument->getAttribute($operator->getAttribute()) ?? 0); - $bounds = $attribute->bounds(); - if ($stored === null || $bounds === null || ! $this->isNumericValueInBounds($stored, $attribute)) { - $this->message = "Cannot apply {$methodName} operator: current value is outside the attribute range"; - - return false; - } - - $current = $this->getNumericValue($stored); - $change = $this->getNumericValue($values[0]); - if ($current === null || $change === null) { - return true; - } - $predicted = match ($method) { - OperatorType::Increment => $current + $change, - OperatorType::Decrement => $current - $change, - OperatorType::Multiply => $current * $change, - OperatorType::Divide => $current / $change, - OperatorType::Modulo => (int) $change === 0 ? $current : (int) $current % (int) $change, - default => $current ** $change, - }; - - if ($predicted > (float) $bounds->max) { - $this->message = "Cannot apply {$methodName} operator: would overflow maximum value of {$bounds->max}"; - - return false; - } - - if ($predicted < (float) $bounds->min) { - $this->message = "Cannot apply {$methodName} operator: would underflow minimum value of {$bounds->min}"; - - return false; - } - - return true; - } - - /** - * 7.x checked a numeric item appended to an integer array against the range only, so a fractional item passes - * here; a bigint array, new in 8.0, checks the item is a whole number in range. - */ - private function isArrayItemInBounds(int|float|string $item, Attribute $attribute): bool - { - if ($attribute->type === ColumnType::BigInteger) { - return $this->isNumericValueInBounds($item, $attribute); - } - - $bounds = $attribute->bounds(); - $numeric = $this->getNumericValue($item); - - return $bounds !== null && $numeric !== null && $numeric <= (float) $bounds->max && $numeric >= (float) $bounds->min; - } - private function getIntegerValue(mixed $value): int|string|null { if (\is_int($value)) { @@ -361,14 +274,6 @@ private function validateOperatorForAttribute( return false; } - if ($type !== ColumnType::BigInteger) { - if (! $this->isValidNumericChange($operator, $attribute, $values)) { - return false; - } - - break; - } - if (! isset($values[0]) || ! $this->isNumericValueInBounds($values[0], $attribute)) { $this->message = "Cannot apply {$methodName} operator: value must be numeric, got ".gettype($operator->getValue()); @@ -440,7 +345,7 @@ private function validateOperatorForAttribute( if (! empty($values) && $attribute->isInteger()) { $newItems = \is_array($values[0]) ? $values[0] : $values; foreach ($newItems as $item) { - if (\is_numeric($item) && ! $this->isArrayItemInBounds($item, $attribute)) { + if (\is_numeric($item) && ! $this->isNumericValueInBounds($item, $attribute)) { $bounds = $attribute->bounds(); if ($bounds === null) { return false; diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index b65375877..c249ce8c6 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -60,9 +60,8 @@ class Structure extends Validator /** * @param list $storedAttributes Attributes whose values are the stored ones, unchanged by the - * write. They are validated again, as 7.x did, so a value a - * narrowed definition no longer admits fails the update, but an - * object value is held to what 7.x accepted when it was stored. + * write: they are not validated again, as the rules may have + * tightened since those values were stored. */ public function __construct( protected readonly Document $collection, @@ -276,6 +275,10 @@ protected function checkForInvalidAttributeValues(Document $document, array $str continue; } + if (isset($this->storedAttributes[$key])) { + continue; + } + $attribute = $definitions[$key] ?? null; if ($attribute === null) { continue; @@ -361,7 +364,7 @@ protected function checkForInvalidAttributeValues(Document $document, array $str break; case ColumnType::Object: - $validators[] = new ObjectValue(stored: isset($this->storedAttributes[$key])); + $validators[] = new ObjectValue(); break; case ColumnType::Point: diff --git a/src/Database/Validator/UID.php b/src/Database/Validator/UID.php index e55d55137..7ee9d6e8d 100644 --- a/src/Database/Validator/UID.php +++ b/src/Database/Validator/UID.php @@ -14,6 +14,6 @@ public function __construct(int $maxLength = Database::MAX_UID_DEFAULT_LENGTH) #[\Override] public function getDescription(): string { - return 'UID must contain at most '.$this->maxLength.' chars. Valid chars are a-z, A-Z, 0-9, and underscore. Can\'t start with a leading underscore'; + return 'UID must contain at most '.$this->maxLength.' chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a leading period, hyphen, or underscore'; } } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 4f930cadf..dec413e7a 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -2029,18 +2029,54 @@ public function testContainsAllOnAStringMatchesAnyWholeValueAs7xDid(): void $this->assertSame($adapter instanceof Mongo ? [] : ['lower', 'other'], $ids(['alpha', 'beta'])); } - public function testFindFulltextExactTermOnPostgresMatchesEveryWordInAnyOrder(): void + public function testFindFulltextExactTermMatchesTheAdjacentPhraseOnEveryEngine(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (! $database->getAdapter() instanceof Postgres) { + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->createPhraseSearchCollection($database, 'full_text_exact_phrase'); + + $this->assertSame(['phrase', 'sentence'], $this->searchedIds($database, $collection, '"donald trump"')); + + if ($database->getAdapter() instanceof Mongo) { + return; + } + + $excluded = \array_map( + static fn (Document $document): string => $document->getId(), + $database->find($collection, [Query::notSearch('text', '"donald trump"')]), + ); + \sort($excluded); + $this->assertSame(['apart', 'one', 'other', 'reversed'], $excluded); + } + + public function testFindFulltextWordsWithoutQuotesMatchAnyWordInAnyOrder(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::IndexFulltext)) { $this->expectNotToPerformAssertions(); return; } - $collection = 'full_text_exact_words'; + $collection = $this->createPhraseSearchCollection($database, 'full_text_any_word'); + + $anyWord = ['apart', 'one', 'phrase', 'reversed', 'sentence']; + $this->assertSame($anyWord, $this->searchedIds($database, $collection, 'donald trump')); + $this->assertSame($anyWord, $this->searchedIds($database, $collection, 'trump donald')); + $this->assertSame($anyWord, $this->searchedIds($database, $collection, 'donald/trump')); + } + + private function createPhraseSearchCollection(Database $database, string $collection): string + { $database->createCollection(Collection::create(id: $collection, permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), @@ -2048,7 +2084,15 @@ public function testFindFulltextExactTermOnPostgresMatchesEveryWordInAnyOrder(): $database->createAttribute($collection, Attribute::string(key: 'text', size: 128, required: true)); $database->createIndex($collection, Index::fulltext(key: 'text-ft', attributes: ['text'])); - foreach (['phrase' => 'donald trump', 'reversed' => 'trump donald', 'apart' => 'donald j trump', 'one' => 'donald duck'] as $id => $text) { + $texts = [ + 'phrase' => 'donald trump', + 'sentence' => 'president donald trump spoke', + 'reversed' => 'trump donald', + 'apart' => 'donald john trump', + 'one' => 'donald duck', + 'other' => 'mickey mouse', + ]; + foreach ($texts as $id => $text) { $database->createDocument($collection, new Document([ '$id' => $id, '$permissions' => [Permission::read(Role::any())], @@ -2056,13 +2100,19 @@ public function testFindFulltextExactTermOnPostgresMatchesEveryWordInAnyOrder(): ])); } - $this->assertSame(['apart', 'phrase', 'reversed'], $this->searchedIds($database, $collection, '"donald trump"')); + return $collection; + } - $excluded = \array_map( - static fn (Document $document): string => $document->getId(), - $database->find($collection, [Query::notSearch('text', '"donald trump"')]), - ); - $this->assertSame(['one'], $excluded); + private function engineFamily(Database $database): ?string + { + $adapter = $database->getAdapter(); + + return match (true) { + $adapter instanceof MariaDB => 'mariadb', + $adapter instanceof Postgres => 'postgres', + $adapter instanceof Mongo => 'mongo', + default => null, + }; } /** @@ -10867,11 +10917,10 @@ private function statementsOn(Database $database, string $table, callable $read) return [$result, $statements]; } - public function testIncreaseAndDecreaseCompareAFractionalBoundOnAnIntegerAs7xDid(): void + public function testIncreaseAndDecreaseRefuseAFractionalBoundOnAnInteger(): void { /** @var Database $database */ $database = $this->getDatabase(); - $adapter = $database->getAdapter(); $collection = 'fractional_bound_'.uniqid(); $database->createCollection(Collection::create(id: $collection, permissions: [ @@ -10880,42 +10929,35 @@ public function testIncreaseAndDecreaseCompareAFractionalBoundOnAnIntegerAs7xDid Permission::update(Role::any()), ], documentSecurity: false)); $database->createAttribute($collection, Attribute::integer(key: 'count', required: true)); - foreach (['up', 'past', 'down', 'whole'] as $id) { - $database->createDocument($collection, new Document(['$id' => $id, 'count' => 5])); - } + $database->createDocument($collection, new Document(['$id' => 'counter', 'count' => 100])); try { try { - $database->increaseDocumentAttribute($collection, 'past', 'count', 2, 6.4); - $this->fail('An increase past a fractional maximum was accepted'); - } catch (LimitException $error) { - $this->assertSame('Attribute value exceeds maximum limit: 6.4', $error->getMessage()); + $database->increaseDocumentAttribute($collection, 'counter', 'count', 1, 102.4); + $this->fail('A fractional maximum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Max must be an integer.', $error->getMessage()); } - $this->assertSame(5, $database->getDocument($collection, 'past')->getAttribute('count')); - if ($adapter instanceof Postgres) { - $this->assertInstanceOf(\PDOException::class, $this->failureOf(fn () => $database->increaseDocumentAttribute($collection, 'up', 'count', 1, 6.4))); - $this->assertInstanceOf(\PDOException::class, $this->failureOf(fn () => $database->decreaseDocumentAttribute($collection, 'down', 'count', 1, 0.5))); - $this->assertSame(5, $database->getDocument($collection, 'up')->getAttribute('count')); - } else { - $this->assertSame(6, $database->increaseDocumentAttribute($collection, 'up', 'count', 1, 6.4)->getAttribute('count')); - $this->assertSame(4, $database->decreaseDocumentAttribute($collection, 'down', 'count', 1, 0.5)->getAttribute('count')); - $this->assertSame(6, $database->getDocument($collection, 'up')->getAttribute('count')); - $this->assertSame(4, $database->getDocument($collection, 'down')->getAttribute('count')); + try { + $database->decreaseDocumentAttribute($collection, 'counter', 'count', 1, 0.5); + $this->fail('A fractional minimum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Min must be an integer.', $error->getMessage()); } - $this->assertSame(6, $database->increaseDocumentAttribute($collection, 'whole', 'count', 1, 6.0)->getAttribute('count')); - $this->assertSame(5, $database->decreaseDocumentAttribute($collection, 'whole', 'count', 1, 5.0)->getAttribute('count')); + $this->assertSame(100, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(101, $database->increaseDocumentAttribute($collection, 'counter', 'count', 1, 102.0)->getAttribute('count')); + $this->assertSame(100, $database->decreaseDocumentAttribute($collection, 'counter', 'count', 1, 99.0)->getAttribute('count')); } finally { $database->deleteCollection($collection); } } - public function testIncreaseAndDecreaseApplyAFractionalChangeOnAnIntegerAs7xDid(): void + public function testIncreaseAndDecreaseRefuseAFractionalChangeValueOnAnInteger(): void { /** @var Database $database */ $database = $this->getDatabase(); - $adapter = $database->getAdapter(); $collection = 'fractional_change_'.uniqid(); $database->createCollection(Collection::create(id: $collection, permissions: [ @@ -10924,43 +10966,30 @@ public function testIncreaseAndDecreaseApplyAFractionalChangeOnAnIntegerAs7xDid( Permission::update(Role::any()), ], documentSecurity: false)); $database->createAttribute($collection, Attribute::integer(key: 'count', required: true)); - foreach (['up', 'down', 'whole'] as $id) { - $database->createDocument($collection, new Document(['$id' => $id, 'count' => 5])); - } + $database->createDocument($collection, new Document(['$id' => 'counter', 'count' => 100])); try { - if ($adapter instanceof Postgres) { - $this->assertInstanceOf(\PDOException::class, $this->failureOf(fn () => $database->increaseDocumentAttribute($collection, 'up', 'count', 1.5))); - $this->assertInstanceOf(\PDOException::class, $this->failureOf(fn () => $database->decreaseDocumentAttribute($collection, 'down', 'count', 0.5))); - $this->assertSame(5, $database->getDocument($collection, 'up')->getAttribute('count')); - } else { - $this->assertSame(6.5, $database->increaseDocumentAttribute($collection, 'up', 'count', 1.5)->getAttribute('count')); - $this->assertSame(4.5, $database->decreaseDocumentAttribute($collection, 'down', 'count', 0.5)->getAttribute('count')); + try { + $database->increaseDocumentAttribute($collection, 'counter', 'count', 1.5); + $this->fail('A fractional increase of an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Change value must be an integer.', $error->getMessage()); + } - if ($adapter instanceof MariaDB || $adapter instanceof Mongo) { - $this->assertSame(6, $database->getDocument($collection, 'up')->getAttribute('count')); - $this->assertSame(4, $database->getDocument($collection, 'down')->getAttribute('count')); - } + try { + $database->decreaseDocumentAttribute($collection, 'counter', 'count', 0.5); + $this->fail('A fractional decrease of an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Change value must be an integer.', $error->getMessage()); } - $this->assertSame(7.0, $database->increaseDocumentAttribute($collection, 'whole', 'count', 2.0)->getAttribute('count')); - $this->assertSame(7, $database->getDocument($collection, 'whole')->getAttribute('count')); + $this->assertSame(100, $database->getDocument($collection, 'counter')->getAttribute('count')); + $this->assertSame(102, $database->increaseDocumentAttribute($collection, 'counter', 'count', 2)->getAttribute('count')); } finally { $database->deleteCollection($collection); } } - private function failureOf(callable $call): ?Throwable - { - try { - $call(); - } catch (Throwable $error) { - return $error; - } - - return null; - } - public function testDistinctIsRefusedWhereTheAdapterCannotDeduplicate(): void { /** @var Database $database */ diff --git a/tests/e2e/Adapter/Scopes/OperatorTests.php b/tests/e2e/Adapter/Scopes/OperatorTests.php index 95b15a23a..0bf19d41a 100644 --- a/tests/e2e/Adapter/Scopes/OperatorTests.php +++ b/tests/e2e/Adapter/Scopes/OperatorTests.php @@ -5230,13 +5230,19 @@ public function testOperatorWholeFloatLimitHoldsAndAFractionalLimitIsRefused(): $this->assertSame(PHP_INT_MAX - 5, $updated->getAttribute('counter')); $this->assertSame(PHP_INT_MAX - 5, $database->getDocument($collectionId, 'doc')->getAttribute('counter')); - $past = $database->updateDocument($collectionId, 'doc', new Document(['count' => Operator::increment(5, 102.4)])); - $this->assertSame($database->getDocument($collectionId, 'doc')->getAttribute('count'), $past->getAttribute('count')); - - if ($this->engineFamily($database) !== null) { - $this->assertSame(100, $past->getAttribute('count')); - $this->assertSame(102, $database->updateDocument($collectionId, 'doc', new Document(['count' => Operator::increment(2, 102.4)]))->getAttribute('count')); + try { + $database->updateDocument($collectionId, 'doc', new Document([ + 'count' => Operator::increment(5, 102.4), + ])); + $this->fail('A fractional limit on an integer attribute must be refused'); + } catch (StructureException $exception) { + $this->assertSame( + "Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'count', got 102.4", + $exception->getMessage(), + ); } + + $this->assertSame(100, $database->getDocument($collectionId, 'doc')->getAttribute('count')); } finally { $database->deleteCollection($collectionId); } @@ -5287,68 +5293,4 @@ public function testPowerWithANumericTextExponent(): void $database->deleteCollection($collectionId); } } - - public function testFractionalNumericOperatorsOnAnIntegerReachTheEngineAs7xDid(): void - { - $database = static::getDatabase(); - $family = $this->engineFamily($database); - - if (! $database->getAdapter()->supports(Capability::Operators) || $family === null) { - $this->expectNotToPerformAssertions(); - - return; - } - - $collectionId = 'operator_fractional_changes'; - $database->createCollection(Collection::create(id: $collectionId, permissions: [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())], documentSecurity: false)); - $database->createAttribute($collectionId, Attribute::integer(key: 'count')); - $database->createAttribute($collectionId, Attribute::integer(key: 'nums', array: true)); - - try { - $changes = [ - 'increment' => [Operator::increment(1.5), ['mariadb' => 6, 'mongo' => 6]], - 'multiply' => [Operator::multiply(1.5), ['mariadb' => 8, 'mongo' => 7]], - 'divide' => [Operator::divide(0.5), ['mariadb' => 10, 'mongo' => 10]], - ]; - foreach ($changes as $id => [$operator, $expected]) { - $database->createDocument($collectionId, new Document(['$id' => $id, 'count' => 5, 'nums' => [1]])); - - if ($family === 'postgres') { - $failure = null; - try { - $database->updateDocument($collectionId, $id, new Document(['count' => $operator])); - } catch (\Throwable $error) { - $failure = $error; - } - $this->assertInstanceOf(\PDOException::class, $failure, "PostgreSQL stored a fractional {$id} of an integer"); - $this->assertSame(5, $database->getDocument($collectionId, $id)->getAttribute('count'), $id); - - continue; - } - - $this->assertSame($expected[$family], $database->updateDocument($collectionId, $id, new Document(['count' => $operator]))->getAttribute('count'), $id); - $this->assertSame($expected[$family], $database->getDocument($collectionId, $id)->getAttribute('count'), $id); - } - - $database->createDocument($collectionId, new Document(['$id' => 'append', 'count' => 5, 'nums' => [1]])); - $this->assertSame([1, 1], $database->updateDocument($collectionId, 'append', new Document(['nums' => Operator::arrayAppend([1.5])]))->getAttribute('nums')); - } finally { - $database->deleteCollection($collectionId); - } - } - - /** - * The engine a 7.x comparison is pinned for: MariaDB and MySQL, PostgreSQL or MongoDB. - */ - private function engineFamily(Database $database): ?string - { - $adapter = $database->getAdapter(); - - return match (true) { - $adapter instanceof \Utopia\Database\Adapter\MariaDB => 'mariadb', - $adapter instanceof \Utopia\Database\Adapter\Postgres => 'postgres', - $adapter instanceof \Utopia\Database\Adapter\Mongo => 'mongo', - default => null, - }; - } } diff --git a/tests/unit/Adapter/RedisAdapterPathsTest.php b/tests/unit/Adapter/RedisAdapterPathsTest.php index 72344bbb3..78452fe7a 100644 --- a/tests/unit/Adapter/RedisAdapterPathsTest.php +++ b/tests/unit/Adapter/RedisAdapterPathsTest.php @@ -394,7 +394,7 @@ public function testUniqueIndexComparesArrayValuesByContent(): void $adapter->createDocument($this->notes(), new Document(['$id' => 'second', '$permissions' => [], 'tags' => ['a', 'b']])); } - public function testFractionalOperatorLimitOnABigIntegerIsRefusedBeforeTheWrite(): void + public function testFractionalOperatorLimitIsRefusedBeforeTheWrite(): void { $database = $this->database(); $database->create(); @@ -406,12 +406,12 @@ public function testFractionalOperatorLimitOnABigIntegerIsRefusedBeforeTheWrite( $database->createDocument(self::NOTES, new Document(['$id' => 'counter', 'count' => 100, 'big' => PHP_INT_MAX - 5])); try { - $database->updateDocument(self::NOTES, 'counter', new Document(['big' => Operator::increment(5, 4.0e15 + 0.5)])); - $this->fail('A fractional limit on a bigint attribute must be refused'); + $database->updateDocument(self::NOTES, 'counter', new Document(['count' => Operator::increment(5, 102.4)])); + $this->fail('A fractional limit on an integer attribute must be refused'); } catch (StructureException $exception) { - $this->assertStringStartsWith("Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'big'", $exception->getMessage()); + $this->assertSame("Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'count', got 102.4", $exception->getMessage()); } - $this->assertSame(PHP_INT_MAX - 5, $database->getDocument(self::NOTES, 'counter')->getAttribute('big')); + $this->assertSame(100, $database->getDocument(self::NOTES, 'counter')->getAttribute('count')); $database->updateDocument(self::NOTES, 'counter', new Document(['big' => Operator::increment(10, 9.0e18)])); $this->assertSame(PHP_INT_MAX - 5, $database->getDocument(self::NOTES, 'counter')->getAttribute('big')); diff --git a/tests/unit/Builder/SearchTermTest.php b/tests/unit/Builder/SearchTermTest.php index 7b39973d6..3c6ab33f5 100644 --- a/tests/unit/Builder/SearchTermTest.php +++ b/tests/unit/Builder/SearchTermTest.php @@ -18,23 +18,23 @@ final class SearchTermTest extends TestCase public static function postgreSQLTerms(): array { return [ - 'slash separates words' => ['foo/bar', "'foo or bar'"], - 'comma separates words' => ['foo,bar', "'foo or bar'"], - 'semicolon separates words' => ['foo;bar', "'foo or bar'"], - 'percent separates words' => ['foo%bar', "'foo or bar'"], - 'equals separates words' => ['foo=bar', "'foo or bar'"], - 'question mark separates words' => ['foo?bar', "'foo or bar'"], - 'hash separates words' => ['foo#bar', "'foo or bar'"], - 'colon separates words' => ['foo:bar', "'foo or bar'"], - 'pipe separates words' => ['foo|bar', "'foo or bar'"], - 'ampersand separates words' => ['foo&bar', "'foo or bar'"], - 'exclamation mark separates words' => ['foo!bar', "'foo or bar'"], - 'underscore stays inside a word' => ['foo_bar', "'foo_bar'"], - 'mixed separators and spaces' => ['baz, foo/bar; qux', "'baz or foo or bar or qux'"], - 'operators are dropped' => ['+foo -bar* @3 ~qux (quux)', "'foo or bar or 3 or baz or qux or quux'"], - 'accented words are kept' => ['@García!', "'García'"], - 'exact term matches every word in single quotes' => ['"foo/bar baz"', "'foo bar baz'"], - 'unbalanced quote is not exact' => ['"foo/bar', "'foo or bar'"], + 'slash separates words' => ['foo/bar', 'foo or bar'], + 'comma separates words' => ['foo,bar', 'foo or bar'], + 'semicolon separates words' => ['foo;bar', 'foo or bar'], + 'percent separates words' => ['foo%bar', 'foo or bar'], + 'equals separates words' => ['foo=bar', 'foo or bar'], + 'question mark separates words' => ['foo?bar', 'foo or bar'], + 'hash separates words' => ['foo#bar', 'foo or bar'], + 'colon separates words' => ['foo:bar', 'foo or bar'], + 'pipe separates words' => ['foo|bar', 'foo or bar'], + 'ampersand separates words' => ['foo&bar', 'foo or bar'], + 'exclamation mark separates words' => ['foo!bar', 'foo or bar'], + 'underscore stays inside a word' => ['foo_bar', 'foo_bar'], + 'mixed separators and spaces' => ['baz, foo/bar; qux', 'baz or foo or bar or qux'], + 'operators are dropped' => ['+foo -bar* @3 ~qux (quux)', 'foo or bar or 3 or baz or qux or quux'], + 'accented words are kept' => ['@García!', 'García'], + 'exact phrase keeps its quotes' => ['"foo/bar baz"', '"foo bar baz"'], + 'unbalanced quote is not exact' => ['"foo/bar', 'foo or bar'], ]; } @@ -77,16 +77,6 @@ public function testPostgreSQLSearchesEachWordOfATerm(string $term, string $boun $this->assertSame([$bound], $this->bindings(new Postgres(), Query::notSearch('title', $term))); } - public function testPostgreSQLMatchesTheTermWithWebsearchToTsquery(): void - { - $search = (new Postgres())->compileFilters([Query::search('title', '"foo bar"')]); - $notSearch = (new Postgres())->compileFilters([Query::notSearch('title', '"foo bar"')]); - - $this->assertStringContainsString("@@ websearch_to_tsquery(?)", $search->expression); - $this->assertStringStartsWith('NOT (', $notSearch->expression); - $this->assertSame(["'foo bar'"], $search->bindings); - } - #[DataProvider('mySQLTerms')] public function testMySQLSearchesEachWordOfATerm(string $term, string $bound): void { diff --git a/tests/unit/Documents/FractionalBoundTest.php b/tests/unit/Documents/FractionalBoundTest.php index 1e14d929d..7b454a485 100644 --- a/tests/unit/Documents/FractionalBoundTest.php +++ b/tests/unit/Documents/FractionalBoundTest.php @@ -13,6 +13,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Limit as LimitException; +use Utopia\Database\Exception\Type as TypeException; use Utopia\Database\Operator; use Utopia\Database\Permission; use Utopia\Database\Role; @@ -34,38 +35,44 @@ public static function lanes(): iterable } #[DataProvider('lanes')] - public function testAFractionalMaximumOnAnIntegerIsComparedAs7xDid(bool $definedAttributes): void + public function testIncreaseWithAFractionalMaximumOnAnIntegerIsRefused(bool $definedAttributes): void { $database = $this->database($definedAttributes); - $this->assertSame(101, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 102.4)->getAttribute('count')); - $this->assertSame(102, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '102.5')->getAttribute('count')); - try { - $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 102.5); - $this->fail('An increase past a fractional maximum was accepted'); - } catch (LimitException $error) { - $this->assertSame('Attribute value exceeds maximum limit: 102.5', $error->getMessage()); + $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 102.4); + $this->fail('A fractional maximum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Max must be an integer.', $error->getMessage()); } - $this->assertSame(102, $this->stored($database, 'count')); + $this->assertSame(100, $this->stored($database, 'count')); } #[DataProvider('lanes')] - public function testAFractionalMinimumOnAnIntegerIsComparedAs7xDid(bool $definedAttributes): void + public function testDecreaseWithAFractionalMinimumOnAnIntegerIsRefused(bool $definedAttributes): void { $database = $this->database($definedAttributes); - $this->assertSame(99, $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 0.5)->getAttribute('count')); - try { - $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 99, 0.5); - $this->fail('A decrease past a fractional minimum was accepted'); - } catch (LimitException $error) { - $this->assertSame('Attribute value exceeds minimum limit: 0.5', $error->getMessage()); + $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, 0.5); + $this->fail('A fractional minimum on an integer attribute was accepted'); + } catch (TypeException $error) { + $this->assertSame('Min must be an integer.', $error->getMessage()); } - $this->assertSame(99, $this->stored($database, 'count')); + $this->assertSame(100, $this->stored($database, 'count')); + } + + #[DataProvider('lanes')] + public function testANonNumericBoundOnAnIntegerIsRefused(bool $definedAttributes): void + { + $database = $this->database($definedAttributes); + + $this->expectException(TypeException::class); + $this->expectExceptionMessage('Max must be an integer.'); + + $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1, '102.5'); } #[DataProvider('lanes')] @@ -111,14 +118,24 @@ public function testWholeNumberStringBoundsOnAnIntegerAreAcceptedAsOperatorLimit } #[DataProvider('lanes')] - public function testAFractionalChangeOnAnIntegerReturnsTheSumAs7xDid(bool $definedAttributes): void + public function testAFractionalChangeValueOnAnIntegerIsRefused(bool $definedAttributes): void { - $this->assertSame(101.5, $this->database($definedAttributes)->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5)->getAttribute('count')); - $this->assertSame(99.5, $this->database($definedAttributes)->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 0.5)->getAttribute('count')); - $this->assertSame(101.5, $this->database($definedAttributes)->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', '1.5')->getAttribute('count')); - $this->assertSame(102.0, $this->database($definedAttributes)->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 2.0)->getAttribute('count')); - $database = $this->database($definedAttributes); + + foreach ([ + 'increase' => static fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 1.5), + 'decrease' => static fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 0.5), + 'increase by a numeric string' => static fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', '1.5'), + ] as $case => $change) { + try { + $change(); + $this->fail("A fractional change value on an integer attribute was accepted ({$case})"); + } catch (TypeException $error) { + $this->assertSame('Change value must be an integer.', $error->getMessage(), $case); + } + } + + $this->assertSame(100, $this->stored($database, 'count')); $this->assertSame(102, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'count', 2)->getAttribute('count')); $this->assertSame(3.0, $database->increaseDocumentAttribute(self::COLLECTION, self::DOCUMENT, 'ratio', 1.5)->getAttribute('ratio')); } diff --git a/tests/unit/Documents/FractionalOperatorLimitTest.php b/tests/unit/Documents/FractionalOperatorLimitTest.php index d13cf367b..621e83a4c 100644 --- a/tests/unit/Documents/FractionalOperatorLimitTest.php +++ b/tests/unit/Documents/FractionalOperatorLimitTest.php @@ -19,36 +19,34 @@ final class FractionalOperatorLimitTest extends TestCase { private const string COLLECTION = 'counters'; + private const string REFUSAL = "Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'count', got 102.4"; - public function testUpdateDocumentAppliesAFractionalLimitOnAnIntegerAs7xDid(): void + public function testUpdateDocumentRefusesAFractionalLimitBeforeTheWrite(): void { $database = $this->database(); - $updated = $database->updateDocument(self::COLLECTION, 'counter', new Document(['count' => Operator::increment(5, 102.4)])); - - $this->assertSame($database->getDocument(self::COLLECTION, 'counter')->getAttribute('count'), $updated->getAttribute('count')); - } - - public function testUpdateDocumentsAppliesAFractionalLimitOnAnIntegerAs7xDid(): void - { - $database = $this->database(); + try { + $database->updateDocument(self::COLLECTION, 'counter', new Document(['count' => Operator::increment(5, 102.4)])); + $this->fail('A fractional limit on an integer attribute must be refused'); + } catch (StructureException $exception) { + $this->assertSame(self::REFUSAL, $exception->getMessage()); + } - $this->assertSame(1, $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(1, 102.4)]))); - $this->assertSame(101, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('count')); + $this->assertSame(100, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('count')); } - public function testUpdateDocumentRefusesAFractionalLimitOnABigIntegerBeforeTheWrite(): void + public function testUpdateDocumentsRefusesAFractionalLimitBeforeTheWrite(): void { $database = $this->database(); try { - $database->updateDocument(self::COLLECTION, 'counter', new Document(['big' => Operator::increment(5, 4.0e15 + 0.5)])); - $this->fail('A fractional limit on a bigint attribute must be refused'); + $database->updateDocuments(self::COLLECTION, new Document(['count' => Operator::increment(1, 102.4)])); + $this->fail('A fractional limit on an integer attribute must be refused'); } catch (StructureException $exception) { - $this->assertStringStartsWith("Invalid document structure: Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'big'", $exception->getMessage()); + $this->assertSame(self::REFUSAL, $exception->getMessage()); } - $this->assertSame(0, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('big')); + $this->assertSame(100, $database->getDocument(self::COLLECTION, 'counter')->getAttribute('count')); } public function testAWholeFloatLimitHoldsABigIntegerAtTheSignedEdge(): void diff --git a/tests/unit/Documents/IncreaseDecreaseTest.php b/tests/unit/Documents/IncreaseDecreaseTest.php index acc45c032..80b3eb746 100644 --- a/tests/unit/Documents/IncreaseDecreaseTest.php +++ b/tests/unit/Documents/IncreaseDecreaseTest.php @@ -432,7 +432,7 @@ public function testIncreaseDocumentAttributeExceedsMax(): void public function testIncreaseDocumentAttributeWithZeroValue(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(TypeException::class); $this->expectExceptionMessage('Value must be numeric and greater than 0'); $doc = new Document([ @@ -449,7 +449,7 @@ public function testIncreaseDocumentAttributeWithZeroValue(): void public function testIncreaseDocumentAttributeWithNegativeValue(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(TypeException::class); $this->expectExceptionMessage('Value must be numeric and greater than 0'); $doc = new Document([ @@ -526,7 +526,7 @@ public function testDecreaseDocumentAttributeExceedsMin(): void public function testDecreaseDocumentAttributeWithZeroValue(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(TypeException::class); $this->expectExceptionMessage('Value must be numeric and greater than 0'); $doc = new Document([ diff --git a/tests/unit/Documents/IncreaseValueTest.php b/tests/unit/Documents/IncreaseValueTest.php index 1fe750dcd..2d990ef4a 100644 --- a/tests/unit/Documents/IncreaseValueTest.php +++ b/tests/unit/Documents/IncreaseValueTest.php @@ -2,11 +2,12 @@ namespace Tests\Unit\Documents; -use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\Unit\Event\HookFixture; use Utopia\Database\Database; +use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Exception\Type as TypeException; final class IncreaseValueTest extends TestCase { @@ -43,11 +44,11 @@ public function testDecreaseRefusesAChangeThatIsNotAPositiveNumber(int|float|str $this->assertSame(1, $database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('views')); } - public function testTheRefusalIsAnInvalidArgumentAs7xThrew(): void + public function testTheRefusalIsADatabaseException(): void { $database = $this->database(); - $this->expectException(InvalidArgumentException::class); + $this->expectException(DatabaseException::class); $database->increaseDocumentAttribute(HookFixture::COLLECTION, 'first', 'views', 0); } @@ -65,7 +66,7 @@ private function assertRefused(callable $operation): void try { $operation(); $this->fail('A change that is not a positive number was accepted'); - } catch (InvalidArgumentException $error) { + } catch (TypeException $error) { $this->assertSame('Value must be numeric and greater than 0', $error->getMessage()); } } diff --git a/tests/unit/Documents/NumericUpdateGuardsTest.php b/tests/unit/Documents/NumericUpdateGuardsTest.php index 5027971c4..afa3e5cc3 100644 --- a/tests/unit/Documents/NumericUpdateGuardsTest.php +++ b/tests/unit/Documents/NumericUpdateGuardsTest.php @@ -120,8 +120,8 @@ public function testAnIntegerStringChangeIsCheckedAsAnInteger(): void $this->assertSame(12, $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', '3')->getAttribute('count')); foreach (['0', '-4'] as $change) { - $this->assertRefused(\InvalidArgumentException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); - $this->assertRefused(\InvalidArgumentException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); + $this->assertRefused(TypeException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); + $this->assertRefused(TypeException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->decreaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); } $this->assertSame(12, $database->getDocument(self::COLLECTION, 'ledger')->getAttribute('count')); } diff --git a/tests/unit/Documents/StoredValueRevalidationTest.php b/tests/unit/Documents/StoredValueRevalidationTest.php index 644b2ad51..e5193eccb 100644 --- a/tests/unit/Documents/StoredValueRevalidationTest.php +++ b/tests/unit/Documents/StoredValueRevalidationTest.php @@ -37,30 +37,44 @@ public static function setUpBeforeClass(): void }, ColumnType::String); } - public function testAnUpdateFailsOnAStoredValueANarrowedRangeNoLongerAdmits(): void + public function testAnUpdateKeepsAStoredValueANarrowedRangeNoLongerAdmits(): void { $database = $this->database(); $database->updateAttribute(self::COLLECTION, 'level', new AttributeUpdate(format: new Format('storedRange', ['min' => 0, 'max' => 10]))); - try { - $database->updateDocument(self::COLLECTION, 'row', new Document(['note' => 'after narrow'])); - $this->fail('An update kept a stored value the narrowed range refuses'); - } catch (StructureException $error) { - $this->assertSame('Invalid document structure: Attribute "level" has invalid format. Value must be a valid range between 0 and 10', $error->getMessage()); - } + $updated = $database->updateDocument(self::COLLECTION, 'row', new Document(['note' => 'after narrow'])); - $this->assertSame('before', $database->getDocument(self::COLLECTION, 'row')->getAttribute('note')); + $this->assertSame('after narrow', $updated->getAttribute('note')); + $stored = $database->getDocument(self::COLLECTION, 'row'); + $this->assertSame('after narrow', $stored->getAttribute('note')); + $this->assertSame(50, $stored->getAttribute('level')); } - public function testAnUpdateFailsOnAStoredValueAShrunkEnumNoLongerAdmits(): void + public function testAnUpdateKeepsAStoredValueAShrunkEnumNoLongerAdmits(): void { $database = $this->database(); $database->updateAttribute(self::COLLECTION, 'kind', new AttributeUpdate(format: new Format('storedEnum', ['elements' => ['a']]))); - $this->expectException(StructureException::class); - $this->expectExceptionMessage('Attribute "kind" has invalid format'); - $database->updateDocument(self::COLLECTION, 'row', new Document(['note' => 'after shrink'])); + + $stored = $database->getDocument(self::COLLECTION, 'row'); + $this->assertSame('after shrink', $stored->getAttribute('note')); + $this->assertSame('b', $stored->getAttribute('kind')); + } + + public function testAnUpdateThatWritesAValueTheNarrowedRangeRefusesFails(): void + { + $database = $this->database(); + $database->updateAttribute(self::COLLECTION, 'level', new AttributeUpdate(format: new Format('storedRange', ['min' => 0, 'max' => 10]))); + + try { + $database->updateDocument(self::COLLECTION, 'row', new Document(['level' => 60, 'note' => 'written'])); + $this->fail('An update wrote a value the narrowed range refuses'); + } catch (StructureException $error) { + $this->assertSame('Invalid document structure: Attribute "level" has invalid format. Value must be a valid range between 0 and 10', $error->getMessage()); + } + + $this->assertSame('before', $database->getDocument(self::COLLECTION, 'row')->getAttribute('note')); } private function database(): Database diff --git a/tests/unit/Event/DocumentPurgeTest.php b/tests/unit/Event/DocumentPurgeTest.php index d8f135e42..3e633cb51 100644 --- a/tests/unit/Event/DocumentPurgeTest.php +++ b/tests/unit/Event/DocumentPurgeTest.php @@ -233,7 +233,7 @@ public function testPurgeCachedDocumentPurgesTheDocument(Closure $database): voi $this->assertSame(['posts/first'], $this->purged($recorder)); } - public function testDocumentPurgeFiresForEveryAttemptThatReachesItsCommit(): void + public function testDocumentPurgeFiresOnceWhenTheTransactionIsRetried(): void { $adapter = new class () extends Memory { public int $commitFailures = 0; @@ -258,16 +258,13 @@ public function commitTransaction(): bool $database->deleteDocuments(HookFixture::COLLECTION); $this->assertSame([ - Event::DocumentPurge, Event::DocumentPurge, Event::DocumentUpdate, Event::DocumentPurge, Event::DocumentPurge, - Event::DocumentPurge, - Event::DocumentPurge, Event::DocumentsDelete, ], $recorder->getEvents()); - $this->assertSame(['posts/first', 'posts/first', 'posts/first', 'posts/second', 'posts/first', 'posts/second'], $this->purged($recorder)); + $this->assertSame(['posts/first', 'posts/first', 'posts/second'], $this->purged($recorder)); } /** @@ -289,26 +286,6 @@ public function testDocumentPurgeHookFailureReachesTheCaller(Closure $call): voi } } - /** - * @param Closure(Database): mixed $write - */ - #[DataProvider('writes')] - public function testAPurgeListenerFailureRollsTheWriteBack(Closure $write): void - { - $database = HookFixture::sqlite(); - HookFixture::seed($database, ['first', 'second']); - $stored = static fn (): array => \array_map( - static fn (Document $document): array => [$document->getId(), $document->getAttribute('title'), $document->getAttribute('views')], - $database->find(HookFixture::COLLECTION), - ); - $before = $stored(); - $failure = new RuntimeException('region broadcast failed'); - $database->addHook(new FailingLifecycle(Event::DocumentPurge, $failure)); - - $this->assertSame($failure, $this->failureOf(static fn () => $write($database))); - $this->assertSame($before, $stored()); - } - public function testDocumentPurgeThroughMirrorReachesTheCaller(): void { $source = HookFixture::sqlite(); @@ -383,17 +360,55 @@ public static function writes(): iterable * @param Closure(Database): mixed $write */ #[DataProvider('writes')] - public function testPurgeEventsInsideACallerTransactionFireBeforeItsCommit(Closure $write): void + public function testPurgeEventsInsideACallerTransactionFireAfterTheCommit(Closure $write): void { [$database, $recorder] = $this->seeded(HookFixture::sqlite(), ['first']); $inTransaction = $this->observePurges($database, static fn (): bool => $database->getAdapter()->inTransaction()); $database->withTransaction(static fn (): mixed => $write($database)); - $this->assertSame([true], $inTransaction->getArrayCopy()); + $this->assertSame([false], $inTransaction->getArrayCopy()); $this->assertSame(['posts/first'], $this->purged($recorder)); } + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('writes')] + public function testACachePurgeFailureRollsTheWriteBackAndFiresNoPurgeEvent(Closure $write): void + { + $cache = new class () extends MemoryCache { + public bool $unreachable = false; + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->unreachable) { + throw new RuntimeException('cache went away'); + } + + return parent::purge($key, $hash); + } + }; + $database = HookFixture::sqlite(); + $database->setCache(new Cache($cache)); + [$database, $recorder] = $this->seeded($database, ['first']); + $stored = static fn (): array => \array_map( + static fn (Document $document): array => [$document->getId(), $document->getAttribute('title'), $document->getAttribute('views')], + $database->find(HookFixture::COLLECTION), + ); + $before = $stored(); + + $cache->unreachable = true; + $thrown = $this->failureOf(static fn (): mixed => $write($database)); + $cache->unreachable = false; + + $this->assertInstanceOf(RuntimeException::class, $thrown); + $this->assertSame('cache went away', $thrown->getMessage()); + $this->assertSame($before, $stored()); + $this->assertSame([], $this->purged($recorder)); + } + /** * @param Closure(Database): mixed $write */ @@ -489,7 +504,7 @@ static function () use ($database, $abandoned): never { * @param Closure(Database): mixed $write */ #[DataProvider('writes')] - public function testPurgeEventsOfARetriedCallerTransactionFireForEveryAttemptThatReachesItsCommit(Closure $write): void + public function testPurgeEventsOfARetriedCallerTransactionFireOnce(Closure $write): void { $adapter = new class (new PDO('sqlite::memory:')) extends SQLite { public int $commitFailures = 0; @@ -512,7 +527,7 @@ public function commitTransaction(): bool $database->withTransaction(static fn (): mixed => $write($database)); $this->assertSame(0, $adapter->commitFailures); - $this->assertSame(['posts/first', 'posts/first'], $this->purged($recorder)); + $this->assertSame(['posts/first'], $this->purged($recorder)); } public function testPurgeEventsOfWritesWithoutAnAdapterTransactionFireAtOnce(): void @@ -727,7 +742,7 @@ public function testPurgeEventsOfAnUnconfirmedCommitFire(Closure $write, bool $s } #[DataProvider('savepoints')] - public function testEveryAttemptOfARetriedTransactionThatReachesItsCommitAnnouncesItsPurgeEvents(bool $savepoints): void + public function testOnlyTheUnconfirmedAttemptOfARetriedTransactionAnnouncesItsPurgeEvents(bool $savepoints): void { [$database, $recorder] = $this->unconfirmed($savepoints, ['first', 'second'], commitFailures: 1); $attempts = 0; @@ -741,7 +756,7 @@ public function testEveryAttemptOfARetriedTransactionThatReachesItsCommitAnnounc $this->assertSame(2, $attempts); $this->assertInstanceOf(UnconfirmedException::class, $thrown); - $this->assertSame(['posts/first', 'posts/second'], $this->purged($recorder)); + $this->assertSame(['posts/second'], $this->purged($recorder)); } #[DataProvider('savepoints')] @@ -763,11 +778,10 @@ static function () use ($database, $abandoned): never { } #[DataProvider('savepoints')] - public function testAPurgeListenerFailureRollsTheTransactionBackBeforeItsCommit(bool $savepoints): void + public function testAnUnconfirmedCommitStaysTheFailureWhenAPurgeListenerFails(bool $savepoints): void { [$database, $recorder] = $this->unconfirmed($savepoints, ['first', 'second']); - $failure = new RuntimeException('region broadcast failed'); - $database->addHook(new FailingLifecycle(Event::DocumentPurge, $failure)); + $database->addHook(new FailingLifecycle(Event::DocumentPurge, new RuntimeException('region broadcast failed'))); $thrown = $this->failureOf(static function () use ($database): void { $database->withTransaction(static function () use ($database): void { @@ -776,10 +790,8 @@ public function testAPurgeListenerFailureRollsTheTransactionBackBeforeItsCommit( }); }); - $this->assertSame($failure, $thrown); + $this->assertInstanceOf(UnconfirmedException::class, $thrown); $this->assertSame(['posts/first', 'posts/second'], $this->purged($recorder)); - $this->assertSame('first', $database->getDocument(HookFixture::COLLECTION, 'first')->getAttribute('title')); - $this->assertSame('second', $database->getDocument(HookFixture::COLLECTION, 'second')->getAttribute('title')); } /** @@ -805,7 +817,7 @@ static function () use ($database, $foreign): never { } #[DataProvider('savepoints')] - public function testOnlyTheAttemptThatReachedItsCommitAnnouncesWhenARetriedCallbackThrowsAnotherCommitsUnconfirmed(bool $savepoints): void + public function testPurgeEventsAreDroppedWhenARetriedCallbackThrowsAnotherCommitsUnconfirmed(bool $savepoints): void { [$database, $recorder] = $this->unconfirmed($savepoints, ['first', 'second'], commitFailures: 1, confirmed: true); $foreign = new UnconfirmedException('Failed to commit transaction: the commit could not be confirmed'); @@ -824,7 +836,7 @@ public function testOnlyTheAttemptThatReachedItsCommitAnnouncesWhenARetriedCallb $this->assertSame($foreign, $this->failureOf(static fn (): mixed => $database->withTransaction($attempt))); $this->assertSame(2, $attempts); - $this->assertSame(['posts/first'], $this->purged($recorder)); + $this->assertSame([], $this->purged($recorder)); } #[DataProvider('savepoints')] diff --git a/tests/unit/Operator/OperatorLimitTest.php b/tests/unit/Operator/OperatorLimitTest.php index 886f1e78c..655693d19 100644 --- a/tests/unit/Operator/OperatorLimitTest.php +++ b/tests/unit/Operator/OperatorLimitTest.php @@ -14,36 +14,29 @@ final class OperatorLimitTest extends TestCase { /** - * @return array + * @return array */ public static function fractionalLimits(): array { return [ - 'increment max' => [OperatorType::Increment, 102.4], - 'decrement min' => [OperatorType::Decrement, -0.5], - 'multiply max' => [OperatorType::Multiply, 99.9], - 'divide min' => [OperatorType::Divide, 1.5], - 'power max' => [OperatorType::Power, 1000.01], - 'numeric string' => [OperatorType::Increment, '102.4'], + 'increment max' => [OperatorType::Increment, 'count', 102.4], + 'decrement min' => [OperatorType::Decrement, 'count', -0.5], + 'multiply max' => [OperatorType::Multiply, 'count', 99.9], + 'divide min' => [OperatorType::Divide, 'count', 1.5], + 'power max' => [OperatorType::Power, 'count', 1000.01], + 'numeric string' => [OperatorType::Increment, 'count', '102.4'], + 'big integer' => [OperatorType::Increment, 'big', 4.0e15 + 0.5], ]; } #[DataProvider('fractionalLimits')] - public function testAFractionalLimitOnAnIntegerAttributeIsLeftToTheEngineAs7xDid(OperatorType $method, int|float|string $limit): void + public function testAFractionalLimitOnAnIntegerAttributeIsRefused(OperatorType $method, string $attribute, int|float|string $limit): void { $validator = $this->validator(); - $this->assertTrue($validator->isValid(new Operator($method, 'count', [2, $limit])), $validator->getDescription()); - } - - public function testAFractionalLimitOnABigIntegerAttributeIsRefused(): void - { - $validator = $this->validator(); - $limit = 4.0e15 + 0.5; - - $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'big', [2, $limit]))); + $this->assertFalse($validator->isValid(new Operator($method, $attribute, [2, $limit]))); $this->assertSame( - "Cannot apply increment operator: max/min limit must be a whole number for integer attribute 'big', got {$limit}", + "Cannot apply {$method->value} operator: max/min limit must be a whole number for integer attribute '{$attribute}', got {$limit}", $validator->getDescription(), ); } diff --git a/tests/unit/Validator/OperatorValidatorTest.php b/tests/unit/Validator/OperatorValidatorTest.php index c55d775e0..894c38a98 100644 --- a/tests/unit/Validator/OperatorValidatorTest.php +++ b/tests/unit/Validator/OperatorValidatorTest.php @@ -163,11 +163,13 @@ public function testAStoredValueInsideTheAttributeRangeIsAccepted(): void $this->assertTrue($validator->isValid(new Operator(OperatorType::Increment, 'count', [1])), $validator->getDescription()); } - public function testAFractionalResultInRangeIsLeftToTheEngineAs7xDid(): void + public function testAResultThatCannotBePredictedIsRejected(): void { $validator = $this->numericValidator(new Document(['count' => 2])); - $this->assertTrue($validator->isValid(new Operator(OperatorType::Power, 'count', [-1])), $validator->getDescription()); + $this->assertFalse($validator->isValid(new Operator(OperatorType::Power, 'count', [-1]))); + $this->assertSame('Cannot apply power operator: result is outside the attribute range', $validator->getDescription()); + $this->assertTrue($validator->isValid(new Operator(OperatorType::Power, 'count', [3])), $validator->getDescription()); } @@ -251,8 +253,7 @@ public function testAWholeFloatOperandOnAnIntegerIsAcceptedAsTheInteger(): void $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'count', [6.0]))); $this->assertStringContainsString('would overflow maximum value', $validator->getDescription()); $this->assertFalse($validator->isValid(new Operator(OperatorType::Increment, 'count', [5.5]))); - $this->assertSame('Cannot apply increment operator: would overflow maximum value of 2147483647', $validator->getDescription()); - $this->assertTrue($validator->isValid(new Operator(OperatorType::Increment, 'count', [4.5])), $validator->getDescription()); + $this->assertStringStartsWith('Cannot apply increment operator: value must be numeric', $validator->getDescription()); $this->assertFalse($validator->isValid(new Operator(OperatorType::Divide, 'count', [0.0]))); $this->assertSame('Cannot apply divide operator: division by zero', $validator->getDescription()); } diff --git a/tests/unit/Validator/QueriesCompatibilityTest.php b/tests/unit/Validator/QueriesCompatibilityTest.php index c6c47a2c7..2d673e363 100644 --- a/tests/unit/Validator/QueriesCompatibilityTest.php +++ b/tests/unit/Validator/QueriesCompatibilityTest.php @@ -94,11 +94,17 @@ public function test_tenant_passes_select_validation_without_shared_tables(): vo $this->assertTrue($this->documents(sharedTables: true)->isValid(['{"method":"select","values":["$tenant"]}'])); } - public function test_uid_description(): void + public function test_uid_description_names_every_character_it_accepts(): void { + $uid = new UID(); + + $this->assertTrue($uid->isValid('a.b-c_d')); + $this->assertFalse($uid->isValid('.abc')); + $this->assertFalse($uid->isValid('-abc')); + $this->assertFalse($uid->isValid('_abc')); $this->assertSame( - 'UID must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, and underscore. Can\'t start with a leading underscore', - (new UID())->getDescription(), + 'UID must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a leading period, hyphen, or underscore', + $uid->getDescription(), ); } }