Count from state

This commit is contained in:
Jake Barnby 2025-10-03 00:03:20 +13:00
parent 473be9284d
commit 36a8365ebb
No known key found for this signature in database
GPG key ID: C437A8CC85B96E9C
2 changed files with 258 additions and 9 deletions

View file

@ -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
*/

View file

@ -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);