@@ -419,6 +419,107 @@ public function scopeEncryptedPrefixMulti(Builder $query, array $fields, string
419419 });
420420 }
421421
422+ /**
423+ * Scope: search for a term across multiple fields using OR logic.
424+ *
425+ * @param Builder $query
426+ * @param array<int, string> $fields
427+ * @param string $term
428+ * @param string $type Either 'exact' or 'prefix'
429+ * @return Builder
430+ */
431+ public function scopeEncryptedSearchAny (Builder $ query , array $ fields , string $ term , string $ type = 'exact ' ): Builder
432+ {
433+ if ($ type === 'exact ' ) {
434+ return $ this ->scopeEncryptedExactMulti ($ query , $ fields , $ term );
435+ }
436+
437+ return $ this ->scopeEncryptedPrefixMulti ($ query , $ fields , $ term );
438+ }
439+
440+ /**
441+ * Scope: search for multiple field-term pairs using AND logic.
442+ *
443+ * All specified field-term pairs must match for a record to be returned.
444+ *
445+ * @param Builder $query
446+ * @param array<string, string> $fieldTerms Associative array of field => term pairs
447+ * @param string $type Either 'exact' or 'prefix'
448+ * @return Builder
449+ */
450+ public function scopeEncryptedSearchAll (Builder $ query , array $ fieldTerms , string $ type = 'exact ' ): Builder
451+ {
452+ if (empty ($ fieldTerms )) {
453+ return $ query ->whereRaw ('1=0 ' );
454+ }
455+
456+ $ pepper = (string ) config ('encrypted-search.search_pepper ' , '' );
457+ $ minLength = (int ) config ('encrypted-search.min_prefix_length ' , 1 );
458+ $ useElastic = config ('encrypted-search.elasticsearch.enabled ' , false );
459+
460+ // Build conditions for each field-term pair
461+ foreach ($ fieldTerms as $ field => $ term ) {
462+ $ normalized = Normalizer::normalize ($ term );
463+
464+ if (!$ normalized ) {
465+ return $ query ->whereRaw ('1=0 ' );
466+ }
467+
468+ if ($ type === 'prefix ' ) {
469+ // Check minimum length for prefix searches
470+ if (mb_strlen ($ normalized , 'UTF-8 ' ) < $ minLength ) {
471+ return $ query ->whereRaw ('1=0 ' );
472+ }
473+
474+ $ tokens = Tokens::prefixes (
475+ $ normalized ,
476+ (int ) config ('encrypted-search.max_prefix_depth ' , 6 ),
477+ $ pepper ,
478+ $ minLength
479+ );
480+
481+ if (empty ($ tokens )) {
482+ return $ query ->whereRaw ('1=0 ' );
483+ }
484+
485+ // AND logic: intersect model IDs for each field-term pair
486+ if ($ useElastic ) {
487+ $ modelIds = $ this ->searchElasticsearch ($ field , $ tokens , 'prefix ' );
488+ $ query ->whereIn ($ this ->getQualifiedKeyName (), $ modelIds );
489+ } else {
490+ $ query ->whereIn ($ this ->getQualifiedKeyName (), function ($ sub ) use ($ field , $ tokens ) {
491+ $ sub ->select ('model_id ' )
492+ ->from ('encrypted_search_index ' )
493+ ->where ('model_type ' , static ::class)
494+ ->where ('field ' , $ field )
495+ ->where ('type ' , 'prefix ' )
496+ ->whereIn ('token ' , $ tokens );
497+ });
498+ }
499+ } else {
500+ // Exact match
501+ $ token = Tokens::exact ($ normalized , $ pepper );
502+
503+ // AND logic: intersect model IDs for each field-term pair
504+ if ($ useElastic ) {
505+ $ modelIds = $ this ->searchElasticsearch ($ field , $ token , 'exact ' );
506+ $ query ->whereIn ($ this ->getQualifiedKeyName (), $ modelIds );
507+ } else {
508+ $ query ->whereIn ($ this ->getQualifiedKeyName (), function ($ sub ) use ($ field , $ token ) {
509+ $ sub ->select ('model_id ' )
510+ ->from ('encrypted_search_index ' )
511+ ->where ('model_type ' , static ::class)
512+ ->where ('field ' , $ field )
513+ ->where ('type ' , 'exact ' )
514+ ->where ('token ' , $ token );
515+ });
516+ }
517+ }
518+ }
519+
520+ return $ query ;
521+ }
522+
422523 /**
423524 * Check if a field has an encrypted cast.
424525 *
0 commit comments