diff --git a/src/Appwrite/Databases/TransactionState.php b/src/Appwrite/Databases/TransactionState.php index 1caae8c629..6d51356f89 100644 --- a/src/Appwrite/Databases/TransactionState.php +++ b/src/Appwrite/Databases/TransactionState.php @@ -18,6 +18,56 @@ class TransactionState $this->dbForProject = $dbForProject; } + /** + * Apply projection (select) semantics from queries to a document + */ + private function applyProjection(Document $doc, array $queries): Document + { + if (empty($queries)) { + return $doc; + } + + // Extract selections from queries + $selections = []; + foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_SELECT) { + $values = $query->getValues(); + foreach ($values as $value) { + // Skip relationship selections (containing '.') + if (!\str_contains($value, '.')) { + $selections[] = $value; + } + } + } + } + + // If no selections or wildcard present, return document as-is + if (empty($selections) || \in_array('*', $selections)) { + return $doc; + } + + // Create a new document with only selected attributes + $projected = new Document(); + + // Always preserve internal attributes + $projected->setAttribute('$id', $doc->getId()); + $projected->setAttribute('$collection', $doc->getCollection()); + $projected->setAttribute('$createdAt', $doc->getCreatedAt()); + $projected->setAttribute('$updatedAt', $doc->getUpdatedAt()); + if ($doc->offsetExists('$permissions')) { + $projected->setAttribute('$permissions', $doc->getPermissions()); + } + + // Add selected attributes + foreach ($selections as $attribute) { + if ($doc->offsetExists($attribute)) { + $projected->setAttribute($attribute, $doc->getAttribute($attribute)); + } + } + + return $projected; + } + /** * Get the current state of a transaction by replaying its operations */ @@ -67,7 +117,11 @@ class TransactionState $existingDocument->setAttribute($key, $value); } } - $state[$collectionId][$documentId]['action'] = 'update'; + // Only set action to 'update' if it's not already 'create' or 'upsert' + $currentAction = $state[$collectionId][$documentId]['action']; + if ($currentAction !== 'create' && $currentAction !== 'upsert') { + $state[$collectionId][$documentId]['action'] = 'update'; + } } else { // Document doesn't exist in transaction state, will be merged with committed version $state[$collectionId][$documentId] = [ @@ -141,7 +195,7 @@ class TransactionState if ($docState['action'] === 'create') { // Document was created in transaction, return the created version - return $docState['document']; + return $this->applyProjection($docState['document'], $queries); } if ($docState['action'] === 'update' || $docState['action'] === 'upsert') { @@ -154,10 +208,12 @@ class TransactionState $committedDoc->setAttribute($key, $value); } } - return $committedDoc; + // committedDoc already has projection applied by dbForProject->getDocument() + // But we need to reapply in case transaction added new fields + return $this->applyProjection($committedDoc, $queries); } elseif ($docState['action'] === 'upsert') { // Upsert created a new document since committed doc doesn't exist - return $docState['document']; + return $this->applyProjection($docState['document'], $queries); } } } @@ -195,8 +251,8 @@ class TransactionState // Document was deleted, remove from results unset($documentMap[$docId]); } elseif ($docState['action'] === 'create') { - // Document was created, add to results - $documentMap[$docId] = $docState['document']; + // Document was created, add to results with projection + $documentMap[$docId] = $this->applyProjection($docState['document'], $queries); } elseif ($docState['action'] === 'update' || $docState['action'] === 'upsert') { if (isset($documentMap[$docId])) { // Update existing document @@ -205,9 +261,11 @@ class TransactionState $documentMap[$docId]->setAttribute($key, $value); } } + // Reapply projection in case transaction added new fields + $documentMap[$docId] = $this->applyProjection($documentMap[$docId], $queries); } elseif ($docState['action'] === 'upsert') { - // Upsert created a new document - $documentMap[$docId] = $docState['document']; + // Upsert created a new document, apply projection + $documentMap[$docId] = $this->applyProjection($docState['document'], $queries); } } } @@ -216,6 +274,197 @@ class TransactionState return array_values($documentMap); } + /** + * Count documents with transaction-aware logic + */ + public function countDocuments( + string $collectionId, + ?string $transactionId = null, + array $queries = [] + ): int { + // If no transaction, use normal database count + if ($transactionId === null) { + return $this->dbForProject->count($collectionId, $queries, APP_LIMIT_COUNT); + } + + $state = $this->getTransactionState($transactionId); + + // Get base count from database + $baseCount = $this->dbForProject->count($collectionId, $queries, APP_LIMIT_COUNT); + + // If no transaction state for this collection, return base count + if (!isset($state[$collectionId])) { + return $baseCount; + } + + // Build a set of committed document IDs that match the query + // We need to find which documents match the filters + $committedDocs = $this->dbForProject->find($collectionId, $queries); + $committedDocIds = []; + foreach ($committedDocs as $doc) { + $committedDocIds[$doc->getId()] = true; + } + + $adjustedCount = $baseCount; + + // Apply transaction state changes to the count + foreach ($state[$collectionId] as $docId => $docState) { + if (!$docState['exists']) { + // Document was deleted in transaction + if (isset($committedDocIds[$docId])) { + $adjustedCount--; // Was in results, now deleted + } + } elseif ($docState['action'] === 'create') { + // Document was created in transaction + // We need to check if it would match the query filters + // For now, we'll conservatively add it if no filters are present + // or apply basic filter matching + if ($this->documentMatchesFilters($docState['document'], $queries)) { + $adjustedCount++; // New document that matches + } + } elseif ($docState['action'] === 'update' || $docState['action'] === 'upsert') { + // Document was updated/upserted + $wasInResults = isset($committedDocIds[$docId]); + $nowMatches = $this->documentMatchesFilters($docState['document'], $queries); + + if (!$wasInResults && $nowMatches && $docState['action'] === 'upsert') { + $adjustedCount++; // Upsert created new document that matches + } elseif ($wasInResults && !$nowMatches) { + $adjustedCount--; // Update/upsert made document no longer match + } elseif (!$wasInResults && $nowMatches) { + // Update shouldn't add a new doc, but upsert might have + if ($docState['action'] === 'upsert') { + $adjustedCount++; + } + } + } + } + + return max(0, $adjustedCount); + } + + /** + * Check if a document matches filter queries (simplified implementation) + */ + private function documentMatchesFilters(Document $doc, array $queries): bool + { + // Extract filter queries + $filters = []; + foreach ($queries as $query) { + $method = $query->getMethod(); + // Only process filter queries, not limit/offset/cursor/select + if (!\in_array($method, [ + Query::TYPE_LIMIT, + Query::TYPE_OFFSET, + Query::TYPE_CURSOR_AFTER, + Query::TYPE_CURSOR_BEFORE, + Query::TYPE_SELECT, + Query::TYPE_ORDER_ASC, + Query::TYPE_ORDER_DESC + ])) { + $filters[] = $query; + } + } + + // If no filters, document matches + if (empty($filters)) { + return true; + } + + // Check each filter + foreach ($filters as $filter) { + $attribute = $filter->getAttribute(); + $values = $filter->getValues(); + $docValue = $doc->getAttribute($attribute); + + switch ($filter->getMethod()) { + case Query::TYPE_EQUAL: + if (!\in_array($docValue, $values)) { + return false; + } + break; + case Query::TYPE_NOT_EQUAL: + if (\in_array($docValue, $values)) { + return false; + } + break; + case Query::TYPE_CONTAINS: + $matches = false; + foreach ($values as $value) { + if (\is_array($docValue) && \in_array($value, $docValue)) { + $matches = true; + break; + } + } + if (!$matches) { + return false; + } + break; + case Query::TYPE_STARTS_WITH: + $matches = false; + foreach ($values as $value) { + if (\is_string($docValue) && \str_starts_with($docValue, $value)) { + $matches = true; + break; + } + } + if (!$matches) { + return false; + } + break; + case Query::TYPE_ENDS_WITH: + $matches = false; + foreach ($values as $value) { + if (\is_string($docValue) && \str_ends_with($docValue, $value)) { + $matches = true; + break; + } + } + if (!$matches) { + return false; + } + break; + case Query::TYPE_GREATER_THAN: + if (!($docValue > $values[0])) { + return false; + } + break; + case Query::TYPE_GREATER_THAN_EQUAL: + if (!($docValue >= $values[0])) { + return false; + } + break; + case Query::TYPE_LESSER_THAN: + if (!($docValue < $values[0])) { + return false; + } + break; + case Query::TYPE_LESSER_THAN_EQUAL: + if (!($docValue <= $values[0])) { + return false; + } + break; + case Query::TYPE_IS_NULL: + if (!\is_null($docValue)) { + return false; + } + break; + case Query::TYPE_IS_NOT_NULL: + if (\is_null($docValue)) { + return false; + } + break; + case Query::TYPE_BETWEEN: + if (!($docValue >= $values[0] && $docValue <= $values[1])) { + return false; + } + break; + } + } + + return true; + } + /** * Check if a document exists with transaction-aware logic */ diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index 26d315e52e..a30fed47ed 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -129,7 +129,7 @@ class XList extends Action // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { $documents = $transactionState->listDocuments($collectionTableId, $transactionId, $queries); - $total = count($documents); // For transaction-aware queries, we count the actual results + $total = $transactionState->countDocuments($collectionTableId, $transactionId, $queries); } elseif (! empty($selectQueries)) { // has selects, allow relationship on documents $documents = $dbForProject->find($collectionTableId, $queries);