diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bba575e8..9408c239 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,23 +6,28 @@ jobs: test: name: Run tests with PHP v8.3 runs-on: ubuntu-latest - services: - typesense: - image: typesense/typesense:28.0.rc36 - ports: - - 8108:8108/tcp - volumes: - - /tmp/typesense-server-data:/data - env: - TYPESENSE_DATA_DIR: /data - TYPESENSE_API_KEY: xyz - TYPESENSE_ENABLE_CORS: true 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 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..47e57465 100644 --- a/src/AnalyticsEvents.php +++ b/src/AnalyticsEvents.php @@ -4,6 +4,8 @@ /** * Class AnalyticsEvents + * + * Implements the updated analytics events API for Typesense v30.0+ * * @package \Typesense */ @@ -27,21 +29,25 @@ public function __construct(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/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/AnalyticsRules.php b/src/AnalyticsRules.php index 444d1cb2..4e80a07d 100644 --- a/src/AnalyticsRules.php +++ b/src/AnalyticsRules.php @@ -7,38 +7,58 @@ class AnalyticsRules implements \ArrayAccess const RESOURCE_PATH = '/analytics/rules'; private ApiCall $apiCall; - private $analyticsRules = []; + + /** + * @var array + */ + private array $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)); + if (isset($this->{$ruleName})) { + return $this->{$ruleName}; + } + if (!isset($this->analyticsRules[$ruleName])) { + $this->analyticsRules[$ruleName] = new AnalyticsRule($ruleName, $this->apiCall); + } + + return $this->analyticsRules[$ruleName]; } /** - * @inheritDoc + * ArrayAccess implementation for backwards compatibility */ public function offsetExists($offset): bool { @@ -72,4 +92,4 @@ public function offsetUnset($offset): void { unset($this->analyticsRules[$offset]); } -} +} \ 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/AnalyticsV1.php b/src/AnalyticsV1.php new file mode 100644 index 00000000..371a1bb0 --- /dev/null +++ b/src/AnalyticsV1.php @@ -0,0 +1,35 @@ +apiCall = $apiCall; + } + + public function rules() + { + if (!isset($this->rules)) { + $this->rules = new AnalyticsRulesV1($this->apiCall); + } + return $this->rules; + } + + public function events() + { + if (!isset($this->events)) { + $this->events = new AnalyticsEventsV1($this->apiCall); + } + return $this->events; + } +} diff --git a/src/Client.php b/src/Client.php index a4f8a084..db8c3b8a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -70,6 +70,11 @@ class Client */ public Presets $presets; + /** + * @var AnalyticsV1 + */ + public AnalyticsV1 $analyticsV1; + /** * @var Analytics */ @@ -90,6 +95,16 @@ class Client */ public NLSearchModels $nlSearchModels; + /** + * @var SynonymSets + */ + public SynonymSets $synonymSets; + + /** + * @var CurationSets + */ + public CurationSets $curationSets; + /** * @var ApiCall */ @@ -118,9 +133,12 @@ public function __construct(array $config) $this->multiSearch = new MultiSearch($this->apiCall); $this->presets = new Presets($this->apiCall); $this->analytics = new Analytics($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); + $this->synonymSets = new SynonymSets($this->apiCall); + $this->curationSets = new CurationSets($this->apiCall); } /** @@ -234,4 +252,20 @@ public function getNLSearchModels(): NLSearchModels { return $this->nlSearchModels; } + + /** + * @return SynonymSets + */ + public function getSynonymSets(): SynonymSets + { + return $this->synonymSets; + } + + /** + * @return CurationSets + */ + public function getCurationSets(): CurationSets + { + return $this->curationSets; + } } 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]); + } +} 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 diff --git a/tests/Feature/AnalyticsEventsTest.php b/tests/Feature/AnalyticsEventsTest.php index c1fa70d6..3f9ff3e8 100644 --- a/tests/Feature/AnalyticsEventsTest.php +++ b/tests/Feature/AnalyticsEventsTest.php @@ -3,69 +3,166 @@ namespace Feature; use Tests\TestCase; +use Exception; class AnalyticsEventsTest extends TestCase { - private $ruleName = 'product_queries_aggregation'; + private $ruleName = 'test__rule'; + private $ruleConfiguration; protected function setUp(): void { parent::setUp(); - $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(); - $this->client()->analytics->rules()->{'product_queries_aggregation'}->delete(); + if ($this->isV30OrAbove()) { + try { + $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 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 testCanCreateAnEvent(): void + public function testCanCreateMultipleEventsWithAPI(): void { - $response = $this->client()->analytics->events()->create([ - "type" => "click", - "name" => "products_click_event", + $event1 = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "doc_id" => "1", + "user_id" => "test_user_1" + ] + ]; + + $event2 = [ + "name" => $this->ruleName, + "event_type" => "click", "data" => [ - "q" => "nike shoes", - "doc_id" => "1024", - "user_id" => "111112" + "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 + { + $event = [ + "name" => $this->ruleName, + "event_type" => "click", + "data" => [ + "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/AnalyticsRulesTest.php b/tests/Feature/AnalyticsRulesTest.php index 882ef896..03ea912c 100644 --- a/tests/Feature/AnalyticsRulesTest.php +++ b/tests/Feature/AnalyticsRulesTest.php @@ -3,63 +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(); - $this->ruleUpsertResponse = $this->client()->analytics->rules()->upsert($this->ruleName, $this->ruleConfiguration); + + $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) { + } + + try { + $this->client()->analytics->rules()->create([$this->ruleConfiguration]); + } catch (Exception $e) { + } } 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)) { + 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/CurationSetItemsTest.php b/tests/Feature/CurationSetItemsTest.php new file mode 100644 index 00000000..35cb74a0 --- /dev/null +++ b/tests/Feature/CurationSetItemsTest.php @@ -0,0 +1,87 @@ + [ + [ + '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 { + if ($this->curationSets !== null) { + $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..b7355e3b --- /dev/null +++ b/tests/Feature/CurationSetsTest.php @@ -0,0 +1,94 @@ + [ + [ + '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 { + if ($this->curationSets !== null) { + $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(); + } +} 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 new file mode 100644 index 00000000..0ad6ef21 --- /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['items'], $this->upsertResponse['items']); + } + + 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['items'], $returnData['items']); + } + + 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 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; diff --git a/tests/TestCase.php b/tests/TestCase.php index 6a26447b..95a51bf9 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; + } + } }