* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\PeopleProviderLocal\Store\Personal; use KTXC\Db\DataStore; use KTXF\Resource\Filter\Filter; use KTXF\Resource\Filter\FilterComparisonOperator; use KTXF\Resource\Filter\FilterConjunctionOperator; use KTXF\Resource\Range\Range; use KTXF\Resource\Range\RangeType; use KTXF\Resource\Sort\Sort; use KTXF\Utile\UUID; use KTXM\PeopleProviderLocal\Providers\Personal\Collection; use KTXM\PeopleProviderLocal\Providers\Personal\Entity; class Store { protected string $_CollectionTable = 'people_provider_local_collection'; protected string $_CollectionClass = 'KTXM\PeopleProviderLocal\Providers\Personal\Collection'; protected string $_EntityTable = 'people_provider_local_entity'; protected string $_EntityClass = 'KTXM\PeopleProviderLocal\Providers\Personal\Entity'; protected string $_ChronicleTable = 'people_provider_local_chronicle'; protected array $_CollectionFilterAttributeMap = [ 'id' => 'cid', 'label' => 'label', 'description' => 'description', ]; protected array $_CollectionFilterAttributeComparatorDefault = [ 'id' => FilterComparisonOperator::IN, 'label' => FilterComparisonOperator::LIKE, 'description' => FilterComparisonOperator::LIKE, ]; protected array $_EntityFilterAttributeMap = [ 'id' => 'eid', 'label' => 'data.name.givenName', 'email' => 'data.emails.address', 'phone' => 'data.phones.number', 'organization' => 'data.organization.name', 'tags' => 'data.tags', ]; public function __construct( protected readonly DataStore $_store ) { } protected function constructFilter(array $map, Filter $filter): array { $mongoFilter = []; foreach ($filter->conditions() as $entry) { if (!isset($map[$entry['attribute']])) { continue; } $attribute = $map[$entry['attribute']]; $value = $entry['value']; $comparator = $entry['comparator'] ?? FilterComparisonOperator::EQ; $condition = match ($comparator) { FilterComparisonOperator::EQ => $value, FilterComparisonOperator::NEQ => ['$ne' => $value], FilterComparisonOperator::GT => ['$gt' => $value], FilterComparisonOperator::GTE => ['$gte' => $value], FilterComparisonOperator::LT => ['$lt' => $value], FilterComparisonOperator::LTE => ['$lte' => $value], FilterComparisonOperator::IN => ['$in' => is_array($value) ? $value : [$value]], FilterComparisonOperator::NIN => ['$nin' => is_array($value) ? $value : [$value]], FilterComparisonOperator::LIKE => ['$regex' => $value, '$options' => 'i'], FilterComparisonOperator::NLIKE => ['$not' => ['$regex' => $value, '$options' => 'i']], default => $value }; if (isset($mongoFilter[$attribute])) { // Handle conjunction if ($entry['conjunction'] === FilterConjunctionOperator::OR) { $mongoFilter['$or'][] = [$attribute => $condition]; } else { // AND conjunction - merge with existing if (is_array($mongoFilter[$attribute]) && !isset($mongoFilter[$attribute]['$and'])) { $mongoFilter[$attribute] = ['$and' => [$mongoFilter[$attribute], $condition]]; } else { $mongoFilter[$attribute] = $condition; } } } else { $mongoFilter[$attribute] = $condition; } } return $mongoFilter; } protected function constructSort(array $map, Sort $sort): array { $mongoSort = []; foreach ($sort->conditions() as $entry) { if (!isset($map[$entry['attribute']])) { continue; } $attribute = $map[$entry['attribute']]; $direction = $entry['direction'] ? 1 : -1; $mongoSort[$attribute] = $direction; } return $mongoSort; } /** * retrieve collections from data store * * @since Release 1.0.0 * * @param Filter $filter filter options * @param Sort $sort sort options * * @return array */ public function collectionList(string $tenantId, string $userId, ?Filter $filter = null, ?Sort $sort = null): array { $query = ['tid' => $tenantId, 'uid' => $userId]; // Apply filter if provided if ($filter !== null) { $filterConditions = $this->constructFilter($this->_CollectionFilterAttributeMap, $filter); $query = array_merge($query, $filterConditions); } $options = []; // Apply sort if provided if ($sort !== null) { $sortConditions = $this->constructSort($this->_CollectionFilterAttributeMap, $sort); $options['sort'] = $sortConditions; } $cursor = $this->_store->selectCollection($this->_CollectionTable)->find($query, $options); $list = []; foreach ($cursor as $entry) { $entry = (new Collection())->fromStore($entry); $list[$entry->id()] = $entry; } return $list; } /** * confirm if collections exist in data store * * @since Release 1.0.0 * * @param string $userId user id * @param string $id collection id * */ public function collectionExtant(string $tenantId, string $userId, string $identifier): bool { $cursor = $this->_store->selectCollection($this->_CollectionTable)->findOne([ 'tid' => $tenantId, 'uid' => $userId, 'cid' => $identifier ]); return $cursor !== null; } /** * retrieve collection from data store * * @since Release 1.0.0 * * @param string $userId user identifier * @param string $identifier collection identifier * * @return Collection */ public function collectionFetch(string $tenantId, string $userId, string $identifier): ?Collection { $cursor = $this->_store->selectCollection($this->_CollectionTable)->findOne([ 'tid' => $tenantId, 'uid' => $userId, 'cid' => $identifier ]); if ($cursor === null) { return null; } $entry = (new Collection())->fromStore($cursor); return $entry; } /** * fresh instance of a collection entity * * @since Release 1.0.0 * * @return Collection */ public function collectionFresh(): Collection { return new $this->_CollectionClass; } /** * create a collection entry in the data store * * @since Release 1.0.0 * * @param string $userId user identifier * @param Collection $entity * * @return Collection */ public function collectionCreate(string $tenantId, string $userId, Collection $entity): Collection { // convert entity to store format $data = $entity->toStore(); // prepare data for creation $data['tid'] = $tenantId; $data['uid'] = $userId; $data['cid'] = UUID::v4(); $data['createdOn'] = date('c'); $data['modifiedOn'] = $data['createdOn']; // create entry $result = $this->_store->selectCollection($this->_CollectionTable)->insertOne($data); if ($result->getInsertedCount() === 1) { $entity = new Collection(); $entity->fromStore($data); } return $entity; } /** * modify a collection entry in the data store * * @since Release 1.0.0 * * @param string $userId user identifier * @param Collection $entity * * @return Collection */ public function collectionModify(string $tenantId, string $userId, Collection $entity): Collection { // convert entity to store format $data = $entity->toStore(); // prepare data for modification $cid = $entity->id(); $data['modifiedOn'] = date('c'); unset($data['_id'], $data['tid'], $data['uid'], $data['cid']); // modify entry $this->_store->selectCollection($this->_CollectionTable)->updateOne( ['tid' => $tenantId, 'uid' => $userId, 'cid' => $cid], ['$set' => $data] ); return $entity; } /** * delete a collection entry from the data store * * @since Release 1.0.0 * * @param Collection $entity * * @return Collection */ public function collectionDestroy(string $tenantId, string $userId, Collection $entity): Collection { return $this->collectionDestroyById($tenantId, $userId, $entity->id()) ? $entity : $entity; } /** * delete a collection entry from the data store by ID and user * * @since Release 1.0.0 * * @param string $userId user identifier * @param string $collectionId collection identifier * * @return bool */ public function collectionDestroyById(string $tenantId, string $userId, string $collectionId): bool { $result = $this->_store->selectCollection($this->_CollectionTable)->deleteOne([ 'tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId ]); return $result->getDeletedCount() === 1; } /** * retrieve entities from data store * * @since Release 1.0.0 * * @param string $collection collection identifier * @param Filter $filter filter options * @param Sort $sort sort options * @param Range $range range options * * @return array of entities */ public function entityList(string $tenantId, string $userId, string $collectionId, ?Filter $filter = null, ?Sort $sort = null, ?Range $range = null, ?array $options = null): array { $query = ['tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId]; // Apply filter if provided if ($filter !== null) { $filterConditions = $this->constructFilter($this->_EntityFilterAttributeMap, $filter); $query = array_merge($query, $filterConditions); } $findOptions = []; // Apply sort if provided if ($sort !== null) { $sortConditions = $this->constructSort($this->_EntityFilterAttributeMap, $sort); $findOptions['sort'] = $sortConditions; } // Apply range/pagination if provided if ($range !== null && $range->type() === RangeType::TALLY) { // For TALLY ranges, use position (skip) and tally (limit) /** @var IRangeTally $rangeTally */ $rangeTally = $range; $findOptions['skip'] = $rangeTally->getPosition(); $findOptions['limit'] = $rangeTally->getTally(); } $cursor = $this->_store->selectCollection($this->_EntityTable)->find($query, $findOptions); $list = []; foreach ($cursor as $entry) { $entity = (new Entity())->fromStore($entry); $list[$entity->id()] = $entity; } return $list; } /** * confirm if entity(ies) exist in data store * * @since Release 1.0.0 * * @param string $collection collection identifier * @param string ...$identifiers entity identifiers (eid UUID strings) * * @return array */ public function entityExtant(string $tenantId, string $userId, string $collectionId, string ...$identifiers): array { // Query for all entity IDs at once, but only retrieve the eid field (projection) $cursor = $this->_store->selectCollection($this->_EntityTable)->find( [ 'tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId, 'eid' => ['$in' => $identifiers] ], [ 'projection' => ['eid' => 1, '_id' => 0] ] ); // Build flat array of found IDs $found = []; foreach ($cursor as $entry) { $found[] = $entry['eid']; } $result = array_fill_keys($found, true); $result = array_merge($result, array_fill_keys(array_diff($identifiers, $found), false)); return $result; } /** * retrieve entity(ies) from data store * * @since Release 1.0.0 * * @param string $collection collection identifier * @param string ...$identifiers entity identifiers (eid UUID strings) * * @return array */ public function entityFetch(string $tenantId, string $userId, string $collectionId, string ...$identifiers): array { // Query for entities using eid field $cursor = $this->_store->selectCollection($this->_EntityTable)->find([ 'tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId, 'eid' => ['$in' => $identifiers] ]); $list = []; foreach ($cursor as $entry) { $entity = (new Entity())->fromStore($entry); $list[$entity->id()] = $entity; } return $list; } /** * fresh instance of a entity * * @since Release 1.0.0 * * @return Entity */ public function entityFresh(): Entity { return new Entity(); } /** * create a entity entry in the data store * * @since Release 1.0.0 * * @param Entity $entity entity to create * * @return Entity */ /** * create a entity entry in the data store * * @since Release 1.0.0 * * @param string $userId user identifier * @param string $collection collection identifier * @param Entity $entity entity to create * * @return Entity */ public function entityCreate(string $tenantId, string $userId, string $collectionId, Entity $entity): Entity { // convert entity to store format $data = $entity->toStore(); // assign identifiers and timestamps $data['tid'] = $tenantId; $data['uid'] = $userId; $data['cid'] = $collectionId; $data['eid'] = UUID::v4(); $data['createdOn'] = date('c'); $data['createdBy'] = $userId; $data['modifiedOn'] = $data['createdOn']; $data['modifiedBy'] = $data['createdBy']; $result = $this->_store->selectCollection($this->_EntityTable)->insertOne($data); if ($result->getInsertedCount() === 1) { $eid = $data['eid']; $entity->fromStore(['eid' => $eid, 'tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId]); // Chronicle the creation (operation 1) $this->chronicleDocument($tenantId, $collectionId, $eid, 1); } return $entity; } /** * modify a entity entry in the data store * * @since Release 1.0.0 * * @param string $userId user identifier * @param string $collection collection identifier * @param string $identifier entity identifier * @param Entity $entity entity to modify * * @return Entity */ public function entityModify(string $tenantId, string $userId, string $collectionId, string $identifier, Entity $entity): Entity { // convert entity to store format $data = $entity->toStore(); $data['modifiedOn'] = date('c'); $data['modifiedBy'] = $userId; // Remove identifiers from update data (they shouldn't change) unset($data['_id'], $data['tid'], $data['uid'], $data['cid'], $data['eid']); $result = $this->_store->selectCollection($this->_EntityTable)->updateOne( ['tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId, 'eid' => $identifier], ['$set' => $data] ); if ($result->getModifiedCount() > 0) { // Chronicle the modification (operation 2) $this->chronicleDocument($tenantId, $collectionId, $identifier, 2); } return $entity; } /** * delete a entity from the data store * * @since Release 1.0.0 * * @param string $userId user identifier * @param string $collection collection identifier * @param Entity $entity entity to delete * * @return Entity */ public function entityDestroy(string $tenantId, string $userId, string $collectionId, Entity $entity): Entity { $identifier = $entity->id(); $result = $this->_store->selectCollection($this->_EntityTable)->deleteOne([ 'tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId, 'eid' => $identifier ]); if ($result->getDeletedCount() === 1) { // Chronicle the deletion (operation 3) $this->chronicleDocument($tenantId, $collectionId, $identifier, 3); } return $entity; } /** * delete a entity from the data store by ID * * @since Release 1.0.0 * * @param string $userId user identifier * @param string $collection collection identifier * @param string $entityId entity identifier * * @return bool */ public function entityDestroyById(string $tenantId, string $userId, string $collectionId, string $identifier): bool { $result = $this->_store->selectCollection($this->_EntityTable)->deleteOne([ 'tid' => $tenantId, 'uid' => $userId, 'cid' => $collectionId, 'eid' => $identifier ]); if ($result->getDeletedCount() === 1) { // Chronicle the deletion (operation 3) $this->chronicleDocument($tenantId, $collectionId, $identifier, 3); return true; } return false; } /** * chronicle a operation to an entity to the data store * * @since Release 1.0.0 * * @param string $tid tenant identifier * @param string $cid collection identifier * @param string $eid entity identifier * @param int $operation operation type (1 - Created, 2 - Modified, 3 - Deleted) */ private function chronicleDocument(string $tid, string $cid, string $eid, int $operation): void { // retrieve current token from collection $collection = $this->_store->selectCollection($this->_CollectionTable)->findOne([ 'cid' => $cid ], [ 'projection' => ['signature' => 1, '_id' => 0] ]); $signature = $collection['signature'] ?? 0; // document operation in chronicle $this->_store->selectCollection($this->_ChronicleTable)->insertOne([ 'tid' => $tid, 'cid' => $cid, 'eid' => $eid, 'operation' => $operation, 'signature' => $signature, 'mutatedOn' => time(), ]); // increment token atomically $this->_store->selectCollection($this->_CollectionTable)->updateOne( ['cid' => $cid], ['$inc' => ['signature' => 1]] ); } /** * reminisce operations to entities in data store * * @since Release 1.0.0 * * @param string $cid collection id * @param bool $encode weather to encode the result * * @return int|float|string */ public function chronicleApex(string $tid, string $cid, bool $encode = true): int|float|string { // Use aggregation pipeline to find max signature $cursor = $this->_store->selectCollection($this->_ChronicleTable)->aggregate([ [ '$match' => ['tid' => $tid, 'cid' => $cid] ], [ '$group' => [ '_id' => null, 'maxToken' => ['$max' => '$signature'] ] ] ]); $result = $cursor->toArray(); $stampApex = !empty($result) ? ($result[0]['maxToken'] ?? 0) : 0; if ($encode) { return base64_encode((string)max(0, $stampApex)); } else { return max(0, $stampApex); } } /** * reminisce operations to entities in data store * * @since Release 1.0.0 * * @param string $collection collection id * @param string $signature encoded token * * @return array */ public function chronicleReminisce(string $tenantId, string $collectionId, string $signature): array { // retrieve apex signature $tokenApex = $this->chronicleApex($tenantId, $collectionId, false); // determine nadir signature $tokenNadir = !empty($signature) ? base64_decode($signature) : ''; $initial = !is_numeric($tokenNadir); $tokenNadir = $initial ? 0 : (int)$tokenNadir; // Build aggregation pipeline to retrieve additions/modifications/deletions $matchStage = [ '$match' => [ 'tid' => $tenantId, 'cid' => $collectionId ] ]; // If not initial sync, filter by signature range if (!$initial) { $matchStage['$match']['signature'] = [ '$gt' => $tokenNadir, '$lte' => (int)$tokenApex ]; } $pipeline = [ $matchStage, [ '$group' => [ '_id' => '$eid', 'operation' => ['$max' => '$operation'], 'eid' => ['$first' => '$eid'] ] ] ]; // For initial sync, exclude deleted entries if ($initial) { $pipeline[] = [ '$match' => [ 'operation' => ['$ne' => 3] ] ]; } // define place holder $chronicle = ['additions' => [], 'modifications' => [], 'deletions' => [], 'signature' => base64_encode((string)$tokenApex)]; // execute aggregation $cursor = $this->_store->selectCollection($this->_ChronicleTable)->aggregate($pipeline); // process result foreach ($cursor as $entry) { switch ($entry['operation']) { case 1: $chronicle['additions'][] = $entry['eid']; break; case 2: $chronicle['modifications'][] = $entry['eid']; break; case 3: $chronicle['deletions'][] = $entry['eid']; break; } } // return chronicle return $chronicle; } /** * delete chronicle entries for a specific collection(s) from data store * * @since Release 1.0.0 * * @param array $identifiers collection of identifiers */ private function chronicleExpungeByCollectionId(array $identifiers): void { // Delete chronicle entries for the specified collection identifiers $this->_store->selectCollection($this->_ChronicleTable)->deleteMany([ 'cid' => ['$in' => $identifiers] ]); } }