diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 2332d9745..9a86cc61f 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -325,6 +325,32 @@ public function clearTimeout(string $event): void $this->before($event, 'timeout'); } + /** + * Bulk update attributes for many documents by their IDs. + * + * Default implementation performs per-ID updates using updateDocument with + * permissions update skipped. Adapters can override for optimized paths. + * + * @param Document $collection + * @param array $ids + * @param array $attributes Encoded attribute keys expected by adapter + * @return int Number of documents processed + */ + public function updateManyByIds(Document $collection, array $ids, array $attributes): int + { + if (empty($ids) || empty($attributes)) { + return 0; + } + + $count = 0; + foreach ($ids as $id) { + $this->updateDocument($collection, $id, new Document($attributes), true); + $count++; + } + + return $count; + } + /** * Start a new transaction. * diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 799596d2d..6401728c3 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -245,6 +245,11 @@ public function updateDocuments(Document $collection, Document $updates, array $ return $this->delegate(__FUNCTION__, \func_get_args()); } + public function updateManyByIds(Document $collection, array $ids, array $attributes): int + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function upsertDocuments(Document $collection, string $attribute, array $changes): array { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 84fae6ce7..e976b7c73 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2100,6 +2100,78 @@ public function createDocuments(Document $collection, array $documents): array return $documents; } + /** + * Bulk update attributes for many documents by their IDs + * + * @param Document $collection + * @param array $ids + * @param array $attributes Public attribute keys (e.g. "$updatedAt", "author") + * @return int Number of rows updated + * @throws \Exception + */ + public function updateManyByIds(Document $collection, array $ids, array $attributes): int + { + if (empty($ids) || empty($attributes)) { + return 0; + } + + $collectionId = $this->filter($collection->getId()); + + // Map attribute keys to internal column names and build bindings + $binds = []; + $setParts = []; + $bindIndex = 0; + + foreach ($attributes as $key => $value) { + $internal = $this->filter($this->getInternalKeyForAttribute($key)); + + // JSON encode arrays/objects; cast bools to int + if (\is_array($value)) { + $value = \json_encode($value); + } elseif (\is_bool($value)) { + $value = (int) $value; + } + + $bindKey = ":val_{$bindIndex}"; + $setParts[] = "{$this->quote($internal)} = {$bindKey}"; + $binds[$bindKey] = $value; + $bindIndex++; + } + + // Build IN list for IDs + $idPlaceholders = []; + foreach ($ids as $i => $id) { + $ph = ":id_{$i}"; + $idPlaceholders[] = $ph; + $binds[$ph] = $id; + } + + $tenantClause = ''; + if ($this->sharedTables) { + $tenantClause = " AND {$this->quote('_tenant')} = :_tenant"; + $binds[':_tenant'] = $this->getTenant(); + } + + $setSQL = \implode(', ', $setParts); + $inSQL = \implode(', ', $idPlaceholders); + + $sql = " + UPDATE {$this->getSQLTable($collectionId)} + SET {$setSQL} + WHERE {$this->quote('_uid')} IN ({$inSQL}){$tenantClause} + "; + + $stmt = $this->getPDO()->prepare($sql); + + foreach ($binds as $key => $val) { + $stmt->bindValue($key, $val, $this->getPDOType($val)); + } + + $this->execute($stmt); + + return $stmt->rowCount(); + } + /** * @param Document $collection * @param string $attribute diff --git a/src/Database/Database.php b/src/Database/Database.php index 56a65e724..ba66fbc82 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -110,6 +110,22 @@ class Database self::PERMISSION_DELETE, ]; + /** + * Check if bulk relationship write optimizations are enabled. + * Controlled via environment variable DB_RELATIONSHIP_BULK_WRITES (default: enabled). + * + * @return bool + */ + private function shouldUseRelationshipBulkWrites(): bool + { + $val = getenv('DB_RELATIONSHIP_BULK_WRITES'); + if ($val === false || $val === '') { + return true; + } + $val = strtolower((string)$val); + return !in_array($val, ['0', 'false', 'off'], true); + } + // Collections public const METADATA = '_metadata'; @@ -4430,39 +4446,107 @@ private function createDocumentRelationships(Document $collection, Document $doc } // List of documents or IDs + $idRelations = []; + $objectRelations = []; foreach ($value as $related) { switch (\gettype($related)) { case 'object': if (!$related instanceof Document) { throw new RelationshipException('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); } - $this->relateDocuments( + $objectRelations[] = $related; + break; + case 'string': + $idRelations[] = $related; + break; + default: + throw new RelationshipException('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); + } + } + + // Process object relations: detect nested relationships and handle appropriately + $idOnlyDocs = []; + $richDocsNoNesting = []; + + foreach ($objectRelations as $objRel) { + // Check if document has nested relationships + $hasNestedRelationships = false; + foreach ($objRel->getAttributes() as $attrKey => $attrValue) { + if ($attrKey === '$id' || $attrKey === '$permissions') { + continue; + } + // Check if attribute is a Document or array of Documents (nested relationship) + if ($attrValue instanceof Document || + (is_array($attrValue) && !empty($attrValue) && isset($attrValue[0]) && $attrValue[0] instanceof Document)) { + $hasNestedRelationships = true; + break; + } + } + + if ($hasNestedRelationships) { + // Use original method for nested relationships - handles everything including links + $this->relateDocuments( + $collection, + $relatedCollection, + $key, + $document, + $objRel, + $relationType, + $twoWay, + $twoWayKey, + $side + ); + } elseif ($this->isIdOnlyDocument($objRel)) { + $idOnlyDocs[] = $objRel; + } else { + $richDocsNoNesting[] = $objRel; + } + } + + // Ensure ID-only docs in batch (create missing) and collect their IDs + $ensuredIds = $this->batchEnsureIdOnlyDocuments($relatedCollection, $idOnlyDocs, $document); + + // Ensure rich docs (without nesting) one-by-one + foreach ($richDocsNoNesting as $relatedDoc) { + $ensuredIds[] = $this->ensureRelatedDocumentAndGetId( + $relatedCollection, + $relatedDoc, + $document, + $relationType, + $twoWay, + $twoWayKey, + $side + ); + } + + $linkIds = [...$ensuredIds, ...$idRelations]; + + if (!empty($linkIds)) { + switch ($relationType) { + case Database::RELATION_MANY_TO_MANY: + $this->batchCreateJunctionLinks( $collection, $relatedCollection, + $side, $key, - $document, - $related, - $relationType, - $twoWay, $twoWayKey, - $side, + $document->getId(), + $linkIds ); break; - case 'string': - $this->relateDocumentsById( + case Database::RELATION_ONE_TO_MANY: + case Database::RELATION_MANY_TO_ONE: + $this->batchUpdateBackReferences( $collection, $relatedCollection, - $key, - $document->getId(), - $related, $relationType, - $twoWay, - $twoWayKey, $side, + $key, + $twoWayKey, + $document->getId(), + $linkIds ); break; - default: - throw new RelationshipException('Invalid relationship value. Must be either a document, document ID, or an array of documents or document IDs.'); } } $document->removeAttribute($key); @@ -4706,6 +4790,339 @@ private function relateDocumentsById( } } + + /** + * Batch insert junction links for many-to-many relationships. + * + * Optimizes bulk relationship creation by using bulk insert operations + * instead of individual document inserts for each junction link. + * + * @param Document $collection Parent collection + * @param Document $relatedCollection Related collection + * @param string $side Relationship side (parent/child) + * @param string $key Relationship attribute key + * @param string $twoWayKey Two-way relationship key + * @param string $documentId Parent document ID + * @param array $relationIds Array of related document IDs to link + * @return void + */ + private function batchCreateJunctionLinks( + Document $collection, + Document $relatedCollection, + string $side, + string $key, + string $twoWayKey, + string $documentId, + array $relationIds + ): void { + if (!$this->shouldUseRelationshipBulkWrites()) { + foreach ($relationIds as $rid) { + $this->relateDocumentsById( + $collection, + $relatedCollection, + $key, + $documentId, + (string)$rid, + Database::RELATION_MANY_TO_MANY, + true, + $twoWayKey, + $side, + ); + } + return; + } + $junctionDocs = []; + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + foreach ($relationIds as $rid) { + $this->purgeCachedDocument($relatedCollection->getId(), $rid); + $junctionDocs[] = new Document([ + $key => $rid, + $twoWayKey => $documentId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ] + ]); + } + $this->skipRelationships(fn () => $this->createDocuments($junction, $junctionDocs)); + } + + /** + * Batch update back-references for one-to-many and many-to-one relationships. + * + * Optimizes bulk relationship updates by using SQL UPDATE with IN clause + * instead of individual document updates for each relationship. + * + * @param Document $collection Parent collection + * @param Document $relatedCollection Related collection + * @param string $relationType Type of relationship (O2M or M2O) + * @param string $side Relationship side (parent/child) + * @param string $key Relationship attribute key + * @param string $twoWayKey Two-way relationship key + * @param string $documentId Parent document ID + * @param array $relationIds Array of related document IDs to update + * @return void + */ + private function batchUpdateBackReferences( + Document $collection, + Document $relatedCollection, + string $relationType, + string $side, + string $key, + string $twoWayKey, + string $documentId, + array $relationIds + ): void { + // Only for string IDs; fall back otherwise + $allStrings = true; + foreach ($relationIds as $rid) { + if (!\is_string($rid)) { + $allStrings = false; + break; + } + } + if (!$allStrings) { + foreach ($relationIds as $rid) { + $this->relateDocumentsById( + $collection, + $relatedCollection, + $key, + $documentId, + $rid instanceof Document ? $rid->getId() : (string)$rid, + $relationType, + true, + $twoWayKey, + $side, + ); + } + return; + } + + // At this point, all elements are confirmed to be strings + /** @var array $stringIds */ + $stringIds = $relationIds; + + $this->skipRelationships(function () use ($relatedCollection, $twoWayKey, $documentId, $stringIds) { + // Prefilter allowed IDs when documentSecurity is enabled by issuing a single authorized find + $relatedDocSecurity = $relatedCollection->getAttribute('documentSecurity', false); + $idsAllowed = $stringIds; + if ($relatedDocSecurity) { + // Skip authorization to match original relateDocumentsById behavior + $allowedDocs = Authorization::skip(fn () => $this->silent(fn () => $this->find( + $relatedCollection->getId(), + [Query::select(['$id', '$updatedAt']), Query::equal('$id', $stringIds), Query::limit(count($stringIds))] + ))); + $idsAllowed = array_map(fn ($d) => $d->getId(), $allowedDocs); + if (empty($idsAllowed)) { + return; // nothing to update + } + // Conflict check vs request timestamp + if (!\is_null($this->timestamp)) { + foreach ($allowedDocs as $docAllowed) { + $oldUpdatedAt = new \DateTime($docAllowed->getUpdatedAt()); + if ($oldUpdatedAt > $this->timestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } + } + } + } else { + // Skip authorization to match original relateDocumentsById behavior + $found = Authorization::skip(fn () => $this->silent(fn () => $this->find( + $relatedCollection->getId(), + [Query::select(['$id', '$updatedAt']), Query::equal('$id', $stringIds), Query::limit(count($stringIds))] + ))); + // Conflict check vs request timestamp + if (!\is_null($this->timestamp)) { + foreach ($found as $docFound) { + $oldUpdatedAt = new \DateTime($docFound->getUpdatedAt()); + if ($oldUpdatedAt > $this->timestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } + } + } + } + + // Prepare update payload with full parity: encode + partial structure + updatedAt + $now = DateTime::now(); + $updateDoc = new Document([ + $twoWayKey => $documentId, + '$updatedAt' => $now, + ]); + + $updateEncoded = $this->encode($relatedCollection, $updateDoc, applyDefaults: false); + + $structureValidator = new PartialStructure( + $relatedCollection, + $this->adapter->getIdAttributeType(), + $this->adapter->getMinDateTime(), + $this->adapter->getMaxDateTime(), + ); + if (!$structureValidator->isValid($updateEncoded)) { + throw new StructureException($structureValidator->getDescription()); + } + + if (!$this->shouldUseRelationshipBulkWrites()) { + // Use standard updateDocuments (parity path) + $this->updateDocuments( + $relatedCollection->getId(), + $updateEncoded, + [Query::equal('$id', $idsAllowed)] + ); + return; + } + + $modified = $this->withTransaction(function () use ($relatedCollection, $updateEncoded, $idsAllowed) { + return $this->adapter->updateManyByIds($relatedCollection, $idsAllowed, $updateEncoded->getAttributes()); + }); + + foreach ($idsAllowed as $id) { + $this->purgeCachedDocument($relatedCollection->getId(), (string)$id); + } + + $this->trigger(self::EVENT_DOCUMENTS_UPDATE, new Document([ + '$collection' => $relatedCollection->getId(), + 'modified' => $modified + ])); + }); + } + + /** + * Detect if a Document contains only ID and permissions metadata. + * + * @param Document $doc Document to check + * @return bool True if document has only $id and optionally $permissions + */ + private function isIdOnlyDocument(Document $doc): bool + { + foreach ($doc->getAttributes() as $k => $_) { + if ($k === '$id' || $k === '$permissions') { + continue; + } + // Any other attribute (including other '$' keys) makes it a rich document + return false; + } + return !empty($doc->getId()); + } + + /** + * Batch ensure ID-only related Documents exist (create missing) and return their IDs. + * Uses createDocuments for missing IDs to preserve validation and events. + * + * @param Document $relatedCollection + * @param array $docs + * @param Document $parent Parent document (for inheriting permissions if missing) + * @return array + */ + private function batchEnsureIdOnlyDocuments(Document $relatedCollection, array $docs, Document $parent): array + { + if (empty($docs)) { + return []; + } + + // Collect requested IDs and per-doc permissions (if provided) + $ids = []; + $idPerms = []; + foreach ($docs as $d) { + $ids[] = $d->getId(); + $idPerms[$d->getId()] = $d->getPermissions(); + } + + // Fetch existing IDs in one call + $existing = $this->skipRelationships(fn () => $this->find( + $relatedCollection->getId(), + [Query::select(['$id']), Query::equal('$id', $ids), Query::limit(count($ids))] + )); + $found = array_map(fn (Document $d) => $d->getId(), $existing); + + // Compute missing and create them + $missing = array_values(array_diff($ids, $found)); + foreach ($missing as $missingId) { + $perms = $idPerms[$missingId] ?? $parent->getPermissions(); + $newDoc = new Document([ + '$id' => $missingId, + '$permissions' => $perms + ]); + $this->skipRelationships(fn () => $this->createDocument($relatedCollection->getId(), $newDoc)); + } + + return $ids; + } + + /** + * Ensure a related Document exists (create or update) and return its ID. + * + * This method creates the related document if it doesn't exist, or updates it if it does. + * It does not handle junction table writes or back-reference updates. + * + * @param Document $relatedCollection Collection containing the related document + * @param Document $relation Related document to ensure + * @param Document $parent Parent document + * @param string $relationType Type of relationship + * @param bool $twoWay Whether this is a two-way relationship + * @param string $twoWayKey Two-way relationship key + * @param string $side Relationship side (parent/child) + * @return string ID of the ensured document + */ + private function ensureRelatedDocumentAndGetId( + Document $relatedCollection, + Document $relation, + Document $parent, + string $relationType, + bool $twoWay, + string $twoWayKey, + string $side + ): string { + // Set back-reference attribute on the relation document BEFORE create/update + // This is critical for documents that only have READ permission (not UPDATE) + switch ($relationType) { + case Database::RELATION_ONE_TO_ONE: + if ($twoWay) { + $relation->setAttribute($twoWayKey, $parent->getId()); + } + break; + case Database::RELATION_ONE_TO_MANY: + if ($side === Database::RELATION_SIDE_PARENT) { + $relation->setAttribute($twoWayKey, $parent->getId()); + } + break; + case Database::RELATION_MANY_TO_ONE: + if ($side === Database::RELATION_SIDE_CHILD) { + $relation->setAttribute($twoWayKey, $parent->getId()); + } + break; + } + + // Try to get the related document + $related = $this->skipRelationships(fn () => $this->getDocument($relatedCollection->getId(), $relation->getId())); + + if ($related->isEmpty()) { + // If the related document doesn't exist, create it, inheriting permissions if none are set + if (!isset($relation['$permissions'])) { + $relation->setAttribute('$permissions', $parent->getPermissions()); + } + + // This method is only called for documents without nested relationships, + // so we can safely skip relationship processing + $created = $this->skipRelationships(fn () => $this->createDocument($relatedCollection->getId(), $relation)); + return $created->getId(); + } + + // If the related document exists and the data is not the same, update it + $needsUpdate = ($related->getAttributes() != $relation->getAttributes()); + if ($needsUpdate) { + foreach ($relation->getAttributes() as $attribute => $value) { + $related->setAttribute($attribute, $value); + } + + $updated = $this->skipRelationships(fn () => $this->updateDocument($relatedCollection->getId(), $related->getId(), $related)); + return $updated->getId(); + } + + return $related->getId(); + } + /** * Update Document * diff --git a/tests/benchmarking/relationship_write_benchmark.php b/tests/benchmarking/relationship_write_benchmark.php new file mode 100644 index 000000000..61f48b55e --- /dev/null +++ b/tests/benchmarking/relationship_write_benchmark.php @@ -0,0 +1,289 @@ + [ + 'tags' => 100, + 'tags_per_post' => 20, + 'num_posts' => 5, + 'articles' => 80, + 'articles_per_author' => 20, + 'num_authors' => 5, + 'users' => 50, + 'num_profiles' => 10, + 'num_companies' => 5, + 'employees_per_company' => 20, + ], + 'MEDIUM' => [ + 'tags' => 300, + 'tags_per_post' => 60, + 'num_posts' => 12, + 'articles' => 300, + 'articles_per_author' => 40, + 'num_authors' => 8, + 'users' => 150, + 'num_profiles' => 20, + 'num_companies' => 6, + 'employees_per_company' => 40, + ], + 'HEAVY' => [ + 'tags' => 800, + 'tags_per_post' => 120, + 'num_posts' => 24, + 'articles' => 800, + 'articles_per_author' => 60, + 'num_authors' => 12, + 'users' => 400, + 'num_profiles' => 40, + 'num_companies' => 10, + 'employees_per_company' => 70, + ], +]; + +if (!isset($levels[$level])) { + Console::error("Invalid level: {$level}"); + exit(1); +} + +/** + * @param array $config + * @return array + */ +function bench(array $config, string $shape): array +{ + Authorization::setRole('any'); + Authorization::setDefaultStatus(true); + + // DB + Cache + $dbFile = sys_get_temp_dir() . '/rel-bench-' . uniqid() . '.db'; + @unlink($dbFile); + $pdo = new DbPDO('sqlite:' . $dbFile, null, null, SQLite::getPDOAttributes()); + $adapter = new SQLite($pdo); + + $redis = new Redis(); + $redis->connect('redis', 6379); + $redis->flushAll(); + $cache = new Cache(new RedisAdapter($redis)); + + $database = new Database($adapter, $cache); + $database->setDatabase('bench')->setNamespace('bench_' . uniqid()); + if ($database->exists()) { + $database->delete(); + } + $database->create(); + // no internal toggles; the "fast" flag determines whether we provide string IDs (fast) or Document objects (baseline) in relationship arrays + + // Query counter + $queries = [ + 'select' => 0, + 'insert' => 0, + 'update' => 0, + 'delete' => 0, + 'total' => 0, + ]; + $database->getAdapter()->before(Database::EVENT_ALL, 'counter', function ($sql) use (&$queries) { + $queries['total']++; + $q = strtoupper(ltrim($sql)); + if (str_starts_with($q, 'SELECT')) { + $queries['select']++; + } elseif (str_starts_with($q, 'INSERT')) { + $queries['insert']++; + } elseif (str_starts_with($q, 'UPDATE')) { + $queries['update']++; + } elseif (str_starts_with($q, 'DELETE')) { + $queries['delete']++; + } + return $sql; + }); + + // Schema + $database->createCollection('posts'); + $database->createAttribute('posts', 'title', Database::VAR_STRING, 255, true); + $database->createCollection('tags'); + $database->createAttribute('tags', 'name', Database::VAR_STRING, 100, true); + $database->createRelationship('posts', 'tags', Database::RELATION_MANY_TO_MANY, true, 'tags', 'posts'); + + $database->createCollection('authors'); + $database->createAttribute('authors', 'name', Database::VAR_STRING, 100, true); + $database->createCollection('articles'); + $database->createAttribute('articles', 'title', Database::VAR_STRING, 255, true); + $database->createRelationship('authors', 'articles', Database::RELATION_ONE_TO_MANY, true, 'articles', 'author'); + + $database->createCollection('users'); + $database->createAttribute('users', 'username', Database::VAR_STRING, 100, true); + $database->createCollection('profiles'); + $database->createAttribute('profiles', 'bio', Database::VAR_STRING, 255, true); + $database->createRelationship('users', 'profiles', Database::RELATION_ONE_TO_ONE, false, 'profile'); + + $database->createCollection('companies'); + $database->createAttribute('companies', 'name', Database::VAR_STRING, 100, true); + $database->createCollection('employees'); + $database->createAttribute('employees', 'name', Database::VAR_STRING, 100, true); + $database->createRelationship('employees', 'companies', Database::RELATION_MANY_TO_ONE, false, 'company'); + + // Seed referenced docs + $tagIds = []; + for ($i = 1; $i <= $config['tags']; $i++) { + $tagIds[] = $database->createDocument('tags', new Document([ + '$id' => 'tag' . $i, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Tag ' . $i, + ]))->getId(); + } + + $articleIds = []; + for ($i = 1; $i <= $config['articles']; $i++) { + $articleIds[] = $database->createDocument('articles', new Document([ + '$id' => 'article' . $i, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'title' => 'Article ' . $i, + ]))->getId(); + } + + $profileIds = []; + for ($i = 1; $i <= $config['users']; $i++) { + $profileIds[] = $database->createDocument('profiles', new Document([ + '$id' => 'profile' . $i, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'bio' => 'Bio ' . $i, + ]))->getId(); + } + + $companyIds = []; + for ($i = 1; $i <= $config['num_companies']; $i++) { + $companyIds[] = $database->createDocument('companies', new Document([ + '$id' => 'company' . $i, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Company ' . $i, + ]))->getId(); + } + + // Measure segments + $result = []; + + // M2M + $start = microtime(true); + for ($i = 1; $i <= $config['num_posts']; $i++) { + $offset = (($i - 1) * 7) % $config['tags']; + $selected = array_slice($tagIds, $offset, $config['tags_per_post']); + if ($shape === 'docs') { + $selected = array_map(fn ($id) => new Document(['$id' => $id, '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())]]), $selected); + } + $database->createDocument('posts', new Document([ + '$id' => 'post' . $i, + '$permissions' => [Permission::read(Role::any())], + 'title' => 'Post ' . $i, + 'tags' => $selected, + ])); + } + $result['m2m_time_ms'] = (microtime(true) - $start) * 1000; + + // O2M + $start = microtime(true); + for ($i = 1; $i <= $config['num_authors']; $i++) { + $offset = ($i - 1) * $config['articles_per_author']; + $selected = array_slice($articleIds, $offset, $config['articles_per_author']); + if ($shape === 'docs') { + $selected = array_map(fn ($id) => new Document(['$id' => $id, '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())]]), $selected); + } + $database->createDocument('authors', new Document([ + '$id' => 'author' . $i, + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Author ' . $i, + 'articles' => $selected, + ])); + } + $result['o2m_time_ms'] = (microtime(true) - $start) * 1000; + + // O2O + $start = microtime(true); + for ($i = 1; $i <= $config['num_profiles']; $i++) { + $profile = $profileIds[($i * 3) % count($profileIds)]; + if ($shape === 'docs') { + $profile = new Document(['$id' => $profile, '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())]]); + } + $database->createDocument('users', new Document([ + '$id' => 'user' . $i, + '$permissions' => [Permission::read(Role::any())], + 'username' => 'User ' . $i, + 'profile' => $profile, + ])); + } + $result['o2o_time_ms'] = (microtime(true) - $start) * 1000; + + // M2O + $start = microtime(true); + for ($i = 1; $i <= $config['num_companies']; $i++) { + $company = $companyIds[$i - 1]; + if ($shape === 'docs') { + $company = new Document(['$id' => $company, '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())]]); + } + for ($j = 1; $j <= $config['employees_per_company']; $j++) { + $id = (($i - 1) * $config['employees_per_company']) + $j; + $database->createDocument('employees', new Document([ + '$id' => 'employee' . $id, + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Employee ' . $id, + 'company' => $company, + ])); + } + } + $result['m2o_time_ms'] = (microtime(true) - $start) * 1000; + + $result['total_time_ms'] = $result['m2m_time_ms'] + $result['o2m_time_ms'] + $result['o2o_time_ms'] + $result['m2o_time_ms']; + $result['queries'] = $queries; + + @unlink($dbFile); + return $result; +} + +$cfg = $levels[$level]; + +$res = bench($cfg, $shape); + +// Simple key=value output mode for scripts +$kv = getenv('BENCH_KV'); +if ($kv !== false && $kv !== '') { + echo "M2M=" . (int) $res['m2m_time_ms'] . "\n"; + echo "O2M=" . (int) $res['o2m_time_ms'] . "\n"; + echo "O2O=" . (int) $res['o2o_time_ms'] . "\n"; + echo "M2O=" . (int) $res['m2o_time_ms'] . "\n"; + echo "TOTAL=" . (int) $res['total_time_ms'] . "\n"; + echo "QUERIES_TOTAL={$res['queries']['total']}\n"; + echo "SELECT={$res['queries']['select']}\n"; + echo "INSERT={$res['queries']['insert']}\n"; + echo "UPDATE={$res['queries']['update']}\n"; + echo "DELETE={$res['queries']['delete']}\n"; + exit(0); +} + +Console::info("\nRunning relationship write benchmark at level {$level} (shape={$shape})\n"); + +Console::log("\nResults (Time)"); +Console::log(sprintf("%-10s | %8dms", 'M2M', (int)$res['m2m_time_ms'])); +Console::log(sprintf("%-10s | %8dms", 'O2M', (int)$res['o2m_time_ms'])); +Console::log(sprintf("%-10s | %8dms", 'O2O', (int)$res['o2o_time_ms'])); +Console::log(sprintf("%-10s | %8dms", 'M2O', (int)$res['m2o_time_ms'])); +Console::log(str_repeat('-', 24)); +Console::success(sprintf("%-10s | %8dms", 'TOTAL', (int)$res['total_time_ms'])); + +Console::log("\nQueries (total): {$res['queries']['total']}"); +Console::log("Select: {$res['queries']['select']} | " . + "Insert: {$res['queries']['insert']} | " . + "Update: {$res['queries']['update']} | " . + "Delete: {$res['queries']['delete']}"); diff --git a/tests/benchmarking/run-benchmark.sh b/tests/benchmarking/run-benchmark.sh new file mode 100755 index 000000000..674aed10d --- /dev/null +++ b/tests/benchmarking/run-benchmark.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: ./tests/benchmarking/run-benchmark.sh [LEVEL] +# LEVEL: LIGHT | MEDIUM | HEAVY (default: MEDIUM) + +LEVEL=${1:-MEDIUM} + +printf "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" +printf " Relationship Write Benchmark\n" +printf "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + +printf "\n🔧 Starting Docker containers...\n" +docker-compose up -d --build --remove-orphans >/dev/null +printf "✅ Docker containers ready\n" + +run_case_kv() { + local bulk=$1 + local shape=$2 + docker-compose exec -T tests sh -lc "export DB_RELATIONSHIP_BULK_WRITES=${bulk} BENCH_KV=1; php tests/benchmarking/relationship_write_benchmark.php '${LEVEL}' '${shape}'" +} + +percent() { + # args: base opt + awk -v b="$1" -v o="$2" 'BEGIN { if (b==0) {print 0} else { printf "%.1f", ((b-o)/b)*100 } }' +} + +print_row() { + local label=$1 base=$2 opt=$3 + printf "%-8s | %7dms | %7dms | %6.1f%%\n" "$label" "$base" "$opt" $(percent "$base" "$opt") +} + +printf "\n▶ Baseline (bulk=OFF, shape=ids)\n" +BASE_IDS_KV=$(run_case_kv 0 ids) +printf "\n▶ Baseline (bulk=OFF, shape=docs)\n" +BASE_DOCS_KV=$(run_case_kv 0 docs) + +printf "\n▶ Optimized (bulk=ON, shape=ids)\n" +OPT_IDS_KV=$(run_case_kv 1 ids) +printf "\n▶ Optimized (bulk=ON, shape=docs)\n" +OPT_DOCS_KV=$(run_case_kv 1 docs) + +# Extract values +getv() { echo "$1" | grep -E "^$2=" | head -n1 | cut -d'=' -f2; } + +BASE_IDS_M2M=$(getv "$BASE_IDS_KV" M2M); OPT_IDS_M2M=$(getv "$OPT_IDS_KV" M2M) +BASE_IDS_O2M=$(getv "$BASE_IDS_KV" O2M); OPT_IDS_O2M=$(getv "$OPT_IDS_KV" O2M) +BASE_IDS_O2O=$(getv "$BASE_IDS_KV" O2O); OPT_IDS_O2O=$(getv "$OPT_IDS_KV" O2O) +BASE_IDS_M2O=$(getv "$BASE_IDS_KV" M2O); OPT_IDS_M2O=$(getv "$OPT_IDS_KV" M2O) +BASE_IDS_TOTAL=$(getv "$BASE_IDS_KV" TOTAL); OPT_IDS_TOTAL=$(getv "$OPT_IDS_KV" TOTAL) +BASE_IDS_Q=$(getv "$BASE_IDS_KV" QUERIES_TOTAL); OPT_IDS_Q=$(getv "$OPT_IDS_KV" QUERIES_TOTAL) +BASE_IDS_SEL=$(getv "$BASE_IDS_KV" SELECT); OPT_IDS_SEL=$(getv "$OPT_IDS_KV" SELECT) +BASE_IDS_INS=$(getv "$BASE_IDS_KV" INSERT); OPT_IDS_INS=$(getv "$OPT_IDS_KV" INSERT) +BASE_IDS_UPD=$(getv "$BASE_IDS_KV" UPDATE); OPT_IDS_UPD=$(getv "$OPT_IDS_KV" UPDATE) +BASE_IDS_DEL=$(getv "$BASE_IDS_KV" DELETE); OPT_IDS_DEL=$(getv "$OPT_IDS_KV" DELETE) + +BASE_DOCS_M2M=$(getv "$BASE_DOCS_KV" M2M); OPT_DOCS_M2M=$(getv "$OPT_DOCS_KV" M2M) +BASE_DOCS_O2M=$(getv "$BASE_DOCS_KV" O2M); OPT_DOCS_O2M=$(getv "$OPT_DOCS_KV" O2M) +BASE_DOCS_O2O=$(getv "$BASE_DOCS_KV" O2O); OPT_DOCS_O2O=$(getv "$OPT_DOCS_KV" O2O) +BASE_DOCS_M2O=$(getv "$BASE_DOCS_KV" M2O); OPT_DOCS_M2O=$(getv "$OPT_DOCS_KV" M2O) +BASE_DOCS_TOTAL=$(getv "$BASE_DOCS_KV" TOTAL); OPT_DOCS_TOTAL=$(getv "$OPT_DOCS_KV" TOTAL) +BASE_DOCS_Q=$(getv "$BASE_DOCS_KV" QUERIES_TOTAL); OPT_DOCS_Q=$(getv "$OPT_DOCS_KV" QUERIES_TOTAL) +BASE_DOCS_SEL=$(getv "$BASE_DOCS_KV" SELECT); OPT_DOCS_SEL=$(getv "$OPT_DOCS_KV" SELECT) +BASE_DOCS_INS=$(getv "$BASE_DOCS_KV" INSERT); OPT_DOCS_INS=$(getv "$OPT_DOCS_KV" INSERT) +BASE_DOCS_UPD=$(getv "$BASE_DOCS_KV" UPDATE); OPT_DOCS_UPD=$(getv "$OPT_DOCS_KV" UPDATE) +BASE_DOCS_DEL=$(getv "$BASE_DOCS_KV" DELETE); OPT_DOCS_DEL=$(getv "$OPT_DOCS_KV" DELETE) + +printf "\n════════════════════════════════════════════════════════════════\n" +printf " RESULT SUMMARY \n" +printf "════════════════════════════════════════════════════════════════\n" +printf "\nTime (ms) — shape: ids\n" +printf "+--------+----------+----------+--------+\n" +printf "| Label | Baseline | Optimized| Gain |\n" +printf "+--------+----------+----------+--------+\n" +printf "| %-6s | %8d | %8d | %6.1f%% |\n" M2M "$BASE_IDS_M2M" "$OPT_IDS_M2M" $(percent "$BASE_IDS_M2M" "$OPT_IDS_M2M") +printf "| %-6s | %8d | %8d | %6.1f%% |\n" O2M "$BASE_IDS_O2M" "$OPT_IDS_O2M" $(percent "$BASE_IDS_O2M" "$OPT_IDS_O2M") +printf "| %-6s | %8d | %8d | %6.1f%% |\n" O2O "$BASE_IDS_O2O" "$OPT_IDS_O2O" $(percent "$BASE_IDS_O2O" "$OPT_IDS_O2O") +printf "| %-6s | %8d | %8d | %6.1f%% |\n" M2O "$BASE_IDS_M2O" "$OPT_IDS_M2O" $(percent "$BASE_IDS_M2O" "$OPT_IDS_M2O") +printf "+--------+----------+----------+--------+\n" +printf "| %-6s | %8d | %8d | %6.1f%% |\n" TOTAL "$BASE_IDS_TOTAL" "$OPT_IDS_TOTAL" $(percent "$BASE_IDS_TOTAL" "$OPT_IDS_TOTAL") +printf "+--------+----------+----------+--------+\n" + +printf "\nQueries — shape: ids\n" +printf "+---------+----------+----------+\n" +printf "| Metric | Baseline | Optimized|\n" +printf "+---------+----------+----------+\n" +printf "| %-7s | %8d | %8d |\n" total "$BASE_IDS_Q" "$OPT_IDS_Q" +printf "| %-7s | %8d | %8d |\n" select "$BASE_IDS_SEL" "$OPT_IDS_SEL" +printf "| %-7s | %8d | %8d |\n" insert "$BASE_IDS_INS" "$OPT_IDS_INS" +printf "| %-7s | %8d | %8d |\n" update "$BASE_IDS_UPD" "$OPT_IDS_UPD" +printf "| %-7s | %8d | %8d |\n" delete "$BASE_IDS_DEL" "$OPT_IDS_DEL" +printf "+---------+----------+----------+\n" + +printf "\nTime (ms) — shape: docs\n" +printf "+--------+----------+----------+--------+\n" +printf "| Label | Baseline | Optimized| Gain |\n" +printf "+--------+----------+----------+--------+\n" +printf "| %-6s | %8d | %8d | %6.1f%% |\n" M2M "$BASE_DOCS_M2M" "$OPT_DOCS_M2M" $(percent "$BASE_DOCS_M2M" "$OPT_DOCS_M2M") +printf "| %-6s | %8d | %8d | %6.1f%% |\n" O2M "$BASE_DOCS_O2M" "$OPT_DOCS_O2M" $(percent "$BASE_DOCS_O2M" "$OPT_DOCS_O2M") +printf "| %-6s | %8d | %8d | %6.1f%% |\n" O2O "$BASE_DOCS_O2O" "$OPT_DOCS_O2O" $(percent "$BASE_DOCS_O2O" "$OPT_DOCS_O2O") +printf "| %-6s | %8d | %8d | %6.1f%% |\n" M2O "$BASE_DOCS_M2O" "$OPT_DOCS_M2O" $(percent "$BASE_DOCS_M2O" "$OPT_DOCS_M2O") +printf "+--------+----------+----------+--------+\n" +printf "| %-6s | %8d | %8d | %6.1f%% |\n" TOTAL "$BASE_DOCS_TOTAL" "$OPT_DOCS_TOTAL" $(percent "$BASE_DOCS_TOTAL" "$OPT_DOCS_TOTAL") +printf "+--------+----------+----------+--------+\n" + +printf "\nQueries — shape: docs\n" +printf "+---------+----------+----------+\n" +printf "| Metric | Baseline | Optimized|\n" +printf "+---------+----------+----------+\n" +printf "| %-7s | %8d | %8d |\n" total "$BASE_DOCS_Q" "$OPT_DOCS_Q" +printf "| %-7s | %8d | %8d |\n" select "$BASE_DOCS_SEL" "$OPT_DOCS_SEL" +printf "| %-7s | %8d | %8d |\n" insert "$BASE_DOCS_INS" "$OPT_DOCS_INS" +printf "| %-7s | %8d | %8d |\n" update "$BASE_DOCS_UPD" "$OPT_DOCS_UPD" +printf "| %-7s | %8d | %8d |\n" delete "$BASE_DOCS_DEL" "$OPT_DOCS_DEL" +printf "+---------+----------+----------+\n" + +printf "\nTip: toggle DB_RELATIONSHIP_BULK_WRITES=0/1 to run a single mode.\n" + +printf "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" +printf " Benchmark Complete\n" +printf "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"