Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/advanced_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ $objectManager->contains($user);
$objectManager->getExpirationTime($user);
```

### Handling unique constraint violations

If any entity has a `#[Unique]` constraint, `flush()` may throw `UniqueConstraintViolationException`. Wrap it where needed:

```php
use Talleu\RedisOm\Exception\UniqueConstraintViolationException;

try {
$objectManager->persist($user);
$objectManager->flush();
} catch (UniqueConstraintViolationException $e) {
// $e->getMessage() describes which field(s) and value(s) conflicted
}
```

See [mapping.md](mapping.md#unique-constraints) for the full reference: composite constraints, merge/remove behavior, concurrency guarantees, and limitations.

You can also retrieve and query your objects with the ObjectManager or a given repository
```php
$objectManager = new RedisObjectManager(); // For Symfony users directly inject RedisObjectManagerInterface in your constructor
Expand Down
133 changes: 133 additions & 0 deletions docs/mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,139 @@ Each of these parameters are optional and can be omitted. Here is a description

The #[RedisOm\Id] attribute is by default indexed and could be requested.

## Unique constraints

`#[Unique]` guarantees that no two objects of the same class share the same value for a given field (or combination of fields) at the time of `flush()`.

### Single-field constraint

Place `#[RedisOm\Unique]` on a property alongside `#[RedisOm\Property]`:

```php
<?php

use Talleu\RedisOm\Om\Mapping as RedisOm;

#[RedisOm\Entity]
class User
{
#[RedisOm\Id]
#[RedisOm\Property]
public int $id;

#[RedisOm\Property(index: true)]
#[RedisOm\Unique]
public string $email;

#[RedisOm\Property]
public string $name;
}
```

Attempting to persist two objects with the same email throws on `flush()`:

```php
use Talleu\RedisOm\Exception\UniqueConstraintViolationException;

$alice = new User(); $alice->id = 1; $alice->email = 'alice@example.com';
$bob = new User(); $bob->id = 2; $bob->email = 'alice@example.com'; // duplicate

$objectManager->persist($alice);
$objectManager->persist($bob);

try {
$objectManager->flush();
} catch (UniqueConstraintViolationException $e) {
echo $e->getMessage();
// Unique constraint violation on App\Entity\User::email, value "alice@example.com" already exists.
}
```

### Composite constraint

Place `#[RedisOm\Unique(properties: [...])]` at the **class level** to enforce uniqueness on a combination of fields.
The attribute is repeatable, so multiple independent constraints can be declared on the same class.

```php
<?php

use Talleu\RedisOm\Om\Mapping as RedisOm;

#[RedisOm\Entity]
#[RedisOm\Unique(properties: ['username', 'tenantId'])]
#[RedisOm\Unique(properties: ['email', 'tenantId'])]
class User
{
#[RedisOm\Id]
#[RedisOm\Property]
public int $id;

#[RedisOm\Property]
public string $username;

#[RedisOm\Property]
public int $tenantId;

#[RedisOm\Property]
public string $email;
}
```

The same `username` is allowed across different tenants; only the combination `(username, tenantId)` must be unique:

```php
// OK: same username, different tenants
$u1 = new User(); $u1->id = 1; $u1->username = 'john'; $u1->tenantId = 1;
$u2 = new User(); $u2->id = 2; $u2->username = 'john'; $u2->tenantId = 2;
$objectManager->persist($u1);
$objectManager->persist($u2);
$objectManager->flush(); // succeeds

// NOT OK: duplicate combination
$u3 = new User(); $u3->id = 3; $u3->username = 'john'; $u3->tenantId = 1;
$objectManager->persist($u3);
$objectManager->flush(); // throws UniqueConstraintViolationException
// Unique constraint violation on App\Entity\User: combination (tenantId="1", username="john") already exists.
```

### Behavior during merge and remove

**merge():** when you load an object via `find()`, change a unique field, and call `merge()`, the library detects the change, deletes the old unique key and claims the new one — atomically. If the new value is already taken, `flush()` throws.

```php
$user = $objectManager->find(User::class, 1); // email = 'old@example.com'
$user->email = 'new@example.com';

$objectManager->merge($user);
$objectManager->flush(); // old key released, new key claimed
```

**remove():** unique keys are deleted inside the same transaction as the object, so the value becomes immediately available for another object:

```php
$objectManager->remove($user);
$objectManager->flush(); // unique key released

$other->email = $user->email;
$objectManager->persist($other);
$objectManager->flush(); // succeeds
```

### Concurrency guarantee

`flush()` uses Redis [WATCH][watch] + [MULTI][multi]/[EXEC][exec] to prevent race conditions: all relevant unique keys are watched before the transaction, checked for collisions, then written atomically. If a concurrent process claims the same key between `WATCH` and `EXEC`, the transaction is aborted and `UniqueConstraintViolationException::concurrentModification()` is thrown.

### Limitations

- Violations are detected only at `flush()` time, not at `persist()` or `merge()` time.
- Unique fields must be scalar values (string, int, float). Arrays and nested objects are not supported.
- `#[Unique]` does not imply `#[Property(index: true)]`. Add the index separately if you need to query by that field.
- The library does not scan existing data when `#[Unique]` is added to an existing class. Run your own deduplication before deploying the constraint.

[watch]: https://redis.io/docs/latest/commands/watch/
[multi]: https://redis.io/docs/latest/commands/multi/
[exec]: https://redis.io/docs/latest/commands/exec/

## Update the schema
After each modification of your classes, you have to update the schema in Redis. You can do it by running the following command:

Expand Down
36 changes: 34 additions & 2 deletions src/Client/PredisClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,38 @@ public function jsonGetMultiple(array $keys): array
return $data;
}

