Skip to content

Commit e3d8930

Browse files
Merge pull request #31 from ginkelsoft-development/develop
develop into main
2 parents 5e8e9a2 + 41bf9ac commit e3d8930

File tree

5 files changed

+514
-135
lines changed

5 files changed

+514
-135
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ pnpm-debug.log
1818
.DS_Store
1919
Thumbs.db
2020

21+
# Claude Code
22+
.claude/
23+
2124
# Environment & local config
2225
.env
2326
.env.*

README.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,13 +278,38 @@ When a record is saved, searchable tokens are automatically generated in `encryp
278278

279279
### Searching
280280

281+
#### Single Field Search
282+
281283
```php
282-
// Exact match
284+
// Exact match on a single field
283285
$clients = Client::encryptedExact('last_names', 'Vermeer')->get();
284286

285-
// Prefix match
287+
// Prefix match on a single field
286288
$clients = Client::encryptedPrefix('first_names', 'Wie')->get();
287289
```
290+
291+
#### Multi-Field Search
292+
293+
Search across multiple fields simultaneously using OR logic:
294+
295+
```php
296+
// Exact match across multiple fields
297+
// Finds records where 'John' appears in first_names OR last_names
298+
$clients = Client::encryptedExactMulti(['first_names', 'last_names'], 'John')->get();
299+
300+
// Prefix match across multiple fields
301+
// Finds records where 'Wie' is a prefix of first_names OR last_names
302+
$clients = Client::encryptedPrefixMulti(['first_names', 'last_names'], 'Wie')->get();
303+
```
304+
305+
**Use cases for multi-field search:**
306+
- Search for a name that could be in either first name or last name fields
307+
- Search across multiple encrypted fields without multiple queries
308+
- Implement autocomplete across multiple fields
309+
- Unified search experience across related fields
310+
311+
**Note:** Multi-field searches automatically deduplicate results, so if a record matches in multiple fields, it will only appear once in the results.
312+
288313
Attributes always override global or $encryptedSearch configuration for the same field.
289314

290315
---

composer.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
"require": {
2222
"php": "^8.1 || ^8.2 || ^8.3 || ^8.4",
2323
"illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0",
24-
"ext-intl": "*"
24+
"ext-intl": "*",
25+
"guzzlehttp/guzzle": "^7.2"
2526
},
2627
"require-dev": {
2728
"phpunit/phpunit": "^9.5.10 || ^10.0 || ^11.0",
28-
"orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0"
29+
"orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0",
30+
"doctrine/dbal": "^3.0"
2931
},
3032
"autoload": {
3133
"psr-4": {

src/Traits/HasEncryptedSearchIndex.php

Lines changed: 116 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,49 @@ public function scopeEncryptedExact(Builder $query, string $field, string $term)
265265
});
266266
}
267267

268+
/**
269+
* Scope: query models by exact encrypted token match across multiple fields.
270+
*
271+
* Searches for an exact match in any of the specified fields (OR logic).
272+
*
273+
* @param Builder $query
274+
* @param array<int, string> $fields
275+
* @param string $term
276+
* @return Builder
277+
*/
278+
public function scopeEncryptedExactMulti(Builder $query, array $fields, string $term): Builder
279+
{
280+
if (empty($fields)) {
281+
return $query->whereRaw('1=0');
282+
}
283+
284+
$pepper = (string) config('encrypted-search.search_pepper', '');
285+
$normalized = Normalizer::normalize($term);
286+
287+
if (!$normalized) {
288+
return $query->whereRaw('1=0');
289+
}
290+
291+
$token = Tokens::exact($normalized, $pepper);
292+
293+
// Check if Elasticsearch is enabled
294+
if (config('encrypted-search.elasticsearch.enabled', false)) {
295+
$modelIds = $this->searchElasticsearchMulti($fields, $token, 'exact');
296+
return $query->whereIn($this->getQualifiedKeyName(), $modelIds);
297+
}
298+
299+
// Fallback to database - use OR logic for multiple fields
300+
return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $token) {
301+
$sub->select('model_id')
302+
->from('encrypted_search_index')
303+
->where('model_type', static::class)
304+
->whereIn('field', $fields)
305+
->where('type', 'exact')
306+
->where('token', $token)
307+
->distinct();
308+
});
309+
}
310+
268311
/**
269312
* Scope: query models by prefix-based encrypted token match.
270313
*
@@ -318,169 +361,64 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term
318361
}
319362

320363
/**
321-
* Scope: search across multiple fields with OR logic (any field matches).
364+
* Scope: query models by prefix-based encrypted token match across multiple fields.
322365
*
323-
* Efficiently searches multiple fields for the same term in a single query.
324-
* Returns models where at least one field matches.
325-
*
326-
* Example:
327-
* Client::encryptedSearchAny(['first_names', 'last_names'], 'John', 'exact')->get();
366+
* Searches for a prefix match in any of the specified fields (OR logic).
328367
*
329368
* @param Builder $query
330-
* @param array<int, string> $fields Array of field names to search
331-
* @param string $term Search term
332-
* @param string $type Search type: 'exact' or 'prefix'
369+
* @param array<int, string> $fields
370+
* @param string $term
333371
* @return Builder
334372
*/
335-
public function scopeEncryptedSearchAny(Builder $query, array $fields, string $term, string $type = 'exact'): Builder
373+
public function scopeEncryptedPrefixMulti(Builder $query, array $fields, string $term): Builder
336374
{
337375
if (empty($fields)) {
338376
return $query->whereRaw('1=0');
339377
}
340378

341379
$pepper = (string) config('encrypted-search.search_pepper', '');
380+
$minLength = (int) config('encrypted-search.min_prefix_length', 1);
342381
$normalized = Normalizer::normalize($term);
343382

344383
if (!$normalized) {
345384
return $query->whereRaw('1=0');
346385
}
347386

348-
// Generate tokens based on search type
349-
if ($type === 'prefix') {
350-
$minLength = (int) config('encrypted-search.min_prefix_length', 1);
351-
352-
if (mb_strlen($normalized, 'UTF-8') < $minLength) {
353-
return $query->whereRaw('1=0');
354-
}
387+
// Check if search term meets minimum length requirement
388+
if (mb_strlen($normalized, 'UTF-8') < $minLength) {
389+
return $query->whereRaw('1=0');
390+
}
355391

356-
$tokens = Tokens::prefixes(
357-
$normalized,
358-
(int) config('encrypted-search.max_prefix_depth', 6),
359-
$pepper,
360-
$minLength
361-
);
392+
$tokens = Tokens::prefixes(
393+
$normalized,
394+
(int) config('encrypted-search.max_prefix_depth', 6),
395+
$pepper,
396+
$minLength
397+
);
362398

363-
if (empty($tokens)) {
364-
return $query->whereRaw('1=0');
365-
}
366-
} else {
367-
$tokens = [Tokens::exact($normalized, $pepper)];
399+
// If no tokens generated (term too short), return no results
400+
if (empty($tokens)) {
401+
return $query->whereRaw('1=0');
368402
}
369403

370404
// Check if Elasticsearch is enabled
371405
if (config('encrypted-search.elasticsearch.enabled', false)) {
372-
$allModelIds = [];
373-
374-
foreach ($fields as $field) {
375-
$modelIds = $this->searchElasticsearch($field, $tokens, $type);
376-
$allModelIds = array_merge($allModelIds, $modelIds);
377-
}
378-
379-
return $query->whereIn($this->getQualifiedKeyName(), array_unique($allModelIds));
406+
$modelIds = $this->searchElasticsearchMulti($fields, $tokens, 'prefix');
407+
return $query->whereIn($this->getQualifiedKeyName(), $modelIds);
380408
}
381409

382-
// Fallback to database - use OR conditions
383-
return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $tokens, $type) {
410+
// Fallback to database - use OR logic for multiple fields
411+
return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $tokens) {
384412
$sub->select('model_id')
385413
->from('encrypted_search_index')
386414
->where('model_type', static::class)
387-
->where('type', $type)
388415
->whereIn('field', $fields)
389-
->whereIn('token', $tokens);
416+
->where('type', 'prefix')
417+
->whereIn('token', $tokens)
418+
->distinct();
390419
});
391420
}
392421

393-
/**
394-
* Scope: search across multiple fields with AND logic (all fields must match).
395-
*
396-
* Returns models where ALL specified fields match their respective terms.
397-
*
398-
* Example:
399-
* Client::encryptedSearchAll([
400-
* 'first_names' => 'John',
401-
* 'last_names' => 'Doe'
402-
* ], 'exact')->get();
403-
*
404-
* @param Builder $query
405-
* @param array<string, string> $fieldTerms Associative array of field => term
406-
* @param string $type Search type: 'exact' or 'prefix'
407-
* @return Builder
408-
*/
409-
public function scopeEncryptedSearchAll(Builder $query, array $fieldTerms, string $type = 'exact'): Builder
410-
{
411-
if (empty($fieldTerms)) {
412-
return $query->whereRaw('1=0');
413-
}
414-
415-
$pepper = (string) config('encrypted-search.search_pepper', '');
416-
$minLength = (int) config('encrypted-search.min_prefix_length', 1);
417-
$maxDepth = (int) config('encrypted-search.max_prefix_depth', 6);
418-
419-
// Check if Elasticsearch is enabled
420-
if (config('encrypted-search.elasticsearch.enabled', false)) {
421-
// Start with all IDs, then intersect
422-
$resultIds = null;
423-
424-
foreach ($fieldTerms as $field => $term) {
425-
$normalized = Normalizer::normalize($term);
426-
427-
if (!$normalized || ($type === 'prefix' && mb_strlen($normalized, 'UTF-8') < $minLength)) {
428-
return $query->whereRaw('1=0');
429-
}
430-
431-
$tokens = $type === 'prefix'
432-
? Tokens::prefixes($normalized, $maxDepth, $pepper, $minLength)
433-
: [Tokens::exact($normalized, $pepper)];
434-
435-
if (empty($tokens)) {
436-
return $query->whereRaw('1=0');
437-
}
438-
439-
$modelIds = $this->searchElasticsearch($field, $tokens, $type);
440-
441-
if ($resultIds === null) {
442-
$resultIds = $modelIds;
443-
} else {
444-
$resultIds = array_intersect($resultIds, $modelIds);
445-
}
446-
447-
if (empty($resultIds)) {
448-
return $query->whereRaw('1=0');
449-
}
450-
}
451-
452-
return $query->whereIn($this->getQualifiedKeyName(), $resultIds);
453-
}
454-
455-
// Fallback to database - use nested queries with intersections
456-
foreach ($fieldTerms as $field => $term) {
457-
$normalized = Normalizer::normalize($term);
458-
459-
if (!$normalized || ($type === 'prefix' && mb_strlen($normalized, 'UTF-8') < $minLength)) {
460-
return $query->whereRaw('1=0');
461-
}
462-
463-
$tokens = $type === 'prefix'
464-
? Tokens::prefixes($normalized, $maxDepth, $pepper, $minLength)
465-
: [Tokens::exact($normalized, $pepper)];
466-
467-
if (empty($tokens)) {
468-
return $query->whereRaw('1=0');
469-
}
470-
471-
$query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($field, $tokens, $type) {
472-
$sub->select('model_id')
473-
->from('encrypted_search_index')
474-
->where('model_type', static::class)
475-
->where('field', $field)
476-
->where('type', $type)
477-
->whereIn('token', $tokens);
478-
});
479-
}
480-
481-
return $query;
482-
}
483-
484422
/**
485423
* Check if a field has an encrypted cast.
486424
*
@@ -545,6 +483,53 @@ protected function searchElasticsearch(string $field, $tokens, string $type): ar
545483
}
546484
}
547485

486+
/**
487+
* Search for model IDs in Elasticsearch based on token(s) across multiple fields.
488+
*
489+
* @param array<int, string> $fields
490+
* @param string|array<int, string> $tokens Single token or array of tokens
491+
* @param string $type Either 'exact' or 'prefix'
492+
* @return array<int, mixed> Array of model IDs
493+
*/
494+
protected function searchElasticsearchMulti(array $fields, $tokens, string $type): array
495+
{
496+
$index = config('encrypted-search.elasticsearch.index', 'encrypted_search');
497+
$service = app(ElasticsearchService::class);
498+
499+
// Normalize tokens to array
500+
$tokenArray = is_array($tokens) ? $tokens : [$tokens];
501+
502+
// Build Elasticsearch query with multiple fields (OR logic)
503+
$query = [
504+
'query' => [
505+
'bool' => [
506+
'must' => [
507+
['term' => ['model_type.keyword' => static::class]],
508+
['terms' => ['field.keyword' => $fields]],
509+
['term' => ['type.keyword' => $type]],
510+
['terms' => ['token.keyword' => $tokenArray]],
511+
],
512+
],
513+
],
514+
'_source' => ['model_id'],
515+
'size' => 10000,
516+
];
517+
518+
try {
519+
$results = $service->search($index, $query);
520+
521+
// Extract unique model IDs from results
522+
return collect($results)
523+
->pluck('_source.model_id')
524+
->unique()
525+
->values()
526+
->toArray();
527+
} catch (\Throwable $e) {
528+
logger()->warning('[EncryptedSearch] Elasticsearch multi-field search failed: ' . $e->getMessage());
529+
return [];
530+
}
531+
}
532+
548533
/**
549534
* Resolve the encrypted search configuration for this model.
550535
*

0 commit comments

Comments
 (0)