diff --git a/src/Traits/HasEncryptedSearchIndex.php b/src/Traits/HasEncryptedSearchIndex.php index 211b122..2be409b 100644 --- a/src/Traits/HasEncryptedSearchIndex.php +++ b/src/Traits/HasEncryptedSearchIndex.php @@ -317,6 +317,170 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term }); } + /** + * Scope: search across multiple fields with OR logic (any field matches). + * + * 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(); + * + * @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' + * @return Builder + */ + public function scopeEncryptedSearchAny(Builder $query, array $fields, string $term, string $type = 'exact'): 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'); + } + + // 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'); + } + + $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)]; + } + + // 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)); + } + + // Fallback to database - use OR conditions + return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $tokens, $type) { + $sub->select('model_id') + ->from('encrypted_search_index') + ->where('model_type', static::class) + ->where('type', $type) + ->whereIn('field', $fields) + ->whereIn('token', $tokens); + }); + } + + /** + * 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. * diff --git a/tests/Feature/BatchQueryTest.php b/tests/Feature/BatchQueryTest.php new file mode 100644 index 0000000..98aeeb2 --- /dev/null +++ b/tests/Feature/BatchQueryTest.php @@ -0,0 +1,335 @@ +set('database.default', 'testing'); + config()->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + config()->set('encrypted-search.elasticsearch.enabled', false); + config()->set('encrypted-search.search_pepper', 'test-pepper-secret'); + config()->set('encrypted-search.min_prefix_length', 1); + + \Illuminate\Database\Eloquent\Model::unsetEventDispatcher(); + \Illuminate\Database\Eloquent\Model::setEventDispatcher(app('events')); + \Ginkelsoft\EncryptedSearch\Tests\Models\Client::boot(); + + 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 encryptedSearchAny with exact match (OR logic). + * + * @return void + */ + public function test_encrypted_search_any_with_exact_match(): void + { + Client::create(['first_names' => 'John', 'last_names' => 'Doe']); + Client::create(['first_names' => 'Jane', 'last_names' => 'Smith']); + Client::create(['first_names' => 'Bob', 'last_names' => 'John']); + + // Search for "John" in either first_names or last_names + $results = Client::encryptedSearchAny(['first_names', 'last_names'], 'John', 'exact')->get(); + + // Should find: John Doe (first_names) and Bob John (last_names) + $this->assertCount(2, $results); + $names = $results->pluck('first_names')->toArray(); + $this->assertContains('John', $names); + $this->assertContains('Bob', $names); + } + + /** + * Test encryptedSearchAny with prefix match (OR logic). + * + * @return void + */ + public function test_encrypted_search_any_with_prefix_match(): void + { + Client::create(['first_names' => 'Wietse', 'last_names' => 'van Ginkel']); + Client::create(['first_names' => 'John', 'last_names' => 'Williams']); + Client::create(['first_names' => 'Jane', 'last_names' => 'Wilson']); + + // Search for "Wi" prefix in either field + $results = Client::encryptedSearchAny(['first_names', 'last_names'], 'Wi', 'prefix')->get(); + + // Should find: Wietse (first_names), Williams (last_names), Wilson (last_names) + $this->assertCount(3, $results); + } + + /** + * Test encryptedSearchAll with exact match (AND logic). + * + * @return void + */ + public function test_encrypted_search_all_with_exact_match(): void + { + Client::create(['first_names' => 'John', 'last_names' => 'Doe']); + Client::create(['first_names' => 'John', 'last_names' => 'Smith']); + Client::create(['first_names' => 'Jane', 'last_names' => 'Doe']); + + // Search for John AND Doe + $results = Client::encryptedSearchAll([ + 'first_names' => 'John', + 'last_names' => 'Doe' + ], 'exact')->get(); + + // Should only find: John Doe + $this->assertCount(1, $results); + $this->assertEquals('John', $results->first()->first_names); + $this->assertEquals('Doe', $results->first()->last_names); + } + + /** + * Test encryptedSearchAll with prefix match (AND logic). + * + * @return void + */ + public function test_encrypted_search_all_with_prefix_match(): void + { + Client::create(['first_names' => 'John', 'last_names' => 'Doe']); + Client::create(['first_names' => 'Johnny', 'last_names' => 'Doyle']); + Client::create(['first_names' => 'Jane', 'last_names' => 'Smith']); + + // Search for "Jo" prefix in first_names AND "Do" prefix in last_names + $results = Client::encryptedSearchAll([ + 'first_names' => 'Jo', + 'last_names' => 'Do' + ], 'prefix')->get(); + + // Should find: John Doe, Johnny Doyle (both have "Jo" in first_names and "Do" in last_names) + // Jane Smith is excluded (no "Do" prefix in last_names) + $this->assertCount(2, $results); + $names = $results->pluck('first_names')->toArray(); + $this->assertContains('John', $names); + $this->assertContains('Johnny', $names); + } + + /** + * Test encryptedSearchAny with no matching results. + * + * @return void + */ + public function test_encrypted_search_any_with_no_results(): void + { + Client::create(['first_names' => 'John', 'last_names' => 'Doe']); + + $results = Client::encryptedSearchAny(['first_names', 'last_names'], 'NonExistent', 'exact')->get(); + + $this->assertCount(0, $results); + } + + /** + * Test encryptedSearchAll with no matching results. + * + * @return void + */ + public function test_encrypted_search_all_with_no_results(): void + { + Client::create(['first_names' => 'John', 'last_names' => 'Doe']); + + // John exists but Smith doesn't + $results = Client::encryptedSearchAll([ + 'first_names' => 'John', + 'last_names' => 'Smith' + ], 'exact')->get(); + + $this->assertCount(0, $results); + } + + /** + * Test encryptedSearchAny with empty fields array. + * + * @return void + */ + public function test_encrypted_search_any_with_empty_fields(): void + { + Client::create(['first_names' => 'John', 'last_names' => 'Doe']); + + $results = Client::encryptedSearchAny([], 'John', 'exact')->get(); + + $this->assertCount(0, $results); + } + + /** + * Test encryptedSearchAll with empty field terms. + * + * @return void + */ + public function test_encrypted_search_all_with_empty_field_terms(): void + { + Client::create(['first_names' => 'John', 'last_names' => 'Doe']); + + $results = Client::encryptedSearchAll([], 'exact')->get(); + + $this->assertCount(0, $results); + } + + /** + * Test encryptedSearchAny with single field (should work like regular search). + * + * @return void + */ + public function test_encrypted_search_any_with_single_field(): void + { + Client::create(['first_names' => 'John', 'last_names' => 'Doe']); + Client::create(['first_names' => 'Jane', 'last_names' => 'Smith']); + + $results = Client::encryptedSearchAny(['first_names'], 'John', 'exact')->get(); + + $this->assertCount(1, $results); + $this->assertEquals('John', $results->first()->first_names); + } + + /** + * Test encryptedSearchAll with single field. + * + * @return void + */ + public function test_encrypted_search_all_with_single_field(): void + { + Client::create(['first_names' => 'John', 'last_names' => 'Doe']); + Client::create(['first_names' => 'Jane', 'last_names' => 'Smith']); + + $results = Client::encryptedSearchAll(['first_names' => 'John'], 'exact')->get(); + + $this->assertCount(1, $results); + $this->assertEquals('John', $results->first()->first_names); + } + + /** + * Test that encryptedSearchAny is more efficient than multiple OR queries. + * + * @return void + */ + public function test_encrypted_search_any_efficiency(): void + { + // Create test data + for ($i = 0; $i < 50; $i++) { + Client::create([ + 'first_names' => 'Name' . $i, + 'last_names' => 'Last' . $i + ]); + } + + // Single batch query + $startTime = microtime(true); + $results1 = Client::encryptedSearchAny(['first_names', 'last_names'], 'Name1', 'exact')->get(); + $batchTime = microtime(true) - $startTime; + + // Multiple individual queries + $startTime = microtime(true); + $results2 = Client::where(function ($query) { + $query->encryptedExact('first_names', 'Name1') + ->orWhere(function ($q) { + $q->encryptedExact('last_names', 'Name1'); + }); + })->get(); + $individualTime = microtime(true) - $startTime; + + // Both should return same results + $this->assertEquals($results1->count(), $results2->count()); + + // Batch query should be faster (or at least not significantly slower) + // We don't assert this strictly as it depends on system performance + $this->assertLessThanOrEqual($individualTime * 2, $batchTime); + } + + /** + * Test encryptedSearchAny respects minimum prefix length. + * + * @return void + */ + public function test_encrypted_search_any_respects_min_prefix_length(): void + { + config()->set('encrypted-search.min_prefix_length', 3); + + Client::create(['first_names' => 'Wilma', 'last_names' => 'Jansen']); + + // Should return no results (too short) + $results = Client::encryptedSearchAny(['first_names', 'last_names'], 'Wi', 'prefix')->get(); + $this->assertCount(0, $results); + + // Should work (meets minimum) + $results = Client::encryptedSearchAny(['first_names', 'last_names'], 'Wil', 'prefix')->get(); + $this->assertCount(1, $results); + } + + /** + * Test encryptedSearchAll respects minimum prefix length. + * + * @return void + */ + public function test_encrypted_search_all_respects_min_prefix_length(): void + { + config()->set('encrypted-search.min_prefix_length', 3); + + Client::create(['first_names' => 'Wilma', 'last_names' => 'Jansen']); + + // Should return no results (first_names too short) + $results = Client::encryptedSearchAll([ + 'first_names' => 'Wi', + 'last_names' => 'Jan' + ], 'prefix')->get(); + $this->assertCount(0, $results); + + // Should work (both meet minimum) + $results = Client::encryptedSearchAll([ + 'first_names' => 'Wil', + 'last_names' => 'Jan' + ], 'prefix')->get(); + $this->assertCount(1, $results); + } +}