diff --git a/AGENTS.md b/AGENTS.md index b26494221..ffa31d4fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ This file provides guidance to AI agents when working with code in this reposito - Prioritize backward compatibility and type safety in all code changes. - Ensure comprehensive test coverage for any new or modified code. - Keep the `CHANGELOG.md` file updated with all significant changes. + - **MANDATORY**: Run `make fix-phpcs` before each commit to ensure code follows PSR-2 standards. ## Tools @@ -52,6 +53,13 @@ This file provides guidance to AI agents when working with code in this reposito - **Fixing coding standards**: `make fix-phpcs` - **Running static analysis**: `make run-phpstan` +### Commit Workflow + +- **Before each commit**: ALWAYS run `make fix-phpcs` to ensure code follows PSR-2 standards +- **After fixing**: Run `make run-phpcs` to verify no issues remain +- **Test before commit**: Run relevant tests to ensure functionality works +- **Update CHANGELOG**: Add entries for significant changes (except test-only changes) + ## Knowledge ### Project Overview diff --git a/CHANGELOG.md b/CHANGELOG.md index 9649f760a..6f96ddfb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added support for Component Template [#2274](https://github.com/ruflin/Elastica/pull/2274) * Added support for Index Template [#2274](https://github.com/ruflin/Elastica/pull/2274) * Added Template class to target only legacy Template [#2274](https://github.com/ruflin/Elastica/pull/2274) +* Added support for `seq_no_primary_term` search option to retrieve sequence numbers and primary terms in search results +* Added support for `if_seq_no` and `if_primary_term` options in `Index::addDocument()` for optimistic concurrency control ### Changed ### Deprecated ### Removed diff --git a/src/Index.php b/src/Index.php index 169f43d49..62e409b37 100644 --- a/src/Index.php +++ b/src/Index.php @@ -207,6 +207,8 @@ public function addDocument(Document $doc): Response $options = $doc->getOptions( [ 'consistency', + 'if_seq_no', + 'if_primary_term', 'op_type', 'parent', 'percolate', diff --git a/src/Search.php b/src/Search.php index 7b1b288e3..e5ef0fcfd 100644 --- a/src/Search.php +++ b/src/Search.php @@ -41,6 +41,7 @@ class Search public const OPTION_SHARD_REQUEST_CACHE = 'request_cache'; public const OPTION_FILTER_PATH = 'filter_path'; public const OPTION_TYPED_KEYS = 'typed_keys'; + public const OPTION_SEQ_NO_PRIMARY_TERM = 'seq_no_primary_term'; /* * Search types @@ -413,6 +414,7 @@ protected function validateOption(string $key): void case self::OPTION_SHARD_REQUEST_CACHE: case self::OPTION_FILTER_PATH: case self::OPTION_TYPED_KEYS: + case self::OPTION_SEQ_NO_PRIMARY_TERM: return; } diff --git a/tests/IndexSeqNoPrimaryTermTest.php b/tests/IndexSeqNoPrimaryTermTest.php new file mode 100644 index 000000000..1a5c22560 --- /dev/null +++ b/tests/IndexSeqNoPrimaryTermTest.php @@ -0,0 +1,117 @@ +index = $this->_createIndex(); + } + + protected function tearDown(): void + { + $this->index->delete(); + parent::tearDown(); + } + + /** + * @covers \Elastica\AbstractUpdateAction::setPrimaryTerm + * @covers \Elastica\AbstractUpdateAction::setSequenceNumber + * @covers \Elastica\Index::addDocument + * + * @group functional + */ + public function testAddDocumentWithSeqNoPrimaryTerm(): void + { + $doc = new Document('1', ['title' => 'Test document']); + $doc->setSequenceNumber(1); + $doc->setPrimaryTerm(1); + + $response = $this->index->addDocument($doc); + + $this->assertTrue($response->isOk()); + } + + /** + * @covers \Elastica\AbstractUpdateAction::setPrimaryTerm + * @covers \Elastica\AbstractUpdateAction::setSequenceNumber + * @covers \Elastica\Index::addDocument + * + * @group functional + */ + public function testAddDocumentWithOptimisticConcurrencyControl(): void + { + // First, add a document + $doc1 = new Document('1', ['title' => 'Original document']); + $response1 = $this->index->addDocument($doc1); + $this->assertTrue($response1->isOk()); + + // Get the document to retrieve its sequence number and primary term + $retrievedDoc = $this->index->getDocument('1'); + $this->assertTrue($retrievedDoc->hasSequenceNumber()); + $this->assertTrue($retrievedDoc->hasPrimaryTerm()); + + // Update the document using the retrieved sequence number and primary term + $doc2 = new Document('1', ['title' => 'Updated document']); + $doc2->setSequenceNumber($retrievedDoc->getSequenceNumber()); + $doc2->setPrimaryTerm($retrievedDoc->getPrimaryTerm()); + + $response2 = $this->index->addDocument($doc2); + $this->assertTrue($response2->isOk()); + + // Verify the document was updated + $updatedDoc = $this->index->getDocument('1'); + $this->assertEquals('Updated document', $updatedDoc->get('title')); + } + + /** + * @covers \Elastica\AbstractUpdateAction::setPrimaryTerm + * @covers \Elastica\AbstractUpdateAction::setSequenceNumber + * @covers \Elastica\Index::addDocument + * + * @group functional + */ + public function testAddDocumentWithStaleSeqNoPrimaryTerm(): void + { + // First, add a document + $doc1 = new Document('1', ['title' => 'Original document']); + $response1 = $this->index->addDocument($doc1); + $this->assertTrue($response1->isOk()); + + // Get the document to retrieve its sequence number and primary term + $retrievedDoc = $this->index->getDocument('1'); + $originalSeqNo = $retrievedDoc->getSequenceNumber(); + $originalPrimaryTerm = $retrievedDoc->getPrimaryTerm(); + + // Update the document once to change the sequence number + $doc2 = new Document('1', ['title' => 'First update']); + $doc2->setSequenceNumber($originalSeqNo); + $doc2->setPrimaryTerm($originalPrimaryTerm); + $response2 = $this->index->addDocument($doc2); + $this->assertTrue($response2->isOk()); + + // Try to update with the old sequence number and primary term (should fail) + $doc3 = new Document('1', ['title' => 'Second update']); + $doc3->setSequenceNumber($originalSeqNo); + $doc3->setPrimaryTerm($originalPrimaryTerm); + + $response3 = $this->index->addDocument($doc3); + $this->assertFalse($response3->isOk()); + } +} diff --git a/tests/SearchSeqNoPrimaryTermTest.php b/tests/SearchSeqNoPrimaryTermTest.php new file mode 100644 index 000000000..761b2a387 --- /dev/null +++ b/tests/SearchSeqNoPrimaryTermTest.php @@ -0,0 +1,97 @@ +index = $this->_createIndex(); + $this->index->addDocument(new Document('1', ['title' => 'Test document'])); + $this->index->refresh(); + } + + protected function tearDown(): void + { + $this->index->delete(); + parent::tearDown(); + } + + /** + * @covers \Elastica\Search::setOption + * + * @group unit + */ + public function testSetSeqNoPrimaryTermOption(): void + { + $client = $this->createMock(Client::class); + $search = new Search($client); + + $search->setOption(Search::OPTION_SEQ_NO_PRIMARY_TERM, true); + + $this->assertTrue($search->hasOption(Search::OPTION_SEQ_NO_PRIMARY_TERM)); + $this->assertTrue($search->getOption(Search::OPTION_SEQ_NO_PRIMARY_TERM)); + } + + /** + * @covers \Elastica\Search::search + * + * @group functional + */ + public function testSearchWithSeqNoPrimaryTerm(): void + { + $search = new Search($this->_getClient()); + $search->addIndex($this->index); + $search->setOption(Search::OPTION_SEQ_NO_PRIMARY_TERM, true); + + $resultSet = $search->search(); + + $this->assertGreaterThan(0, $resultSet->count()); + + foreach ($resultSet as $result) { + $this->assertArrayHasKey('_seq_no', $result->getHit()); + $this->assertArrayHasKey('_primary_term', $result->getHit()); + $this->assertIsInt($result->getHit()['_seq_no']); + $this->assertIsInt($result->getHit()['_primary_term']); + } + } + + /** + * @covers \Elastica\Search::setOptionsAndQuery + * + * @group unit + */ + public function testSetOptionsAndQueryWithSeqNoPrimaryTerm(): void + { + $client = $this->createMock(Client::class); + $search = new Search($client); + + $options = [ + Search::OPTION_SEQ_NO_PRIMARY_TERM => true, + Search::OPTION_SIZE => 10, + ]; + + $search->setOptionsAndQuery($options); + + $this->assertTrue($search->hasOption(Search::OPTION_SEQ_NO_PRIMARY_TERM)); + $this->assertTrue($search->getOption(Search::OPTION_SEQ_NO_PRIMARY_TERM)); + $this->assertEquals(10, $search->getOption(Search::OPTION_SIZE)); + } +}