@@ -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