From 9fa53ffd311bdc4b74202a33775ba3cf794d268a Mon Sep 17 00:00:00 2001 From: dmitry krokhin Date: Mon, 14 Oct 2024 13:33:57 +0300 Subject: [PATCH] initial commit --- .github/workflows/coverage.yml | 19 + .github/workflows/tests.yml | 16 + .gitignore | 5 + composer.json | 28 ++ phpunit.xml.dist | 36 ++ src/Aggregator.php | 375 +++++++++++++++++ src/Entity/Entity.php | 26 ++ src/Entity/Link.php | 35 ++ src/Entity/LinkAggregate.php | 29 ++ src/Entity/Override.php | 32 ++ src/Entity/OverrideAggregate.php | 29 ++ src/Entity/Reference.php | 33 ++ src/Entity/ReferenceAggregate.php | 30 ++ src/Entity/ReferenceState.php | 31 ++ src/Temporal.php | 563 ++++++++++++++++++++++++++ tests/TemporalTest.php | 643 ++++++++++++++++++++++++++++++ 16 files changed, 1930 insertions(+) create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 phpunit.xml.dist create mode 100644 src/Aggregator.php create mode 100644 src/Entity/Entity.php create mode 100644 src/Entity/Link.php create mode 100644 src/Entity/LinkAggregate.php create mode 100644 src/Entity/Override.php create mode 100644 src/Entity/OverrideAggregate.php create mode 100644 src/Entity/Reference.php create mode 100644 src/Entity/ReferenceAggregate.php create mode 100644 src/Entity/ReferenceState.php create mode 100644 src/Temporal.php create mode 100644 tests/TemporalTest.php diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..3974122 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,19 @@ +name: coverage +on: [push] +jobs: + coverage: + runs-on: ubuntu-latest + strategy: + matrix: + tarantool: ["2.11", "3.2"] + steps: + - uses: actions/checkout@v2 + - run: docker run -d -p 3301:3301 tarantool/tarantool:${{ matrix.tarantool }} + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + extensions: xdebug + tools: phpunit, composer:v2 + - run: composer install + - run: vendor/bin/phpunit tests + - run: cat ./coverage.txt \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..118284c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,16 @@ +name: tests +on: [push] +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php: ["8.1", "8.2", "8.3"] + tarantool: ["2.11", "3.2"] + steps: + - uses: actions/checkout@v2 + - uses: php-actions/composer@v6 + with: + php_version: ${{ matrix.php }} + - run: docker run -d -p 3301:3301 tarantool/tarantool:${{ matrix.tarantool }} + - run: vendor/bin/phpunit tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4a0b87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.phpunit.result.cache +composer.lock +composer.phar +phpunit.xml +vendor diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..08fab6c --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "tarantool/temporal", + "description": "Temporal api for Tarantool mapper.", + "keywords": ["tarantool", "client", "pure", "nosql", "mapper", "temporal"], + "type": "library", + "license": "MIT", + "require": { + "nesbot/carbon": "^3.8", + "php": ">=8.0", + "tarantool/mapper": ">=6.0.0" + }, + "require-dev": { + "monolog/monolog": "^2.9.3", + "phpunit/phpunit": "^9.5" + }, + "autoload": { + "psr-4": { + "Tarantool\\Temporal\\": "src/", + "Tarantool\\Temporal\\Tests\\": "tests/" + } + }, + "authors": [ + { + "name": "Dmitry Krokhin", + "email": "nekufa@gmail.com" + } + ] +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..dd36441 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,36 @@ + + + + + src + + + + + + + + + + + + + + + + + ./tests + + + diff --git a/src/Aggregator.php b/src/Aggregator.php new file mode 100644 index 0000000..ab84e2e --- /dev/null +++ b/src/Aggregator.php @@ -0,0 +1,375 @@ +createReferenceAggregate = $value; + return $this; + } + + public function getLeafs(Link $link): array + { + if ($link->timestamp) { + return [$link]; + } + + $leafs = []; + foreach ($this->mapper->find(Link::class, ['parent' => $link->id]) as $child) { + foreach ($this->getLeafs($child) as $leaf) { + $leafs[] = $leaf; + } + } + return $leafs; + } + + public function updateReferenceState(int $entity, int $id, int $target): array + { + $params = [ + 'entity' => $entity, + 'id' => $id, + 'target' => $target, + ]; + + $changes = $this->mapper->find(Reference::class, $params); + $states = $this->generateStates($changes, function ($state, $change) { + $state->data = $change->targetId; + }); + + $affected = []; + foreach ($this->mapper->find(ReferenceState::class, $params) as $state) { + $this->mapper->delete(ReferenceState::class, $state); + } + + foreach ($states as $state) { + $entity = $this->mapper->create(ReferenceState::class, array_merge($params, [ + 'begin' => $state->begin, + 'end' => $state->end, + 'targetId' => $state->data, + ])); + if (!in_array([$entity->target, $entity->targetId], $affected)) { + $affected[] = [$entity->target, $entity->targetId]; + } + } + + if (!$this->createReferenceAggregate) { + return $affected; + } + + foreach ($affected as $affect) { + list($entity, $entityId) = $affect; + $changes = $this->mapper->find(ReferenceState::class, [ + 'target' => $entity, + 'targetId' => $entityId, + 'entity' => $params['entity'], + ]); + $aggregates = $this->generateStates($changes, function ($state, $change) { + if (!in_array($change->id, $state->data)) { + $state->data[] = $change->id; + } + $state->exists = false; + }); + + $aggregateParams = [ + 'entity' => $entity, + 'id' => $entityId, + 'source' => $params['entity'] + ]; + foreach ($this->mapper->find(ReferenceAggregate::class, $aggregateParams) as $aggregate) { + foreach ($aggregates as $candidate) { + if ($candidate->begin == $aggregate->begin && $candidate->end == $aggregate->end) { + if ($candidate->data == $aggregate->data) { + $candidate->exists = true; + continue 2; + } + } + } + $this->mapper->delete(ReferenceAggregate::class, $aggregate); + } + foreach ($aggregates as $aggregate) { + if ($aggregate->exists) { + continue; + } + $this->mapper->create(ReferenceAggregate::class, array_merge($aggregateParams, [ + 'begin' => $aggregate->begin, + 'end' => $aggregate->end, + 'data' => $aggregate->data, + ])); + } + } + + return $affected; + } + + + public function updateLinkAggregation(Link $node): void + { + $todo = [ + $node->entity => $node->entityId, + ]; + + $current = $node; + while ($current->parent) { + $current = $this->mapper->findOne(Link::class, ['id' => $current->parent]); + $todo[$current->entity] = $current->entityId; + } + + foreach ($todo as $entity => $id) { + $spaceId = $entity; + $source = $this->mapper->find(Link::class, [ + 'entity' => $spaceId, + 'entityId' => $id, + ]); + + $leafs = []; + foreach ($source as $node) { + foreach ($this->getLeafs($node) as $detail) { + $leafs[] = $detail; + } + } + + $changeaxis = []; + + foreach ($leafs as $leaf) { + $current = $leaf; + $ref = []; + + if (property_exists($leaf, 'idle') && $leaf->idle) { + continue; + } + + while ($current) { + if ($current->entity != $spaceId) { + $ref[$current->entity] = $current->entityId; + } + if ($current->parent) { + $current = $this->mapper->findOne(Link::class, ['id' => $current->parent]); + } else { + $current = null; + } + } + + $data = [$ref]; + if (property_exists($leaf, 'data') && $leaf->data) { + $data[] = $leaf->data; + } + + if (!array_key_exists($leaf->timestamp, $changeaxis)) { + $changeaxis[$leaf->timestamp] = []; + } + $changeaxis[$leaf->timestamp][] = (object) [ + 'begin' => $leaf->begin, + 'end' => $leaf->end, + 'data' => $data + ]; + } + + $params = [ + 'entity' => $spaceId, + 'id' => $id, + ]; + + $timeaxis = []; + foreach ($changeaxis as $timestamp => $changes) { + foreach ($changes as $change) { + foreach (['begin', 'end'] as $field) { + if (!array_key_exists($change->$field, $timeaxis)) { + $timeaxis[$change->$field] = (object) [ + 'begin' => $change->$field, + 'end' => $change->$field, + 'data' => [], + ]; + } + } + } + } + + ksort($changeaxis); + ksort($timeaxis); + + $nextSliceId = null; + foreach (array_reverse(array_keys($timeaxis)) as $timestamp) { + if ($nextSliceId) { + $timeaxis[$timestamp]->end = $nextSliceId; + } else { + $timeaxis[$timestamp]->end = 0; + } + $nextSliceId = $timestamp; + } + + $states = []; + foreach ($timeaxis as $state) { + foreach ($changeaxis as $changes) { + foreach ($changes as $change) { + if ($change->begin > $state->begin) { + // future override + continue; + } + if ($change->end && ($change->end < $state->end || !$state->end)) { + // complete override + continue; + } + $state->data[] = $change->data; + } + } + if (count($state->data)) { + $states[] = array_merge(get_object_vars($state), $params); + } + } + + // merge states + $clean = false; + while (!$clean) { + $clean = true; + foreach ($states as $i => $state) { + if (array_key_exists($i + 1, $states)) { + $next = $states[$i + 1]; + if (json_encode($state['data']) == json_encode($next['data'])) { + $states[$i]['end'] = $next['end']; + unset($states[$i + 1]); + $states = array_values($states); + $clean = false; + break; + } + } + } + } + + foreach ($this->mapper->find(LinkAggregate::class, $params) as $state) { + $this->mapper->delete(LinkAggregate::class, $state); + } + + foreach ($states as $state) { + $this->mapper->create(LinkAggregate::class, $state); + } + } + } + + public function updateOverrideAggregation(int $entity, int $id): void + { + $params = [ + 'entity' => $entity, + 'id' => $id, + ]; + + $changes = $this->mapper->find('_temporal_override', $params); + $states = $this->generateStates($changes, function ($state, $change) { + $state->data = array_merge($state->data, $change->data); + $state->exists = false; + }); + foreach ($this->mapper->find(OverrideAggregate::class, $params) as $aggregate) { + foreach ($states as $state) { + if ($state->begin == $aggregate->begin && $state->end == $aggregate->end) { + if ($state->data == $aggregate->data) { + $state->exists = true; + continue 2; + } + } + } + $this->mapper->delete(OverrideAggregate::class, $aggregate); + } + foreach ($states as $aggregate) { + if ($aggregate->exists) { + continue; + } + $this->mapper->create(OverrideAggregate::class, array_merge($params, [ + 'begin' => $aggregate->begin, + 'end' => $aggregate->end, + 'data' => $aggregate->data, + ])); + } + } + + private function generateStates(array $changes, callable $callback): array + { + $slices = []; + foreach ($changes as $i => $change) { + if (property_exists($change, 'idle') && $change->idle) { + unset($changes[$i]); + } + } + foreach ($changes as $change) { + foreach (['begin', 'end'] as $field) { + if (!array_key_exists($change->$field, $slices)) { + $slices[$change->$field] = (object) [ + 'begin' => $change->$field, + 'end' => $change->$field, + 'data' => [], + ]; + } + } + } + ksort($slices); + + $nextSliceId = null; + foreach (array_reverse(array_keys($slices)) as $timestamp) { + if ($nextSliceId) { + $slices[$timestamp]->end = $nextSliceId; + } else { + $slices[$timestamp]->end = 0; + } + $nextSliceId = $timestamp; + } + + // calculate states + $states = []; + foreach ($slices as $slice) { + foreach ($changes as $change) { + if ($change->begin > $slice->begin) { + // future change + continue; + } + if ($change->end && ($change->end < $slice->end || !$slice->end)) { + // complete change + continue; + } + $callback($slice, $change); + } + if (count((array) $slice->data)) { + $states[] = $slice; + } + } + + // merge states + $clean = false; + while (!$clean) { + $clean = true; + foreach ($states as $i => $state) { + if (array_key_exists($i + 1, $states)) { + $next = $states[$i + 1]; + if ($state->end && $state->end < $next->begin) { + // unmergable + continue; + } + if (json_encode($state->data) == json_encode($next->data)) { + $state->end = $next->end; + unset($states[$i + 1]); + $states = array_values($states); + $clean = false; + break; + } + } + } + } + + return $states; + } +} diff --git a/src/Entity/Entity.php b/src/Entity/Entity.php new file mode 100644 index 0000000..1725f0e --- /dev/null +++ b/src/Entity/Entity.php @@ -0,0 +1,26 @@ +addIndex(['name']); + } +} diff --git a/src/Entity/Link.php b/src/Entity/Link.php new file mode 100644 index 0000000..1dbc8eb --- /dev/null +++ b/src/Entity/Link.php @@ -0,0 +1,35 @@ +addIndex(['entity', 'entityId', 'parent', 'begin', 'timestamp', 'actor']); + $space->addIndex(['parent'], ['unique' => false]); + } +} diff --git a/src/Entity/LinkAggregate.php b/src/Entity/LinkAggregate.php new file mode 100644 index 0000000..9159658 --- /dev/null +++ b/src/Entity/LinkAggregate.php @@ -0,0 +1,29 @@ +addIndex(['entity', 'id', 'begin']); + } +} diff --git a/src/Entity/Override.php b/src/Entity/Override.php new file mode 100644 index 0000000..d830126 --- /dev/null +++ b/src/Entity/Override.php @@ -0,0 +1,32 @@ +addIndex(['entity', 'id', 'begin', 'timestamp', 'actor']); + } +} diff --git a/src/Entity/OverrideAggregate.php b/src/Entity/OverrideAggregate.php new file mode 100644 index 0000000..35eddec --- /dev/null +++ b/src/Entity/OverrideAggregate.php @@ -0,0 +1,29 @@ +addIndex(['entity', 'id', 'begin']); + } +} diff --git a/src/Entity/Reference.php b/src/Entity/Reference.php new file mode 100644 index 0000000..2e7c5e1 --- /dev/null +++ b/src/Entity/Reference.php @@ -0,0 +1,33 @@ +addIndex(['entity', 'id', 'target', 'begin', 'timestamp', 'targetId', 'actor']); + } +} diff --git a/src/Entity/ReferenceAggregate.php b/src/Entity/ReferenceAggregate.php new file mode 100644 index 0000000..f49eea4 --- /dev/null +++ b/src/Entity/ReferenceAggregate.php @@ -0,0 +1,30 @@ +addIndex(['entity', 'id', 'source', 'begin']); + } +} diff --git a/src/Entity/ReferenceState.php b/src/Entity/ReferenceState.php new file mode 100644 index 0000000..c9a315f --- /dev/null +++ b/src/Entity/ReferenceState.php @@ -0,0 +1,31 @@ +addIndex(['entity', 'id', 'target', 'begin']); + $space->addIndex(['target', 'targetId', 'entity', 'begin', 'id']); + } +} diff --git a/src/Temporal.php b/src/Temporal.php new file mode 100644 index 0000000..68bc435 --- /dev/null +++ b/src/Temporal.php @@ -0,0 +1,563 @@ +aggregator = new Aggregator($mapper); + $mapper->registerClass(Entity::class); + $mapper->registerClass(Link::class); + $mapper->registerClass(LinkAggregate::class); + $mapper->registerClass(Override::class); + $mapper->registerClass(Override::class); + $mapper->registerClass(OverrideAggregate::class); + $mapper->registerClass(Reference::class); + $mapper->registerClass(ReferenceAggregate::class); + $mapper->registerClass(ReferenceState::class); + } + + public function getEntityName(int $id): string + { + return $this->get(Entity::class, $id)->name; + } + + public function getEntityId(string $name): int + { + return $this->findOrCreate(Entity::class, ['name' => $name])->id; + } + + public function getSpace(string $name): Space + { + return $this->mapper->getSpace($name); + } + + public function hasSpace(string $class): bool + { + return $this->mapper->hasSpace($class); + } + + public function getReference(int|string $entity, int $id, int|string $target, int|string $date): ?int + { + if (!$this->hasSpace(ReferenceState::class)) { + return null; + } + + $entity = $this->getEntityId($entity); + $target = $this->getEntityId($target); + $date = $this->getTimestamp($date); + + $state = $this->findOne( + ReferenceState::class, + Criteria::key([$entity, $id, $target, $date])->andLimit(1)->andLeIterator() + ); + + if ($state instanceof ReferenceState) { + if ($state->entity == $entity && $state->id == $id && $state->target == $target) { + if (!$state->end || $state->end >= $date) { + return $state->targetId; + } + } + } + + return null; + } + + public function getReferenceLog(int|string $entity, int $id, string|int $target): array + { + if (!$this->hasSpace(Reference::class)) { + return []; + } + + return $this->find(Reference::class, [ + 'entity' => $this->getEntityId($entity), + 'id' => $id, + 'target' => $this->getEntityId($target), + ]); + } + + public function getReferenceStates( + int|string $entity, + int $entityId, + int|string $target, + int $begin, + int $end + ): array { + if (!$this->hasSpace(ReferenceState::class)) { + return []; + } + + $states = $this->find(ReferenceState::class, [ + 'entity' => $this->getEntityId($entity), + 'id' => $entityId, + 'target' => $this->getEntityId($target), + ]); + + $begin = $this->getTimestamp($begin); + $end = $this->getTimestamp($end); + + $slices = []; + foreach ($states as $state) { + if ($state->begin < $end && ($begin < $state->end || !$state->end)) { + $slices[] = [ + 'begin' => +date('Ymd', max($state->begin, $begin)), + 'end' => +date('Ymd', min($state->end ?: $end, $end)), + 'value' => $state->targetId, + ]; + } + } + + return $slices; + } + + public function getReferences(int|string $target, int $id, int|string $source, int|string $date): array + { + if (!$this->hasSpace(ReferenceAggregate::class)) { + return []; + } + + $target = $this->getEntityId($target); + $source = $this->getEntityId($source); + $date = $this->getTimestamp($date); + + $state = $this->findOne( + ReferenceAggregate::class, + Criteria::key([$target, $id, $source, $date])->andLimit(1)->andLeIterator() + ); + + if ($state instanceof ReferenceAggregate) { + if ($state->entity == $target && $state->id == $id && $state->source == $source) { + if (!$state->end || $state->end > $date) { + return $state->data; + } + } + } + + return []; + } + + public function reference(array $reference): Reference + { + $reference = $this->parseConfig($reference); + + foreach ($reference as $k => $v) { + if (!in_array($k, ['entity', 'id', 'begin', 'end', 'data'])) { + $reference['entity'] = $k; + $reference['id'] = $v; + unset($reference[$k]); + } + } + + if (!array_key_exists('entity', $reference)) { + throw new Exception("no entity defined"); + } + + if (count($reference['data']) != 1) { + throw new Exception("Invalid reference configuration"); + } + + [$targetName] = array_keys($reference['data']); + $reference['target'] = $this->getEntityId($targetName); + $reference['targetId'] = $reference['data'][$targetName]; + + // set entity id + $entityName = $reference['entity']; + $reference['entity'] = $this->getEntityId($entityName); + $reference['actor'] = $this->actor; + $reference['timestamp'] = Carbon::now()->timestamp; + + $reference = $this->mapper->create(Reference::class, $reference); + + $this->aggregator->updateReferenceState($reference->entity, $reference->id, $reference->target); + + return $reference; + } + + public function getLinksLog($entity, $entityId, $filter = []): array + { + if (!$this->hasSpace(Link::class)) { + return []; + } + + $nodes = $this->find(Link::class, [ + 'entity' => $this->getEntityId($entity), + 'entityId' => $entityId, + ]); + + $links = []; + + foreach ($nodes as $node) { + foreach ($this->aggregator->getLeafs($node) as $leaf) { + $entityName = $this->getEntityName($leaf->entity); + $link = [ + $entityName => $leaf->entityId, + 'id' => $leaf->id, + 'begin' => $leaf->begin, + 'end' => $leaf->end, + 'timestamp' => $leaf->timestamp, + 'actor' => $leaf->actor, + 'idle' => $leaf->idle, + ]; + + $current = $leaf; + while ($current->parent) { + $current = $this->get(Link::class, $current->parent); + $entityName = $this->getEntityName($current->entity); + $link[$entityName] = $current->entityId; + } + + if (count($filter)) { + foreach ($filter as $required) { + if (!array_key_exists($required, $link)) { + continue 2; + } + } + } + $links[] = $link; + } + } + + return $links; + } + + public function getLinks(int|string $entity, int $id, int|string $date): array + { + if (!$this->hasSpace(LinkAggregate::class)) { + return []; + } + + $links = $this->getData($entity, $id, $date, LinkAggregate::class); + foreach ($links as $i => $source) { + $link = array_key_exists(1, $source) ? ['data' => $source[1]] : []; + foreach ($source[0] as $spaceId => $entityId) { + $spaceName = $this->get(Entity::class, $spaceId)->name; + $link[$spaceName] = $entityId; + } + $links[$i] = $link; + } + return $links; + } + + public function getState(int|string $entity, int $id, int|string $date): array + { + if (!$this->hasSpace(OverrideAggregate::class)) { + return []; + } + + return $this->getData($entity, $id, $date, OverrideAggregate::class); + } + + private function getData(int|string $entity, int $id, int|string $date, string $space): array + { + $entity = $this->getEntityId($entity); + $date = $this->getTimestamp($date); + + $instance = $this->findOne($space, Criteria::key([$entity, $id, $date])->andLimit(1)->andLeIterator()); + if ($instance) { + $tuple = $this->getSpace($space)->getTuple($instance); + if ($tuple[0] == $entity && $tuple[1] == $id) { + if (!$instance->end || $instance->end >= $date) { + return $instance->data; + } + } + } + + return []; + } + + public function getOverrides(string $entityName, int $id): array + { + if (!$this->hasSpace(Override::class)) { + return []; + } + + return $this->find(Override::class, [ + 'entity' => $this->getEntityId($entityName), + 'id' => $id, + ]); + } + + public function override(array $override): void + { + $override = $this->parseConfig($override); + + foreach ($override as $k => $v) { + if (!in_array($k, ['entity', 'id', 'begin', 'end', 'data'])) { + $override['entity'] = $k; + $override['id'] = $v; + unset($override[$k]); + } + } + + if (!array_key_exists('entity', $override)) { + throw new Exception("no entity defined"); + } + + // set entity id + $entityName = $override['entity']; + $override['entity'] = $this->getEntityId($entityName); + $override['actor'] = $this->actor; + $override['timestamp'] = Carbon::now()->timestamp; + + $this->mapper->create(Override::class, $override); + $this->aggregator->updateOverrideAggregation($override['entity'], $override['id']); + } + + public function setLinkIdle(int $id, bool $flag): void + { + $link = $this->get(Link::class, $id); + if ($link->idle > 0 == $flag) { + return; + } + + $this->mapper->update(Link::class, $link, [ + 'idle' => $link->idle ? 0 : time() + ]); + + $this->aggregator->updateLinkAggregation($link); + } + + public function setReferenceIdle( + int|string $entity, + int $id, + int|string $target, + int $targetId, + int $begin, + int $actor, + int $timestamp, + bool $flag, + ) { + $reference = $this->findOrFail(Reference::class, [ + 'entity' => $this->getEntityId($entity), + 'id' => $id, + 'target' => $this->getEntityId($target), + 'targetId' => $targetId, + 'begin' => $begin, + 'actor' => $actor, + 'timestamp' => $timestamp, + ]); + + if ($reference->idle > 0 == $flag) { + return; + } + + $this->mapper->update(Reference::class, $reference, [ + 'idle' => $reference->idle ? 0 : time() + ]); + + $this->aggregator->updateReferenceState($reference->entity, $id, $reference->target); + } + + public function setOverrideIdle( + int|string $entity, + int $id, + int $begin, + int $actor, + int $timestamp, + bool $flag + ) { + $override = $this->findOrFail(Override::class, [ + 'entity' => $this->getEntityId($entity), + 'id' => $id, + 'begin' => $begin, + 'actor' => $actor, + 'timestamp' => $timestamp, + ]); + + if ($override->idle > 0 == $flag) { + return; + } + + $this->mapper->update(Override::class, $override, ['idle' => $flag ? time() : 0]); + + $this->aggregator->updateOverrideAggregation($override->entity, $override->id); + } + + public function setReferenceEnd( + int|string $entity, + int $id, + int|string $target, + int $targetId, + int $begin, + int $actor, + int $timestamp, + int $end, + ) { + $reference = $this->findOrFail(Reference::class, [ + 'entity' => $this->getEntityId($entity), + 'id' => $id, + 'target' => $this->getEntityId($target), + 'targetId' => $targetId, + 'begin' => $begin, + 'actor' => $actor, + 'timestamp' => $timestamp, + ]); + + if ($reference->end != $end) { + $this->mapper->update(Reference::class, $reference, [ + 'end' => $end, + ]); + $this->aggregator->updateReferenceState($reference->entity, $id, $target); + } + } + + public function setOverrideEnd( + int|string $entity, + int $id, + int $begin, + int $actor, + int $timestamp, + int $end + ) { + $override = $this->findOrFail(Override::class, [ + 'entity' => $this->getEntityId($entity), + 'id' => $id, + 'begin' => $begin, + 'actor' => $actor, + 'timestamp' => $timestamp, + ]); + + if ($override->end != $end) { + $this->mapper->update(Override::class, $override, ['end' => $end]); + $this->aggregator->updateOverrideAggregation($entity, $id); + } + } + + + public function link(array $link): void + { + $link = $this->parseConfig($link); + + $config = []; + foreach ($link as $entity => $id) { + if (!in_array($entity, ['begin', 'end', 'data'])) { + $config[$entity] = $id; + } + } + + ksort($config); + $node = null; + + foreach (array_keys($config) as $i => $entity) { + $id = $config[$entity]; + $spaceId = $this->getEntityId($entity); + $params = [ + 'entity' => $spaceId, + 'entityId' => $id, + 'parent' => $node ? $node->id : 0, + 'data' => [], + ]; + if (count($config) == $i + 1) { + $params['begin'] = $link['begin']; + $params['timestamp'] = 0; + } + $node = $this->findOrCreate(Link::class, [ + 'entity' => $spaceId, + 'entityId' => $id, + 'parent' => $node ? $node->id : 0, + 'begin' => array_key_exists('begin', $params) ? $params['begin'] : 0, + 'timestamp' => 0, + 'actor' => $this->actor + + ], $params); + } + + if (!$node || !$node->parent) { + throw new Exception("Invalid link configuration"); + } + + $this->mapper->update(Link::class, $node, [ + 'begin' => $link['begin'], + 'end' => $link['end'], + 'actor' => $this->actor, + 'timestamp' => Carbon::now()->timestamp, + ]); + + if (array_key_exists('data', $link)) { + $this->mapper->update(Link::class, $node, [ + 'data' => $link['data'], + ]); + } + + $this->aggregator->updateLinkAggregation($node); + } + + public function getActor(): int + { + return $this->actor; + } + + public function setActor(int $actor): self + { + $this->actor = $actor; + return $this; + } + + private function getTimestamp(int|string $string): int + { + if (Carbon::hasTestNow() || !array_key_exists($string, $this->timestamps)) { + if (strlen('' . $string) == 8 && is_numeric($string)) { + $value = Carbon::createFromFormat('Ymd', $string)->setTime(0, 0, 0)->timestamp; + } else { + $value = Carbon::parse($string)->timestamp; + } + if (Carbon::hasTestNow()) { + return $value; + } + $this->timestamps[$string] = $value; + } + return $this->timestamps[$string]; + } + + private function parseConfig(array $data): array + { + if (!$this->actor) { + throw new Exception("actor is undefined"); + } + + if (array_key_exists('actor', $data)) { + throw new Exception("actor is defined"); + } + + if (array_key_exists('timestamp', $data)) { + throw new Exception("timestamp is defined"); + } + + foreach (['begin', 'end'] as $field) { + if (array_key_exists($field, $data) && $data[$field]) { + $data[$field] = $this->getTimestamp($data[$field]); + } else { + $data[$field] = 0; + } + } + + return $data; + } +} diff --git a/tests/TemporalTest.php b/tests/TemporalTest.php new file mode 100644 index 0000000..ab9ae8b --- /dev/null +++ b/tests/TemporalTest.php @@ -0,0 +1,643 @@ +dropUserSpaces(); + return new Temporal($mapper); + } + + + public function testReferenceCacheClear() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + $this->assertSame($temporal->getActor(), 1); + + $temporal->reference([ + 'person' => 1, + 'data' => [ + 'position' => 1 + ] + ]); + + $this->assertSame($temporal->getReference('person', 1, 'position', 'now'), 1); + + $temporal->reference([ + 'begin' => Carbon::now()->format('Ymd'), + 'person' => 1, + 'data' => [ + 'position' => 2 + ] + ]); + + $this->assertSame($temporal->getReference('person', 1, 'position', 'now'), 2); + } + + public function testReferenceSchema() + { + $temporal = $this->createTemporal(); + $this->assertSame(null, $temporal->getReference('person', 1, 'position', 'now')); + } + + public function testReferencesSchema() + { + $temporal = $this->createTemporal(); + $this->assertSame([], $temporal->getReferences('person', 1, 'position', 'now')); + } + + public function testStateSchema() + { + $temporal = $this->createTemporal(); + $this->assertSame([], $temporal->getState('person', 1, 'now')); + } + + public function testOverrideSchema() + { + $temporal = $this->createTemporal(); + $this->assertSame([], $temporal->getOverrides('person', 1)); + } + + public function testLinkSchema() + { + $temporal = $this->createTemporal(); + $this->assertSame([], $temporal->getLinks('person', 1, 'now')); + } + + public function testTemporalReference() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->reference([ + 'person' => 11, + 'begin' => 20170801, + 'data' => [ + 'position' => 1, + ] + ]); + + $this->assertCount(1, $temporal->find('_temporal_reference')); + $states = $temporal->find('_temporal_reference_state'); + $this->assertCount(1, $states); + $this->assertSame($states[0]->id, 11); + + $this->assertNull($temporal->getReference('person', 11, 'position', 20170705)); + $this->assertEquals(1, $temporal->getReference('person', 11, 'position', 20170810)); + + $this->assertCount(1, $temporal->getReferenceLog('person', 11, 'position')); + + $temporal->reference([ + 'person' => 11, + 'begin' => 20170815, + 'data' => [ + 'position' => 2, + ] + ]); + + $states = $temporal->getReferenceStates('person', 11, 'position', 20170815, 20181123); + $this->assertCount(1, $states); + $this->assertSame($states[0]['begin'], 20170815); + $this->assertSame($states[0]['end'], 20181123); + + $states = $temporal->getReferenceStates('person', 11, 'position', 20170819, 20181123); + $this->assertCount(1, $states); + $this->assertSame($states[0]['begin'], 20170819); + $this->assertSame($states[0]['end'], 20181123); + + $states = $temporal->getReferenceStates('person', 11, 'position', 20170810, 20181123); + $this->assertCount(2, $states); + $this->assertSame($states[0]['begin'], 20170810); + $this->assertSame($states[0]['end'], 20170815); + $this->assertSame($states[1]['begin'], 20170815); + $this->assertSame($states[1]['end'], 20181123); + + + $states = $temporal->getReferenceStates('person', 11, 'position', 20170715, 20181123); + $this->assertCount(2, $states); + $this->assertSame($states[0]['begin'], 20170801); + $this->assertSame($states[0]['end'], 20170815); + $this->assertSame($states[1]['begin'], 20170815); + $this->assertSame($states[1]['end'], 20181123); + + + $temporal->reference([ + 'person' => 22, + 'begin' => 20170825, + 'data' => [ + 'position' => 2, + ] + ]); + + $temporal->reference([ + 'person' => 22, + 'begin' => 20170810, + 'data' => [ + 'position' => 1, + ] + ]); + + $this->assertCount(2, $temporal->getReferenceLog('person', 11, 'position')); + $this->assertCount(2, $temporal->getReferenceLog('person', 22, 'position')); + $this->assertCount(0, $temporal->getReferenceLog('person', 33, 'position')); + + + $this->assertNull($temporal->getReference('person', 11, 'position', 20170705)); + $this->assertEquals(1, $temporal->getReference('person', 11, 'position', 20170801)); + $this->assertEquals(1, $temporal->getReference('person', 11, 'position', 20170810)); + $this->assertEquals(2, $temporal->getReference('person', 11, 'position', 20170815)); + $this->assertEquals(2, $temporal->getReference('person', 11, 'position', 20170820)); + $this->assertEquals(1, $temporal->getReference('person', 22, 'position', 20170810)); + $this->assertEquals(1, $temporal->getReference('person', 22, 'position', 20170821)); + $this->assertEquals(2, $temporal->getReference('person', 22, 'position', 20170825)); + $this->assertEquals(2, $temporal->getReference('person', 22, 'position', 20170901)); + + $this->assertCount(0, $temporal->getReferences('position', 1, 'person', 20170707)); + $this->assertCount(2, $temporal->getReferences('position', 1, 'person', 20170810)); + $this->assertCount(1, $temporal->getReferences('position', 1, 'person', 20170820)); + $this->assertCount(1, $temporal->getReferences('position', 1, 'person', 20170824)); + $this->assertCount(0, $temporal->getReferences('position', 1, 'person', 20170825)); + $this->assertCount(0, $temporal->getReferences('position', 1, 'person', 20170826)); + $this->assertCount(0, $temporal->getReferences('position', 1, 'person', 20170901)); + $this->assertSame($temporal->getReferences('position', 1, 'person', 20170810), [11, 22]); + $this->assertSame($temporal->getReferences('position', 1, 'person', 20170820), [22]); + + $this->assertCount(0, $temporal->getReferences('position', 2, 'person', 20170810)); + $this->assertCount(1, $temporal->getReferences('position', 2, 'person', 20170820)); + $this->assertCount(2, $temporal->getReferences('position', 2, 'person', 20170825)); + $this->assertCount(2, $temporal->getReferences('position', 2, 'person', 20171231)); + $this->assertSame($temporal->getReferences('position', 2, 'person', 20170820), [11]); + $this->assertSame($temporal->getReferences('position', 2, 'person', 20170825), [11, 22]); + $this->assertSame($temporal->getReferences('position', 2, 'person', 20170831), [11, 22]); + + $firstReference = $temporal->findOne('_temporal_reference'); + $temporal->setReferenceIdle('person', $firstReference->id, 'position', $firstReference->targetId, $firstReference->begin, $firstReference->actor, $firstReference->timestamp, true); + $this->assertNull($temporal->getReference('person', 11, 'position', 20170801)); + $this->assertNull($temporal->getReference('person', 11, 'position', 20170810)); + $this->assertEquals(2, $temporal->getReference('person', 11, 'position', 20170815)); + // idle exists in reference log + $this->assertCount(2, $temporal->getReferenceLog('person', 11, 'position')); + $this->assertCount(2, $temporal->getReferenceLog('person', 22, 'position')); + + $temporal->setReferenceIdle('person', $firstReference->id, 'position', $firstReference->targetId, $firstReference->begin, $firstReference->actor, $firstReference->timestamp, false); + $this->assertSame(1, $temporal->getReference('person', 11, 'position', 20170801)); + $this->assertSame(1, $temporal->getReference('person', 11, 'position', 20170810)); + $this->assertEquals(2, $temporal->getReference('person', 11, 'position', 20170815)); + } + + public function testLinkIdle() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->link([ + 'begin' => 20170801, + 'end' => 20170802, + 'person' => 1, + 'role' => 1, + ]); + + $temporal->link([ + 'begin' => 20170805, + 'end' => 20170806, + 'person' => 1, + 'role' => 1, + ]); + + $links = $temporal->find('_temporal_link'); + $this->assertCount(3, $links); + + $this->assertCount(1, $temporal->getLinks('person', 1, 20170805)); + $this->assertCount(1, $temporal->getLinks('role', 1, 20170805)); + + // last link 0805-0806 + $this->assertSame(date('Ymd', $links[2]->begin), '20170805'); + + $temporal->setLinkIdle($links[2]->id, true); + + $log = $temporal->getLinksLog('person', 1); + $this->assertCount(2, $log); + $this->assertArrayHasKey('id', $log[0]); + $this->assertSame($log[0]['idle'], 0); + $this->assertNotSame($log[1]['idle'], 0); + $this->assertCount(0, $temporal->getLinks('person', 1, 20170805)); + $this->assertCount(0, $temporal->getLinks('role', 1, 20170805)); + + $temporal->setLinkIdle($links[2]->id, false); + $this->assertCount(1, $temporal->getLinks('person', 1, 20170805)); + $this->assertCount(1, $temporal->getLinks('role', 1, 20170805)); + $log = $temporal->getLinksLog('person', 1); + $this->assertCount(2, $log); + $this->assertSame($log[0]['idle'], 0); + $this->assertSame($log[1]['idle'], 0); + } + + public function testMultipleLinks() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->link([ + 'begin' => 20170801, + 'end' => 20170802, + 'person' => 1, + 'role' => 1, + ]); + + $temporal->link([ + 'begin' => 20170805, + 'end' => 20170806, + 'person' => 1, + 'role' => 1, + ]); + + $links = $temporal->find('_temporal_link'); + $this->assertCount(3, $links); + $this->assertCount(2, $temporal->getLinksLog('person', 1)); + } + + public function testEmptyString() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->link([ + 'begin' => 0, + 'end' => "", + 'person' => 1, + 'role' => 1, + ]); + + $links = $temporal->find('_temporal_link'); + $this->assertCount(2, $links); + $target = null; + foreach ($links as $link) { + if ($link->actor) { + $target = $link; + break; + } + } + $this->assertNotNull($target); + $this->assertSame($target->begin, 0); + $this->assertSame($target->end, 0); + } + + public function testLinkLog() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->link([ + 'begin' => 0, + 'end' => 0, + 'person' => 1, + 'role' => 2, + 'sector' => 3, + ]); + + Carbon::setTestNow(Carbon::parse("+1 sec")); + + $temporal->link([ + 'begin' => 0, + 'end' => 0, + 'person' => 1, + 'role' => 3, + ]); + + Carbon::setTestNow(Carbon::parse("+2 sec")); + + $temporal->link([ + 'begin' => 0, + 'end' => 0, + 'person' => 1, + 'sector' => 5, + ]); + + $this->assertCount(0, $temporal->getLinksLog('person', 2)); + $this->assertCount(3, $temporal->getLinksLog('person', 1)); + $this->assertCount(2, $temporal->getLinksLog('person', 1, ['sector'])); + $this->assertCount(2, $temporal->getLinksLog('person', 1, ['role'])); + $this->assertCount(1, $temporal->getLinksLog('sector', 5)); + $this->assertCount(1, $temporal->getLinksLog('sector', 5, ['person'])); + $this->assertCount(0, $temporal->getLinksLog('sector', 5, ['role'])); + } + + public function testThreeLinks() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->link([ + 'begin' => 0, + 'end' => 0, + 'person' => 1, + 'role' => 2, + 'sector' => 3, + ]); + + $links = $temporal->getLinks('person', 1, 'now'); + $this->assertCount(1, $links); + $this->assertArrayNotHasKey('data', $links[0]); + + $links = $temporal->getLinks('person', 1, date('Ymd')); + $this->assertCount(1, $links); + $this->assertArrayNotHasKey('data', $links[0]); + } + + public function testTwoWayLinks() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->link([ + 'person' => 1, + 'role' => 2, + ]); + + $links = $temporal->getLinks('person', 1, 'now'); + $this->assertCount(1, $links); + $this->assertArrayNotHasKey('data', $links[0]); + $links = $temporal->getLinks('person', 1, date('Ymd')); + $this->assertCount(1, $links); + $this->assertArrayNotHasKey('data', $links[0]); + } + + public function testLinks() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->link([ + 'begin' => '-1 day', + 'person' => 1, + 'role' => 2, + 'sector' => 3, + ]); + + $temporal->link([ + 'end' => '+2 day', + 'person' => 1, + 'role' => 4, + 'sector' => 3, + ]); + + $temporal->link([ + 'begin' => '-1 week', + 'end' => '+1 week', + 'person' => 2, + 'role' => 22, + 'sector' => 3, + 'data' => ['superuser' => true], + ]); + + // link data validation + $thirdSectorLinksForToday = $temporal->getLinks('sector', 3, 'today'); + + $this->assertCount(3, $thirdSectorLinksForToday); + + $superuserLink = null; + foreach ($thirdSectorLinksForToday as $link) { + if ($link['person'] == 2) { + $superuserLink = $link; + } + } + + $this->assertNotNull($superuserLink); + $this->assertArrayHasKey('data', $superuserLink); + $this->assertArrayHasKey('superuser', $superuserLink['data']); + $this->assertSame($superuserLink['data']['superuser'], true); + } + + public function testState() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->override([ + 'post' => 1, + 'begin' => 'yesterday', + 'end' => '+2 days', + 'data' => [ + 'title' => 'hello world', + ] + ]); + + $this->assertCount(1, $temporal->getOverrides('post', 1)); + + Carbon::setTestNow(Carbon::parse("+1 sec")); + + $temporal->override([ + 'post' => 1, + 'begin' => '5 days ago', + 'data' => [ + 'title' => 'test post', + ] + ]); + + $this->assertCount(2, $temporal->getOverrides('post', 1)); + + $this->assertCount(0, $temporal->getState('post', 1, '1 year ago')); + + foreach (['5 days ago', '-2 days', '+2 days', '+1 year'] as $time) { + $state = $temporal->getState('post', 1, $time); + $this->assertArrayHasKey('title', $state, "Validation: $time"); + $this->assertSame($state['title'], 'test post', "Validation: $time"); + } + + foreach (['midnight', 'tomorrow'] as $time) { + $state = $temporal->getState('post', 1, $time); + $this->assertArrayHasKey('title', $state); + $this->assertSame($state['title'], 'hello world', "Validation: $time"); + } + + Carbon::setTestNow(Carbon::parse("+2 sec")); + $temporal->override([ + 'post' => 1, + 'begin' => '+1 day', + 'end' => '+4 days', + 'data' => [ + 'title' => 'new title', + 'notice' => 'my precious' + ] + ]); + + foreach (['5 days ago', '-2 days', '+3 year', '+4 days'] as $time) { + $state = $temporal->getState('post', 1, $time); + $this->assertArrayHasKey('title', $state); + $this->assertSame($state['title'], 'test post', "Validation: $time"); + } + + foreach (['+1 day', '+2 days', '+3 days', '+4 days -1 sec'] as $time) { + $state = $temporal->getState('post', 1, $time); + $this->assertArrayHasKey('title', $state); + $this->assertSame($state['title'], 'new title', "Validation: $time"); + $this->assertSame($state['notice'], 'my precious', "Validation: $time"); + } + + foreach (['midnight', 'tomorrow'] as $time) { + $state = $temporal->getState('post', 1, $time); + $this->assertArrayHasKey('title', $state); + $this->assertSame($state['title'], 'hello world', "Validation: $time"); + } + + $override = $temporal->findOne('_temporal_override'); + $this->assertSame($override->data, ['title' => 'test post']); + + $temporal->setOverrideIdle('post', 1, $override->begin, $override->actor, $override->timestamp, true); + + foreach (['5 days ago', '-2 days'] as $time) { + $state = $temporal->getState('post', 1, $time); + $this->assertArrayNotHasKey('title', $state); + } + + foreach (['+1 day', '+2 days', '+3 days', '+4 days -1 sec'] as $time) { + $state = $temporal->getState('post', 1, $time); + $this->assertArrayHasKey('title', $state); + $this->assertSame($state['title'], 'new title', "Validation: $time"); + $this->assertSame($state['notice'], 'my precious', "Validation: $time"); + } + + foreach (['midnight', 'tomorrow'] as $time) { + $state = $temporal->getState('post', 1, $time); + $this->assertArrayHasKey('title', $state); + $this->assertSame($state['title'], 'hello world', "Validation: $time"); + } + } + + public function testStateComplex() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->override([ + 'post' => 1, + 'begin' => 20170801, + 'data' => ['key1' => 20170801] + ]); + $temporal->override([ + 'post' => 1, + 'begin' => 20170802, + 'data' => ['key2' => 20170802] + ]); + $this->assertCount(2, $temporal->find('_temporal_override_aggregate')); + + $temporal->override([ + 'post' => 1, + 'begin' => 20170803, + 'data' => ['key1' => 20170803] + ]); + $temporal->override([ + 'post' => 1, + 'begin' => 20170805, + 'data' => ['key1' => 20170805] + ]); + + $temporal->override([ + 'post' => 1, + 'begin' => 20170804, + 'data' => ['key2' => 20170804] + ]); + $temporal->override([ + 'post' => 1, + 'begin' => 20170806, + 'data' => ['key2' => 20170806] + ]); + + Carbon::setTestNow(Carbon::parse("+1 sec")); + + // [20170804, 20170805] + $temporal->override([ + 'post' => 1, + 'begin' => 20170804, + 'end' => 20170806, + 'data' => ['period' => 'x'], + ]); + + $this->assertSame($temporal->getState('post', 1, 20170801), [ + 'key1' => 20170801 + ]); + + $this->assertSame($temporal->getState('post', 1, 20170802), [ + 'key1' => 20170801, + 'key2' => 20170802, + ]); + $this->assertSame($temporal->getState('post', 1, 20170803), [ + 'key1' => 20170803, + 'key2' => 20170802, + ]); + $this->assertSame($temporal->getState('post', 1, 20170804), [ + 'key1' => 20170803, + 'key2' => 20170804, + 'period' => 'x', + ]); + $this->assertSame($temporal->getState('post', 1, 20170805), [ + 'key1' => 20170805, + 'key2' => 20170804, + 'period' => 'x', + ]); + $this->assertSame($temporal->getState('post', 1, 20170806), [ + 'key1' => 20170805, + 'key2' => 20170806, + ]); + } + + public function testReferenceStateAggregation() + { + $temporal = $this->createTemporal(); + $temporal->setActor(1); + + $temporal->reference([ + 'begin' => 20190114, + 'end' => 20190115, + 'person' => 27, + 'data' => [ + 'position' => 2 + ] + ]); + + $temporal->reference([ + 'begin' => 20190121, + 'end' => 20190122, + 'person' => 27, + 'data' => [ + 'position' => 2 + ] + ]); + + $this->assertCount(2, $temporal->find('_temporal_reference_state')); + + $temporal->reference([ + 'begin' => 20190115, + 'end' => 20190119, + 'person' => 27, + 'data' => [ + 'position' => 2 + ] + ]); + $this->assertCount(2, $temporal->find('_temporal_reference_state')); + + $temporal->reference([ + 'begin' => 20190119, + 'end' => 20190121, + 'person' => 27, + 'data' => [ + 'position' => 2 + ] + ]); + $this->assertCount(1, $temporal->find('_temporal_reference_state')); + } +} \ No newline at end of file