@@ -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 $ queryarray  $ fieldsstring  $ termBuilder 
279+     {
280+         if  (empty ($ fields
281+             return  $ querywhereRaw ('1=0 ' );
282+         }
283+ 
284+         $ pepperstring ) config ('encrypted-search.search_pepper ' , '' );
285+         $ normalizednormalize ($ term
286+ 
287+         if  (!$ normalized
288+             return  $ querywhereRaw ('1=0 ' );
289+         }
290+ 
291+         $ tokenexact ($ 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  $ querywhereIn ($ this getQualifiedKeyName (), $ modelIds
297+         }
298+ 
299+         // Fallback to database - use OR logic for multiple fields 
300+         return  $ querywhereIn ($ this getQualifiedKeyName (), function  ($ subuse  ($ fields$ token
301+             $ subselect ('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 $ queryarray  $ fieldsstring  $ term,  string   $ type  =  ' exact ' Builder 
373+     public  function  scopeEncryptedPrefixMulti (Builder $ queryarray  $ fieldsstring  $ termBuilder 
336374    {
337375        if  (empty ($ fields
338376            return  $ querywhereRaw ('1=0 ' );
339377        }
340378
341379        $ pepperstring ) config ('encrypted-search.search_pepper ' , '' );
380+         $ minLengthint ) config ('encrypted-search.min_prefix_length ' , 1 );
342381        $ normalizednormalize ($ term
343382
344383        if  (!$ normalized
345384            return  $ querywhereRaw ('1=0 ' );
346385        }
347386
348-         // Generate tokens based on search type 
349-         if  ($ type'prefix ' ) {
350-             $ minLengthint ) config ('encrypted-search.min_prefix_length ' , 1 );
351- 
352-             if  (mb_strlen ($ normalized'UTF-8 ' ) < $ minLength
353-                 return  $ querywhereRaw ('1=0 ' );
354-             }
387+         // Check if search term meets minimum length requirement 
388+         if  (mb_strlen ($ normalized'UTF-8 ' ) < $ minLength
389+             return  $ querywhereRaw ('1=0 ' );
390+         }
355391
356-              $ tokensprefixes (
357-                  $ normalized
358-                  (int ) config ('encrypted-search.max_prefix_depth ' , 6 ),
359-                  $ pepper
360-                  $ minLength
361-              );
392+         $ tokensprefixes (
393+             $ normalized
394+             (int ) config ('encrypted-search.max_prefix_depth ' , 6 ),
395+             $ pepper
396+             $ minLength
397+         );
362398
363-             if  (empty ($ tokens
364-                 return  $ querywhereRaw ('1=0 ' );
365-             }
366-         } else  {
367-             $ tokensexact ($ normalized$ pepper
399+         // If no tokens generated (term too short), return no results 
400+         if  (empty ($ tokens
401+             return  $ querywhereRaw ('1=0 ' );
368402        }
369403
370404        // Check if Elasticsearch is enabled 
371405        if  (config ('encrypted-search.elasticsearch.enabled ' , false )) {
372-             $ allModelIds
373- 
374-             foreach  ($ fieldsas  $ field
375-                 $ modelIds$ this searchElasticsearch ($ field$ tokens$ type
376-                 $ allModelIdsarray_merge ($ allModelIds$ modelIds
377-             }
378- 
379-             return  $ querywhereIn ($ this getQualifiedKeyName (), array_unique ($ allModelIds
406+             $ modelIds$ this searchElasticsearchMulti ($ fields$ tokens'prefix ' );
407+             return  $ querywhereIn ($ this getQualifiedKeyName (), $ modelIds
380408        }
381409
382-         // Fallback to database - use OR conditions  
383-         return  $ querywhereIn ($ this getQualifiedKeyName (), function  ($ subuse  ($ fields$ tokens,  $ type 
410+         // Fallback to database - use OR logic for multiple fields  
411+         return  $ querywhereIn ($ this getQualifiedKeyName (), function  ($ subuse  ($ fields$ tokens
384412            $ subselect ('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 $ queryarray  $ fieldTermsstring  $ type'exact ' ): Builder 
410-     {
411-         if  (empty ($ fieldTerms
412-             return  $ querywhereRaw ('1=0 ' );
413-         }
414- 
415-         $ pepperstring ) config ('encrypted-search.search_pepper ' , '' );
416-         $ minLengthint ) config ('encrypted-search.min_prefix_length ' , 1 );
417-         $ maxDepthint ) 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-             $ resultIdsnull ;
423- 
424-             foreach  ($ fieldTermsas  $ field$ term
425-                 $ normalizednormalize ($ term
426- 
427-                 if  (!$ normalized$ type'prefix '  && mb_strlen ($ normalized'UTF-8 ' ) < $ minLength
428-                     return  $ querywhereRaw ('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  $ querywhereRaw ('1=0 ' );
437-                 }
438- 
439-                 $ modelIds$ this searchElasticsearch ($ field$ tokens$ type
440- 
441-                 if  ($ resultIdsnull ) {
442-                     $ resultIds$ modelIds
443-                 } else  {
444-                     $ resultIdsarray_intersect ($ resultIds$ modelIds
445-                 }
446- 
447-                 if  (empty ($ resultIds
448-                     return  $ querywhereRaw ('1=0 ' );
449-                 }
450-             }
451- 
452-             return  $ querywhereIn ($ this getQualifiedKeyName (), $ resultIds
453-         }
454- 
455-         // Fallback to database - use nested queries with intersections 
456-         foreach  ($ fieldTermsas  $ field$ term
457-             $ normalizednormalize ($ term
458- 
459-             if  (!$ normalized$ type'prefix '  && mb_strlen ($ normalized'UTF-8 ' ) < $ minLength
460-                 return  $ querywhereRaw ('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  $ querywhereRaw ('1=0 ' );
469-             }
470- 
471-             $ querywhereIn ($ this getQualifiedKeyName (), function  ($ subuse  ($ field$ tokens$ type
472-                 $ subselect ('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$ tokensstring  $ typearray 
495+     {
496+         $ indexconfig ('encrypted-search.elasticsearch.index ' , 'encrypted_search ' );
497+         $ serviceapp (ElasticsearchService::class);
498+ 
499+         // Normalize tokens to array 
500+         $ tokenArrayis_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$ servicesearch ($ 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:  '  . $ egetMessage ());
529+             return  [];
530+         }
531+     }
532+ 
548533    /** 
549534     * Resolve the encrypted search configuration for this model. 
550535     * 
0 commit comments