diff --git a/.gitignore b/.gitignore index 60f5f24..cd5e067 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ pnpm-debug.log .DS_Store Thumbs.db +# Claude Code +.claude/ + # Environment & local config .env .env.* diff --git a/README.md b/README.md index 969a7ee..a34d757 100644 --- a/README.md +++ b/README.md @@ -278,13 +278,38 @@ When a record is saved, searchable tokens are automatically generated in `encryp ### Searching +#### Single Field Search + ```php -// Exact match +// Exact match on a single field $clients = Client::encryptedExact('last_names', 'Vermeer')->get(); -// Prefix match +// Prefix match on a single field $clients = Client::encryptedPrefix('first_names', 'Wie')->get(); ``` + +#### Multi-Field Search + +Search across multiple fields simultaneously using OR logic: + +```php +// Exact match across multiple fields +// Finds records where 'John' appears in first_names OR last_names +$clients = Client::encryptedExactMulti(['first_names', 'last_names'], 'John')->get(); + +// Prefix match across multiple fields +// Finds records where 'Wie' is a prefix of first_names OR last_names +$clients = Client::encryptedPrefixMulti(['first_names', 'last_names'], 'Wie')->get(); +``` + +**Use cases for multi-field search:** +- Search for a name that could be in either first name or last name fields +- Search across multiple encrypted fields without multiple queries +- Implement autocomplete across multiple fields +- Unified search experience across related fields + +**Note:** Multi-field searches automatically deduplicate results, so if a record matches in multiple fields, it will only appear once in the results. + Attributes always override global or $encryptedSearch configuration for the same field. --- diff --git a/composer.json b/composer.json index 83f1221..e7c6ccb 100644 --- a/composer.json +++ b/composer.json @@ -21,11 +21,13 @@ "require": { "php": "^8.1 || ^8.2 || ^8.3 || ^8.4", "illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0", - "ext-intl": "*" + "ext-intl": "*", + "guzzlehttp/guzzle": "^7.2" }, "require-dev": { "phpunit/phpunit": "^9.5.10 || ^10.0 || ^11.0", - "orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0" + "orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0", + "doctrine/dbal": "^3.0" }, "autoload": { "psr-4": { diff --git a/src/Traits/HasEncryptedSearchIndex.php b/src/Traits/HasEncryptedSearchIndex.php index 2be409b..f37baab 100644 --- a/src/Traits/HasEncryptedSearchIndex.php +++ b/src/Traits/HasEncryptedSearchIndex.php @@ -265,6 +265,49 @@ public function scopeEncryptedExact(Builder $query, string $field, string $term) }); } + /** + * Scope: query models by exact encrypted token match across multiple fields. + * + * Searches for an exact match in any of the specified fields (OR logic). + * + * @param Builder $query + * @param array $fields + * @param string $term + * @return Builder + */ + public function scopeEncryptedExactMulti(Builder $query, array $fields, string $term): Builder + { + if (empty($fields)) { + return $query->whereRaw('1=0'); + } + + $pepper = (string) config('encrypted-search.search_pepper', ''); + $normalized = Normalizer::normalize($term); + + if (!$normalized) { + return $query->whereRaw('1=0'); + } + + $token = Tokens::exact($normalized, $pepper); + + // Check if Elasticsearch is enabled + if (config('encrypted-search.elasticsearch.enabled', false)) { + $modelIds = $this->searchElasticsearchMulti($fields, $token, 'exact'); + return $query->whereIn($this->getQualifiedKeyName(), $modelIds); + } + + // Fallback to database - use OR logic for multiple fields + return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $token) { + $sub->select('model_id') + ->from('encrypted_search_index') + ->where('model_type', static::class) + ->whereIn('field', $fields) + ->where('type', 'exact') + ->where('token', $token) + ->distinct(); + }); + } + /** * Scope: query models by prefix-based encrypted token match. * @@ -318,169 +361,64 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term } /** - * Scope: search across multiple fields with OR logic (any field matches). + * Scope: query models by prefix-based encrypted token match across multiple fields. * - * Efficiently searches multiple fields for the same term in a single query. - * Returns models where at least one field matches. - * - * Example: - * Client::encryptedSearchAny(['first_names', 'last_names'], 'John', 'exact')->get(); + * Searches for a prefix match in any of the specified fields (OR logic). * * @param Builder $query - * @param array $fields Array of field names to search - * @param string $term Search term - * @param string $type Search type: 'exact' or 'prefix' + * @param array $fields + * @param string $term * @return Builder */ - public function scopeEncryptedSearchAny(Builder $query, array $fields, string $term, string $type = 'exact'): Builder + public function scopeEncryptedPrefixMulti(Builder $query, array $fields, string $term): Builder { if (empty($fields)) { return $query->whereRaw('1=0'); } $pepper = (string) config('encrypted-search.search_pepper', ''); + $minLength = (int) config('encrypted-search.min_prefix_length', 1); $normalized = Normalizer::normalize($term); if (!$normalized) { return $query->whereRaw('1=0'); } - // Generate tokens based on search type - if ($type === 'prefix') { - $minLength = (int) config('encrypted-search.min_prefix_length', 1); - - if (mb_strlen($normalized, 'UTF-8') < $minLength) { - return $query->whereRaw('1=0'); - } + // Check if search term meets minimum length requirement + if (mb_strlen($normalized, 'UTF-8') < $minLength) { + return $query->whereRaw('1=0'); + } - $tokens = Tokens::prefixes( - $normalized, - (int) config('encrypted-search.max_prefix_depth', 6), - $pepper, - $minLength - ); + $tokens = Tokens::prefixes( + $normalized, + (int) config('encrypted-search.max_prefix_depth', 6), + $pepper, + $minLength + ); - if (empty($tokens)) { - return $query->whereRaw('1=0'); - } - } else { - $tokens = [Tokens::exact($normalized, $pepper)]; + // If no tokens generated (term too short), return no results + if (empty($tokens)) { + return $query->whereRaw('1=0'); } // Check if Elasticsearch is enabled if (config('encrypted-search.elasticsearch.enabled', false)) { - $allModelIds = []; - - foreach ($fields as $field) { - $modelIds = $this->searchElasticsearch($field, $tokens, $type); - $allModelIds = array_merge($allModelIds, $modelIds); - } - - return $query->whereIn($this->getQualifiedKeyName(), array_unique($allModelIds)); + $modelIds = $this->searchElasticsearchMulti($fields, $tokens, 'prefix'); + return $query->whereIn($this->getQualifiedKeyName(), $modelIds); } - // Fallback to database - use OR conditions - return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $tokens, $type) { + // Fallback to database - use OR logic for multiple fields + return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $tokens) { $sub->select('model_id') ->from('encrypted_search_index') ->where('model_type', static::class) - ->where('type', $type) ->whereIn('field', $fields) - ->whereIn('token', $tokens); + ->where('type', 'prefix') + ->whereIn('token', $tokens) + ->distinct(); }); } - /** - * Scope: search across multiple fields with AND logic (all fields must match). - * - * Returns models where ALL specified fields match their respective terms. - * - * Example: - * Client::encryptedSearchAll([ - * 'first_names' => 'John', - * 'last_names' => 'Doe' - * ], 'exact')->get(); - * - * @param Builder $query - * @param array $fieldTerms Associative array of field => term - * @param string $type Search type: 'exact' or 'prefix' - * @return Builder - */ - public function scopeEncryptedSearchAll(Builder $query, array $fieldTerms, string $type = 'exact'): Builder - { - if (empty($fieldTerms)) { - return $query->whereRaw('1=0'); - } - - $pepper = (string) config('encrypted-search.search_pepper', ''); - $minLength = (int) config('encrypted-search.min_prefix_length', 1); - $maxDepth = (int) config('encrypted-search.max_prefix_depth', 6); - - // Check if Elasticsearch is enabled - if (config('encrypted-search.elasticsearch.enabled', false)) { - // Start with all IDs, then intersect - $resultIds = null; - - foreach ($fieldTerms as $field => $term) { - $normalized = Normalizer::normalize($term); - - if (!$normalized || ($type === 'prefix' && mb_strlen($normalized, 'UTF-8') < $minLength)) { - return $query->whereRaw('1=0'); - } - - $tokens = $type === 'prefix' - ? Tokens::prefixes($normalized, $maxDepth, $pepper, $minLength) - : [Tokens::exact($normalized, $pepper)]; - - if (empty($tokens)) { - return $query->whereRaw('1=0'); - } - - $modelIds = $this->searchElasticsearch($field, $tokens, $type); - - if ($resultIds === null) { - $resultIds = $modelIds; - } else { - $resultIds = array_intersect($resultIds, $modelIds); - } - - if (empty($resultIds)) { - return $query->whereRaw('1=0'); - } - } - - return $query->whereIn($this->getQualifiedKeyName(), $resultIds); - } - - // Fallback to database - use nested queries with intersections - foreach ($fieldTerms as $field => $term) { - $normalized = Normalizer::normalize($term); - - if (!$normalized || ($type === 'prefix' && mb_strlen($normalized, 'UTF-8') < $minLength)) { - return $query->whereRaw('1=0'); - } - - $tokens = $type === 'prefix' - ? Tokens::prefixes($normalized, $maxDepth, $pepper, $minLength) - : [Tokens::exact($normalized, $pepper)]; - - if (empty($tokens)) { - return $query->whereRaw('1=0'); - } - - $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($field, $tokens, $type) { - $sub->select('model_id') - ->from('encrypted_search_index') - ->where('model_type', static::class) - ->where('field', $field) - ->where('type', $type) - ->whereIn('token', $tokens); - }); - } - - return $query; - } - /** * Check if a field has an encrypted cast. * @@ -545,6 +483,53 @@ protected function searchElasticsearch(string $field, $tokens, string $type): ar } } + /** + * Search for model IDs in Elasticsearch based on token(s) across multiple fields. + * + * @param array $fields + * @param string|array $tokens Single token or array of tokens + * @param string $type Either 'exact' or 'prefix' + * @return array Array of model IDs + */ + protected function searchElasticsearchMulti(array $fields, $tokens, string $type): array + { + $index = config('encrypted-search.elasticsearch.index', 'encrypted_search'); + $service = app(ElasticsearchService::class); + + // Normalize tokens to array + $tokenArray = is_array($tokens) ? $tokens : [$tokens]; + + // Build Elasticsearch query with multiple fields (OR logic) + $query = [ + 'query' => [ + 'bool' => [ + 'must' => [ + ['term' => ['model_type.keyword' => static::class]], + ['terms' => ['field.keyword' => $fields]], + ['term' => ['type.keyword' => $type]], + ['terms' => ['token.keyword' => $tokenArray]], + ], + ], + ], + '_source' => ['model_id'], + 'size' => 10000, + ]; + + try { + $results = $service->search($index, $query); + + // Extract unique model IDs from results + return collect($results) + ->pluck('_source.model_id') + ->unique() + ->values() + ->toArray(); + } catch (\Throwable $e) { + logger()->warning('[EncryptedSearch] Elasticsearch multi-field search failed: ' . $e->getMessage()); + return []; + } + } + /** * Resolve the encrypted search configuration for this model. * diff --git a/tests/Feature/MultiFieldSearchTest.php b/tests/Feature/MultiFieldSearchTest.php new file mode 100644 index 0000000..e7bf3eb --- /dev/null +++ b/tests/Feature/MultiFieldSearchTest.php @@ -0,0 +1,364 @@ + + */ + protected function getPackageProviders($app): array + { + return [EncryptedSearchServiceProvider::class]; + } + + protected function setUp(): void + { + parent::setUp(); + + // Configure in-memory SQLite database + config()->set('database.default', 'testing'); + config()->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + // Disable Elasticsearch during tests + config()->set('encrypted-search.elasticsearch.enabled', false); + + config(['encrypted-search.search_pepper' => 'test-pepper']); + config(['encrypted-search.max_prefix_depth' => 6]); + config(['encrypted-search.min_prefix_length' => 1]); + + // Ensure Eloquent events are active + \Illuminate\Database\Eloquent\Model::unsetEventDispatcher(); + \Illuminate\Database\Eloquent\Model::setEventDispatcher(app('events')); + + // Boot model traits manually for Testbench + \Ginkelsoft\EncryptedSearch\Tests\Models\Client::boot(); + + // Create schema tables + Schema::create('clients', function (Blueprint $table): void { + $table->id(); + $table->string('first_names'); + $table->string('last_names'); + $table->timestamps(); + }); + + Schema::create('encrypted_search_index', function (Blueprint $table): void { + $table->id(); + $table->string('model_type'); + $table->unsignedBigInteger('model_id'); + $table->string('field'); + $table->string('type'); + $table->string('token'); + $table->timestamps(); + $table->index(['model_type', 'field', 'type', 'token'], 'esi_lookup'); + }); + } + + /** @test */ + public function it_can_search_exact_match_across_multiple_fields(): void + { + // Create test clients + $client1 = Client::create([ + 'first_names' => 'John', + 'last_names' => 'Doe', + ]); + + $client2 = Client::create([ + 'first_names' => 'Jane', + 'last_names' => 'Smith', + ]); + + $client3 = Client::create([ + 'first_names' => 'John', + 'last_names' => 'Johnson', + ]); + + // Search for "John" in both first_names and last_names + $results = Client::encryptedExactMulti(['first_names', 'last_names'], 'John') + ->pluck('id') + ->toArray(); + + // Should find client1 (first_names=John) and client3 (first_names=John and last_names contains John) + $this->assertCount(2, $results); + $this->assertContains($client1->id, $results); + $this->assertContains($client3->id, $results); + $this->assertNotContains($client2->id, $results); + } + + /** @test */ + public function it_can_search_prefix_match_across_multiple_fields(): void + { + $client1 = Client::create([ + 'first_names' => 'Wietse', + 'last_names' => 'Vermeer', + ]); + + $client2 = Client::create([ + 'first_names' => 'Vincent', + 'last_names' => 'Wieland', + ]); + + $client3 = Client::create([ + 'first_names' => 'Tom', + 'last_names' => 'Schmidt', + ]); + + // Search for "Wie" prefix in both fields + $results = Client::encryptedPrefixMulti(['first_names', 'last_names'], 'Wie') + ->pluck('id') + ->toArray(); + + // Should find client1 (first_names starts with Wie) and client2 (last_names starts with Wie) + $this->assertCount(2, $results); + $this->assertContains($client1->id, $results); + $this->assertContains($client2->id, $results); + $this->assertNotContains($client3->id, $results); + } + + /** @test */ + public function exact_multi_returns_unique_results_when_matching_multiple_fields(): void + { + // Create client where search term appears in multiple fields + $client = Client::create([ + 'first_names' => 'Smith', + 'last_names' => 'Smith', + ]); + + $results = Client::encryptedExactMulti(['first_names', 'last_names'], 'Smith') + ->get(); + + // Should only return the client once, even though it matches in two fields + $this->assertCount(1, $results); + $this->assertEquals($client->id, $results->first()->id); + } + + /** @test */ + public function prefix_multi_returns_unique_results_when_matching_multiple_fields(): void + { + $client = Client::create([ + 'first_names' => 'Alexander', + 'last_names' => 'Alexis', + ]); + + $results = Client::encryptedPrefixMulti(['first_names', 'last_names'], 'Alex') + ->get(); + + // Should only return the client once + $this->assertCount(1, $results); + $this->assertEquals($client->id, $results->first()->id); + } + + /** @test */ + public function exact_multi_returns_no_results_when_no_fields_match(): void + { + Client::create([ + 'first_names' => 'John', + 'last_names' => 'Doe', + ]); + + $results = Client::encryptedExactMulti(['first_names', 'last_names'], 'NonExistent') + ->get(); + + $this->assertCount(0, $results); + } + + /** @test */ + public function prefix_multi_returns_no_results_when_no_fields_match(): void + { + Client::create([ + 'first_names' => 'John', + 'last_names' => 'Doe', + ]); + + $results = Client::encryptedPrefixMulti(['first_names', 'last_names'], 'Xyz') + ->get(); + + $this->assertCount(0, $results); + } + + /** @test */ + public function exact_multi_with_empty_fields_array_returns_no_results(): void + { + Client::create([ + 'first_names' => 'John', + 'last_names' => 'Doe', + ]); + + $results = Client::encryptedExactMulti([], 'John')->get(); + + $this->assertCount(0, $results); + } + + /** @test */ + public function prefix_multi_with_empty_fields_array_returns_no_results(): void + { + Client::create([ + 'first_names' => 'John', + 'last_names' => 'Doe', + ]); + + $results = Client::encryptedPrefixMulti([], 'John')->get(); + + $this->assertCount(0, $results); + } + + /** @test */ + public function exact_multi_with_empty_search_term_returns_no_results(): void + { + Client::create([ + 'first_names' => 'John', + 'last_names' => 'Doe', + ]); + + $results = Client::encryptedExactMulti(['first_names', 'last_names'], '')->get(); + + $this->assertCount(0, $results); + } + + /** @test */ + public function prefix_multi_with_empty_search_term_returns_no_results(): void + { + Client::create([ + 'first_names' => 'John', + 'last_names' => 'Doe', + ]); + + $results = Client::encryptedPrefixMulti(['first_names', 'last_names'], '')->get(); + + $this->assertCount(0, $results); + } + + /** @test */ + public function exact_multi_search_is_case_insensitive(): void + { + $client = Client::create([ + 'first_names' => 'JOHN', + 'last_names' => 'doe', + ]); + + $results = Client::encryptedExactMulti(['first_names', 'last_names'], 'john') + ->pluck('id') + ->toArray(); + + $this->assertContains($client->id, $results); + } + + /** @test */ + public function prefix_multi_search_is_case_insensitive(): void + { + $client = Client::create([ + 'first_names' => 'JOHN', + 'last_names' => 'DOE', + ]); + + $results = Client::encryptedPrefixMulti(['first_names', 'last_names'], 'joh') + ->pluck('id') + ->toArray(); + + $this->assertContains($client->id, $results); + } + + /** @test */ + public function exact_multi_handles_diacritics_consistently(): void + { + $client = Client::create([ + 'first_names' => 'José', + 'last_names' => 'García', + ]); + + $results = Client::encryptedExactMulti(['first_names', 'last_names'], 'Jose') + ->pluck('id') + ->toArray(); + + $this->assertContains($client->id, $results); + } + + /** @test */ + public function prefix_multi_handles_diacritics_consistently(): void + { + $client = Client::create([ + 'first_names' => 'José', + 'last_names' => 'García', + ]); + + $results = Client::encryptedPrefixMulti(['first_names', 'last_names'], 'Jos') + ->pluck('id') + ->toArray(); + + $this->assertContains($client->id, $results); + } + + /** @test */ + public function prefix_multi_respects_minimum_length_requirement(): void + { + config(['encrypted-search.min_prefix_length' => 3]); + + $client = Client::create([ + 'first_names' => 'John', + 'last_names' => 'Doe', + ]); + + // Search with 2 characters (below minimum) + $results = Client::encryptedPrefixMulti(['first_names', 'last_names'], 'Jo')->get(); + $this->assertCount(0, $results); + + // Search with 3 characters (at minimum) + $results = Client::encryptedPrefixMulti(['first_names', 'last_names'], 'Joh')->get(); + $this->assertCount(1, $results); + } + + /** @test */ + public function exact_multi_can_search_single_field(): void + { + $client = Client::create([ + 'first_names' => 'John', + 'last_names' => 'Doe', + ]); + + // Multi-field with single field should work the same as regular exact + $results = Client::encryptedExactMulti(['first_names'], 'John') + ->pluck('id') + ->toArray(); + + $this->assertContains($client->id, $results); + } + + /** @test */ + public function prefix_multi_can_search_single_field(): void + { + $client = Client::create([ + 'first_names' => 'John', + 'last_names' => 'Doe', + ]); + + // Multi-field with single field should work the same as regular prefix + $results = Client::encryptedPrefixMulti(['first_names'], 'Joh') + ->pluck('id') + ->toArray(); + + $this->assertContains($client->id, $results); + } +}