@@ -317,6 +317,170 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term
317317 });
318318 }
319319
320+ /**
321+ * Scope: search across multiple fields with OR logic (any field matches).
322+ *
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();
328+ *
329+ * @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'
333+ * @return Builder
334+ */
335+ public function scopeEncryptedSearchAny (Builder $ query , array $ fields , string $ term , string $ type = 'exact ' ): Builder
336+ {
337+ if (empty ($ fields )) {
338+ return $ query ->whereRaw ('1=0 ' );
339+ }
340+
341+ $ pepper = (string ) config ('encrypted-search.search_pepper ' , '' );
342+ $ normalized = Normalizer::normalize ($ term );
343+
344+ if (!$ normalized ) {
345+ return $ query ->whereRaw ('1=0 ' );
346+ }
347+
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+ }
355+
356+ $ tokens = Tokens::prefixes (
357+ $ normalized ,
358+ (int ) config ('encrypted-search.max_prefix_depth ' , 6 ),
359+ $ pepper ,
360+ $ minLength
361+ );
362+
363+ if (empty ($ tokens )) {
364+ return $ query ->whereRaw ('1=0 ' );
365+ }
366+ } else {
367+ $ tokens = [Tokens::exact ($ normalized , $ pepper )];
368+ }
369+
370+ // Check if Elasticsearch is enabled
371+ 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 ));
380+ }
381+
382+ // Fallback to database - use OR conditions
383+ return $ query ->whereIn ($ this ->getQualifiedKeyName (), function ($ sub ) use ($ fields , $ tokens , $ type ) {
384+ $ sub ->select ('model_id ' )
385+ ->from ('encrypted_search_index ' )
386+ ->where ('model_type ' , static ::class)
387+ ->where ('type ' , $ type )
388+ ->whereIn ('field ' , $ fields )
389+ ->whereIn ('token ' , $ tokens );
390+ });
391+ }
392+
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+
320484 /**
321485 * Check if a field has an encrypted cast.
322486 *
0 commit comments