<?php
declare(strict_types=1);
namespace Fahara02\UdbLaravel\Generated;
use Fahara02\UdbLaravel\Exceptions\UdbConfigurationException;
use Fahara02\UdbLaravel\Exceptions\UdbRpcException;
use Fahara02\UdbLaravel\UdbMetadata;
use Grpc\BaseStub;
use Grpc\ChannelCredentials;
final class GeneratedClient
{
private const RETRYABLE_CODES = [
14, 8, ];
public const OPERATION_KIND = [
"{{RPC_WIRE_NAME}}" => "{{RPC_OPERATION_KIND}}",
];
public const OPERATION_KIND_BY_RPC = [
"{{SERVICE_NAME}}/{{RPC_WIRE_NAME}}" => "{{RPC_OPERATION_KIND}}",
];
public const API_ALIAS = [
"{{SERVICE_NAME}}/{{RPC_WIRE_NAME}}" => "{{RPC_ALIAS_SNAKE}}",
];
public const OPERATION_ID = [
"{{SERVICE_NAME}}/{{RPC_WIRE_NAME}}" => "{{REST_OPERATION_ID}}",
];
public const HTTP_METHOD = [
"{{SERVICE_NAME}}/{{RPC_WIRE_NAME}}" => "{{RPC_HTTP_METHOD}}",
];
public const HTTP_PATH = [
"{{SERVICE_NAME}}/{{RPC_WIRE_NAME}}" => "{{RPC_HTTP_PATH}}",
];
public const REPLAY_SAFE = [
"Delete" => true,
"Upsert" => true,
];
public const ENTITIES = [
"{{ENTITY_MESSAGE_TYPE}}" => ['table' => "{{ENTITY_TABLE}}", 'primary_keys' => [{{ENTITY_PRIMARY_KEYS}}], 'fields' => [{{ENTITY_JSON_FIELDS}}], 'relations_json' => {{ENTITY_RELATIONS_JSON_STRING}}, 'version_field' => "{{ENTITY_VERSION_FIELD}}", 'tenant_field' => "{{ENTITY_TENANT_FIELD}}", 'project_field' => "{{ENTITY_PROJECT_FIELD}}", 'php_type' => "{{ENTITY_PHP_TYPE}}"],
];
public const METHOD_ALIASES = [
{{PHP_METHOD_ALIAS_ENTRIES}}
];
private ?UdbMetadata $boundContext = null;
private array $stubs = [];
private array $lastResponseMetadata = [];
private ?array $channelOptions = null;
public function __construct(private readonly array $config)
{
if (! \extension_loaded('grpc')) {
throw new UdbConfigurationException(
'The "grpc" PHP extension is not loaded. Install via PECL '
. '(`pecl install grpc`) and add "extension=grpc.so" to php.ini.'
);
}
$endpoint = trim((string) ($this->config['endpoint'] ?? ''));
if ($endpoint === '') {
throw new UdbConfigurationException(
'UDB endpoint is not configured. Set UDB_ENDPOINT or '
. 'config/udb.php#endpoint to host:port (e.g. "127.0.0.1:50051").'
);
}
}
public function bindContext(UdbMetadata $metadata): void
{
$this->boundContext = $metadata;
}
public function __call(string $name, array $arguments)
{
$target = self::METHOD_ALIASES[$name] ?? null;
if ($target !== null && $target !== $name && method_exists($this, $target)) {
return $this->{$target}(...$arguments);
}
throw new \BadMethodCallException("Undefined generated UDB method {$name}");
}
private function context(): UdbMetadata
{
if ($this->boundContext === null) {
throw new UdbConfigurationException(
'No UDB request context is bound. Run inside an HTTP request '
. 'with UdbContextMiddleware enabled, call bindContext(), or '
. 'pass an explicit UdbMetadata as the last RPC argument.'
);
}
return $this->boundContext;
}
public function {{PHP_RPC_METHOD_CAMEL}}($request, ?UdbMetadata $metadata = null)
{
return $this->invokeUnary(
'{{RPC_WIRE_NAME}}',
'{{SERVICE_NAME}}',
'{{SERVICE_PKG}}',
fn (BaseStub $stub, array $md, array $opts) => $stub->{{RPC_WIRE_NAME}}($request, $md, $opts),
$metadata,
'{{RPC_OPERATION_KIND}}' === 'read_only',
$request,
);
}
public function {{PHP_RPC_METHOD_CAMEL}}($request, ?UdbMetadata $metadata = null)
{
$stub = $this->stubFor('{{SERVICE_NAME}}', '{{SERVICE_PKG}}');
return $stub->{{RPC_WIRE_NAME}}($request, $this->headers($metadata), $this->callOptions());
}
public function {{PHP_RPC_METHOD_CAMEL}}(?UdbMetadata $metadata = null)
{
$stub = $this->stubFor('{{SERVICE_NAME}}', '{{SERVICE_PKG}}');
return $stub->{{RPC_WIRE_NAME}}($this->headers($metadata), $this->callOptions());
}
public function {{PHP_RPC_METHOD_CAMEL}}(?UdbMetadata $metadata = null)
{
$stub = $this->stubFor('{{SERVICE_NAME}}', '{{SERVICE_PKG}}');
return $stub->{{RPC_WIRE_NAME}}($this->headers($metadata), $this->callOptions());
}
public function {{SERVICE_NAME}}Stub(): BaseStub
{
return $this->stubFor('{{SERVICE_NAME}}', '{{SERVICE_PKG}}');
}
private function stubFor(string $serviceName, string $servicePkg): BaseStub
{
$fqn = $this->stubClass($serviceName, $servicePkg);
if (isset($this->stubs[$fqn])) {
return $this->stubs[$fqn];
}
if (! class_exists($fqn)) {
throw new UdbConfigurationException(
"UDB generated stub `{$fqn}` was not found. Run `buf generate` "
. 'so the gen/ stubs exist before using GeneratedClient.'
);
}
$stub = new $fqn((string) $this->config['endpoint'], $this->buildChannelOptions());
$this->stubs[$fqn] = $stub;
return $stub;
}
private function stubClass(string $serviceName, string $servicePkg): string
{
$ns = implode('\\', array_map('ucfirst', explode('.', $servicePkg)));
return '\\' . $ns . '\\' . $serviceName . 'Client';
}
private function buildChannelOptions(): array
{
if ($this->channelOptions !== null) {
return $this->channelOptions;
}
$tls = (array) ($this->config['tls'] ?? []);
if ((bool) ($tls['enabled'] ?? false)) {
$rootCerts = $tls['root_certs'] ?? null;
if ($rootCerts !== null && ! is_readable((string) $rootCerts)) {
throw new UdbConfigurationException(
"UDB TLS root cert bundle not readable at: {$rootCerts}"
);
}
$pem = $rootCerts !== null ? (string) file_get_contents((string) $rootCerts) : null;
$credentials = ChannelCredentials::createSsl($pem);
} else {
$credentials = ChannelCredentials::createInsecure();
}
$options = (array) ($this->config['channel_options'] ?? []);
$options['credentials'] = $credentials;
$target = $tls['target'] ?? $this->config['endpoint'];
if ($target !== $this->config['endpoint']) {
$options['grpc.default_authority'] = (string) $target;
}
$this->channelOptions = $options;
return $options;
}
private function callOptions(): array
{
$opts = [];
$deadlineMs = (int) ($this->config['deadline_ms'] ?? 30_000);
if ($deadlineMs > 0) {
$opts['timeout'] = $deadlineMs * 1000;
}
return $opts;
}
private function headers(?UdbMetadata $metadata): array
{
return ($metadata ?? $this->context())->toGrpcMetadata();
}
private function invokeUnary(
string $rpcName,
string $serviceName,
string $servicePkg,
callable $invoker,
?UdbMetadata $metadata,
bool $readOnly,
mixed $request = null,
) {
$stub = $this->stubFor($serviceName, $servicePkg);
$md = $this->headers($metadata);
$opts = $this->callOptions();
$replaySafe = (bool) (self::REPLAY_SAFE[$rpcName] ?? false);
$hasIdempotencyKey = $this->hasIdempotencyKey($request);
$retry = (array) ($this->config['retry'] ?? []);
$maxAttempts = max(1, (int) ($retry['max_attempts'] ?? 4));
$baseDelayMs = max(1, (int) ($retry['base_delay_ms'] ?? 50));
$maxDelayMs = max($baseDelayMs, (int) ($retry['max_delay_ms'] ?? 2_000));
$lastStatus = null;
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$call = $invoker($stub, $md, $opts);
$result = $call->wait();
[$response, $status] = $result;
$this->lastResponseMetadata = method_exists($call, 'getMetadata')
? ((array) ($call->getMetadata() ?? []))
: [];
$code = $this->statusCode($status);
if ($code === 0) {
if ($response === null) {
throw new UdbRpcException(
status: 13, details: "UDB {$rpcName} returned OK status with empty body",
raw: $status,
rpcName: $rpcName,
);
}
return $response;
}
$lastStatus = $status;
if ($attempt < $maxAttempts
&& $this->isRetryable($code, $readOnly, $replaySafe, $hasIdempotencyKey)) {
$this->sleepBackoff($attempt, $baseDelayMs, $maxDelayMs);
continue;
}
break;
}
throw $this->mapError($lastStatus, $rpcName);
}
public function lastResponseMetadata(): array
{
return $this->lastResponseMetadata;
}
public const DEFAULT_IR_BACKEND = 'postgres';
private const ORM_TIERS_JSON = {{ORM_TIERS_STRING}};
private const BACKEND_ROLES_JSON = {{BACKEND_ROLES_STRING}};
public static function query(string $messageType): IrQuery
{
return new IrQuery($messageType);
}
public static function writeTo(string $messageType): IrWriteQuery
{
return new IrWriteQuery($messageType);
}
public static function deleteFrom(string $messageType): IrDeleteQuery
{
return new IrDeleteQuery($messageType);
}
public static function repository(string $messageType): EntityRepository
{
$binding = self::ENTITIES[$messageType] ?? null;
if ($binding === null) {
throw new \InvalidArgumentException("udb: unknown entity '{$messageType}'");
}
return new EntityRepository($messageType, $binding);
}
public static function unitOfWork(): UnitOfWork
{
return new UnitOfWork();
}
public static function backendRoles(): array
{
$roles = json_decode(self::BACKEND_ROLES_JSON, true, 512, JSON_THROW_ON_ERROR);
if (! is_array($roles)) {
throw new UdbConfigurationException('UDB backend role map is invalid.');
}
return $roles;
}
public static function ormTiers(): array
{
$tiers = json_decode(self::ORM_TIERS_JSON, true, 512, JSON_THROW_ON_ERROR);
if (! is_array($tiers)) {
throw new UdbConfigurationException('UDB ORM tier map is invalid.');
}
return $tiers;
}
public static function requireEagerIncludeBackend(string $backend): void
{
$tiers = self::ormTiers();
$tier = $tiers[$backend] ?? null;
if ($tier !== 'relational') {
throw new EagerIncludeUnsupportedBackendException($backend, $tier);
}
}
public static function {{ENTITY_ALIAS_CAMEL}}Repository(): EntityRepository
{
return self::repository("{{ENTITY_MESSAGE_TYPE}}");
}
public static function rawDispatchRequest(
string $backend,
string $operation,
string $specJson,
string $resourceName = '',
): object {
$fqn = '\\Udb\\Entity\\V1\\GenericDispatchRequest';
if (! class_exists($fqn)) {
throw new UdbConfigurationException(
'UDB GenericDispatchRequest class not found. Run buf generate before using IR builders.'
);
}
$request = new $fqn();
$request->setBackend($backend);
$request->setOperation($operation);
$request->setResourceName($resourceName);
$request->setSpecJson($specJson);
return $request;
}
private function statusCode(mixed $status): int
{
return is_object($status) ? (int) ($status->code ?? -1) : (int) ($status['code'] ?? -1);
}
private function isRetryable(int $code, bool $readOnly, bool $replaySafe, bool $hasIdempotencyKey): bool
{
if ($readOnly) {
if ($code === 4) { return true;
}
return in_array($code, self::RETRYABLE_CODES, true);
}
if (! $replaySafe || ! $hasIdempotencyKey) {
return false;
}
return in_array($code, self::RETRYABLE_CODES, true);
}
private function hasIdempotencyKey(mixed $request): bool
{
if (! is_object($request) || ! method_exists($request, 'getIdempotencyKey')) {
return false;
}
try {
return trim((string) $request->getIdempotencyKey()) !== '';
} catch (\Throwable) {
return false;
}
}
private function sleepBackoff(int $attempt, int $baseDelayMs, int $maxDelayMs): void
{
$ceil = (int) min($maxDelayMs, $baseDelayMs * (2 ** ($attempt - 1)));
$jittered = random_int(0, max(0, $ceil));
if ($jittered > 0) {
usleep($jittered * 1000);
}
}
private function mapError(mixed $status, string $rpcName): UdbRpcException
{
$detail = $this->decodeErrorDetail($status) ?? $this->synthesizeTransportErrorDetail($status);
$exception = UdbRpcException::fromGrpcStatus($status, $rpcName);
if ($detail !== null && property_exists($exception, 'errorDetail')) {
$exception->errorDetail = $detail;
}
return $exception;
}
private function decodeErrorDetail(mixed $status): ?object
{
$metadata = is_object($status) ? ($status->metadata ?? null) : ($status['metadata'] ?? null);
if (! is_array($metadata)) {
return null;
}
$values = $metadata['udb-error-detail-bin'] ?? null;
if (! is_array($values) || $values === []) {
return null;
}
$fqn = '\\Udb\\Entity\\V1\\ErrorDetail';
if (! class_exists($fqn)) {
return null;
}
try {
$detail = new $fqn();
$detail->mergeFromString((string) $values[0]);
return $detail;
} catch (\Throwable) {
return null;
}
}
private function synthesizeTransportErrorDetail(mixed $status): ?object
{
$code = $this->statusCode($status);
if (! in_array($code, [1, 4, 14], true)) { return null;
}
$detailFqn = '\\Udb\\Entity\\V1\\ErrorDetail';
$kindFqn = '\\Udb\\Entity\\V1\\ErrorKind';
if (! class_exists($detailFqn) || ! class_exists($kindFqn)) {
return null;
}
$operation = match ($code) {
1 => 'cancelled',
4 => 'deadline_exceeded',
14 => 'unavailable',
default => 'transport',
};
try {
$detail = new $detailFqn();
$detail->setBackend('transport');
$detail->setOperation($operation);
$detail->setRetryable($code !== 1);
$detail->setRetryAfterMs(0);
$detail->setKind($kindFqn::ERROR_KIND_RETRYABLE);
return $detail;
} catch (\Throwable) {
return null;
}
}
}
final class IrJson
{
private const COMPARISON_TOKENS = [
'eq' => 'eq',
'ne' => 'ne',
'gt' => 'gt',
'ge' => 'ge',
'lt' => 'lt',
'le' => 'le',
'like' => 'like',
];
public static function logicalValue(mixed $value): mixed
{
if ($value === null) {
return 'Null';
}
if (is_bool($value)) {
return ['Bool' => $value];
}
if (is_int($value)) {
return ['Int' => $value];
}
if (is_float($value)) {
return ['Float' => $value];
}
if (is_string($value)) {
return ['String' => $value];
}
if ($value instanceof \DateTimeInterface) {
$utc = \DateTimeImmutable::createFromInterface($value)
->setTimezone(new \DateTimeZone('UTC'));
return ['Timestamp' => $utc->format('Y-m-d\TH:i:s.u\Z')];
}
if (is_array($value)) {
if (array_is_list($value)) {
return ['Array' => array_map([self::class, 'logicalValue'], $value)];
}
return ['Json' => $value];
}
return ['Json' => $value];
}
public static function logicalRecord(array $row): array
{
ksort($row, SORT_STRING);
$out = [];
foreach ($row as $key => $value) {
$out[(string) $key] = self::logicalValue($value);
}
return $out;
}
public static function comparison(string $field, string $op, mixed $value): array
{
$token = self::COMPARISON_TOKENS[$op] ?? null;
if ($token === null) {
throw new \InvalidArgumentException("udb: unsupported IR operator '{$op}'");
}
return ['Comparison' => ['field' => $field, 'op' => $token, 'value' => self::logicalValue($value)]];
}
public static function inList(string $field, iterable $values): array
{
$encoded = [];
foreach ($values as $value) {
$encoded[] = self::logicalValue($value);
}
return ['InList' => ['field' => $field, 'values' => $encoded]];
}
public static function andFilter(array $predicates): array
{
return ['And' => array_values($predicates)];
}
public static function orFilter(array $predicates): array
{
return ['Or' => array_values($predicates)];
}
public static function filter(array $predicates): ?array
{
$count = count($predicates);
if ($count === 0) {
return null;
}
if ($count === 1) {
return $predicates[0];
}
return ['And' => array_values($predicates)];
}
public static function specJson(array $body): string
{
return json_encode(
['ir' => $body],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
}
}
abstract class IrPredicateBuilder
{
protected array $predicates = [];
public function where(string $field, string $op, mixed $value): static
{
if ($op === 'in') {
return $this->whereIn($field, is_iterable($value) ? $value : [$value]);
}
$this->predicates[] = IrJson::comparison($field, $op, $value);
return $this;
}
public function whereIn(string $field, iterable $values): static
{
$this->predicates[] = IrJson::inList($field, $values);
return $this;
}
public function whereFilter(array $filter): static
{
$this->predicates[] = $filter;
return $this;
}
}
final class EagerIncludeUnsupportedBackendException extends \RuntimeException
{
public function __construct(public readonly string $backend, public readonly ?string $tier = null)
{
parent::__construct(
"udb: backend '{$backend}' is " . ($tier ?? 'unknown') . '; eager include requires a relational backend'
);
}
}
final class IrQuery extends IrPredicateBuilder
{
private ?array $projection = null;
private array $sorts = [];
private array $includes = [];
private ?int $limit = null;
private ?int $offset = null;
public function __construct(private readonly string $messageType) {}
public function select(string ...$fields): self
{
$this->projection = array_values($fields);
return $this;
}
public function orderBy(string $field, string $direction = 'asc'): self
{
$this->sorts[] = ['field' => $field, 'direction' => $direction];
return $this;
}
public function include(string $relation): self
{
if ($relation === '') {
throw new \InvalidArgumentException('udb: include relation name is required');
}
$this->includes[] = ['relation' => $relation];
return $this;
}
public function limit(int $n): self
{
$this->limit = $n;
return $this;
}
public function offset(int $n): self
{
$this->offset = $n;
return $this;
}
public function toEnvelope(): array
{
$body = ['op' => 'read', 'message_type' => $this->messageType];
$filter = IrJson::filter($this->predicates);
if ($filter !== null) {
$body['filter'] = $filter;
}
if ($this->projection !== null && $this->projection !== []) {
$body['projection'] = ['fields' => $this->projection];
}
if ($this->sorts !== []) {
$body['sort'] = $this->sorts;
}
if ($this->includes !== []) {
$body['include'] = $this->includes;
}
if ($this->limit !== null || $this->offset !== null) {
$pagination = [];
if ($this->limit !== null) {
$pagination['limit'] = $this->limit;
}
if ($this->offset !== null) {
$pagination['offset'] = $this->offset;
}
$body['pagination'] = $pagination;
}
return ['ir' => $body];
}
public function toSpecJson(): string
{
return IrJson::specJson($this->toEnvelope()['ir']);
}
public function toRequest(string $backend = GeneratedClient::DEFAULT_IR_BACKEND): object
{
if ($this->includes !== []) {
GeneratedClient::requireEagerIncludeBackend($backend);
}
return GeneratedClient::rawDispatchRequest($backend, 'query', $this->toSpecJson());
}
public function execute(GeneratedClient $client, string $backend = GeneratedClient::DEFAULT_IR_BACKEND, ?UdbMetadata $metadata = null): object
{
return $client->genericDispatch($this->toRequest($backend), $metadata);
}
}
final class IrWriteQuery
{
private array $rows = [];
private ?array $conflict = null;
private array $returnFields = [];
public function __construct(private readonly string $messageType) {}
public function record(array $row): self
{
$this->rows[] = $row;
return $this;
}
public function records(iterable $rows): self
{
foreach ($rows as $row) {
$this->rows[] = (array) $row;
}
return $this;
}
public function merge(): self
{
$this->conflict = ['kind' => 'replace'];
return $this;
}
public function ignoreConflicts(): self
{
$this->conflict = ['kind' => 'ignore'];
return $this;
}
public function updateOnConflict(array $fields, array $conflictOn = []): self
{
$this->conflict = ['kind' => 'update', 'fields' => array_values($fields)];
if ($conflictOn !== []) {
$this->conflict['conflict_on'] = array_values($conflictOn);
}
return $this;
}
public function returning(string ...$fields): self
{
array_push($this->returnFields, ...$fields);
return $this;
}
public function toEnvelope(): array
{
if ($this->rows === []) {
throw new \LogicException('udb: write requires at least one record(...)');
}
$body = [
'op' => 'write',
'message_type' => $this->messageType,
'records' => array_map([IrJson::class, 'logicalRecord'], $this->rows),
];
if ($this->conflict !== null) {
$body['conflict'] = $this->conflict;
}
if ($this->returnFields !== []) {
$body['return_fields'] = $this->returnFields;
}
return ['ir' => $body];
}
public function toSpecJson(): string
{
return IrJson::specJson($this->toEnvelope()['ir']);
}
public function toRequest(string $backend = GeneratedClient::DEFAULT_IR_BACKEND): object
{
return GeneratedClient::rawDispatchRequest($backend, 'mutate', $this->toSpecJson());
}
public function execute(GeneratedClient $client, string $backend = GeneratedClient::DEFAULT_IR_BACKEND, ?UdbMetadata $metadata = null): object
{
return $client->genericDispatch($this->toRequest($backend), $metadata);
}
}
final class IrDeleteQuery extends IrPredicateBuilder
{
private array $returnFields = [];
public function __construct(private readonly string $messageType) {}
public function returning(string ...$fields): self
{
array_push($this->returnFields, ...$fields);
return $this;
}
public function toEnvelope(): array
{
$filter = IrJson::filter($this->predicates);
if ($filter === null) {
throw new \LogicException('udb: delete requires at least one where(...) predicate (no delete-everything path)');
}
$body = ['op' => 'delete', 'message_type' => $this->messageType, 'filter' => $filter];
if ($this->returnFields !== []) {
$body['return_fields'] = $this->returnFields;
}
return ['ir' => $body];
}
public function toSpecJson(): string
{
return IrJson::specJson($this->toEnvelope()['ir']);
}
public function toRequest(string $backend = GeneratedClient::DEFAULT_IR_BACKEND): object
{
return GeneratedClient::rawDispatchRequest($backend, 'mutate', $this->toSpecJson());
}
public function execute(GeneratedClient $client, string $backend = GeneratedClient::DEFAULT_IR_BACKEND, ?UdbMetadata $metadata = null): object
{
return $client->genericDispatch($this->toRequest($backend), $metadata);
}
}
final class EntityRepository
{
public function __construct(
private readonly string $messageType,
private readonly array $binding,
) {
if (($this->binding['primary_keys'] ?? []) === []) {
throw new \InvalidArgumentException("udb: entity {$this->messageType} has no descriptor primary key");
}
}
public function query(): IrQuery
{
return GeneratedClient::query($this->messageType);
}
public function messageType(): string
{
return $this->messageType;
}
public function binding(): array
{
return $this->binding;
}
public function relations(): array
{
$raw = $this->binding['relations_json'] ?? '[]';
$relations = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (! is_array($relations)) {
throw new \LogicException("udb: invalid relation metadata for entity {$this->messageType}");
}
return array_values($relations);
}
public function relation(string $name): ?array
{
foreach ($this->relations() as $relation) {
if (($relation['name'] ?? null) === $name) {
return $relation;
}
}
return null;
}
public function requireRelation(string $name): array
{
$relation = $this->relation($name);
if ($relation === null) {
throw new \InvalidArgumentException("udb: unknown relation '{$name}' on entity {$this->messageType}");
}
$localFields = $relation['local_fields'] ?? [];
$targetFields = $relation['target_fields'] ?? [];
if ($localFields === [] || count($localFields) !== count($targetFields)) {
throw new \LogicException("udb: relation '{$name}' on entity {$this->messageType} has invalid field mapping");
}
if (($relation['target_message_type'] ?? '') === '') {
throw new \LogicException("udb: relation '{$name}' on entity {$this->messageType} has no target entity");
}
return $relation;
}
public function relationQuery(string $name, array $parent): IrQuery
{
$relation = $this->requireRelation($name);
$query = GeneratedClient::query($relation['target_message_type']);
foreach ($relation['local_fields'] as $idx => $localField) {
if (! array_key_exists($localField, $parent)) {
throw new \InvalidArgumentException("udb: relation '{$name}' missing parent field '{$localField}'");
}
$query->where($relation['target_fields'][$idx], 'eq', $parent[$localField]);
}
return $query;
}
public function relationBatchQuery(string $name, array $parents): IrQuery
{
$relation = $this->requireRelation($name);
$localFields = array_values($relation['local_fields']);
$targetFields = array_values($relation['target_fields']);
if (count($localFields) !== count($targetFields)) {
throw new \LogicException("udb: relation '{$name}' on entity {$this->messageType} has invalid field mapping");
}
if ($parents === []) {
throw new \InvalidArgumentException("udb: relation '{$name}' batch query requires at least one parent");
}
if (count($localFields) === 1) {
$localField = (string) $localFields[0];
$values = [];
$seen = [];
foreach ($parents as $parent) {
if (! array_key_exists($localField, $parent)) {
throw new \InvalidArgumentException("udb: relation '{$name}' missing parent field '{$localField}'");
}
$value = $parent[$localField];
$key = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (! array_key_exists($key, $seen)) {
$seen[$key] = true;
$values[] = $value;
}
}
return GeneratedClient::query((string) $relation['target_message_type'])
->whereIn((string) $targetFields[0], $values);
}
$branches = [];
$seen = [];
foreach ($parents as $parent) {
$comparisons = [];
foreach ($localFields as $idx => $localField) {
$localField = (string) $localField;
if (! array_key_exists($localField, $parent)) {
throw new \InvalidArgumentException("udb: relation '{$name}' missing parent field '{$localField}'");
}
$comparisons[] = IrJson::comparison((string) $targetFields[$idx], 'eq', $parent[$localField]);
}
$key = json_encode($comparisons, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (! array_key_exists($key, $seen)) {
$seen[$key] = true;
$branches[] = IrJson::andFilter($comparisons);
}
}
return GeneratedClient::query((string) $relation['target_message_type'])
->whereFilter(IrJson::orFilter($branches));
}
{{ENTITY_PHP_RELATION_ACCESSORS}}
public function find(array $key, GeneratedClient $client, string $backend = GeneratedClient::DEFAULT_IR_BACKEND, ?UdbMetadata $metadata = null): object
{
$query = $this->query()->limit(1);
foreach ($this->binding['primary_keys'] as $field) {
if (! array_key_exists($field, $key)) {
throw new \InvalidArgumentException("udb: missing primary key field '{$field}'");
}
$query->where($field, 'eq', $key[$field]);
}
return $query->execute($client, $backend, $metadata);
}
public function first(IrQuery $query, GeneratedClient $client, string $backend = GeneratedClient::DEFAULT_IR_BACKEND, ?UdbMetadata $metadata = null): object
{
return $query->limit(1)->execute($client, $backend, $metadata);
}
public function all(IrQuery $query, GeneratedClient $client, string $backend = GeneratedClient::DEFAULT_IR_BACKEND, ?UdbMetadata $metadata = null): object
{
return $query->execute($client, $backend, $metadata);
}
public function upsert(array $record, GeneratedClient $client, string $backend = GeneratedClient::DEFAULT_IR_BACKEND, ?UdbMetadata $metadata = null): object
{
$this->validateRecord($record);
foreach ($this->binding['primary_keys'] as $field) {
if (! array_key_exists($field, $record)) {
throw new \InvalidArgumentException("udb: missing primary key field '{$field}'");
}
}
$updateFields = array_values(array_diff(array_keys($record), $this->binding['primary_keys']));
if ($updateFields === []) {
throw new \InvalidArgumentException('udb: upsert requires at least one non-primary-key field');
}
return GeneratedClient::writeTo($this->messageType)
->record($record)
->updateOnConflict($updateFields, $this->binding['primary_keys'])
->execute($client, $backend, $metadata);
}
public function delete(array $key, GeneratedClient $client, string $backend = GeneratedClient::DEFAULT_IR_BACKEND, ?UdbMetadata $metadata = null): object
{
$delete = GeneratedClient::deleteFrom($this->messageType);
foreach ($this->binding['primary_keys'] as $field) {
if (! array_key_exists($field, $key)) {
throw new \InvalidArgumentException("udb: missing primary key field '{$field}'");
}
$delete->where($field, 'eq', $key[$field]);
}
return $delete->execute($client, $backend, $metadata);
}
private function validateRecord(array $record): void
{
$fields = $this->binding['fields'] ?? [];
if ($fields === []) {
return;
}
$allowed = array_flip($fields);
foreach (array_keys($record) as $field) {
if (! array_key_exists($field, $allowed)) {
throw new \InvalidArgumentException("udb: field '{$field}' is not declared on entity {$this->messageType}");
}
}
}
}
final class UnitOfWorkEntry
{
public function __construct(
public readonly EntityRepository $repository,
public array $record,
public string $snapshot,
) {}
}
class UnitOfWorkTxException extends \RuntimeException
{
public function __construct(string $message, public readonly ?object $status = null)
{
parent::__construct($message);
}
}
final class UnitOfWorkConflictException extends UnitOfWorkTxException {}
final class UnitOfWorkUnsupportedBackendException extends \RuntimeException
{
public function __construct(public readonly string $backend, public readonly ?string $role = null)
{
parent::__construct("udb: backend '{$backend}' is " . ($role ?? 'unknown') . '; UnitOfWork requires a canonical transactional backend');
}
}
final class UnitOfWork
{
private array $entries = [];
public function attach(EntityRepository $repository, array $record): array
{
$binding = $repository->binding();
self::requireVersionForTrackedWrite($repository->messageType(), $binding, $record);
$this->entries[self::entityIdentity($repository->messageType(), $binding, $record)] = new UnitOfWorkEntry(
$repository,
$record,
self::stableRecordJson($record),
);
return $record;
}
public function track(EntityRepository $repository, array $record): array
{
return $this->attach($repository, $record);
}
public function update(EntityRepository $repository, array $record): array
{
$binding = $repository->binding();
self::requireVersionForTrackedWrite($repository->messageType(), $binding, $record);
$identity = self::entityIdentity($repository->messageType(), $binding, $record);
if (! isset($this->entries[$identity])) {
return $this->attach($repository, $record);
}
$this->entries[$identity]->record = $record;
return $record;
}
public function dirtyEntries(): array
{
return array_values(array_filter(
$this->entries,
static fn (UnitOfWorkEntry $entry): bool => self::stableRecordJson($entry->record) !== $entry->snapshot,
));
}
public function txMutations(): array
{
$fqn = self::mutationClass();
return array_map(static function (UnitOfWorkEntry $entry) use ($fqn): object {
$mutation = new $fqn();
$mutation->setOperation('upsert');
$mutation->setMessageType($entry->repository->messageType());
$mutation->setRecordJson(self::stableRecordJson($entry->record));
return $mutation;
}, $this->dirtyEntries());
}
public function commitMutation(): object
{
$fqn = self::mutationClass();
$mutation = new $fqn();
$mutation->setCommit(true);
return $mutation;
}
public function rollbackMutation(): object
{
$fqn = self::mutationClass();
$mutation = new $fqn();
$mutation->setRollback(true);
return $mutation;
}
public function txCommitBatch(string $backend = GeneratedClient::DEFAULT_IR_BACKEND): array
{
$this->requireTransactionalBackend($backend);
return [...$this->txMutations(), $this->commitMutation()];
}
public function requireTransactionalBackend(string $backend = GeneratedClient::DEFAULT_IR_BACKEND): void
{
$role = GeneratedClient::backendRoles()[$backend] ?? null;
if ($role !== 'canonical' && $role !== 'both') {
throw new UnitOfWorkUnsupportedBackendException($backend, $role);
}
}
public function validateTxStatuses(iterable $statuses): void
{
foreach ($statuses as $status) {
$state = method_exists($status, 'getState') ? (string) $status->getState() : '';
$message = method_exists($status, 'getMessage') ? (string) $status->getMessage() : '';
if ($state !== '4' && ! str_contains(strtoupper($state), 'ERROR')) {
continue;
}
$message = $message !== '' ? $message : 'udb: unit-of-work transaction failed';
if (self::isTxConflictMessage($message)) {
throw new UnitOfWorkConflictException($message, $status);
}
throw new UnitOfWorkTxException($message, $status);
}
}
public function flush(
GeneratedClient $client,
string $backend = GeneratedClient::DEFAULT_IR_BACKEND,
?UdbMetadata $metadata = null,
): array {
if (! method_exists($client, 'beginTx')) {
throw new UdbConfigurationException('GeneratedClient::beginTx is unavailable. Run sdk generation before UnitOfWork flush.');
}
$call = $client->beginTx($metadata);
foreach ($this->txCommitBatch($backend) as $mutation) {
$call->write($mutation);
}
$call->writesDone();
$statuses = [];
while (($status = $call->read()) !== null) {
$statuses[] = $status;
}
$grpcStatus = method_exists($call, 'getStatus') ? $call->getStatus() : null;
$code = is_object($grpcStatus) ? (int) ($grpcStatus->code ?? 0) : (int) (($grpcStatus['code'] ?? 0));
if ($code !== 0) {
throw UdbRpcException::fromGrpcStatus($grpcStatus, 'BeginTx');
}
$this->validateTxStatuses($statuses);
$this->markClean();
return $statuses;
}
public function markClean(): void
{
foreach ($this->entries as $entry) {
$entry->snapshot = self::stableRecordJson($entry->record);
}
}
private static function requireVersionForTrackedWrite(string $messageType, array $binding, array $record): void
{
$versionField = $binding['version_field'] ?? '';
if ($versionField !== '' && ! array_key_exists($versionField, $record)) {
throw new \InvalidArgumentException("udb: unit-of-work record for {$messageType} missing version field '{$versionField}'");
}
}
private static function entityIdentity(string $messageType, array $binding, array $record): string
{
$scopeParts = [];
foreach ([$binding['tenant_field'] ?? '', $binding['project_field'] ?? ''] as $field) {
if ($field === '') {
continue;
}
if (! array_key_exists($field, $record)) {
throw new \InvalidArgumentException("udb: unit-of-work record for {$messageType} missing scope field '{$field}'");
}
$scopeParts[] = $field . '=' . json_encode($record[$field], JSON_THROW_ON_ERROR);
}
$parts = [];
foreach ($binding['primary_keys'] as $field) {
if (! array_key_exists($field, $record)) {
throw new \InvalidArgumentException("udb: unit-of-work record for {$messageType} missing primary key field '{$field}'");
}
$parts[] = json_encode($record[$field], JSON_THROW_ON_ERROR);
}
return $messageType . ':' . implode(':', $scopeParts) . ':' . implode(':', $parts);
}
private static function stableRecordJson(array $record): string
{
ksort($record);
return json_encode($record, JSON_THROW_ON_ERROR);
}
private static function mutationClass(): string
{
$fqn = '\\Udb\\Entity\\V1\\Mutation';
if (! class_exists($fqn)) {
throw new UdbConfigurationException('UDB Mutation class not found. Run buf generate before UnitOfWork flush.');
}
return $fqn;
}
private static function isTxConflictMessage(string $message): bool
{
$lower = strtolower($message);
return str_contains($lower, 'aborted') || str_contains($lower, 'version') || str_contains($lower, 'conflict');
}
}