@@ -349,15 +349,16 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term
349349 return $ query ->whereIn ($ this ->getQualifiedKeyName (), $ modelIds );
350350 }
351351
352- // Fallback to database
352+ // Fallback to database with relevance sorting
353+ // Sort by field length (shorter matches = more relevant)
353354 return $ query ->whereIn ($ this ->getQualifiedKeyName (), function ($ sub ) use ($ field , $ tokens ) {
354355 $ sub ->select ('model_id ' )
355356 ->from ('encrypted_search_index ' )
356357 ->where ('model_type ' , static ::class)
357358 ->where ('field ' , $ field )
358359 ->where ('type ' , 'prefix ' )
359360 ->whereIn ('token ' , $ tokens );
360- });
361+ })-> orderByRaw ( " LENGTH( { $ field } ) ASC " ) ;
361362 }
362363
363364 /**
@@ -408,6 +409,8 @@ public function scopeEncryptedPrefixMulti(Builder $query, array $fields, string
408409 }
409410
410411 // Fallback to database - use OR logic for multiple fields
412+ // Note: Multi-field searches don't have relevance sorting due to database compatibility
413+ // Use single-field searches for relevance-sorted results
411414 return $ query ->whereIn ($ this ->getQualifiedKeyName (), function ($ sub ) use ($ fields , $ tokens ) {
412415 $ sub ->select ('model_id ' )
413416 ->from ('encrypted_search_index ' )
@@ -419,6 +422,107 @@ public function scopeEncryptedPrefixMulti(Builder $query, array $fields, string
419422 });
420423 }
421424
425+ /**
426+ * Scope: search for a term across multiple fields using OR logic.
427+ *
428+ * @param Builder $query
429+ * @param array<int, string> $fields
430+ * @param string $term
431+ * @param string $type Either 'exact' or 'prefix'
432+ * @return Builder
433+ */
434+ public function scopeEncryptedSearchAny (Builder $ query , array $ fields , string $ term , string $ type = 'exact ' ): Builder
435+ {
436+ if ($ type === 'exact ' ) {
437+ return $ this ->scopeEncryptedExactMulti ($ query , $ fields , $ term );
438+ }
439+
440+ return $ this ->scopeEncryptedPrefixMulti ($ query , $ fields , $ term );
441+ }
442+
443+ /**
444+ * Scope: search for multiple field-term pairs using AND logic.
445+ *
446+ * All specified field-term pairs must match for a record to be returned.
447+ *
448+ * @param Builder $query
449+ * @param array<string, string> $fieldTerms Associative array of field => term pairs
450+ * @param string $type Either 'exact' or 'prefix'
451+ * @return Builder
452+ */
453+ public function scopeEncryptedSearchAll (Builder $ query , array $ fieldTerms , string $ type = 'exact ' ): Builder
454+ {
455+ if (empty ($ fieldTerms )) {
456+ return $ query ->whereRaw ('1=0 ' );
457+ }
458+
459+ $ pepper = (string ) config ('encrypted-search.search_pepper ' , '' );
460+ $ minLength = (int ) config ('encrypted-search.min_prefix_length ' , 1 );
461+ $ useElastic = config ('encrypted-search.elasticsearch.enabled ' , false );
462+
463+ // Build conditions for each field-term pair
464+ foreach ($ fieldTerms as $ field => $ term ) {
465+ $ normalized = Normalizer::normalize ($ term );
466+
467+ if (!$ normalized ) {
468+ return $ query ->whereRaw ('1=0 ' );
469+ }
470+
471+ if ($ type === 'prefix ' ) {
472+ // Check minimum length for prefix searches
473+ if (mb_strlen ($ normalized , 'UTF-8 ' ) < $ minLength ) {
474+ return $ query ->whereRaw ('1=0 ' );
475+ }
476+
477+ $ tokens = Tokens::prefixes (
478+ $ normalized ,
479+ (int ) config ('encrypted-search.max_prefix_depth ' , 6 ),
480+ $ pepper ,
481+ $ minLength
482+ );
483+
484+ if (empty ($ tokens )) {
485+ return $ query ->whereRaw ('1=0 ' );
486+ }
487+
488+ // AND logic: intersect model IDs for each field-term pair
489+ if ($ useElastic ) {
490+ $ modelIds = $ this ->searchElasticsearch ($ field , $ tokens , 'prefix ' );
491+ $ query ->whereIn ($ this ->getQualifiedKeyName (), $ modelIds );
492+ } else {
493+ $ query ->whereIn ($ this ->getQualifiedKeyName (), function ($ sub ) use ($ field , $ tokens ) {
494+ $ sub ->select ('model_id ' )
495+ ->from ('encrypted_search_index ' )
496+ ->where ('model_type ' , static ::class)
497+ ->where ('field ' , $ field )
498+ ->where ('type ' , 'prefix ' )
499+ ->whereIn ('token ' , $ tokens );
500+ });
501+ }
502+ } else {
503+ // Exact match
504+ $ token = Tokens::exact ($ normalized , $ pepper );
505+
506+ // AND logic: intersect model IDs for each field-term pair
507+ if ($ useElastic ) {
508+ $ modelIds = $ this ->searchElasticsearch ($ field , $ token , 'exact ' );
509+ $ query ->whereIn ($ this ->getQualifiedKeyName (), $ modelIds );
510+ } else {
511+ $ query ->whereIn ($ this ->getQualifiedKeyName (), function ($ sub ) use ($ field , $ token ) {
512+ $ sub ->select ('model_id ' )
513+ ->from ('encrypted_search_index ' )
514+ ->where ('model_type ' , static ::class)
515+ ->where ('field ' , $ field )
516+ ->where ('type ' , 'exact ' )
517+ ->where ('token ' , $ token );
518+ });
519+ }
520+ }
521+ }
522+
523+ return $ query ;
524+ }
525+
422526 /**
423527 * Check if a field has an encrypted cast.
424528 *
0 commit comments