From c31426a220ee97e368aa63dcae360f1d28e4906c Mon Sep 17 00:00:00 2001 From: Wietse van Ginkel Date: Mon, 13 Oct 2025 14:24:14 +0200 Subject: [PATCH 1/5] add missing dependencies for Laravel 9 and 10 compatibility Fixes test failures in Laravel 9 and 10 by adding required dependencies: - guzzlehttp/guzzle: Required by Laravel 9's HTTP client for mocking HTTP requests in tests - doctrine/dbal: Required for Schema::table()->change() operations in migrations/tests Both dependencies use version constraints compatible across all supported Laravel versions (9-12). Tested with: - Laravel 9 + PHP 8.1: All tests pass - Laravel 10 + PHP 8.2: All tests pass Fixes #25 --- composer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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": { From b00054cffae5e21317d06267ca7fe9d907f526b0 Mon Sep 17 00:00:00 2001 From: Wietse van Ginkel Date: Mon, 13 Oct 2025 14:37:43 +0200 Subject: [PATCH 2/5] add Claude Code project configuration Adds Claude Code project instructions and commands to improve development workflow: Changes: - Add .claude/project-instructions.md with PHP 8.4 and Laravel 12 standards - Add .claude/commands/test.md for quick test execution - Update .gitignore to exclude .claude/settings.local.json (user-specific) Project instructions include: - Git branch naming conventions (feature/, fix/, etc.) - Development philosophy: work in small steps, ask before major changes - Never auto-commit/push without explicit user request - PHP 8.4 and Laravel 12 standards - Dutch communication preference --- .claude/commands/test.md | 1 + .claude/project-instructions.md | 98 +++++++++++++++++++++++++++++++++ .gitignore | 3 + 3 files changed, 102 insertions(+) create mode 100644 .claude/commands/test.md create mode 100644 .claude/project-instructions.md diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 0000000..a9f7f6e --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1 @@ +Run all PHPUnit tests with testdox output and show results \ No newline at end of file diff --git a/.claude/project-instructions.md b/.claude/project-instructions.md new file mode 100644 index 0000000..a4829c3 --- /dev/null +++ b/.claude/project-instructions.md @@ -0,0 +1,98 @@ +# Laravel Encrypted Search Index - Project Instructions + +## Development Philosophy + +Je bent een PHP 8.4 ontwikkelaar die werkt in **kleine, gecontroleerde stapjes**. Implementeer nooit grote wijzigingen zonder expliciete goedkeuring van de gebruiker. + +### Werkwijze +- ✅ Werk in kleine, overzichtelijke stappen +- ✅ Vraag altijd om bevestiging voordat je grote wijzigingen doorvoert +- ✅ Leg elke stap uit voordat je deze uitvoert +- ❌ Ga NIET op eigen houtje grote refactorings of features implementeren +- ❌ Commit en push NOOIT zonder dat de gebruiker daar expliciet om vraagt + +## Git Workflow + +### Branch Naming Convention +Gebruik ALTIJD de volgende branch prefixes: +- `feature/` - Voor nieuwe functionaliteit +- `fix/` - Voor bugfixes +- `refactor/` - Voor code refactoring +- `test/` - Voor test-gerelateerde wijzigingen +- `docs/` - Voor documentatie updates +- `chore/` - Voor maintenance taken + +Bijvoorbeeld: +- `feature/search-result-caching` +- `fix/doctrine-dbal-compatibility` +- `test/expand-test-coverage` + +### Commits en Pushes +- **NOOIT** automatisch committen of pushen +- Wacht ALTIJD op expliciete instructie van de gebruiker +- Als de gebruiker zegt "commit" of "push", vraag dan eerst om bevestiging van de commit message + +## Code Standards + +### PHP Version +- Target: **PHP 8.4** +- Gebruik moderne PHP 8.4 features waar mogelijk +- Zorg dat code compatible is met PHP 8.1+ voor backwards compatibility + +### Laravel Version +- Primary target: **Laravel 12** +- Support: Laravel 9, 10, 11, 12 +- Raadpleeg Laravel 12 documentatie voor best practices + +### Code Quality +- Type hints: Gebruik altijd strict types en return types +- DocBlocks: Voeg toe voor alle public/protected methods +- Tests: Schrijf tests voor nieuwe functionaliteit +- PSR-12: Volg PSR-12 coding standards + +## Project Context + +Dit is een Laravel package voor **encrypted search functionaliteit**: +- Privacy-preserving fulltext en prefix search +- Encryptie van zoektermen via SHA-256 hashing met pepper +- Support voor Eloquent models via trait +- Optionele Elasticsearch integratie +- Database-based fallback + +### Key Components +- `HasEncryptedSearchIndex` trait voor models +- `EncryptedSearchService` voor indexering +- `ElasticsearchService` voor Elasticsearch integratie +- `SearchCacheService` voor caching van zoekresultaten +- Token generation via `Tokens` utility class + +## Testing + +### Test Commands +- Run all tests: `vendor/bin/phpunit --testdox` +- Run specific test: `vendor/bin/phpunit --filter TestClassName` + +### Test Coverage +- Feature tests in `tests/Feature/` +- Unit tests in `tests/Unit/` +- Gebruik Orchestra Testbench voor package testing + +## Dependencies + +### Required +- PHP 8.1+ +- Laravel 9+ +- ext-intl (for text normalization) +- guzzlehttp/guzzle ^7.2 (for HTTP mocking in tests) + +### Development +- PHPUnit 9.5.10+ / 10+ / 11+ +- Orchestra Testbench +- doctrine/dbal ^3.0 (for schema changes in tests) + +## Communication Style + +- Communiceer in het Nederlands met de gebruiker +- Wees beknopt en to-the-point +- Gebruik GEEN emoji's tenzij expliciet gevraagd +- Leg technische keuzes altijd uit diff --git a/.gitignore b/.gitignore index 60f5f24..40c5a6f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ pnpm-debug.log .DS_Store Thumbs.db +# Claude Code +.claude/settings.local.json + # Environment & local config .env .env.* From 78d9ff4fd2dfb384cc5a23f5da14d9339e995196 Mon Sep 17 00:00:00 2001 From: Wietse van Ginkel Date: Mon, 13 Oct 2025 14:58:52 +0200 Subject: [PATCH 3/5] add multi-field search functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ability to search across multiple encrypted fields simultaneously using OR logic. New Features: - scopeEncryptedExactMulti(): exact match across multiple fields - scopeEncryptedPrefixMulti(): prefix match across multiple fields - searchElasticsearchMulti(): Elasticsearch support for multi-field queries Key Features: - OR logic: matches if term found in ANY of the specified fields - Automatic deduplication of results - Works with both Database and Elasticsearch backends - Same normalization and validation as single-field search Tests: - 17 comprehensive feature tests covering all edge cases - Tests for empty inputs, case sensitivity, diacritics, minimum length - All tests passing ✅ Documentation: - README.md updated with multi-field search examples and use cases - Clear distinction between single-field and multi-field search - Usage examples for common scenarios Example usage: ```php // Search 'John' in first_names OR last_names Client::encryptedExactMulti(['first_names', 'last_names'], 'John')->get(); // Prefix search 'Wie' across multiple fields Client::encryptedPrefixMulti(['first_names', 'last_names'], 'Wie')->get(); ``` --- .claude/project-instructions.md | 14 +- README.md | 29 +- src/Traits/HasEncryptedSearchIndex.php | 149 ++++++++++ tests/Feature/MultiFieldSearchTest.php | 364 +++++++++++++++++++++++++ 4 files changed, 548 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/MultiFieldSearchTest.php diff --git a/.claude/project-instructions.md b/.claude/project-instructions.md index a4829c3..8cb25c1 100644 --- a/.claude/project-instructions.md +++ b/.claude/project-instructions.md @@ -5,11 +5,14 @@ Je bent een PHP 8.4 ontwikkelaar die werkt in **kleine, gecontroleerde stapjes**. Implementeer nooit grote wijzigingen zonder expliciete goedkeuring van de gebruiker. ### Werkwijze -- ✅ Werk in kleine, overzichtelijke stappen -- ✅ Vraag altijd om bevestiging voordat je grote wijzigingen doorvoert -- ✅ Leg elke stap uit voordat je deze uitvoert -- ❌ Ga NIET op eigen houtje grote refactorings of features implementeren -- ❌ Commit en push NOOIT zonder dat de gebruiker daar expliciet om vraagt +- Werk in kleine, overzichtelijke stappen +- Vraag altijd om bevestiging voordat je grote wijzigingen doorvoert +- Leg elke stap uit voordat je deze uitvoert +- PSR-12 hanteren +- Voor iedere feature moet er ook een unit test geschreven worden +- Documentatie (README.md) moet bij iedere commit gecontrolleerd en waar nodig bijgewerkt worden +- Ga NIET op eigen houtje grote refactorings of features implementeren +- Commit en push NOOIT zonder dat de gebruiker daar expliciet om vraagt ## Git Workflow @@ -91,7 +94,6 @@ Dit is een Laravel package voor **encrypted search functionaliteit**: - doctrine/dbal ^3.0 (for schema changes in tests) ## Communication Style - - Communiceer in het Nederlands met de gebruiker - Wees beknopt en to-the-point - Gebruik GEEN emoji's tenzij expliciet gevraagd diff --git a/README.md b/README.md index 117d237..4e75b51 100644 --- a/README.md +++ b/README.md @@ -222,13 +222,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/src/Traits/HasEncryptedSearchIndex.php b/src/Traits/HasEncryptedSearchIndex.php index 211b122..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. * @@ -317,6 +360,65 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term }); } + /** + * Scope: query models by prefix-based encrypted token match across multiple fields. + * + * Searches for a prefix match in any of the specified fields (OR logic). + * + * @param Builder $query + * @param array $fields + * @param string $term + * @return 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'); + } + + // 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 + ); + + // 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)) { + $modelIds = $this->searchElasticsearchMulti($fields, $tokens, 'prefix'); + return $query->whereIn($this->getQualifiedKeyName(), $modelIds); + } + + // 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) + ->whereIn('field', $fields) + ->where('type', 'prefix') + ->whereIn('token', $tokens) + ->distinct(); + }); + } + /** * Check if a field has an encrypted cast. * @@ -381,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); + } +} From e8304d1fa231da04ffca337c2082bff63ed2d4e5 Mon Sep 17 00:00:00 2001 From: Wietse van Ginkel Date: Mon, 13 Oct 2025 15:02:47 +0200 Subject: [PATCH 4/5] update .gitignore to exclude entire .claude directory --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 40c5a6f..cd5e067 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,7 @@ pnpm-debug.log Thumbs.db # Claude Code -.claude/settings.local.json +.claude/ # Environment & local config .env From 6065c0252c7edb8b2596007f98e4cec7aa538884 Mon Sep 17 00:00:00 2001 From: Wietse van Ginkel Date: Mon, 13 Oct 2025 15:04:06 +0200 Subject: [PATCH 5/5] remove .claude directory from version control --- .claude/commands/test.md | 1 - .claude/project-instructions.md | 100 -------------------------------- 2 files changed, 101 deletions(-) delete mode 100644 .claude/commands/test.md delete mode 100644 .claude/project-instructions.md diff --git a/.claude/commands/test.md b/.claude/commands/test.md deleted file mode 100644 index a9f7f6e..0000000 --- a/.claude/commands/test.md +++ /dev/null @@ -1 +0,0 @@ -Run all PHPUnit tests with testdox output and show results \ No newline at end of file diff --git a/.claude/project-instructions.md b/.claude/project-instructions.md deleted file mode 100644 index 8cb25c1..0000000 --- a/.claude/project-instructions.md +++ /dev/null @@ -1,100 +0,0 @@ -# Laravel Encrypted Search Index - Project Instructions - -## Development Philosophy - -Je bent een PHP 8.4 ontwikkelaar die werkt in **kleine, gecontroleerde stapjes**. Implementeer nooit grote wijzigingen zonder expliciete goedkeuring van de gebruiker. - -### Werkwijze -- Werk in kleine, overzichtelijke stappen -- Vraag altijd om bevestiging voordat je grote wijzigingen doorvoert -- Leg elke stap uit voordat je deze uitvoert -- PSR-12 hanteren -- Voor iedere feature moet er ook een unit test geschreven worden -- Documentatie (README.md) moet bij iedere commit gecontrolleerd en waar nodig bijgewerkt worden -- Ga NIET op eigen houtje grote refactorings of features implementeren -- Commit en push NOOIT zonder dat de gebruiker daar expliciet om vraagt - -## Git Workflow - -### Branch Naming Convention -Gebruik ALTIJD de volgende branch prefixes: -- `feature/` - Voor nieuwe functionaliteit -- `fix/` - Voor bugfixes -- `refactor/` - Voor code refactoring -- `test/` - Voor test-gerelateerde wijzigingen -- `docs/` - Voor documentatie updates -- `chore/` - Voor maintenance taken - -Bijvoorbeeld: -- `feature/search-result-caching` -- `fix/doctrine-dbal-compatibility` -- `test/expand-test-coverage` - -### Commits en Pushes -- **NOOIT** automatisch committen of pushen -- Wacht ALTIJD op expliciete instructie van de gebruiker -- Als de gebruiker zegt "commit" of "push", vraag dan eerst om bevestiging van de commit message - -## Code Standards - -### PHP Version -- Target: **PHP 8.4** -- Gebruik moderne PHP 8.4 features waar mogelijk -- Zorg dat code compatible is met PHP 8.1+ voor backwards compatibility - -### Laravel Version -- Primary target: **Laravel 12** -- Support: Laravel 9, 10, 11, 12 -- Raadpleeg Laravel 12 documentatie voor best practices - -### Code Quality -- Type hints: Gebruik altijd strict types en return types -- DocBlocks: Voeg toe voor alle public/protected methods -- Tests: Schrijf tests voor nieuwe functionaliteit -- PSR-12: Volg PSR-12 coding standards - -## Project Context - -Dit is een Laravel package voor **encrypted search functionaliteit**: -- Privacy-preserving fulltext en prefix search -- Encryptie van zoektermen via SHA-256 hashing met pepper -- Support voor Eloquent models via trait -- Optionele Elasticsearch integratie -- Database-based fallback - -### Key Components -- `HasEncryptedSearchIndex` trait voor models -- `EncryptedSearchService` voor indexering -- `ElasticsearchService` voor Elasticsearch integratie -- `SearchCacheService` voor caching van zoekresultaten -- Token generation via `Tokens` utility class - -## Testing - -### Test Commands -- Run all tests: `vendor/bin/phpunit --testdox` -- Run specific test: `vendor/bin/phpunit --filter TestClassName` - -### Test Coverage -- Feature tests in `tests/Feature/` -- Unit tests in `tests/Unit/` -- Gebruik Orchestra Testbench voor package testing - -## Dependencies - -### Required -- PHP 8.1+ -- Laravel 9+ -- ext-intl (for text normalization) -- guzzlehttp/guzzle ^7.2 (for HTTP mocking in tests) - -### Development -- PHPUnit 9.5.10+ / 10+ / 11+ -- Orchestra Testbench -- doctrine/dbal ^3.0 (for schema changes in tests) - -## Communication Style -- Communiceer in het Nederlands met de gebruiker -- Wees beknopt en to-the-point -- Gebruik GEEN emoji's tenzij expliciet gevraagd -- Leg technische keuzes altijd uit