diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f03c592..08c180a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,3 @@ jobs: - name: PHP-CS-Fixer run: PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --diff - - - name: PHPMD - run: vendor/bin/phpmd src text phpmd.xml diff --git a/.gitignore b/.gitignore index a311532..53a814a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /vendor/ .claude/ CLAUDE.local.md +CLAUDE.md composer.lock .idea composer.phar diff --git a/CHANGELOG.md b/CHANGELOG.md index 02769c0..66fb08e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [8.0.0] - 2026-06-30 + +First stable release. The public API is now frozen: public method parameter +names are part of the API (named arguments are supported) and will not change +in 8.x patch/minor releases. + +### Changed + +- `Doc::refresh()` now accepts `bool` (`true`/`false`) in addition to `string` (`'wait_for'`), matching Elasticsearch's `refresh` parameter. + +See the 8.0.0-beta.5 and 8.0.0-beta.4 entries for the full feature set and breaking changes introduced during the beta cycle. + ## [8.0.0-beta.5] - 2026-06-28 ### Added diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6049386..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,67 +0,0 @@ -# ElasticKit - Elasticsearch DSL Query Builder - -A PHP Elasticsearch DSL query builder library. - -> This file is committed to the repository. Local environment variables live in `CLAUDE.local.md` (gitignored); Claude Code loads both automatically. - -**Versioning:** a.b.c, where `a` tracks the ES major version. `^8` suffices. - -> `master` tracks v8.x (ES 8.x, PHP 8.1+); the 7.x branch is maintained separately (ES 7.x, PHP 7.2+). The two lines are never merged into each other; CLAUDE.md is maintained per branch. - -### Commit message conventions - -- **Parameter names are locked**: public method parameter names are part of the API (named arguments are supported); renaming is forbidden in minor versions - -[Conventional Commits](https://www.conventionalcommits.org/), with an English description: `feat(query): add knn vector search` - -Scope is optional: dsl / index / agg / query / docs. Append `!` for breaking changes. - -### Changelog conventions - -[Keep a Changelog](https://keepachangelog.com), with English categories: - -- **Added** / **Changed** / **Deprecated** / **Removed** / **Fixed** / **Security** -- Record only user-facing changes -- Merge related changes into a single entry -- Mark breaking changes with the `**BC:**` prefix - -### Release flow - -1. Run the full test suite -2. Update CHANGELOG.md -3. Commit and push -4. Confirm the version, then tag and push - -### PHPDoc conventions - -Follow PSR-5. - -## TODO - -- [ ] **Add integration contract tests for Span and Shape queries**: unit DSL tests exist, but there is no Elasticsearch execution coverage (other query families have `tests/Integration/Dsl/*ContractTest.php`) -- [ ] **Cover Rebuild import-failure rollback**: the `createIndex`→`import` try/catch (deletes the new index on failure) is untested; only the alias-swap rollback path is covered - -## Tests - -Tests run inside a Docker container and require these environment variables: - -| Variable | Purpose | -|---|---| -| `PHP_CONTAINER` | Docker container name | -| `PROJECT_PATH` | Project path inside the container | -| `PROXY_PORT` | HTTP proxy port (for pushing) | -| `ELASTICKIT_TEST_HOST` | ES endpoint for integration tests (e.g. `https://localhost:9200`); integration tests are skipped when unset | - -## Pre-push checklist - -```bash -docker exec $PHP_CONTAINER sh -c "cd $PROJECT_PATH && vendor/bin/phpunit --testsuite unit" -docker exec -e ELASTICKIT_TEST_HOST=https://elasticsearch:9200 $PHP_CONTAINER sh -c "cd $PROJECT_PATH && vendor/bin/phpunit --testsuite integration" -docker exec $PHP_CONTAINER sh -c "cd $PROJECT_PATH && vendor/bin/phpunit" -docker exec $PHP_CONTAINER sh -c "cd $PROJECT_PATH && vendor/bin/phpstan analyse --memory-limit=256M" -docker exec $PHP_CONTAINER sh -c "cd $PROJECT_PATH && vendor/bin/phpmd src text phpmd.xml" -docker exec $PHP_CONTAINER sh -c "cd $PROJECT_PATH && PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --diff" - -# Route through the proxy when GitHub is unreachable -https_proxy=http://127.0.0.1:$PROXY_PORT http_proxy=http://127.0.0.1:$PROXY_PORT git push -``` diff --git a/README.md b/README.md index 6f442f9..ece8a86 100644 --- a/README.md +++ b/README.md @@ -304,24 +304,6 @@ EventDispatcher::listen('search.*', function (Event $e) { -## Long-lived processes - -`Index::setClient()`, `ClientManager`, `EventDispatcher`, and `Pagination` hold **static state**. In a long-lived worker (Swoole, RoadRunner, Laravel Octane) this state persists across requests, so a worker leaks the registered client, event listeners, and pagination resolvers between requests. - -Reset them between requests — e.g. in a request-terminated hook: - -```php -use ElasticKit\Index\Support\ClientManager; -use ElasticKit\Index\Support\EventDispatcher; -use ElasticKit\Index\Support\Pagination; - -ClientManager::reset(); -EventDispatcher::reset(); -Pagination::reset(); -``` - -PHP-FPM forks a worker per request, so this only affects persistent workers. - ## Documentation - [Guide](docs/guide.md) — an e-commerce order scenario, the full flow from install to production diff --git a/README.zh.md b/README.zh.md index ffb7583..8150afa 100644 --- a/README.zh.md +++ b/README.zh.md @@ -304,24 +304,6 @@ EventDispatcher::listen('search.*', function (Event $e) { -## 常驻进程 - -`Index::setClient()`、`ClientManager`、`EventDispatcher`、`Pagination` 持有**静态状态**。在常驻 worker(Swoole、RoadRunner、Laravel Octane)中,这些状态跨请求保留,worker 会把已注册的客户端、事件监听器、分页解析器泄漏到下一个请求。 - -请在请求之间重置它们——例如在请求终止钩子里: - -```php -use ElasticKit\Index\Support\ClientManager; -use ElasticKit\Index\Support\EventDispatcher; -use ElasticKit\Index\Support\Pagination; - -ClientManager::reset(); -EventDispatcher::reset(); -Pagination::reset(); -``` - -PHP-FPM 每请求 fork 一个 worker,因此本节仅影响常驻 worker。 - ## 文档 - [实践指南](docs/guide.zh.md)——电商订单场景,从安装到上线的完整流程 diff --git a/composer.json b/composer.json index 003b25d..2fd0524 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,7 @@ "require-dev": { "phpunit/phpunit": "^10.5", "phpstan/phpstan": "^2.1", - "friendsofphp/php-cs-fixer": "^3.9", - "phpmd/phpmd": "^2.15" + "friendsofphp/php-cs-fixer": "^3.9" }, "autoload": { "psr-4": { diff --git a/docs/index.md b/docs/index.md index 868e4fc..c67185c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -391,7 +391,7 @@ Always validate and filter user input before passing it to DSL methods. | `forceMerge()` | Force-merge index segments | | `addAlias($alias)` | Add an alias | | `removeAlias($alias)` | Remove an alias | -| `swapAlias($alias, $target)` | Swap where an alias points | +| `swapAlias($alias, $fromIndex)` | Swap where an alias points | | `getAliases()` | Get the index's aliases | ### Event list diff --git a/docs/index.zh.md b/docs/index.zh.md index 2a40120..6d4f64e 100644 --- a/docs/index.zh.md +++ b/docs/index.zh.md @@ -391,7 +391,7 @@ Index::setClient($client); | `forceMerge()` | 强制合并索引段 | | `addAlias($alias)` | 添加别名 | | `removeAlias($alias)` | 移除别名 | -| `swapAlias($alias, $target)` | 切换别名指向 | +| `swapAlias($alias, $fromIndex)` | 切换别名指向 | | `getAliases()` | 获取索引别名 | ### 事件列表 diff --git a/phpmd.xml b/phpmd.xml deleted file mode 100644 index bb8e0b4..0000000 --- a/phpmd.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - elastickit PHPMD ruleset - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/DSL/Node.php b/src/DSL/Node.php index 5e663cb..5e1b059 100644 --- a/src/DSL/Node.php +++ b/src/DSL/Node.php @@ -15,7 +15,6 @@ * Abstract base class for DSL nodes (query types, params). * * @phpstan-consistent-constructor - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) the DSL base accumulates many thin accessors */ abstract class Node { diff --git a/src/DSL/Query.php b/src/DSL/Query.php index 664c0c8..8062cdf 100644 --- a/src/DSL/Query.php +++ b/src/DSL/Query.php @@ -22,7 +22,6 @@ * Query container that combines multiple query conditions into an Elasticsearch DSL query. * * @phpstan-consistent-constructor - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) */ class Query extends Node { diff --git a/src/DSL/Support/RegistersAgg.php b/src/DSL/Support/RegistersAgg.php index ecb95aa..6bba5d0 100644 --- a/src/DSL/Support/RegistersAgg.php +++ b/src/DSL/Support/RegistersAgg.php @@ -19,8 +19,6 @@ trait RegistersAgg /** * Register an aggregation under an alias into the given store. * - * @SuppressWarnings(PHPMD.NPathComplexity) flat type-dispatch on polymorphic input; each branch is simple - * * @param mixed $alias aggregation alias (string), Agg instance (when passed as the only argument), or null * @param mixed $aggs Agg instance, array, closure, or null * @param array $store the target aggregation store (by reference) diff --git a/src/Index/Doc.php b/src/Index/Doc.php index 421e5de..c98c585 100644 --- a/src/Index/Doc.php +++ b/src/Index/Doc.php @@ -20,9 +20,9 @@ class Doc private int $retryOnConflict = 0; /** - * @var string|null + * @var string|bool|null */ - private ?string $refresh = null; + private string|bool|null $refresh = null; /** * @param string|int|null $id document id, or null/'' to let ES auto-generate @@ -59,10 +59,10 @@ public function retryOnConflict(int $count): static /** * Set refresh for subsequent write operations (true/false/wait_for). * - * @param string $value + * @param string|bool $value * @return $this */ - public function refresh(string $value): static + public function refresh(string|bool $value): static { $this->refresh = $value; diff --git a/src/Index/Index.php b/src/Index/Index.php index 20e6c39..c833e43 100644 --- a/src/Index/Index.php +++ b/src/Index/Index.php @@ -238,7 +238,6 @@ public function trackTotalHits(): int|bool * * @param array $context user-defined context passed from rebuild * @return iterable> - * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public function source(array $context = []): iterable { diff --git a/src/Index/Rebuild.php b/src/Index/Rebuild.php index 4256b4a..55b0dad 100644 --- a/src/Index/Rebuild.php +++ b/src/Index/Rebuild.php @@ -16,7 +16,6 @@ * $name is always the app-facing name. After rebuild, $name becomes an alias * pointing to a backing index generated by rebuildName(). * - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) */ class Rebuild { diff --git a/tests/Integration/Dsl/ShapeContractTest.php b/tests/Integration/Dsl/ShapeContractTest.php new file mode 100644 index 0000000..bd204dd --- /dev/null +++ b/tests/Integration/Dsl/ShapeContractTest.php @@ -0,0 +1,75 @@ +shapeIndex !== null && static::$esClient !== null) { + try { + static::$esClient->indices()->delete(['index' => $this->shapeIndex]); + } catch (\Throwable $e) { + // best-effort cleanup + } + $this->shapeIndex = null; + } + parent::tearDown(); + } + + private function withShapeIndex(): string + { + $client = static::$esClient; + $index = 'ek_shape_' . bin2hex(random_bytes(4)); + $this->shapeIndex = $index; + + $client->indices()->create([ + 'index' => $index, + 'body' => ['mappings' => ['properties' => ['geom' => ['type' => 'shape']]]], + ]); + $client->index(['index' => $index, 'id' => '1', 'body' => ['geom' => ['type' => 'envelope', 'coordinates' => [[0, 10], [10, 0]]]]]); + $client->index(['index' => $index, 'id' => '2', 'body' => ['geom' => ['type' => 'envelope', 'coordinates' => [[100, 110], [110, 100]]]]]); + $client->indices()->refresh(['index' => $index]); + + return $index; + } + + public function testShapeIntersects(): void + { + $index = $this->withShapeIndex(); + // query envelope overlaps doc 1's [[0,0],[10,10]] only + $q = (new Query())->shape('geom', function (Shape $shape) { + $shape->shape(['type' => 'envelope', 'coordinates' => [[5, 15], [15, 5]]]) + ->relation('intersects'); + }); + $total = static::$esClient->search(['index' => $index, 'body' => $q->toArray()])->asArray()['hits']['total']['value'] ?? 0; + $this->assertSame(1, $total); + } + + public function testShapeDisjoint(): void + { + $index = $this->withShapeIndex(); + // relation DISJOINT inverts: doc 2 (far) matches instead of doc 1 + $q = (new Query())->shape('geom', function (Shape $shape) { + $shape->shape(['type' => 'envelope', 'coordinates' => [[5, 15], [15, 5]]]) + ->relation('disjoint'); + }); + $total = static::$esClient->search(['index' => $index, 'body' => $q->toArray()])->asArray()['hits']['total']['value'] ?? 0; + $this->assertSame(1, $total); + } +} diff --git a/tests/Integration/Dsl/SpanContractTest.php b/tests/Integration/Dsl/SpanContractTest.php new file mode 100644 index 0000000..6968f50 --- /dev/null +++ b/tests/Integration/Dsl/SpanContractTest.php @@ -0,0 +1,51 @@ +assertQueryEs((new Query())->spanTerm('content', 'elasticsearch'), 2); + } + + public function testSpanNear(): void + { + // "database design" adjacent in docs 1 and 3 + $q = (new Query())->spanNear(function (SpanNear $near) { + $near->clauses(function (Query $query) { + $query->spanTerm('content', 'database'); + $query->spanTerm('content', 'design'); + })->slop(1)->inOrder(true); + }); + $this->assertQueryEs($q, 2); + } + + public function testSpanOr(): void + { + // elasticsearch (docs 1,2) or php (doc 2) -> docs 1,2 + $q = (new Query())->spanOr(function (SpanOr $or) { + $or->clauses(function (Query $query) { + $query->spanTerm('content', 'elasticsearch'); + $query->spanTerm('content', 'php'); + }); + }); + $this->assertQueryEs($q, 2); + } +} diff --git a/tests/Integration/Index/BulkContractTest.php b/tests/Integration/Index/BulkContractTest.php index a3540d4..b913b0b 100644 --- a/tests/Integration/Index/BulkContractTest.php +++ b/tests/Integration/Index/BulkContractTest.php @@ -113,4 +113,19 @@ public function testOnErrorCanResendFailures(): void $this->refreshIndex(); $this->assertSame('resend', $index->newDoc('1')->source()['title']); } + + public function testFlushThrowsWhenNoErrorHandler(): void + { + // create on the already-seeded id '1' triggers a bulk error; with no + // onError handler, flush() must throw a RuntimeException describing it. + $index = $this->makeIndex(); + $bulk = (new Bulk($index))->create('1', ['title' => 'dup']); + + try { + $bulk->flush(); + $this->fail('Expected flush() to throw on bulk errors with no onError handler'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('Bulk request has errors', $e->getMessage()); + } + } } diff --git a/tests/Integration/Index/DocContractTest.php b/tests/Integration/Index/DocContractTest.php index 67a5205..c82beed 100644 --- a/tests/Integration/Index/DocContractTest.php +++ b/tests/Integration/Index/DocContractTest.php @@ -110,4 +110,13 @@ public function testRefreshOption(): void // refresh=wait_for makes it searchable immediately $this->assertSame('refreshed', $index->newDoc('42')->source()['title']); } + + public function testRefreshAcceptsBool(): void + { + $index = $this->makeIndex(); + // bool refresh (true/false) is valid per ES alongside 'wait_for'; + // verify the widened string|bool type is accepted and the write lands. + $index->newDoc('43')->refresh(true)->index(['title' => 'bool-refresh']); + $this->assertSame('bool-refresh', $index->newDoc('43')->source()['title']); + } } diff --git a/tests/Integration/Index/RebuildContractTest.php b/tests/Integration/Index/RebuildContractTest.php index 53ac043..93cfcb7 100644 --- a/tests/Integration/Index/RebuildContractTest.php +++ b/tests/Integration/Index/RebuildContractTest.php @@ -161,4 +161,73 @@ public function testAllowEmpty(): void $this->assertNotEmpty($result['newIndex']); $this->assertNull($result['oldIndex']); } + + public function testImportFailureDeletesNewIndex(): void + { + $alias = 'ek_rebuild_' . bin2hex(random_bytes(4)); + $newIndex = $alias . '_backing_' . bin2hex(random_bytes(2)); + $index = new class ($alias, $newIndex) extends Index { + public function __construct(string $alias, private string $backing) + { + $this->name = $alias; + } + + public function rebuildName(): string + { + return $this->backing; + } + + public function source(array $context = []): iterable + { + yield 1 => ['title' => 'A']; + throw new \RuntimeException('import blew up'); + } + }; + + try { + (new Rebuild($index))->run(); + $this->fail('Expected rebuild to throw on import failure'); + } catch (\RuntimeException $e) { + $this->assertSame('import blew up', $e->getMessage()); + } + + $this->assertFalse( + $index->getClient()->indices()->exists(['index' => $newIndex])->asBool(), + 'Orphan new backing index must be deleted after an import failure' + ); + } + + public function testSwapFailureDeletesNewIndex(): void + { + // $this->indexName is a real index (created by setUp), not an alias + $newIndex = $this->indexName . '_backing_' . bin2hex(random_bytes(2)); + $index = new class ($this->indexName, $newIndex) extends Index { + public function __construct(string $name, private string $backing) + { + $this->name = $name; + } + + public function rebuildName(): string + { + return $this->backing; + } + + public function source(array $context = []): iterable + { + yield 1 => ['title' => 'A']; // import succeeds; the swap then fails + } + }; + + try { + (new Rebuild($index))->run(); + $this->fail('Expected rebuild to throw on a real index'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('is a real index', $e->getMessage()); + } + + $this->assertFalse( + $index->getClient()->indices()->exists(['index' => $newIndex])->asBool(), + 'Orphan new backing index must be deleted after a swap failure' + ); + } } diff --git a/tests/Integration/Index/SearchContractTest.php b/tests/Integration/Index/SearchContractTest.php index b008896..bfa2799 100644 --- a/tests/Integration/Index/SearchContractTest.php +++ b/tests/Integration/Index/SearchContractTest.php @@ -52,9 +52,28 @@ public function testPaginateLastPage(): void public function testScroll(): void { - $results = $this->makeIndex()->newQuery()->matchAll()->scroll(null, '1m'); + $search = $this->makeIndex()->newQuery()->matchAll(); + $results = $search->scroll(null, '1m'); $this->assertNotEmpty($results->scrollId()); - $this->assertGreaterThanOrEqual(1, count($results->hits())); + $this->assertCount(3, $results->hits()); + $search->clear($results); + } + + public function testScrollNextAndClear(): void + { + $search = $this->makeIndex()->newQuery()->matchAll()->size(2); + + $first = $search->scroll(null, '1m'); + $this->assertNotEmpty($first->scrollId()); + $this->assertCount(2, $first->hits()); + + $second = $search->next($first, '1m'); + $this->assertCount(1, $second->hits()); + + $third = $search->next($second, '1m'); + $this->assertCount(0, $third->hits()); + + $search->clear($third); } public function testChunk(): void