/**
* @inheritdoc
*/
public function get(string $key): ?string
{
return $this->redis->get(Converter::prefix($key));
}

/**
* @inheritdoc
*/
public function set(string $key, string $value): void
{
$this->redis->set(Converter::prefix($key), $value);
}

/**
* @inheritdoc
*/
public function watch(string ...$keys): void
{
$this->redis->watch(...array_map([Converter::class, 'prefix'], $keys));
}

/**
* @inheritdoc
*/
public function unwatch(): void
{
$this->redis->unwatch();
}

/**
* @inheritdoc
*/
Expand All @@ -396,9 +428,9 @@ public function multi(): void
/**
* @inheritdoc
*/
public function exec(): void
public function exec(): bool
{
$this->redis->exec();
return $this->redis->exec() !== null;
}

/**
Expand Down
37 changes: 35 additions & 2 deletions src/Client/RedisClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,39 @@ public function jsonGetMultiple(array $keys): array
return $data;
}

/**
* @inheritdoc
*/
public function get(string $key): ?string
{
$result = $this->redis->get(Converter::prefix($key));
return $result === false ? null : $result;
}

/**
* @inheritdoc
*/
public function set(string $key, string $value): void
{
$this->redis->set(Converter::prefix($key), $value);
}

/**
* @inheritdoc
*/
public function watch(string ...$keys): void
{
$this->redis->watch(array_map([Converter::class, 'prefix'], $keys));
}

/**
* @inheritdoc
*/
public function unwatch(): void
{
$this->redis->unwatch();
}

/**
* @inheritdoc
*/
Expand All @@ -384,9 +417,9 @@ public function multi(): void
/**
* @inheritdoc
*/
public function exec(): void
public function exec(): bool
{
$this->redis->exec();
return $this->redis->exec() !== false;
}

/**
Expand Down
23 changes: 22 additions & 1 deletion src/Client/RedisClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,36 @@ public function hGetAllMultiple(array $keys): array;
*/
public function jsonGetMultiple(array $keys): array;

/**
* Get a string value by key. Returns null when the key does not exist.
*/
public function get(string $key): ?string;

/**
* Set a string value by key.
*/
public function set(string $key, string $value): void;

/**
* Watch one or more keys for changes before a MULTI/EXEC transaction.
*/
public function watch(string ...$keys): void;

/**
* Cancel all WATCHed keys without starting a transaction.
*/
public function unwatch(): void;

/**
* Begin a Redis transaction (MULTI).
*/
public function multi(): void;

/**
* Execute a Redis transaction (EXEC).
* Returns false when a WATCHed key was modified and the transaction was aborted.
*/
public function exec(): void;
public function exec(): bool;

/**
* Discard a Redis transaction (DISCARD).
Expand Down
42 changes: 42 additions & 0 deletions src/Exception/UniqueConstraintViolationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Talleu\RedisOm\Exception;

final class UniqueConstraintViolationException extends \RuntimeException
{
public static function forField(string $class, string $field, mixed $value): self
{
return self::forFields($class, [$field], [(string) $value]);
}

/**
* @param string[] $fields
* @param string[] $values Values in the same order as $fields.
*/
public static function forFields(string $class, array $fields, array $values): self
{
if (count($fields) === 1) {
return new self(sprintf(
'Unique constraint violation on %s::%s, value "%s" already exists.',
$class,
$fields[0],
$values[0]
));
}

$pairs = array_map(fn (string $f, string $v) => sprintf('%s="%s"', $f, $v), $fields, $values);

return new self(sprintf(
'Unique constraint violation on %s: combination (%s) already exists.',
$class,
implode(', ', $pairs)
));
}

public static function concurrentModification(): self
{
return new self('Transaction aborted: a unique constraint key was modified concurrently. Retry the operation.');
}
}
2 changes: 1 addition & 1 deletion src/Om/Mapping/Property.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* This attribute should be used to persist the properties of an entity in the Redis datastore.
* You could leave the index true to benefits of automatic indexing,
* #[Property(index: true)] will enable an index for this property, by default it will be a text + tag index
* Or you couldd specify the indexe(s) type(s) for each property you want to query :
* Or you could specify the indexe(s) type(s) for each property you want to query :
* #[Property(type: ['title' => 'TEXT', 'title_tag' => 'TAG'])]
* #[Property(type: ['price' => 'NUMERIC', 'price_tag' => 'TAG'])]
*/
Expand Down
20 changes: 20 additions & 0 deletions src/Om/Mapping/Unique.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Talleu\RedisOm\Om\Mapping;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final class Unique
{
/**
* @param string[] $properties Field names for a composite unique constraint (class-level only).
* Leave empty when placed on a property.
*/
public function __construct(
public readonly array $properties = [],
) {
}
}
1 change: 1 addition & 0 deletions src/Om/Persister/AbstractPersister.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public function delete(Entity $objectMapper, $object): ObjectToPersist
persisterClass: get_class($this),
operation: PersisterOperations::OPERATION_DELETE->value,
redisKey: $key,
value: $object,
);
}

Expand Down
1 change: 1 addition & 0 deletions src/Om/Persister/ObjectToPersist.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public function __construct(
public object|array|null $value = null,
public ?int $ttl = null,
public ?array $changedFields = null,
public ?array $previousValues = null,
) {
}
}
Loading
Loading