From 089d8a2eb005038946c7a7bd24b90c81aefcd018 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 19 Aug 2025 18:23:03 +0300 Subject: [PATCH 01/20] feat: add `SynonymSet` and `SynonymSets` classes for global synonym management --- src/SynonymSet.php | 77 +++++++++++++++++++++++++++++++++++++ src/SynonymSets.php | 93 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/SynonymSet.php create mode 100644 src/SynonymSets.php diff --git a/src/SynonymSet.php b/src/SynonymSet.php new file mode 100644 index 00000000..95aa4775 --- /dev/null +++ b/src/SynonymSet.php @@ -0,0 +1,77 @@ +synonymSetName = $synonymSetName; + $this->apiCall = $apiCall; + } + + /** + * @return string + */ + private function endPointPath(): string + { + return sprintf( + '%s/%s', + SynonymSets::RESOURCE_PATH, + encodeURIComponent($this->synonymSetName) + ); + } + + /** + * @param array $params + * + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function upsert(array $params): array + { + return $this->apiCall->put($this->endPointPath(), $params); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function retrieve(): array + { + return $this->apiCall->get($this->endPointPath(), []); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function delete(): array + { + return $this->apiCall->delete($this->endPointPath()); + } +} \ No newline at end of file diff --git a/src/SynonymSets.php b/src/SynonymSets.php new file mode 100644 index 00000000..e99f1200 --- /dev/null +++ b/src/SynonymSets.php @@ -0,0 +1,93 @@ +apiCall = $apiCall; + } + + /** + * @param string $synonymSetName + * @param array $config + * + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function upsert(string $synonymSetName, array $config): array + { + return $this->apiCall->put(sprintf('%s/%s', static::RESOURCE_PATH, encodeURIComponent($synonymSetName)), $config); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function retrieve(): array + { + return $this->apiCall->get(static::RESOURCE_PATH, []); + } + + /** + * @inheritDoc + */ + public function offsetExists($synonymSetName): bool + { + return isset($this->synonymSets[$synonymSetName]); + } + + /** + * @inheritDoc + */ + public function offsetGet($synonymSetName): SynonymSet + { + if (!isset($this->synonymSets[$synonymSetName])) { + $this->synonymSets[$synonymSetName] = new SynonymSet($synonymSetName, $this->apiCall); + } + + return $this->synonymSets[$synonymSetName]; + } + + /** + * @inheritDoc + */ + public function offsetSet($synonymSetName, $value): void + { + $this->synonymSets[$synonymSetName] = $value; + } + + /** + * @inheritDoc + */ + public function offsetUnset($synonymSetName): void + { + unset($this->synonymSets[$synonymSetName]); + } +} \ No newline at end of file From 61f80d91c4d977e3c8afb8613a88f4943a862ad0 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 19 Aug 2025 18:23:29 +0300 Subject: [PATCH 02/20] feat(client): register synonym set classes in client --- src/Client.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Client.php b/src/Client.php index a4f8a084..070ab24d 100644 --- a/src/Client.php +++ b/src/Client.php @@ -90,6 +90,11 @@ class Client */ public NLSearchModels $nlSearchModels; + /** + * @var SynonymSets + */ + public SynonymSets $synonymSets; + /** * @var ApiCall */ @@ -121,6 +126,7 @@ public function __construct(array $config) $this->stemming = new Stemming($this->apiCall); $this->conversations = new Conversations($this->apiCall); $this->nlSearchModels = new NLSearchModels($this->apiCall); + $this->synonymSets = new SynonymSets($this->apiCall); } /** @@ -234,4 +240,12 @@ public function getNLSearchModels(): NLSearchModels { return $this->nlSearchModels; } + + /** + * @return SynonymSets + */ + public function getSynonymSets(): SynonymSets + { + return $this->synonymSets; + } } From bb7596f94203f3a9e4dd9296c56d156d183291e8 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 19 Aug 2025 18:24:26 +0300 Subject: [PATCH 03/20] feat(test): add utility for checking to skip old synonym tests --- tests/TestCase.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/TestCase.php b/tests/TestCase.php index 6a26447b..b9852b11 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,6 +6,7 @@ use Typesense\Client; use Mockery; use Typesense\ApiCall; +use Exception; abstract class TestCase extends BaseTestCase { @@ -98,4 +99,25 @@ protected function tearDownTypesense(): void $this->typesenseClient->collections[$collection['name']]->delete(); } } + + protected function isV30OrAbove(): bool + { + try { + $debug = $this->typesenseClient->debug->retrieve(); + $version = $debug['version']; + + if ($version === 'nightly') { + return true; + } + + if (preg_match('/^v(\d+)/', $version, $matches)) { + $majorVersion = (int) $matches[1]; + return $majorVersion >= 30; + } + + return false; + } catch (Exception $e) { + return false; + } + } } From 79e23ca22f23ac7ae726d3ce308a8952e61f7585 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 19 Aug 2025 18:24:58 +0300 Subject: [PATCH 04/20] feat(test): skip deprecated API tests for Synonyms and Analytics --- tests/Feature/AnalyticsEventsTest.php | 14 +++++++++++++- tests/Feature/AnalyticsRulesTest.php | 19 ++++++++++++++++--- tests/Feature/SynonymsTest.php | 5 +++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/tests/Feature/AnalyticsEventsTest.php b/tests/Feature/AnalyticsEventsTest.php index c1fa70d6..6b9a433c 100644 --- a/tests/Feature/AnalyticsEventsTest.php +++ b/tests/Feature/AnalyticsEventsTest.php @@ -3,6 +3,7 @@ namespace Feature; use Tests\TestCase; +use Exception; class AnalyticsEventsTest extends TestCase { @@ -11,6 +12,11 @@ class AnalyticsEventsTest extends TestCase protected function setUp(): void { parent::setUp(); + + if ($this->isV30OrAbove()) { + $this->markTestSkipped('Analytics is deprecated in Typesense v30+'); + } + $this->client()->collections->create([ "name" => "products", "fields" => [ @@ -52,7 +58,13 @@ protected function setUp(): void protected function tearDown(): void { parent::tearDown(); - $this->client()->analytics->rules()->{'product_queries_aggregation'}->delete(); + + if (!$this->isV30OrAbove()) { + try { + $this->client()->analytics->rules()->{'product_queries_aggregation'}->delete(); + } catch (Exception $e) { + } + } } public function testCanCreateAnEvent(): void diff --git a/tests/Feature/AnalyticsRulesTest.php b/tests/Feature/AnalyticsRulesTest.php index 882ef896..e5d84ead 100644 --- a/tests/Feature/AnalyticsRulesTest.php +++ b/tests/Feature/AnalyticsRulesTest.php @@ -4,6 +4,7 @@ use Tests\TestCase; use Typesense\Exceptions\ObjectNotFound; +use Exception; class AnalyticsRulesTest extends TestCase { @@ -26,14 +27,26 @@ class AnalyticsRulesTest extends TestCase protected function setUp(): void { parent::setUp(); + + if ($this->isV30OrAbove()) { + $this->markTestSkipped('Analytics is deprecated in Typesense v30+'); + } + $this->ruleUpsertResponse = $this->client()->analytics->rules()->upsert($this->ruleName, $this->ruleConfiguration); } protected function tearDown(): void { - $rules = $this->client()->analytics->rules()->retrieve(); - foreach ($rules['rules'] as $rule) { - $this->client()->analytics->rules()->{$rule['name']}->delete(); + if (!$this->isV30OrAbove()) { + try { + $rules = $this->client()->analytics->rules()->retrieve(); + if (is_array($rules) && isset($rules['rules'])) { + foreach ($rules['rules'] as $rule) { + $this->client()->analytics->rules()->{$rule['name']}->delete(); + } + } + } catch (Exception $e) { + } } } diff --git a/tests/Feature/SynonymsTest.php b/tests/Feature/SynonymsTest.php index 6cd5db05..5a61b871 100644 --- a/tests/Feature/SynonymsTest.php +++ b/tests/Feature/SynonymsTest.php @@ -17,6 +17,11 @@ class SynonymsTest extends TestCase protected function setUp(): void { parent::setUp(); + + if ($this->isV30OrAbove()) { + $this->markTestSkipped('Synonyms is deprecated in Typesense v30+, use SynonymSets instead'); + } + $this->setUpCollection('books'); $this->synonyms = $this->client()->collections['books']->synonyms; From 985a4df10faf451b9b2ff888073a693488e21399 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 19 Aug 2025 18:25:09 +0300 Subject: [PATCH 05/20] test: add test suite for synonym sets --- tests/Feature/SynonymSetsTest.php | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/Feature/SynonymSetsTest.php diff --git a/tests/Feature/SynonymSetsTest.php b/tests/Feature/SynonymSetsTest.php new file mode 100644 index 00000000..8a6ff9ea --- /dev/null +++ b/tests/Feature/SynonymSetsTest.php @@ -0,0 +1,61 @@ + [ + [ + 'id' => 'dummy', + 'synonyms' => ['foo', 'bar', 'baz'], + 'root' => '', + ], + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + + if (!$this->isV30OrAbove()) { + $this->markTestSkipped('SynonymSets is only supported in Typesense v30+'); + } + + $this->synonymSets = $this->client()->synonymSets; + $this->upsertResponse = $this->synonymSets->upsert('test-synonym-set', $this->synonymSetData); + } + + + public function testCanUpsertASynonymSet(): void + { + $this->assertEquals($this->synonymSetData['synonyms'], $this->upsertResponse['synonyms']); + } + + public function testCanRetrieveAllSynonymSets(): void + { + $returnData = $this->synonymSets->retrieve(); + $this->assertCount(1, $returnData); + } + + public function testCanRetrieveASpecificSynonymSet(): void + { + $returnData = $this->synonymSets['test-synonym-set']->retrieve(); + $this->assertEquals($this->synonymSetData['synonyms'], $returnData['synonyms']); + } + + public function testCanDeleteASynonymSet(): void + { + $returnData = $this->synonymSets['test-synonym-set']->delete(); + $this->assertEquals('test-synonym-set', $returnData['name']); + + $this->expectException(ObjectNotFound::class); + $this->synonymSets['test-synonym-set']->retrieve(); + } +} \ No newline at end of file From 4e2c8c4e3d201bebbc9efdcdd26af8d95b4dd8a4 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 23 Sep 2025 12:19:33 +0300 Subject: [PATCH 06/20] fix(synonyms): rename `synonyms` to `items` inside synonym sets --- tests/Feature/OverridesTest.php | 5 +++++ tests/Feature/SynonymSetsTest.php | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/Feature/OverridesTest.php b/tests/Feature/OverridesTest.php index 1ac19b33..f524c6af 100644 --- a/tests/Feature/OverridesTest.php +++ b/tests/Feature/OverridesTest.php @@ -15,6 +15,11 @@ class OverridesTest extends TestCase protected function setUp(): void { parent::setUp(); + + if ($this->isV30OrAbove()) { + $this->markTestSkipped('Overrides are deprecated in Typesense v30+, use CurationSets instead'); + } + $this->setUpCollection('books'); $override = [ diff --git a/tests/Feature/SynonymSetsTest.php b/tests/Feature/SynonymSetsTest.php index 8a6ff9ea..0ad6ef21 100644 --- a/tests/Feature/SynonymSetsTest.php +++ b/tests/Feature/SynonymSetsTest.php @@ -11,7 +11,7 @@ class SynonymSetsTest extends TestCase private $upsertResponse = null; private $synonymSets = null; private $synonymSetData = [ - 'synonyms' => [ + 'items' => [ [ 'id' => 'dummy', 'synonyms' => ['foo', 'bar', 'baz'], @@ -35,7 +35,7 @@ protected function setUp(): void public function testCanUpsertASynonymSet(): void { - $this->assertEquals($this->synonymSetData['synonyms'], $this->upsertResponse['synonyms']); + $this->assertEquals($this->synonymSetData['items'], $this->upsertResponse['items']); } public function testCanRetrieveAllSynonymSets(): void @@ -47,7 +47,7 @@ public function testCanRetrieveAllSynonymSets(): void public function testCanRetrieveASpecificSynonymSet(): void { $returnData = $this->synonymSets['test-synonym-set']->retrieve(); - $this->assertEquals($this->synonymSetData['synonyms'], $returnData['synonyms']); + $this->assertEquals($this->synonymSetData['items'], $returnData['items']); } public function testCanDeleteASynonymSet(): void From 2f601a42c2f62028b458df60af6c051d7ab029dc Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 23 Sep 2025 12:19:50 +0300 Subject: [PATCH 07/20] feat(curation): add curation set classes --- src/CurationSet.php | 91 +++++++++++++++++++++++++++++++++++++ src/CurationSetItem.php | 85 ++++++++++++++++++++++++++++++++++ src/CurationSetItems.php | 98 ++++++++++++++++++++++++++++++++++++++++ src/CurationSets.php | 93 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 367 insertions(+) create mode 100644 src/CurationSet.php create mode 100644 src/CurationSetItem.php create mode 100644 src/CurationSetItems.php create mode 100644 src/CurationSets.php diff --git a/src/CurationSet.php b/src/CurationSet.php new file mode 100644 index 00000000..db1c7fec --- /dev/null +++ b/src/CurationSet.php @@ -0,0 +1,91 @@ +curationSetName = $curationSetName; + $this->apiCall = $apiCall; + $this->items = new CurationSetItems($curationSetName, $apiCall); + } + + /** + * @return string + */ + private function endPointPath(): string + { + return sprintf( + '%s/%s', + CurationSets::RESOURCE_PATH, + encodeURIComponent($this->curationSetName) + ); + } + + /** + * @param array $params + * + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function upsert(array $params): array + { + return $this->apiCall->put($this->endPointPath(), $params); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function retrieve(): array + { + return $this->apiCall->get($this->endPointPath(), []); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function delete(): array + { + return $this->apiCall->delete($this->endPointPath()); + } + + /** + * @return CurationSetItems + */ + public function getItems(): CurationSetItems + { + return $this->items; + } +} diff --git a/src/CurationSetItem.php b/src/CurationSetItem.php new file mode 100644 index 00000000..1f419af0 --- /dev/null +++ b/src/CurationSetItem.php @@ -0,0 +1,85 @@ +curationSetName = $curationSetName; + $this->itemId = $itemId; + $this->apiCall = $apiCall; + } + + /** + * @return string + */ + private function endPointPath(): string + { + return sprintf( + '%s/%s/items/%s', + CurationSets::RESOURCE_PATH, + encodeURIComponent($this->curationSetName), + encodeURIComponent($this->itemId) + ); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function retrieve(): array + { + return $this->apiCall->get($this->endPointPath(), []); + } + + /** + * @param array $params + * + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function upsert(array $params): array + { + return $this->apiCall->put($this->endPointPath(), $params); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function delete(): array + { + return $this->apiCall->delete($this->endPointPath()); + } +} diff --git a/src/CurationSetItems.php b/src/CurationSetItems.php new file mode 100644 index 00000000..2523fcb6 --- /dev/null +++ b/src/CurationSetItems.php @@ -0,0 +1,98 @@ +curationSetName = $curationSetName; + $this->apiCall = $apiCall; + } + + /** + * @return string + */ + private function endPointPath(): string + { + return sprintf( + '%s/%s/items', + CurationSets::RESOURCE_PATH, + encodeURIComponent($this->curationSetName) + ); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function retrieve(): array + { + return $this->apiCall->get($this->endPointPath(), []); + } + + /** + * @inheritDoc + */ + public function offsetExists($itemId): bool + { + return isset($this->items[$itemId]); + } + + /** + * @inheritDoc + */ + public function offsetGet($itemId): CurationSetItem + { + if (!isset($this->items[$itemId])) { + $this->items[$itemId] = new CurationSetItem($this->curationSetName, $itemId, $this->apiCall); + } + + return $this->items[$itemId]; + } + + /** + * @inheritDoc + */ + public function offsetSet($itemId, $value): void + { + $this->items[$itemId] = $value; + } + + /** + * @inheritDoc + */ + public function offsetUnset($itemId): void + { + unset($this->items[$itemId]); + } +} diff --git a/src/CurationSets.php b/src/CurationSets.php new file mode 100644 index 00000000..1dcb079e --- /dev/null +++ b/src/CurationSets.php @@ -0,0 +1,93 @@ +apiCall = $apiCall; + } + + /** + * @param string $curationSetName + * @param array $config + * + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function upsert(string $curationSetName, array $config): array + { + return $this->apiCall->put(sprintf('%s/%s', static::RESOURCE_PATH, encodeURIComponent($curationSetName)), $config); + } + + /** + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function retrieve(): array + { + return $this->apiCall->get(static::RESOURCE_PATH, []); + } + + /** + * @inheritDoc + */ + public function offsetExists($curationSetName): bool + { + return isset($this->curationSets[$curationSetName]); + } + + /** + * @inheritDoc + */ + public function offsetGet($curationSetName): CurationSet + { + if (!isset($this->curationSets[$curationSetName])) { + $this->curationSets[$curationSetName] = new CurationSet($curationSetName, $this->apiCall); + } + + return $this->curationSets[$curationSetName]; + } + + /** + * @inheritDoc + */ + public function offsetSet($curationSetName, $value): void + { + $this->curationSets[$curationSetName] = $value; + } + + /** + * @inheritDoc + */ + public function offsetUnset($curationSetName): void + { + unset($this->curationSets[$curationSetName]); + } +} From afa1476d4aef3cad2ad1f9326fc01addc29582cb Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 23 Sep 2025 12:19:58 +0300 Subject: [PATCH 08/20] feat: register curation set classes to client object --- src/Client.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Client.php b/src/Client.php index 070ab24d..7e31903b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -95,6 +95,11 @@ class Client */ public SynonymSets $synonymSets; + /** + * @var CurationSets + */ + public CurationSets $curationSets; + /** * @var ApiCall */ @@ -127,6 +132,7 @@ public function __construct(array $config) $this->conversations = new Conversations($this->apiCall); $this->nlSearchModels = new NLSearchModels($this->apiCall); $this->synonymSets = new SynonymSets($this->apiCall); + $this->curationSets = new CurationSets($this->apiCall); } /** @@ -248,4 +254,12 @@ public function getSynonymSets(): SynonymSets { return $this->synonymSets; } + + /** + * @return CurationSets + */ + public function getCurationSets(): CurationSets + { + return $this->curationSets; + } } From 6eced84cd3b35b375fee6c3bbeab555582742454 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 23 Sep 2025 12:20:08 +0300 Subject: [PATCH 09/20] test(curation): add test suite for curation sets --- tests/Feature/CurationSetItemsTest.php | 85 ++++++++++++++++++++++++ tests/Feature/CurationSetsTest.php | 92 ++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 tests/Feature/CurationSetItemsTest.php create mode 100644 tests/Feature/CurationSetsTest.php diff --git a/tests/Feature/CurationSetItemsTest.php b/tests/Feature/CurationSetItemsTest.php new file mode 100644 index 00000000..d4bafaf3 --- /dev/null +++ b/tests/Feature/CurationSetItemsTest.php @@ -0,0 +1,85 @@ + [ + [ + 'id' => 'rule-1', + 'rule' => [ + 'query' => 'test', + 'match' => 'exact', + ], + 'includes' => [ + [ + 'id' => '123', + 'position' => 1, + ], + ], + ], + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + + if (!$this->isV30OrAbove()) { + $this->markTestSkipped('CurationSetItems is only supported in Typesense v30+'); + } + + $this->curationSets = $this->client()->curationSets; + $this->curationSets->upsert('test-curation-set-items', $this->curationSetData); + } + + protected function tearDown(): void + { + try { + $this->curationSets['test-curation-set-items']->delete(); + } catch (Exception $e) { + // Ignore cleanup errors + } + parent::tearDown(); + } + + public function testCanListItemsInACurationSet(): void + { + $items = $this->curationSets['test-curation-set-items']->getItems()->retrieve(); + + $this->assertIsArray($items); + $this->assertGreaterThan(0, count($items)); + $this->assertEquals('123', $items[0]['includes'][0]['id']); + } + + public function testCanUpsertRetrieveAndDeleteAnItem(): void + { + $upserted = $this->curationSets['test-curation-set-items']->getItems()['rule-1']->upsert([ + 'id' => 'rule-1', + 'rule' => [ + 'query' => 'test', + 'match' => 'exact', + ], + 'includes' => [ + [ + 'id' => '999', + 'position' => 1, + ], + ], + ]); + + $this->assertEquals('rule-1', $upserted['id']); + + $fetched = $this->curationSets['test-curation-set-items']->getItems()['rule-1']->retrieve(); + $this->assertEquals('999', $fetched['includes'][0]['id']); + + $deletion = $this->curationSets['test-curation-set-items']->getItems()['rule-1']->delete(); + $this->assertEquals('rule-1', $deletion['id']); + } +} diff --git a/tests/Feature/CurationSetsTest.php b/tests/Feature/CurationSetsTest.php new file mode 100644 index 00000000..82a74d30 --- /dev/null +++ b/tests/Feature/CurationSetsTest.php @@ -0,0 +1,92 @@ + [ + [ + 'id' => 'rule-1', + 'rule' => [ + 'query' => 'test', + 'match' => 'exact', + ], + 'includes' => [ + [ + 'id' => '123', + 'position' => 1, + ], + ], + ], + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + + if (!$this->isV30OrAbove()) { + $this->markTestSkipped('CurationSets is only supported in Typesense v30+'); + } + + $this->curationSets = $this->client()->curationSets; + $this->upsertResponse = $this->curationSets->upsert('test-curation-set', $this->curationSetData); + } + + protected function tearDown(): void + { + try { + $this->curationSets['test-curation-set']->delete(); + } catch (Exception $e) { + // Ignore cleanup errors + } + parent::tearDown(); + } + + public function testCanUpsertACurationSet(): void + { + $this->assertEquals($this->curationSetData['items'][0]['id'], $this->upsertResponse['items'][0]['id']); + $this->assertEquals($this->curationSetData['items'][0]['rule'], $this->upsertResponse['items'][0]['rule']); + $this->assertEquals($this->curationSetData['items'][0]['includes'], $this->upsertResponse['items'][0]['includes']); + } + + public function testCanRetrieveAllCurationSets(): void + { + $returnData = $this->curationSets->retrieve(); + $this->assertIsArray($returnData); + $this->assertGreaterThan(0, count($returnData)); + + $created = null; + foreach ($returnData as $curationSet) { + if ($curationSet['name'] === 'test-curation-set') { + $created = $curationSet; + break; + } + } + $this->assertNotNull($created); + } + + public function testCanRetrieveASpecificCurationSet(): void + { + $returnData = $this->curationSets['test-curation-set']->retrieve(); + $this->assertEquals($this->curationSetData['items'][0]['id'], $returnData['items'][0]['id']); + $this->assertEquals($this->curationSetData['items'][0]['rule'], $returnData['items'][0]['rule']); + $this->assertEquals($this->curationSetData['items'][0]['includes'], $returnData['items'][0]['includes']); + } + + public function testCanDeleteACurationSet(): void + { + $returnData = $this->curationSets['test-curation-set']->delete(); + $this->assertEquals('test-curation-set', $returnData['name']); + + $this->expectException(ObjectNotFound::class); + $this->curationSets['test-curation-set']->retrieve(); + } +} From 5aa6dafeacb7239e5aa89cac43a69c0fa35db6e4 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 20 Aug 2025 14:53:43 +0300 Subject: [PATCH 10/20] feat(analytics): add analytics v2 classes --- src/AnalyticsEventsV2.php | 45 +++++++++++++++++++++++++ src/AnalyticsRuleV2.php | 51 ++++++++++++++++++++++++++++ src/AnalyticsRulesV2.php | 70 +++++++++++++++++++++++++++++++++++++++ src/AnalyticsV2.php | 35 ++++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 src/AnalyticsEventsV2.php create mode 100644 src/AnalyticsRuleV2.php create mode 100644 src/AnalyticsRulesV2.php create mode 100644 src/AnalyticsV2.php diff --git a/src/AnalyticsEventsV2.php b/src/AnalyticsEventsV2.php new file mode 100644 index 00000000..ae9f4c6b --- /dev/null +++ b/src/AnalyticsEventsV2.php @@ -0,0 +1,45 @@ +apiCall = $apiCall; + } + + /** + * Create an analytics event + * + * @param array $params Event parameters including name, event_type, and data + * @return array Response from the API + * @throws TypesenseClientError|HttpClientException + */ + public function create(array $params) + { + return $this->apiCall->post(self::RESOURCE_PATH, $params); + } + + /** + * Retrieve analytics events + * + * @param array $params Query parameters + * @return array Response from the API + */ + public function retrieve(array $params = []) + { + return $this->apiCall->get(self::RESOURCE_PATH, $params); + } +} \ No newline at end of file diff --git a/src/AnalyticsRuleV2.php b/src/AnalyticsRuleV2.php new file mode 100644 index 00000000..270bb688 --- /dev/null +++ b/src/AnalyticsRuleV2.php @@ -0,0 +1,51 @@ +ruleName = $ruleName; + $this->apiCall = $apiCall; + } + + /** + * Retrieve a specific analytics rule + * + * @return array Response from the API + */ + public function retrieve() + { + return $this->apiCall->get($this->endpointPath(), []); + } + + /** + * Delete a specific analytics rule + * + * @return array Response from the API + */ + public function delete() + { + return $this->apiCall->delete($this->endpointPath()); + } + + /** + * Update a specific analytics rule + * + * @param array $params Rule parameters + * @return array Response from the API + */ + public function update(array $params) + { + return $this->apiCall->put($this->endpointPath(), $params); + } + + private function endpointPath() + { + return AnalyticsRulesV2::RESOURCE_PATH . '/' . encodeURIComponent($this->ruleName); + } +} \ No newline at end of file diff --git a/src/AnalyticsRulesV2.php b/src/AnalyticsRulesV2.php new file mode 100644 index 00000000..39639249 --- /dev/null +++ b/src/AnalyticsRulesV2.php @@ -0,0 +1,70 @@ +apiCall = $apiCall; + } + + /** + * Create multiple analytics rules + * + * @param array $rules Array of rule objects + * @return array Response from the API + */ + public function create(array $rules) + { + return $this->apiCall->post(self::RESOURCE_PATH, $rules); + } + + /** + * Retrieve all analytics rules + * + * @return array Response from the API + */ + public function retrieve() + { + return $this->apiCall->get(self::RESOURCE_PATH, []); + } + + /** + * Get a specific rule by name + * + * @param string $ruleName + * @return AnalyticsRuleV2 + */ + public function __get($ruleName) + { + return new AnalyticsRuleV2($ruleName, $this->apiCall); + } + + /** + * ArrayAccess implementation for backwards compatibility + */ + public function offsetExists($offset): bool + { + return true; // Rules can be accessed by name + } + + public function offsetGet($offset): AnalyticsRuleV2 + { + return new AnalyticsRuleV2($offset, $this->apiCall); + } + + public function offsetSet($offset, $value): void + { + // Not implemented for read-only access + } + + public function offsetUnset($offset): void + { + // Not implemented for read-only access + } +} \ No newline at end of file diff --git a/src/AnalyticsV2.php b/src/AnalyticsV2.php new file mode 100644 index 00000000..1a3629dc --- /dev/null +++ b/src/AnalyticsV2.php @@ -0,0 +1,35 @@ +apiCall = $apiCall; + } + + public function rules() + { + if (!isset($this->rules)) { + $this->rules = new AnalyticsRulesV2($this->apiCall); + } + return $this->rules; + } + + public function events() + { + if (!isset($this->events)) { + $this->events = new AnalyticsEventsV2($this->apiCall); + } + return $this->events; + } +} \ No newline at end of file From c9ac57428b848a035521dacbba78400d19370c5e Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 20 Aug 2025 14:53:56 +0300 Subject: [PATCH 11/20] feat(client): register analytics v2 to client object --- src/Client.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Client.php b/src/Client.php index 7e31903b..de0594f1 100644 --- a/src/Client.php +++ b/src/Client.php @@ -75,6 +75,11 @@ class Client */ public Analytics $analytics; + /** + * @var AnalyticsV2 + */ + public AnalyticsV2 $analyticsV2; + /** * @var Stemming */ @@ -128,6 +133,7 @@ public function __construct(array $config) $this->multiSearch = new MultiSearch($this->apiCall); $this->presets = new Presets($this->apiCall); $this->analytics = new Analytics($this->apiCall); + $this->analyticsV2 = new AnalyticsV2($this->apiCall); $this->stemming = new Stemming($this->apiCall); $this->conversations = new Conversations($this->apiCall); $this->nlSearchModels = new NLSearchModels($this->apiCall); From a3df825334f2a94a8b8650fbf7e41c8b309ec661 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 20 Aug 2025 14:54:13 +0300 Subject: [PATCH 12/20] test: add test suite for analytics v2 --- tests/Feature/AnalyticsEventsV2Test.php | 168 +++++++++++++++++++++++ tests/Feature/AnalyticsRulesV2Test.php | 175 ++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 tests/Feature/AnalyticsEventsV2Test.php create mode 100644 tests/Feature/AnalyticsRulesV2Test.php diff --git a/tests/Feature/AnalyticsEventsV2Test.php b/tests/Feature/AnalyticsEventsV2Test.php new file mode 100644 index 00000000..f6cdb990 --- /dev/null +++ b/tests/Feature/AnalyticsEventsV2Test.php @@ -0,0 +1,168 @@ +ruleConfiguration = [ + "name" => $this->ruleName, + "type" => "counter", + "collection" => "test_products", + "event_type" => "click", + "rule_tag" => "test_tag", + "params" => [ + "counter_field" => "popularity", + "weight" => 1 + ] + ]; + + if (!$this->isV30OrAbove()) { + $this->markTestSkipped('New Analytics API is not supported in Typesense v29.0 and below'); + } + + try { + $this->client()->collections->create([ + 'name' => 'test_products', + 'fields' => [ + ['name' => 'company_name', 'type' => 'string'], + ['name' => 'num_employees', 'type' => 'int32'], + ['name' => 'country', 'type' => 'string', 'facet' => true], + ['name' => 'popularity', 'type' => 'int32', 'optional' => true] + ], + 'default_sorting_field' => 'num_employees' + ]); + } catch (Exception $e) { + } + + try { + $this->client()->analyticsV2->rules()->create([$this->ruleConfiguration]); + } catch (Exception $e) { + } + } + + protected function tearDown(): void + { + if (!$this->isV30OrAbove()) { + try { + $rules = $this->client()->analyticsV2->rules()->retrieve(); + if (is_array($rules)) { + foreach ($rules as $rule) { + if (strpos($rule['name'], 'test_v2_') === 0) { + try { + $this->client()->analyticsV2->rules()[$rule['name']]->delete(); + } catch (Exception $e) { + } + } + } + } + } catch (Exception $e) { + } + + try { + $this->client()->collections['test_products']->delete(); + } catch (Exception $e) { + } + } + } + + public function testCanCreateEventsWithV2API(): void + { + $event = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "doc_ids" => ["1", "2"], + "user_id" => "test_user" + ] + ]; + + $response = $this->client()->analyticsV2->events()->create($event); + $this->assertIsArray($response); + } + + public function testCanCreateMultipleEventsWithV2API(): void + { + $event1 = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "doc_id" => "1", + "user_id" => "test_user_1" + ] + ]; + + $event2 = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "doc_id" => "2", + "user_id" => "test_user_2" + ] + ]; + + $response1 = $this->client()->analyticsV2->events()->create($event1); + $this->assertIsArray($response1); + + $response2 = $this->client()->analyticsV2->events()->create($event2); + $this->assertIsArray($response2); + } + + public function testCanRetrieveEventsWithV2API(): void + { + $event = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "doc_id" => "1", + "user_id" => "test_user" + ] + ]; + + $this->client()->analyticsV2->events()->create($event); + + $response = $this->client()->analyticsV2->events()->retrieve([ + 'user_id' => 'test_user', + 'name' => $this->ruleName, + 'n'=> 10 + ]); + + $this->assertIsArray($response); + } + + public function testCanCreateEventWithDifferentEventTypes(): void + { + $clickEvent = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "doc_id" => "1", + "user_id" => "test_user" + ] + ]; + + $conversionEvent = [ + "name" => $this->ruleName, + "event_type" => "conversion", + "data" => [ + "doc_id" => "1", + "user_id" => "test_user" + ] + ]; + + $clickResponse = $this->client()->analyticsV2->events()->create($clickEvent); + $this->assertIsArray($clickResponse); + + $conversionResponse = $this->client()->analyticsV2->events()->create($conversionEvent); + $this->assertIsArray($conversionResponse); + } +} \ No newline at end of file diff --git a/tests/Feature/AnalyticsRulesV2Test.php b/tests/Feature/AnalyticsRulesV2Test.php new file mode 100644 index 00000000..ec3b5b32 --- /dev/null +++ b/tests/Feature/AnalyticsRulesV2Test.php @@ -0,0 +1,175 @@ +ruleConfiguration = [ + "name" => $this->ruleName, + "type" => "counter", + "collection" => "test_products", + "event_type" => "click", + "rule_tag" => "test_tag", + "params" => [ + "counter_field" => "popularity", + "weight" => 1 + ] + ]; + + if (!$this->isV30OrAbove()) { + $this->markTestSkipped('New Analytics API is not supported in Typesense v29.0 and below'); + } + + try { + $this->client()->collections->create([ + 'name' => 'test_products', + 'fields' => [ + ['name' => 'company_name', 'type' => 'string'], + ['name' => 'num_employees', 'type' => 'int32'], + ['name' => 'country', 'type' => 'string', 'facet' => true], + ['name' => 'popularity', 'type' => 'int32', 'optional' => true] + ], + 'default_sorting_field' => 'num_employees' + ]); + } catch (Exception $e) { + } + + try { + $this->client()->analyticsV2->rules()->create([$this->ruleConfiguration]); + } catch (Exception $e) { + } + } + + protected function tearDown(): void + { + if (!$this->isV30OrAbove()) { + try { + $rules = $this->client()->analyticsV2->rules()->retrieve(); + if (is_array($rules)) { + foreach ($rules as $rule) { + if (strpos($rule['name'], 'test_v2_') === 0) { + try { + $this->client()->analyticsV2->rules()[$rule['name']]->delete(); + } catch (Exception $e) { + } + } + } + } + } catch (Exception $e) { + } + + try { + $this->client()->collections['test_products']->delete(); + } catch (Exception $e) { + } + } + } + + public function testCanCreateRulesWithV2API(): void + { + $rules = [ + [ + "name" => "test_rule_1", + "type" => "counter", + "collection" => "test_products", + "event_type" => "click", + "rule_tag" => "test_tag", + "params" => [ + "counter_field" => "popularity", + "weight" => 1 + ] + ], + [ + "name" => "test_rule_2", + "type" => "counter", + "collection" => "test_products", + "event_type" => "conversion", + "rule_tag" => "test_tag", + "params" => [ + "counter_field" => "popularity", + "weight" => 2 + ] + ] + ]; + + $response = $this->client()->analyticsV2->rules()->create($rules); + $this->assertIsArray($response); + + $allRules = $this->client()->analyticsV2->rules()->retrieve(); + $this->assertIsArray($allRules); + + $ruleNames = array_column($allRules, 'name'); + $this->assertContains('test_rule_1', $ruleNames); + $this->assertContains('test_rule_2', $ruleNames); + } + + public function testCanRetrieveARuleWithV2API(): void + { + $returnData = $this->client()->analyticsV2->rules()[$this->ruleName]->retrieve(); + $this->assertEquals($this->ruleName, $returnData['name']); + $this->assertEquals('counter', $returnData['type']); + $this->assertEquals('test_products', $returnData['collection']); + } + + public function testCanUpdateARuleWithV2API(): void + { + $updateParams = [ + "type" => "counter", + "collection" => "test_products", + "event_type" => "click", + "rule_tag" => "updated_tag", + "params" => [ + "counter_field" => "popularity", + "weight" => 5 + ] + ]; + + $response = $this->client()->analyticsV2->rules()[$this->ruleName]->update($updateParams); + $this->assertEquals($this->ruleName, $response['name']); + $this->assertEquals('updated_tag', $response['rule_tag']); + $this->assertEquals(5, $response['params']['weight']); + } + + public function testCanDeleteARuleWithV2API(): void + { + $returnData = $this->client()->analyticsV2->rules()[$this->ruleName]->delete(); + $this->assertEquals($this->ruleName, $returnData['name']); + + $this->expectException(RequestMalformed::class); + $this->client()->analyticsV2->rules()[$this->ruleName]->retrieve(); + } + + public function testCanRetrieveAllRulesWithV2API(): void + { + $returnData = $this->client()->analyticsV2->rules()->retrieve(); + $this->assertIsArray($returnData); + $this->assertGreaterThanOrEqual(1, count($returnData)); + + $ruleNames = array_column($returnData, 'name'); + $this->assertContains('test_v2_rule', $ruleNames); + $this->assertContains('test_rule_1', $ruleNames); + $this->assertContains('test_rule_2', $ruleNames); + } + + public function testArrayAccessCompatibility(): void + { + $rule = $this->client()->analyticsV2->rules()[$this->ruleName]; + $this->assertInstanceOf('Typesense\AnalyticsRuleV2', $rule); + + $this->assertTrue(isset($this->client()->analyticsV2->rules()[$this->ruleName])); + + $rule = $this->client()->analyticsV2->rules()[$this->ruleName]; + $this->assertInstanceOf('Typesense\AnalyticsRuleV2', $rule); + } +} \ No newline at end of file From e129f691c8cc1dbec58e1bf97ba507170a019e26 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Wed, 20 Aug 2025 15:07:55 +0300 Subject: [PATCH 13/20] refactor(analytics): rename old analytics to analyticsV1 --- src/Analytics.php | 2 +- src/AnalyticsEvents.php | 32 ++-- src/AnalyticsEventsV1.php | 47 ++++++ src/AnalyticsEventsV2.php | 45 ------ src/AnalyticsRule.php | 27 +++- src/AnalyticsRuleV1.php | 30 ++++ src/AnalyticsRuleV2.php | 51 ------ src/AnalyticsRules.php | 61 ++++---- src/AnalyticsRulesV1.php | 75 +++++++++ src/AnalyticsRulesV2.php | 70 --------- src/{AnalyticsV2.php => AnalyticsV1.php} | 12 +- src/Client.php | 10 +- tests/Feature/AnalyticsEventsTest.php | 189 ++++++++++++++++------- tests/Feature/AnalyticsEventsV1Test.php | 83 ++++++++++ tests/Feature/AnalyticsEventsV2Test.php | 168 -------------------- tests/Feature/AnalyticsRulesTest.php | 158 +++++++++++++++---- tests/Feature/AnalyticsRulesV1Test.php | 78 ++++++++++ tests/Feature/AnalyticsRulesV2Test.php | 175 --------------------- 18 files changed, 654 insertions(+), 659 deletions(-) create mode 100644 src/AnalyticsEventsV1.php delete mode 100644 src/AnalyticsEventsV2.php create mode 100644 src/AnalyticsRuleV1.php delete mode 100644 src/AnalyticsRuleV2.php create mode 100644 src/AnalyticsRulesV1.php delete mode 100644 src/AnalyticsRulesV2.php rename src/{AnalyticsV2.php => AnalyticsV1.php} (66%) create mode 100644 tests/Feature/AnalyticsEventsV1Test.php delete mode 100644 tests/Feature/AnalyticsEventsV2Test.php create mode 100644 tests/Feature/AnalyticsRulesV1Test.php delete mode 100644 tests/Feature/AnalyticsRulesV2Test.php diff --git a/src/Analytics.php b/src/Analytics.php index 2f161dd1..4591de04 100644 --- a/src/Analytics.php +++ b/src/Analytics.php @@ -32,4 +32,4 @@ public function events() } return $this->events; } -} +} \ No newline at end of file diff --git a/src/AnalyticsEvents.php b/src/AnalyticsEvents.php index 6b7180bf..6b2ad484 100644 --- a/src/AnalyticsEvents.php +++ b/src/AnalyticsEvents.php @@ -4,6 +4,8 @@ /** * Class AnalyticsEvents + * + * Implements the updated analytics events API for Typesense + * * @package \Typesense */ @@ -11,37 +13,33 @@ class AnalyticsEvents { const RESOURCE_PATH = '/analytics/events'; - /** - * @var ApiCall - */ private ApiCall $apiCall; - /** - * AnalyticsEvents constructor. - * - * @param ApiCall $apiCall - */ public function __construct(ApiCall $apiCall) { $this->apiCall = $apiCall; } /** - * @param array $params - * - * @return array + * Create an analytics event + * + * @param array $params Event parameters including name, event_type, and data + * @return array Response from the API * @throws TypesenseClientError|HttpClientException */ - public function create($params) + public function create(array $params) { - return $this->apiCall->post($this->endpoint_path(), $params); + return $this->apiCall->post(self::RESOURCE_PATH, $params); } /** - * @return string + * Retrieve analytics events + * + * @param array $params Query parameters + * @return array Response from the API */ - private function endpoint_path($operation = null) + public function retrieve(array $params = []) { - return self::RESOURCE_PATH . ($operation === null ? '' : "/$operation"); + return $this->apiCall->get(self::RESOURCE_PATH, $params); } -} +} \ No newline at end of file diff --git a/src/AnalyticsEventsV1.php b/src/AnalyticsEventsV1.php new file mode 100644 index 00000000..407e1a6b --- /dev/null +++ b/src/AnalyticsEventsV1.php @@ -0,0 +1,47 @@ +apiCall = $apiCall; + } + + /** + * @param array $params + * + * @return array + * @throws TypesenseClientError|HttpClientException + */ + public function create($params) + { + return $this->apiCall->post($this->endpoint_path(), $params); + } + + /** + * @return string + */ + private function endpoint_path($operation = null) + { + return self::RESOURCE_PATH . ($operation === null ? '' : "/$operation"); + } +} diff --git a/src/AnalyticsEventsV2.php b/src/AnalyticsEventsV2.php deleted file mode 100644 index ae9f4c6b..00000000 --- a/src/AnalyticsEventsV2.php +++ /dev/null @@ -1,45 +0,0 @@ -apiCall = $apiCall; - } - - /** - * Create an analytics event - * - * @param array $params Event parameters including name, event_type, and data - * @return array Response from the API - * @throws TypesenseClientError|HttpClientException - */ - public function create(array $params) - { - return $this->apiCall->post(self::RESOURCE_PATH, $params); - } - - /** - * Retrieve analytics events - * - * @param array $params Query parameters - * @return array Response from the API - */ - public function retrieve(array $params = []) - { - return $this->apiCall->get(self::RESOURCE_PATH, $params); - } -} \ No newline at end of file diff --git a/src/AnalyticsRule.php b/src/AnalyticsRule.php index a574e301..2c303960 100644 --- a/src/AnalyticsRule.php +++ b/src/AnalyticsRule.php @@ -4,27 +4,48 @@ class AnalyticsRule { - private $ruleName; + private string $ruleName; private ApiCall $apiCall; public function __construct(string $ruleName, ApiCall $apiCall) { $this->ruleName = $ruleName; - $this->apiCall = $apiCall; + $this->apiCall = $apiCall; } + /** + * Retrieve a specific analytics rule + * + * @return array Response from the API + */ public function retrieve() { return $this->apiCall->get($this->endpointPath(), []); } + /** + * Delete a specific analytics rule + * + * @return array Response from the API + */ public function delete() { return $this->apiCall->delete($this->endpointPath()); } + /** + * Update a specific analytics rule + * + * @param array $params Rule parameters + * @return array Response from the API + */ + public function update(array $params) + { + return $this->apiCall->put($this->endpointPath(), $params); + } + private function endpointPath() { return AnalyticsRules::RESOURCE_PATH . '/' . encodeURIComponent($this->ruleName); } -} +} \ No newline at end of file diff --git a/src/AnalyticsRuleV1.php b/src/AnalyticsRuleV1.php new file mode 100644 index 00000000..f4b52b52 --- /dev/null +++ b/src/AnalyticsRuleV1.php @@ -0,0 +1,30 @@ +ruleName = $ruleName; + $this->apiCall = $apiCall; + } + + public function retrieve() + { + return $this->apiCall->get($this->endpointPath(), []); + } + + public function delete() + { + return $this->apiCall->delete($this->endpointPath()); + } + + private function endpointPath() + { + return AnalyticsRulesV1::RESOURCE_PATH . '/' . encodeURIComponent($this->ruleName); + } +} diff --git a/src/AnalyticsRuleV2.php b/src/AnalyticsRuleV2.php deleted file mode 100644 index 270bb688..00000000 --- a/src/AnalyticsRuleV2.php +++ /dev/null @@ -1,51 +0,0 @@ -ruleName = $ruleName; - $this->apiCall = $apiCall; - } - - /** - * Retrieve a specific analytics rule - * - * @return array Response from the API - */ - public function retrieve() - { - return $this->apiCall->get($this->endpointPath(), []); - } - - /** - * Delete a specific analytics rule - * - * @return array Response from the API - */ - public function delete() - { - return $this->apiCall->delete($this->endpointPath()); - } - - /** - * Update a specific analytics rule - * - * @param array $params Rule parameters - * @return array Response from the API - */ - public function update(array $params) - { - return $this->apiCall->put($this->endpointPath(), $params); - } - - private function endpointPath() - { - return AnalyticsRulesV2::RESOURCE_PATH . '/' . encodeURIComponent($this->ruleName); - } -} \ No newline at end of file diff --git a/src/AnalyticsRules.php b/src/AnalyticsRules.php index 444d1cb2..199b2c6b 100644 --- a/src/AnalyticsRules.php +++ b/src/AnalyticsRules.php @@ -7,69 +7,64 @@ class AnalyticsRules implements \ArrayAccess const RESOURCE_PATH = '/analytics/rules'; private ApiCall $apiCall; - private $analyticsRules = []; public function __construct(ApiCall $apiCall) { $this->apiCall = $apiCall; } - public function __get($ruleName) - { - if (!isset($this->analyticsRules[$ruleName])) { - $this->analyticsRules[$ruleName] = new AnalyticsRule($ruleName, $this->apiCall); - } - return $this->analyticsRules[$ruleName]; - } - - public function upsert($ruleName, $params) + /** + * Create multiple analytics rules + * + * @param array $rules Array of rule objects + * @return array Response from the API + */ + public function create(array $rules) { - return $this->apiCall->put($this->endpoint_path($ruleName), $params); + return $this->apiCall->post(self::RESOURCE_PATH, $rules); } + /** + * Retrieve all analytics rules + * + * @return array Response from the API + */ public function retrieve() { - return $this->apiCall->get($this->endpoint_path(), []); + return $this->apiCall->get(self::RESOURCE_PATH, []); } - private function endpoint_path($operation = null) + /** + * Get a specific rule by name + * + * @param string $ruleName + * @return AnalyticsRule + */ + public function __get($ruleName) { - return self::RESOURCE_PATH . ($operation === null ? '' : "/" . encodeURIComponent($operation)); + return new AnalyticsRule($ruleName, $this->apiCall); } /** - * @inheritDoc + * ArrayAccess implementation for backwards compatibility */ public function offsetExists($offset): bool { - return isset($this->analyticsRules[$offset]); + return true; // Rules can be accessed by name } - /** - * @inheritDoc - */ public function offsetGet($offset): AnalyticsRule { - if (!isset($this->analyticsRules[$offset])) { - $this->analyticsRules[$offset] = new AnalyticsRule($offset, $this->apiCall); - } - - return $this->analyticsRules[$offset]; + return new AnalyticsRule($offset, $this->apiCall); } - /** - * @inheritDoc - */ public function offsetSet($offset, $value): void { - $this->analyticsRules[$offset] = $value; + // Not implemented for read-only access } - /** - * @inheritDoc - */ public function offsetUnset($offset): void { - unset($this->analyticsRules[$offset]); + // Not implemented for read-only access } -} +} \ No newline at end of file diff --git a/src/AnalyticsRulesV1.php b/src/AnalyticsRulesV1.php new file mode 100644 index 00000000..59924fc7 --- /dev/null +++ b/src/AnalyticsRulesV1.php @@ -0,0 +1,75 @@ +apiCall = $apiCall; + } + + public function __get($ruleName) + { + if (!isset($this->analyticsRules[$ruleName])) { + $this->analyticsRules[$ruleName] = new AnalyticsRuleV1($ruleName, $this->apiCall); + } + return $this->analyticsRules[$ruleName]; + } + + public function upsert($ruleName, $params) + { + return $this->apiCall->put($this->endpoint_path($ruleName), $params); + } + + public function retrieve() + { + return $this->apiCall->get($this->endpoint_path(), []); + } + + private function endpoint_path($operation = null) + { + return self::RESOURCE_PATH . ($operation === null ? '' : "/" . encodeURIComponent($operation)); + } + + /** + * @inheritDoc + */ + public function offsetExists($offset): bool + { + return isset($this->analyticsRules[$offset]); + } + + /** + * @inheritDoc + */ + public function offsetGet($offset): AnalyticsRuleV1 + { + if (!isset($this->analyticsRules[$offset])) { + $this->analyticsRules[$offset] = new AnalyticsRuleV1($offset, $this->apiCall); + } + + return $this->analyticsRules[$offset]; + } + + /** + * @inheritDoc + */ + public function offsetSet($offset, $value): void + { + $this->analyticsRules[$offset] = $value; + } + + /** + * @inheritDoc + */ + public function offsetUnset($offset): void + { + unset($this->analyticsRules[$offset]); + } +} diff --git a/src/AnalyticsRulesV2.php b/src/AnalyticsRulesV2.php deleted file mode 100644 index 39639249..00000000 --- a/src/AnalyticsRulesV2.php +++ /dev/null @@ -1,70 +0,0 @@ -apiCall = $apiCall; - } - - /** - * Create multiple analytics rules - * - * @param array $rules Array of rule objects - * @return array Response from the API - */ - public function create(array $rules) - { - return $this->apiCall->post(self::RESOURCE_PATH, $rules); - } - - /** - * Retrieve all analytics rules - * - * @return array Response from the API - */ - public function retrieve() - { - return $this->apiCall->get(self::RESOURCE_PATH, []); - } - - /** - * Get a specific rule by name - * - * @param string $ruleName - * @return AnalyticsRuleV2 - */ - public function __get($ruleName) - { - return new AnalyticsRuleV2($ruleName, $this->apiCall); - } - - /** - * ArrayAccess implementation for backwards compatibility - */ - public function offsetExists($offset): bool - { - return true; // Rules can be accessed by name - } - - public function offsetGet($offset): AnalyticsRuleV2 - { - return new AnalyticsRuleV2($offset, $this->apiCall); - } - - public function offsetSet($offset, $value): void - { - // Not implemented for read-only access - } - - public function offsetUnset($offset): void - { - // Not implemented for read-only access - } -} \ No newline at end of file diff --git a/src/AnalyticsV2.php b/src/AnalyticsV1.php similarity index 66% rename from src/AnalyticsV2.php rename to src/AnalyticsV1.php index 1a3629dc..371a1bb0 100644 --- a/src/AnalyticsV2.php +++ b/src/AnalyticsV1.php @@ -2,15 +2,15 @@ namespace Typesense; -class AnalyticsV2 +class AnalyticsV1 { const RESOURCE_PATH = '/analytics'; private ApiCall $apiCall; - private AnalyticsRulesV2 $rules; + private AnalyticsRulesV1 $rules; - private AnalyticsEventsV2 $events; + private AnalyticsEventsV1 $events; public function __construct(ApiCall $apiCall) { @@ -20,7 +20,7 @@ public function __construct(ApiCall $apiCall) public function rules() { if (!isset($this->rules)) { - $this->rules = new AnalyticsRulesV2($this->apiCall); + $this->rules = new AnalyticsRulesV1($this->apiCall); } return $this->rules; } @@ -28,8 +28,8 @@ public function rules() public function events() { if (!isset($this->events)) { - $this->events = new AnalyticsEventsV2($this->apiCall); + $this->events = new AnalyticsEventsV1($this->apiCall); } return $this->events; } -} \ No newline at end of file +} diff --git a/src/Client.php b/src/Client.php index de0594f1..db8c3b8a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -71,14 +71,14 @@ class Client public Presets $presets; /** - * @var Analytics + * @var AnalyticsV1 */ - public Analytics $analytics; + public AnalyticsV1 $analyticsV1; /** - * @var AnalyticsV2 + * @var Analytics */ - public AnalyticsV2 $analyticsV2; + public Analytics $analytics; /** * @var Stemming @@ -133,7 +133,7 @@ public function __construct(array $config) $this->multiSearch = new MultiSearch($this->apiCall); $this->presets = new Presets($this->apiCall); $this->analytics = new Analytics($this->apiCall); - $this->analyticsV2 = new AnalyticsV2($this->apiCall); + $this->analyticsV1 = new AnalyticsV1($this->apiCall); $this->stemming = new Stemming($this->apiCall); $this->conversations = new Conversations($this->apiCall); $this->nlSearchModels = new NLSearchModels($this->apiCall); diff --git a/tests/Feature/AnalyticsEventsTest.php b/tests/Feature/AnalyticsEventsTest.php index 6b9a433c..3f9ff3e8 100644 --- a/tests/Feature/AnalyticsEventsTest.php +++ b/tests/Feature/AnalyticsEventsTest.php @@ -7,77 +7,162 @@ class AnalyticsEventsTest extends TestCase { - private $ruleName = 'product_queries_aggregation'; + private $ruleName = 'test__rule'; + private $ruleConfiguration; protected function setUp(): void { parent::setUp(); - - if ($this->isV30OrAbove()) { - $this->markTestSkipped('Analytics is deprecated in Typesense v30+'); - } - - $this->client()->collections->create([ - "name" => "products", - "fields" => [ - [ - "name" => "title", - "type" => "string" - ], - [ - "name" => "popularity", - "type" => "int32", - "optional" => true - ] - ] - ]); - $this->client()->analytics->rules()->upsert($this->ruleName, [ - "name" => "products_popularity", + + $this->ruleConfiguration = [ + "name" => $this->ruleName, "type" => "counter", + "collection" => "test_products", + "event_type" => "click", + "rule_tag" => "test_tag", "params" => [ - "source" => [ - "collections" => [ - "products" - ], - "events" => [ - [ - "type" => "click", - "weight" => 1, - "name" => "products_click_event" - ] - ] - ], - "destination" => [ - "collection" => "products", - "counter_field" => "popularity" - ] + "counter_field" => "popularity", + "weight" => 1 ] - ]); + ]; + + if (!$this->isV30OrAbove()) { + $this->markTestSkipped('New Analytics API is not supported in Typesense 29.0 and below'); + } + + try { + $this->client()->collections->create([ + 'name' => 'test_products', + 'fields' => [ + ['name' => 'company_name', 'type' => 'string'], + ['name' => 'num_employees', 'type' => 'int32'], + ['name' => 'country', 'type' => 'string', 'facet' => true], + ['name' => 'popularity', 'type' => 'int32', 'optional' => true] + ], + 'default_sorting_field' => 'num_employees' + ]); + } catch (Exception $e) { + } + + try { + $this->client()->analytics->rules()->create([$this->ruleConfiguration]); + } catch (Exception $e) { + } } protected function tearDown(): void { - parent::tearDown(); - - if (!$this->isV30OrAbove()) { + if ($this->isV30OrAbove()) { try { - $this->client()->analytics->rules()->{'product_queries_aggregation'}->delete(); + $rules = $this->client()->analytics->rules()->retrieve(); + if (is_array($rules)) { + foreach ($rules as $rule) { + if (strpos($rule['name'], 'test__') === 0) { + try { + $this->client()->analytics->rules()[$rule['name']]->delete(); + } catch (Exception $e) { + } + } + } + } + } catch (Exception $e) { + } + + try { + $this->client()->collections['test_products']->delete(); } catch (Exception $e) { } } } - public function testCanCreateAnEvent(): void + public function testCanCreateEventsWithAPI(): void + { + $event = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "doc_ids" => ["1", "2"], + "user_id" => "test_user" + ] + ]; + + $response = $this->client()->analytics->events()->create($event); + $this->assertIsArray($response); + } + + public function testCanCreateMultipleEventsWithAPI(): void + { + $event1 = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "doc_id" => "1", + "user_id" => "test_user_1" + ] + ]; + + $event2 = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "doc_id" => "2", + "user_id" => "test_user_2" + ] + ]; + + $response1 = $this->client()->analytics->events()->create($event1); + $this->assertIsArray($response1); + + $response2 = $this->client()->analytics->events()->create($event2); + $this->assertIsArray($response2); + } + + public function testCanRetrieveEventsWithAPI(): void { - $response = $this->client()->analytics->events()->create([ - "type" => "click", - "name" => "products_click_event", + $event = [ + "name" => $this->ruleName, + "event_type" => "click", "data" => [ - "q" => "nike shoes", - "doc_id" => "1024", - "user_id" => "111112" + "doc_id" => "1", + "user_id" => "test_user" ] + ]; + + $this->client()->analytics->events()->create($event); + + $response = $this->client()->analytics->events()->retrieve([ + 'user_id' => 'test_user', + 'name' => $this->ruleName, + 'n'=> 10 ]); - $this->assertTrue($response['ok']); + + $this->assertIsArray($response); + } + + public function testCanCreateEventWithDifferentEventTypes(): void + { + $clickEvent = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "doc_id" => "1", + "user_id" => "test_user" + ] + ]; + + $conversionEvent = [ + "name" => $this->ruleName, + "event_type" => "conversion", + "data" => [ + "doc_id" => "1", + "user_id" => "test_user" + ] + ]; + + $clickResponse = $this->client()->analytics->events()->create($clickEvent); + $this->assertIsArray($clickResponse); + + $conversionResponse = $this->client()->analytics->events()->create($conversionEvent); + $this->assertIsArray($conversionResponse); } -} +} \ No newline at end of file diff --git a/tests/Feature/AnalyticsEventsV1Test.php b/tests/Feature/AnalyticsEventsV1Test.php new file mode 100644 index 00000000..12c809c7 --- /dev/null +++ b/tests/Feature/AnalyticsEventsV1Test.php @@ -0,0 +1,83 @@ +isV30OrAbove()) { + $this->markTestSkipped('Analytics is deprecated in Typesense v30+'); + } + + $this->client()->collections->create([ + "name" => "products", + "fields" => [ + [ + "name" => "title", + "type" => "string" + ], + [ + "name" => "popularity", + "type" => "int32", + "optional" => true + ] + ] + ]); + $this->client()->analyticsV1->rules()->upsert($this->ruleName, [ + "name" => "products_popularity", + "type" => "counter", + "params" => [ + "source" => [ + "collections" => [ + "products" + ], + "events" => [ + [ + "type" => "click", + "weight" => 1, + "name" => "products_click_event" + ] + ] + ], + "destination" => [ + "collection" => "products", + "counter_field" => "popularity" + ] + ] + ]); + } + + protected function tearDown(): void + { + parent::tearDown(); + + if (!$this->isV30OrAbove()) { + try { + $this->client()->analyticsV1->rules()->{'product_queries_aggregation'}->delete(); + } catch (Exception $e) { + } + } + } + + public function testCanCreateAnEvent(): void + { + $response = $this->client()->analyticsV1->events()->create([ + "type" => "click", + "name" => "products_click_event", + "data" => [ + "q" => "nike shoes", + "doc_id" => "1024", + "user_id" => "111112" + ] + ]); + $this->assertTrue($response['ok']); + } +} diff --git a/tests/Feature/AnalyticsEventsV2Test.php b/tests/Feature/AnalyticsEventsV2Test.php deleted file mode 100644 index f6cdb990..00000000 --- a/tests/Feature/AnalyticsEventsV2Test.php +++ /dev/null @@ -1,168 +0,0 @@ -ruleConfiguration = [ - "name" => $this->ruleName, - "type" => "counter", - "collection" => "test_products", - "event_type" => "click", - "rule_tag" => "test_tag", - "params" => [ - "counter_field" => "popularity", - "weight" => 1 - ] - ]; - - if (!$this->isV30OrAbove()) { - $this->markTestSkipped('New Analytics API is not supported in Typesense v29.0 and below'); - } - - try { - $this->client()->collections->create([ - 'name' => 'test_products', - 'fields' => [ - ['name' => 'company_name', 'type' => 'string'], - ['name' => 'num_employees', 'type' => 'int32'], - ['name' => 'country', 'type' => 'string', 'facet' => true], - ['name' => 'popularity', 'type' => 'int32', 'optional' => true] - ], - 'default_sorting_field' => 'num_employees' - ]); - } catch (Exception $e) { - } - - try { - $this->client()->analyticsV2->rules()->create([$this->ruleConfiguration]); - } catch (Exception $e) { - } - } - - protected function tearDown(): void - { - if (!$this->isV30OrAbove()) { - try { - $rules = $this->client()->analyticsV2->rules()->retrieve(); - if (is_array($rules)) { - foreach ($rules as $rule) { - if (strpos($rule['name'], 'test_v2_') === 0) { - try { - $this->client()->analyticsV2->rules()[$rule['name']]->delete(); - } catch (Exception $e) { - } - } - } - } - } catch (Exception $e) { - } - - try { - $this->client()->collections['test_products']->delete(); - } catch (Exception $e) { - } - } - } - - public function testCanCreateEventsWithV2API(): void - { - $event = [ - "name" => $this->ruleName, - "event_type" => "click", - "data" => [ - "doc_ids" => ["1", "2"], - "user_id" => "test_user" - ] - ]; - - $response = $this->client()->analyticsV2->events()->create($event); - $this->assertIsArray($response); - } - - public function testCanCreateMultipleEventsWithV2API(): void - { - $event1 = [ - "name" => $this->ruleName, - "event_type" => "click", - "data" => [ - "doc_id" => "1", - "user_id" => "test_user_1" - ] - ]; - - $event2 = [ - "name" => $this->ruleName, - "event_type" => "click", - "data" => [ - "doc_id" => "2", - "user_id" => "test_user_2" - ] - ]; - - $response1 = $this->client()->analyticsV2->events()->create($event1); - $this->assertIsArray($response1); - - $response2 = $this->client()->analyticsV2->events()->create($event2); - $this->assertIsArray($response2); - } - - public function testCanRetrieveEventsWithV2API(): void - { - $event = [ - "name" => $this->ruleName, - "event_type" => "click", - "data" => [ - "doc_id" => "1", - "user_id" => "test_user" - ] - ]; - - $this->client()->analyticsV2->events()->create($event); - - $response = $this->client()->analyticsV2->events()->retrieve([ - 'user_id' => 'test_user', - 'name' => $this->ruleName, - 'n'=> 10 - ]); - - $this->assertIsArray($response); - } - - public function testCanCreateEventWithDifferentEventTypes(): void - { - $clickEvent = [ - "name" => $this->ruleName, - "event_type" => "click", - "data" => [ - "doc_id" => "1", - "user_id" => "test_user" - ] - ]; - - $conversionEvent = [ - "name" => $this->ruleName, - "event_type" => "conversion", - "data" => [ - "doc_id" => "1", - "user_id" => "test_user" - ] - ]; - - $clickResponse = $this->client()->analyticsV2->events()->create($clickEvent); - $this->assertIsArray($clickResponse); - - $conversionResponse = $this->client()->analyticsV2->events()->create($conversionEvent); - $this->assertIsArray($conversionResponse); - } -} \ No newline at end of file diff --git a/tests/Feature/AnalyticsRulesTest.php b/tests/Feature/AnalyticsRulesTest.php index e5d84ead..03ea912c 100644 --- a/tests/Feature/AnalyticsRulesTest.php +++ b/tests/Feature/AnalyticsRulesTest.php @@ -3,76 +3,168 @@ namespace Feature; use Tests\TestCase; -use Typesense\Exceptions\ObjectNotFound; +use Typesense\Exceptions\RequestMalformed; use Exception; class AnalyticsRulesTest extends TestCase { - private $ruleName = 'test_rule'; - private $ruleConfiguration = [ - "type" => "popular_queries", - "params" => [ - "source" => [ - "collections" => ["products"] - ], - "destination" => [ - "collection" => "product_queries" - ], - "expand_query" => false, - "limit" => 1000 - ] - ]; - private $ruleUpsertResponse = null; + private $ruleName = 'test__rule'; + private $ruleConfiguration; protected function setUp(): void { parent::setUp(); - if ($this->isV30OrAbove()) { - $this->markTestSkipped('Analytics is deprecated in Typesense v30+'); + $this->ruleConfiguration = [ + "name" => $this->ruleName, + "type" => "counter", + "collection" => "test_products", + "event_type" => "click", + "rule_tag" => "test_tag", + "params" => [ + "counter_field" => "popularity", + "weight" => 1 + ] + ]; + + if (!$this->isV30OrAbove()) { + $this->markTestSkipped('New Analytics API is not supported in Typesense 9.0 and below'); + } + + try { + $this->client()->collections->create([ + 'name' => 'test_products', + 'fields' => [ + ['name' => 'company_name', 'type' => 'string'], + ['name' => 'num_employees', 'type' => 'int32'], + ['name' => 'country', 'type' => 'string', 'facet' => true], + ['name' => 'popularity', 'type' => 'int32', 'optional' => true] + ], + 'default_sorting_field' => 'num_employees' + ]); + } catch (Exception $e) { } - $this->ruleUpsertResponse = $this->client()->analytics->rules()->upsert($this->ruleName, $this->ruleConfiguration); + try { + $this->client()->analytics->rules()->create([$this->ruleConfiguration]); + } catch (Exception $e) { + } } protected function tearDown(): void { - if (!$this->isV30OrAbove()) { + if ($this->isV30OrAbove()) { try { $rules = $this->client()->analytics->rules()->retrieve(); - if (is_array($rules) && isset($rules['rules'])) { - foreach ($rules['rules'] as $rule) { - $this->client()->analytics->rules()->{$rule['name']}->delete(); + if (is_array($rules)) { + foreach ($rules as $rule) { + if (strpos($rule['name'], 'test__') === 0) { + try { + $this->client()->analytics->rules()[$rule['name']]->delete(); + } catch (Exception $e) { + } + } } } } catch (Exception $e) { } + + try { + $this->client()->collections['test_products']->delete(); + } catch (Exception $e) { + } } } - public function testCanUpsertARule(): void + public function testCanCreateRulesWithAPI(): void { - $this->assertEquals($this->ruleName, $this->ruleUpsertResponse['name']); + $rules = [ + [ + "name" => "test_rule_1", + "type" => "counter", + "collection" => "test_products", + "event_type" => "click", + "rule_tag" => "test_tag", + "params" => [ + "counter_field" => "popularity", + "weight" => 1 + ] + ], + [ + "name" => "test_rule_2", + "type" => "counter", + "collection" => "test_products", + "event_type" => "conversion", + "rule_tag" => "test_tag", + "params" => [ + "counter_field" => "popularity", + "weight" => 2 + ] + ] + ]; + + $response = $this->client()->analytics->rules()->create($rules); + $this->assertIsArray($response); + + $allRules = $this->client()->analytics->rules()->retrieve(); + $this->assertIsArray($allRules); + + $ruleNames = array_column($allRules, 'name'); + $this->assertContains('test_rule_1', $ruleNames); + $this->assertContains('test_rule_2', $ruleNames); } - public function testCanRetrieveARule(): void + public function testCanRetrieveARuleWithAPI(): void { $returnData = $this->client()->analytics->rules()[$this->ruleName]->retrieve(); - $this->assertEquals($returnData['name'], $this->ruleName); + $this->assertEquals($this->ruleName, $returnData['name']); + $this->assertEquals('counter', $returnData['type']); + $this->assertEquals('test_products', $returnData['collection']); } - public function testCanDeleteARule(): void + public function testCanUpdateARuleWithAPI(): void + { + $updateParams = [ + "type" => "counter", + "collection" => "test_products", + "event_type" => "click", + "rule_tag" => "updated_tag", + "params" => [ + "counter_field" => "popularity", + "weight" => 5 + ] + ]; + + $response = $this->client()->analytics->rules()[$this->ruleName]->update($updateParams); + $this->assertEquals($this->ruleName, $response['name']); + $this->assertEquals('updated_tag', $response['rule_tag']); + $this->assertEquals(5, $response['params']['weight']); + } + + public function testCanDeleteARuleWithAPI(): void { $returnData = $this->client()->analytics->rules()[$this->ruleName]->delete(); - $this->assertEquals($returnData['name'], $this->ruleName); + $this->assertEquals($this->ruleName, $returnData['name']); - $this->expectException(ObjectNotFound::class); + $this->expectException(RequestMalformed::class); $this->client()->analytics->rules()[$this->ruleName]->retrieve(); } - public function testCanRetrieveAllRules(): void + public function testCanRetrieveAllRulesWithAPI(): void { $returnData = $this->client()->analytics->rules()->retrieve(); - $this->assertCount(1, $returnData['rules']); + $this->assertIsArray($returnData); + $this->assertGreaterThanOrEqual(1, count($returnData)); + } + + public function testArrayAccessCompatibility(): void + { + $rule = $this->client()->analytics->rules()[$this->ruleName]; + $this->assertInstanceOf('Typesense\AnalyticsRule', $rule); + + $this->assertTrue(isset($this->client()->analytics->rules()[$this->ruleName])); + + $rule = $this->client()->analytics->rules()[$this->ruleName]; + $this->assertInstanceOf('Typesense\AnalyticsRule', $rule); } -} +} \ No newline at end of file diff --git a/tests/Feature/AnalyticsRulesV1Test.php b/tests/Feature/AnalyticsRulesV1Test.php new file mode 100644 index 00000000..2f55468b --- /dev/null +++ b/tests/Feature/AnalyticsRulesV1Test.php @@ -0,0 +1,78 @@ + "popular_queries", + "params" => [ + "source" => [ + "collections" => ["products"] + ], + "destination" => [ + "collection" => "product_queries" + ], + "expand_query" => false, + "limit" => 1000 + ] + ]; + private $ruleUpsertResponse = null; + + protected function setUp(): void + { + parent::setUp(); + + if ($this->isV30OrAbove()) { + $this->markTestSkipped('Analytics is deprecated in Typesense v30+'); + } + + $this->ruleUpsertResponse = $this->client()->analyticsV1->rules()->upsert($this->ruleName, $this->ruleConfiguration); + } + + protected function tearDown(): void + { + if (!$this->isV30OrAbove()) { + try { + $rules = $this->client()->analyticsV1->rules()->retrieve(); + if (is_array($rules) && isset($rules['rules'])) { + foreach ($rules['rules'] as $rule) { + $this->client()->analyticsV1->rules()->{$rule['name']}->delete(); + } + } + } catch (Exception $e) { + } + } + } + + public function testCanUpsertARule(): void + { + $this->assertEquals($this->ruleName, $this->ruleUpsertResponse['name']); + } + + public function testCanRetrieveARule(): void + { + $returnData = $this->client()->analyticsV1->rules()[$this->ruleName]->retrieve(); + $this->assertEquals($returnData['name'], $this->ruleName); + } + + public function testCanDeleteARule(): void + { + $returnData = $this->client()->analyticsV1->rules()[$this->ruleName]->delete(); + $this->assertEquals($returnData['name'], $this->ruleName); + + $this->expectException(ObjectNotFound::class); + $this->client()->analyticsV1->rules()[$this->ruleName]->retrieve(); + } + + public function testCanRetrieveAllRules(): void + { + $returnData = $this->client()->analyticsV1->rules()->retrieve(); + $this->assertCount(1, $returnData['rules']); + } +} diff --git a/tests/Feature/AnalyticsRulesV2Test.php b/tests/Feature/AnalyticsRulesV2Test.php deleted file mode 100644 index ec3b5b32..00000000 --- a/tests/Feature/AnalyticsRulesV2Test.php +++ /dev/null @@ -1,175 +0,0 @@ -ruleConfiguration = [ - "name" => $this->ruleName, - "type" => "counter", - "collection" => "test_products", - "event_type" => "click", - "rule_tag" => "test_tag", - "params" => [ - "counter_field" => "popularity", - "weight" => 1 - ] - ]; - - if (!$this->isV30OrAbove()) { - $this->markTestSkipped('New Analytics API is not supported in Typesense v29.0 and below'); - } - - try { - $this->client()->collections->create([ - 'name' => 'test_products', - 'fields' => [ - ['name' => 'company_name', 'type' => 'string'], - ['name' => 'num_employees', 'type' => 'int32'], - ['name' => 'country', 'type' => 'string', 'facet' => true], - ['name' => 'popularity', 'type' => 'int32', 'optional' => true] - ], - 'default_sorting_field' => 'num_employees' - ]); - } catch (Exception $e) { - } - - try { - $this->client()->analyticsV2->rules()->create([$this->ruleConfiguration]); - } catch (Exception $e) { - } - } - - protected function tearDown(): void - { - if (!$this->isV30OrAbove()) { - try { - $rules = $this->client()->analyticsV2->rules()->retrieve(); - if (is_array($rules)) { - foreach ($rules as $rule) { - if (strpos($rule['name'], 'test_v2_') === 0) { - try { - $this->client()->analyticsV2->rules()[$rule['name']]->delete(); - } catch (Exception $e) { - } - } - } - } - } catch (Exception $e) { - } - - try { - $this->client()->collections['test_products']->delete(); - } catch (Exception $e) { - } - } - } - - public function testCanCreateRulesWithV2API(): void - { - $rules = [ - [ - "name" => "test_rule_1", - "type" => "counter", - "collection" => "test_products", - "event_type" => "click", - "rule_tag" => "test_tag", - "params" => [ - "counter_field" => "popularity", - "weight" => 1 - ] - ], - [ - "name" => "test_rule_2", - "type" => "counter", - "collection" => "test_products", - "event_type" => "conversion", - "rule_tag" => "test_tag", - "params" => [ - "counter_field" => "popularity", - "weight" => 2 - ] - ] - ]; - - $response = $this->client()->analyticsV2->rules()->create($rules); - $this->assertIsArray($response); - - $allRules = $this->client()->analyticsV2->rules()->retrieve(); - $this->assertIsArray($allRules); - - $ruleNames = array_column($allRules, 'name'); - $this->assertContains('test_rule_1', $ruleNames); - $this->assertContains('test_rule_2', $ruleNames); - } - - public function testCanRetrieveARuleWithV2API(): void - { - $returnData = $this->client()->analyticsV2->rules()[$this->ruleName]->retrieve(); - $this->assertEquals($this->ruleName, $returnData['name']); - $this->assertEquals('counter', $returnData['type']); - $this->assertEquals('test_products', $returnData['collection']); - } - - public function testCanUpdateARuleWithV2API(): void - { - $updateParams = [ - "type" => "counter", - "collection" => "test_products", - "event_type" => "click", - "rule_tag" => "updated_tag", - "params" => [ - "counter_field" => "popularity", - "weight" => 5 - ] - ]; - - $response = $this->client()->analyticsV2->rules()[$this->ruleName]->update($updateParams); - $this->assertEquals($this->ruleName, $response['name']); - $this->assertEquals('updated_tag', $response['rule_tag']); - $this->assertEquals(5, $response['params']['weight']); - } - - public function testCanDeleteARuleWithV2API(): void - { - $returnData = $this->client()->analyticsV2->rules()[$this->ruleName]->delete(); - $this->assertEquals($this->ruleName, $returnData['name']); - - $this->expectException(RequestMalformed::class); - $this->client()->analyticsV2->rules()[$this->ruleName]->retrieve(); - } - - public function testCanRetrieveAllRulesWithV2API(): void - { - $returnData = $this->client()->analyticsV2->rules()->retrieve(); - $this->assertIsArray($returnData); - $this->assertGreaterThanOrEqual(1, count($returnData)); - - $ruleNames = array_column($returnData, 'name'); - $this->assertContains('test_v2_rule', $ruleNames); - $this->assertContains('test_rule_1', $ruleNames); - $this->assertContains('test_rule_2', $ruleNames); - } - - public function testArrayAccessCompatibility(): void - { - $rule = $this->client()->analyticsV2->rules()[$this->ruleName]; - $this->assertInstanceOf('Typesense\AnalyticsRuleV2', $rule); - - $this->assertTrue(isset($this->client()->analyticsV2->rules()[$this->ruleName])); - - $rule = $this->client()->analyticsV2->rules()[$this->ruleName]; - $this->assertInstanceOf('Typesense\AnalyticsRuleV2', $rule); - } -} \ No newline at end of file From 3211ac71cff6f3f9b23bdb04c7651caf022e8d86 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Mon, 29 Sep 2025 19:59:51 +0300 Subject: [PATCH 14/20] chore: lint --- tests/Feature/CurationSetItemsTest.php | 4 +++- tests/Feature/CurationSetsTest.php | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Feature/CurationSetItemsTest.php b/tests/Feature/CurationSetItemsTest.php index d4bafaf3..35cb74a0 100644 --- a/tests/Feature/CurationSetItemsTest.php +++ b/tests/Feature/CurationSetItemsTest.php @@ -42,7 +42,9 @@ protected function setUp(): void protected function tearDown(): void { try { - $this->curationSets['test-curation-set-items']->delete(); + if ($this->curationSets !== null) { + $this->curationSets['test-curation-set-items']->delete(); + } } catch (Exception $e) { // Ignore cleanup errors } diff --git a/tests/Feature/CurationSetsTest.php b/tests/Feature/CurationSetsTest.php index 82a74d30..b7355e3b 100644 --- a/tests/Feature/CurationSetsTest.php +++ b/tests/Feature/CurationSetsTest.php @@ -43,7 +43,9 @@ protected function setUp(): void protected function tearDown(): void { try { - $this->curationSets['test-curation-set']->delete(); + if ($this->curationSets !== null) { + $this->curationSets['test-curation-set']->delete(); + } } catch (Exception $e) { // Ignore cleanup errors } From 917589eacfa30addc731a96d8c8848d6d28010bb Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 21 Oct 2025 18:50:27 +0300 Subject: [PATCH 15/20] ci: upgrade typesense version to v30 --- .github/workflows/tests.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bba575e8..323846b8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,15 +8,20 @@ jobs: runs-on: ubuntu-latest services: typesense: - image: typesense/typesense:28.0.rc36 + image: typesense/typesense:30.0.alpha1 ports: - - 8108:8108/tcp + - 8108:8108 volumes: - - /tmp/typesense-server-data:/data + - /tmp/typesense-data:/data + - /tmp/typesense-analytics:/analytics env: - TYPESENSE_DATA_DIR: /data TYPESENSE_API_KEY: xyz + TYPESENSE_DATA_DIR: /data TYPESENSE_ENABLE_CORS: true + TYPESENSE_ANALYTICS_DIR: /analytics + TYPESENSE_ENABLE_SEARCH_ANALYTICS: true + options: --analytics-minute-rate-limit=50 + steps: - uses: actions/checkout@v4 - name: Setup PHP 8.3 From 268ac3a91bcf21a3767715aa1b53bd2af8aff9bb Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 21 Oct 2025 18:54:08 +0300 Subject: [PATCH 16/20] fix(test): also match non-prefixed `v` versions on check --- tests/TestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index b9852b11..95a51bf9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -110,7 +110,7 @@ protected function isV30OrAbove(): bool return true; } - if (preg_match('/^v(\d+)/', $version, $matches)) { + if (preg_match('/^v?(\d+)/', $version, $matches)) { $majorVersion = (int) $matches[1]; return $majorVersion >= 30; } From 3176e323e9f3c7491011c44aff91d268fefc5cb3 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 21 Oct 2025 19:22:06 +0300 Subject: [PATCH 17/20] ci: increase number of analytics per minute --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 323846b8..c8d0b7f1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,7 +20,7 @@ jobs: TYPESENSE_ENABLE_CORS: true TYPESENSE_ANALYTICS_DIR: /analytics TYPESENSE_ENABLE_SEARCH_ANALYTICS: true - options: --analytics-minute-rate-limit=50 + ANALYTICS_MINUTE_RATE_LIMIT: 500 steps: - uses: actions/checkout@v4 From 696524ce2c1256b009c5bd3a8ae6d279deb5e493 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Tue, 21 Oct 2025 19:27:51 +0300 Subject: [PATCH 18/20] ci: manually run typesense on ci --- .github/workflows/tests.yml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c8d0b7f1..9408c239 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,28 +6,28 @@ jobs: test: name: Run tests with PHP v8.3 runs-on: ubuntu-latest - services: - typesense: - image: typesense/typesense:30.0.alpha1 - ports: - - 8108:8108 - volumes: - - /tmp/typesense-data:/data - - /tmp/typesense-analytics:/analytics - env: - TYPESENSE_API_KEY: xyz - TYPESENSE_DATA_DIR: /data - TYPESENSE_ENABLE_CORS: true - TYPESENSE_ANALYTICS_DIR: /analytics - TYPESENSE_ENABLE_SEARCH_ANALYTICS: true - ANALYTICS_MINUTE_RATE_LIMIT: 500 - steps: + - name: Start Typesense + run: | + docker run -d \ + -p 8108:8108 \ + --name typesense \ + -v /tmp/typesense-data:/data \ + -v /tmp/typesense-analytics-data:/analytics-data \ + typesense/typesense:30.0.alpha1 \ + --api-key=xyz \ + --data-dir=/data \ + --enable-search-analytics=true \ + --analytics-dir=/analytics-data \ + --analytics-flush-interval=60 \ + --analytics-minute-rate-limit=50 \ + --enable-cors + - uses: actions/checkout@v4 - name: Setup PHP 8.3 uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: "8.3" coverage: xdebug - uses: php-actions/composer@v6 - name: Run tests From 26e6f80ad85f323c2031ee4fd812a812c12d9cbf Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 31 Oct 2025 11:49:49 +0200 Subject: [PATCH 19/20] docs: add definition docs to analytic events --- src/AnalyticsEvents.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/AnalyticsEvents.php b/src/AnalyticsEvents.php index 6b2ad484..47e57465 100644 --- a/src/AnalyticsEvents.php +++ b/src/AnalyticsEvents.php @@ -5,7 +5,7 @@ /** * Class AnalyticsEvents * - * Implements the updated analytics events API for Typesense + + * Implements the updated analytics events API for Typesense v30.0+ * * @package \Typesense */ @@ -13,8 +13,16 @@ class AnalyticsEvents { const RESOURCE_PATH = '/analytics/events'; + /** + * @var ApiCall + */ private ApiCall $apiCall; + /** + * AnalyticsEvents constructor. + * + * @param ApiCall $apiCall + */ public function __construct(ApiCall $apiCall) { $this->apiCall = $apiCall; From ad047c938b2c89aed4ed88e591975d3f81fd8908 Mon Sep 17 00:00:00 2001 From: Fanis Tharropoulos Date: Fri, 31 Oct 2025 11:55:29 +0200 Subject: [PATCH 20/20] fix(analytics): add readonly array access to analytics rules --- src/AnalyticsRules.php | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/AnalyticsRules.php b/src/AnalyticsRules.php index 199b2c6b..4e80a07d 100644 --- a/src/AnalyticsRules.php +++ b/src/AnalyticsRules.php @@ -8,6 +8,11 @@ class AnalyticsRules implements \ArrayAccess private ApiCall $apiCall; + /** + * @var array + */ + private array $analyticsRules = []; + public function __construct(ApiCall $apiCall) { $this->apiCall = $apiCall; @@ -42,7 +47,14 @@ public function retrieve() */ public function __get($ruleName) { - return new AnalyticsRule($ruleName, $this->apiCall); + if (isset($this->{$ruleName})) { + return $this->{$ruleName}; + } + if (!isset($this->analyticsRules[$ruleName])) { + $this->analyticsRules[$ruleName] = new AnalyticsRule($ruleName, $this->apiCall); + } + + return $this->analyticsRules[$ruleName]; } /** @@ -50,21 +62,34 @@ public function __get($ruleName) */ public function offsetExists($offset): bool { - return true; // Rules can be accessed by name + return isset($this->analyticsRules[$offset]); } + /** + * @inheritDoc + */ public function offsetGet($offset): AnalyticsRule { - return new AnalyticsRule($offset, $this->apiCall); + if (!isset($this->analyticsRules[$offset])) { + $this->analyticsRules[$offset] = new AnalyticsRule($offset, $this->apiCall); + } + + return $this->analyticsRules[$offset]; } + /** + * @inheritDoc + */ public function offsetSet($offset, $value): void { - // Not implemented for read-only access + $this->analyticsRules[$offset] = $value; } + /** + * @inheritDoc + */ public function offsetUnset($offset): void { - // Not implemented for read-only access + unset($this->analyticsRules[$offset]); } } \ No newline at end of file