recordClass) === '') { throw new \InvalidArgumentException('Derived state keys require a non-empty record class.'); } if (trim($this->recordKey) === '') { throw new \InvalidArgumentException('Derived state keys require a non-empty record key.'); } if (trim($this->variant) === '') { throw new \InvalidArgumentException('Derived state keys require a non-empty variant.'); } } /** * @param array|string|null $context */ public static function fromModel( DerivedStateFamily $family, Model $record, string $variant, array|string|null $context = null, ?int $workspaceId = null, ?int $tenantId = null, ): self { return new self( family: $family, recordClass: $record::class, recordKey: (string) $record->getKey(), variant: $variant, workspaceId: $workspaceId ?? self::normalizeScopeId($record->getAttribute('workspace_id')), tenantId: $tenantId ?? self::normalizeScopeId($record->getAttribute('tenant_id')), contextHash: self::hashContext($context), ); } /** * @return array{ * family: string, * record_class: string, * record_key: string, * variant: string, * workspace_id: ?int, * tenant_id: ?int, * context_hash: ?string * } */ public function toArray(): array { return [ 'family' => $this->family->value, 'record_class' => $this->recordClass, 'record_key' => $this->recordKey, 'variant' => $this->variant, 'workspace_id' => $this->workspaceId, 'tenant_id' => $this->tenantId, 'context_hash' => $this->contextHash, ]; } public function fingerprint(): string { try { /** @var string $json */ $json = json_encode($this->toArray(), JSON_THROW_ON_ERROR); } catch (JsonException $exception) { throw new \RuntimeException('Unable to encode derived state key fingerprint.', previous: $exception); } return $json; } public function matches( DerivedStateFamily $family, ?string $recordClass = null, string|int|null $recordKey = null, ?string $variant = null, ?int $workspaceId = null, ?int $tenantId = null, ): bool { if ($this->family !== $family) { return false; } if ($recordClass !== null && $this->recordClass !== $recordClass) { return false; } if ($recordKey !== null && $this->recordKey !== (string) $recordKey) { return false; } if ($variant !== null && $this->variant !== $variant) { return false; } if ($workspaceId !== null && $this->workspaceId !== $workspaceId) { return false; } if ($tenantId !== null && $this->tenantId !== $tenantId) { return false; } return true; } /** * @param array|string|null $context */ public static function hashContext(array|string|null $context): ?string { if ($context === null) { return null; } if (is_string($context)) { $context = trim($context); return $context === '' ? null : sha1($context); } if ($context === []) { return null; } $normalized = self::normalizeContext($context); try { /** @var string $json */ $json = json_encode($normalized, JSON_THROW_ON_ERROR); } catch (JsonException $exception) { throw new \RuntimeException('Unable to encode derived state context.', previous: $exception); } return sha1($json); } /** * @param array $context * @return array */ private static function normalizeContext(array $context): array { ksort($context); foreach ($context as $key => $value) { if (is_array($value)) { /** @var mixed $normalized */ $normalized = array_is_list($value) ? array_map(static fn (mixed $item): mixed => is_array($item) ? self::normalizeContext($item) : $item, $value) : self::normalizeContext($value); $context[$key] = $normalized; } } return $context; } private static function normalizeScopeId(mixed $value): ?int { if (! is_numeric($value)) { return null; } $normalized = (int) $value; return $normalized > 0 ? $normalized : null; } }