Version 1.0.0

This commit is contained in:
diffhead
2025-11-16 01:51:25 +04:00
committed by Viktor Smagin
commit 8cb7f400fd
43 changed files with 4462 additions and 0 deletions

54
src/Builder.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit;
use Diffhead\PHP\DataEnrichmentKit\Exception\TargetIsNull;
use Diffhead\PHP\DataEnrichmentKit\Object\Item;
use Diffhead\PHP\DataEnrichmentKit\Object\ItemsBag;
use Diffhead\PHP\DataEnrichmentKit\Object\Request;
use Diffhead\PHP\DataEnrichmentKit\Object\Target;
class Builder
{
private array $items = [];
private ?Target $target = null;
public static function withTarget(string $entity, string $field): static
{
$builder = new static();
$builder->target($entity, $field);
return $builder;
}
public function item(string $key, string $alias = ''): static
{
$this->items[] = new Item($key, $alias);
return $this;
}
public function target(string $entity, string $field): static
{
$this->target = new Target($entity, $field);
return $this;
}
public function build(): Request
{
if (is_null($this->target)) {
throw new TargetIsNull();
}
$items = new ItemsBag();
foreach ($this->items as $item) {
$items->push($item);
}
return new Request($items, $this->target);
}
}

20
src/Enricher.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit;
use Diffhead\PHP\DataEnrichmentKit\Interface\Enrichment;
use Diffhead\PHP\DataEnrichmentKit\Storage\Requests;
class Enricher
{
public function __construct(
private Enrichment $enrichment,
) {}
public function enrich(array $data, Requests $requests): array
{
return $this->enrichment->enrich($data, $requests);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Exception;
use RuntimeException;
class InvalidRequest extends RuntimeException
{
public function __construct(string $value)
{
return parent::__construct(
sprintf('Invalid enrichment request: "%s".', $value)
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Exception;
use RuntimeException;
class PayloadIsNotJson extends RuntimeException {}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Exception;
use RuntimeException;
class RepositoryNotFound extends RuntimeException
{
public function __construct(string $target)
{
return parent::__construct(
sprintf('Repository not found for target "%s".', $target)
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Exception;
use LogicException;
class TargetIsNull extends LogicException {}

10
src/Header.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit;
enum Header: string
{
case XEnrichmentRequest = 'X-Enrichment-Request';
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Interface;
use Diffhead\PHP\DataEnrichmentKit\Storage\Requests;
interface Enrichment
{
public function enrich(array $data, Requests $requests): array;
}

10
src/Interface/Entity.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Interface;
use ArrayAccess;
use JsonSerializable;
interface Entity extends ArrayAccess, JsonSerializable {}

12
src/Interface/Parser.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Interface;
use Diffhead\PHP\DataEnrichmentKit\Object\Request;
interface Parser
{
public function parse(string $value): Request;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Interface;
interface Repository
{
/**
* @param string $field
* @param array $values
*
* @return iterable<int,\Diffhead\PHP\DataEnrichmentKit\Interface\Entity>
*/
public function getByFieldValues(string $field, array $values): iterable;
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Interface;
use Diffhead\PHP\DataEnrichmentKit\Object\Request;
interface Serializer
{
public function toString(Request $request): string;
}

85
src/Message.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit;
use BackedEnum;
use Diffhead\PHP\DataEnrichmentKit\Exception\PayloadIsNotJson;
use Diffhead\PHP\DataEnrichmentKit\Interface\Parser;
use Diffhead\PHP\DataEnrichmentKit\Interface\Serializer;
use Diffhead\PHP\DataEnrichmentKit\Storage\Requests;
use Nyholm\Psr7\Stream;
use Psr\Http\Message\MessageInterface;
class Message
{
public function __construct(
private Serializer $serializer,
private Parser $parser,
) {}
/**
* @param \Psr\Http\Message\MessageInterface $message
* @param \BackedEnum $header
* @param \Diffhead\PHP\DataEnrichmentKit\Storage\Requests $requests
*
* @return \Psr\Http\Message\MessageInterface
*/
public function setRequests(
MessageInterface $message,
BackedEnum $header,
Requests $requests
): MessageInterface {
if ($requests->count() === 0) {
return $message;
}
$header = $header->value;
foreach ($requests as $request) {
$request = $this->serializer->toString($request);
$message = $message->withAddedHeader($header, $request);
}
return $message;
}
/**
* @param \Psr\Http\Message\MessageInterface $message
* @param \BackedEnum $header
*
* @return \Diffhead\PHP\DataEnrichmentKit\Storage\Requests
*/
public function getRequests(MessageInterface $message, BackedEnum $header): Requests
{
$header = $header->value;
$requests = $message->getHeader($header);
$container = new Requests();
foreach ($requests as $request) {
$container->append($this->parser->parse($request));
}
return $container;
}
public function getPayload(MessageInterface $message): array
{
$payload = json_decode($message->getBody()->getContents(), true);
if (! is_array($payload)) {
throw new PayloadIsNotJson();
}
return $payload;
}
public function setPayload(MessageInterface $message, array $payload): MessageInterface
{
return $message->withBody(
Stream::create(json_encode($payload))
);
}
}

23
src/Object/Item.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Object;
class Item
{
public function __construct(
private string $key,
private string $alias = ''
) {}
public function key(): string
{
return $this->key;
}
public function alias(): string
{
return $this->alias;
}
}

29
src/Object/ItemsBag.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Object;
use ArrayIterator;
use IteratorAggregate;
use Traversable;
class ItemsBag implements IteratorAggregate
{
private array $items = [];
public function push(Item $item): static
{
$this->items[] = $item;
return $this;
}
/**
* @return \Traversable<int,\Diffhead\PHP\DataEnrichmentKit\Object\Item>
*/
public function getIterator(): Traversable
{
return new ArrayIterator($this->items);
}
}

23
src/Object/Request.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Object;
class Request
{
public function __construct(
private ItemsBag $items,
private Target $target
) {}
public function items(): ItemsBag
{
return $this->items;
}
public function target(): Target
{
return $this->target;
}
}

23
src/Object/Target.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Object;
class Target
{
public function __construct(
private string $entity,
private string $field
) {}
public function entity(): string
{
return $this->entity;
}
public function field(): string
{
return $this->field;
}
}

153
src/Service/Enrichment.php Normal file
View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Service;
use ArrayAccess;
use Diffhead\PHP\DataEnrichmentKit\Interface\Enrichment as EnrichmentInterface;
use Diffhead\PHP\DataEnrichmentKit\Object\Item;
use Diffhead\PHP\DataEnrichmentKit\Object\ItemsBag;
use Diffhead\PHP\DataEnrichmentKit\Object\Request;
use Diffhead\PHP\DataEnrichmentKit\Storage\Repositories;
use Diffhead\PHP\DataEnrichmentKit\Storage\Requests;
use Diffhead\PHP\DataEnrichmentKit\Utility\Arr;
class Enrichment implements EnrichmentInterface
{
public function __construct(
private Repositories $repositories,
) {}
/**
* @param array $payload
* @param \Diffhead\PHP\DataEnrichmentKit\Storage\Requests $requests
*
* @return array
*/
public function enrich(array $data, Requests $requests): array
{
foreach ($requests as $request) {
$data = $this->enrichSingle($data, $request);
}
return $data;
}
private function enrichSingle(array $data, Request $request): array
{
$targetFieldValues = [];
$target = $request->target();
$items = $request->items();
$targetFieldValues = $this->getFieldValuesByItems($data, $items);
$targets = $this->getTargetsByFieldValue(
$target->entity(),
$target->field(),
$targetFieldValues
);
return $this->enrichUsingTargets($data, $items, $targets);
}
private function getFieldValuesByItems(array $data, ItemsBag $items): array
{
$targetFieldValues = [];
/**
* @var \Diffhead\PHP\DataEnrichmentKit\Object\Item $item
*/
foreach ($items as $item) {
$valueOrValues = Arr::get($item->key(), $data);
if (is_array($valueOrValues)) {
$targetFieldValues = array_merge($targetFieldValues, $valueOrValues);
} else {
$targetFieldValues[] = $valueOrValues;
}
}
return array_unique($targetFieldValues);
}
private function getTargetsByFieldValue(string $entity, string $field, array $values): array
{
$targets = $this->repositories->get($entity)
->getByFieldValues($field, $values);
$targetsHaveArrayAccess = is_array($targets) || $targets instanceof ArrayAccess;
$targetsByFieldValue = [];
foreach ($targets as $index => $target) {
$fieldValue = $target[$field];
$targetsByFieldValue[$fieldValue] = $target;
if ($targetsHaveArrayAccess) {
unset($targets[$index]);
}
}
return $targetsByFieldValue;
}
private function enrichUsingTargets(array $data, ItemsBag $items, array $targets): array
{
foreach ($items as $item) {
$data = $this->enrichByItem($data, $item, $targets);
}
return $data;
}
private function enrichByItem(array $data, Item $item, array &$targets): array
{
$keyFirstItemIsWildcard = strpos($item->key(), '*.') === 0;
if (array_is_list($data) && $keyFirstItemIsWildcard) {
/**
* If passed *.user_id key as example then
* we need to remove first wildcard part
*/
$parts = explode('.', $item->key());
$nextKey = implode('.', array_slice($parts, 1));
$item = new Item($nextKey, $item->alias());
foreach ($data as $index => $entry) {
$data[$index] = $this->enrichByItem($entry, $item, $targets);
}
return $data;
}
$keyIsWildcard = is_numeric(strpos($item->key(), '*.'));
if ($keyIsWildcard) {
$parts = explode('.', $item->key());
$first = $parts[0];
$temporary = Arr::get($first, $data, []);
$nextKey = implode('.', array_slice($parts, 1));
$nextItem = new Item($nextKey, $item->alias());
$data[$first] = $this->enrichByItem($temporary, $nextItem, $targets);
} else {
$value = Arr::get($item->key(), $data);
$parts = explode('.', $item->key());
$alias = $item->alias()
? $item->alias()
: $parts[count($parts) - 1];
$parts[count($parts) - 1] = $alias;
$nextKey = implode('.', $parts);
$data = Arr::set($nextKey, $data, $targets[$value] ?? null);
}
return $data;
}
}

57
src/Service/Parser.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Service;
use Diffhead\PHP\DataEnrichmentKit\Exception\InvalidRequest;
use Diffhead\PHP\DataEnrichmentKit\Interface\Parser as ParserInterface;
use Diffhead\PHP\DataEnrichmentKit\Object\Item;
use Diffhead\PHP\DataEnrichmentKit\Object\ItemsBag;
use Diffhead\PHP\DataEnrichmentKit\Object\Request;
use Diffhead\PHP\DataEnrichmentKit\Object\Target;
class Parser implements ParserInterface
{
/**
* @param string $value
*
* @return \Diffhead\PHP\DataEnrichmentKit\Object\Request
*
* @throws \Diffhead\PHP\DataEnrichmentKit\Exception\InvalidRequest
*/
public function parse(string $value): Request
{
$referenceWithTarget = explode('@', $value);
if (! isset($referenceWithTarget[1])) {
throw new InvalidRequest($value);
}
$entityWithField = explode(',', $referenceWithTarget[1]);
if (! isset($entityWithField[0]) || ! isset($entityWithField[1])) {
throw new InvalidRequest($value);
}
$entity = $entityWithField[0];
$field = $entityWithField[1];
$target = new Target($entity, $field);
$references = $referenceWithTarget[0];
$references = explode(',', $references);
$items = new ItemsBag();
foreach ($references as $reference) {
$referenceWithAlias = explode('+', $reference);
$items->push(
new Item($referenceWithAlias[0], $referenceWithAlias[1] ?? '')
);
}
return new Request($items, $target);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Service;
use Diffhead\PHP\DataEnrichmentKit\Interface\Serializer as SerializerInterface;
use Diffhead\PHP\DataEnrichmentKit\Object\Request;
class Serializer implements SerializerInterface
{
public function toString(Request $request): string
{
$targetParts = [
$request->target()->entity(),
$request->target()->field(),
];
$target = implode(',', $targetParts);
$itemsParts = [];
/**
* @var \Diffhead\PHP\DataEnrichmentKit\Object\Item $item
*/
foreach ($request->items() as $item) {
$itemParts = [$item->key()];
if ($alias = $item->alias()) {
$itemParts[] = $alias;
}
$itemsParts[] = implode('+', $itemParts);
}
$items = implode(',', $itemsParts);
return sprintf('%s@%s', $items, $target);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Storage;
use Diffhead\PHP\DataEnrichmentKit\Exception\RepositoryNotFound;
use Diffhead\PHP\DataEnrichmentKit\Interface\Repository;
use InvalidArgumentException;
class Repositories
{
/**
* @param array<string,\Diffhead\PHP\DataEnrichmentKit\Interface\Repository> $map
*
* @throws \InvalidArgumentException
*/
public function __construct(
private array $map = []
) {
foreach ($this->map as $target => $repository) {
if (! $repository instanceof Repository) {
throw new InvalidArgumentException(
'Map should contains only Repository instances as values'
);
}
}
}
public function set(string $target, Repository $repository): static
{
$this->map[$target] = $repository;
return $this;
}
public function get(string $target): Repository
{
if (! isset($this->map[$target])) {
throw new RepositoryNotFound($target);
}
return $this->map[$target];
}
}

54
src/Storage/Requests.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Storage;
use ArrayIterator;
use Diffhead\PHP\DataEnrichmentKit\Object\Request;
use InvalidArgumentException;
use IteratorAggregate;
use Traversable;
class Requests implements IteratorAggregate
{
private int $count = 0;
/**
* @param array<int,\Diffhead\PHP\DataEnrichmentKit\Object\Request> $requests
*
* @throws \InvalidArgumentException
*/
public function __construct(
private array $requests = []
) {
foreach ($this->requests as $request) {
if (! $request instanceof Request) {
throw new InvalidArgumentException(
'Requests should be an array of Request instances'
);
}
$this->count++;
}
}
public function append(Request $request): void
{
$this->count++;
$this->requests[] = $request;
}
public function count(): int
{
return $this->count;
}
/**
* @return \Traversable<int,\Diffhead\PHP\DataEnrichmentKit\Object\Request>
*/
public function getIterator(): Traversable
{
return new ArrayIterator($this->requests);
}
}

174
src/Utility/Arr.php Normal file
View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Diffhead\PHP\DataEnrichmentKit\Utility;
class Arr
{
/**
* Get value inside array using key dot notation is supported
* also supported wildcard keys as "users.*.name" and alike.
*
* Returns empty array when passed wildcard key but no values found.
* This mode will never return the default value and values strictly
* equal to default are skipping.
*
* @param string $key
* @param array $data
* @param mixed $default
*
* @return mixed|array<int,mixed>
*/
public static function get(string $key, array $data, mixed $default = null): mixed
{
$parts = explode('.', $key);
$temporary = $data;
foreach ($parts as $index => $part) {
if ($part === '*') {
$values = [];
foreach ($temporary as $item) {
if (! is_array($item)) {
continue;
}
$key = implode('.', array_slice($parts, $index + 1));
$extracted = self::get((string) $key, $item, $default);
if (is_array($extracted)) {
$values = array_merge($values, $extracted);
} else if ($extracted === $default) {
continue;
} else {
$values[] = $extracted;
}
}
return $values;
}
$isNotArray = ! is_array($temporary);
$isNotExisting = $isNotArray || ! array_key_exists($part, $temporary);
if ($isNotExisting) {
return $default;
}
$temporary = $temporary[$part];
}
return $temporary;
}
/**
* Test array having value inside using key dot notation is supported.
*
* If passed strict as true then will return true if any of the wildcard
* paths exist else will return true only if all wildcard paths exist.
*
* @param string $key
* @param array $data
* @param bool $strict
*
* @return bool
*/
public static function has(string $key, array $data, bool $strict = false): bool
{
$parts = explode('.', $key);
$any = ! $strict;
$temporary = $data;
foreach ($parts as $index => $part) {
if ($part === '*') {
if (! is_array($temporary)) {
return false;
}
$test = null;
foreach ($temporary as $item) {
$key = implode('.', array_slice($parts, $index + 1));
$has = self::has($key, $item, $strict);
if ($strict) {
$test = $has && (is_null($test) ? true : $test);
}
if ($any && $has) {
return true;
}
}
return is_null($test) ? false : $test;
}
$isNotArray = ! is_array($temporary);
$isNotExisting = $isNotArray || ! array_key_exists($part, $temporary);
if ($isNotExisting) {
return false;
}
$temporary = $temporary[$part];
}
return true;
}
/**
* Set value inside array using key dot notation support
* Also supported wildcard keys as "users.*.name" and alike
*
* @param string $key Includes dot notation support
* @param array $data
* @param mixed $value
*
* @return array
*/
public static function set(string $key, array $data, mixed $value): array
{
$parts = explode('.', $key);
$last = array_pop($parts);
$temporary = &$data;
foreach ($parts as $index => $part) {
if ($part === '*') {
$isNotArray = ! is_array($temporary);
$isEmptyArray = is_array($temporary) && ! count($temporary);
if ($isNotArray || $isEmptyArray) {
$temporary[] = [];
}
foreach ($temporary as $i => $item) {
$actuallyParts = array_slice($parts, $index + 1);
$actuallyParts[] = $last;
$key = implode('.', $actuallyParts);
$temporary[$i] = self::set($key, $item, $value);
}
return $data;
}
$isNotExisting = ! array_key_exists($part, $temporary);
$isNotArray = $isNotExisting || ! is_array($temporary[$part]);
if ($isNotExisting && $isNotArray) {
$temporary[$part] = [];
}
$temporary = &$temporary[$part];
}
$temporary[$last] = $value;
return $data;
}
}