Skip to content

Commit 08ec941

Browse files
merge batch search methods from feature branch to fix failing tests
This commit brings the missing encryptedSearchAny() and encryptedSearchAll() methods from the feature branch to develop, along with encryption validation and relevance scoring improvements. Changes: - Add scopeEncryptedSearchAny() for OR logic across fields - Add scopeEncryptedSearchAll() for AND logic with field-term pairs - Add LENGTH-based relevance scoring to scopeEncryptedPrefix() - Add encryption validation in RebuildIndex command - All 106 tests now passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 41bf9ac commit 08ec941

File tree

2 files changed

+143
-4
lines changed

2 files changed

+143
-4
lines changed

src/Console/RebuildIndex.php

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,42 @@ public function handle(): int
7171
$query = $class::query();
7272

7373
$count = 0;
74+
$encrypted = 0;
7475

75-
$query->chunk($chunk, function ($models) use (&$count) {
76+
$query->chunk($chunk, function ($models) use (&$count, &$encrypted) {
7677
foreach ($models as $model) {
78+
// Check if model has encrypted casts and ensure data is encrypted
79+
if (method_exists($model, 'getCasts')) {
80+
$casts = $model->getCasts();
81+
$needsSave = false;
82+
83+
foreach ($casts as $field => $cast) {
84+
if (str_contains(strtolower($cast), 'encrypted')) {
85+
// Get the raw value from database (bypassing accessors)
86+
$attributes = $model->getAttributes();
87+
$rawValue = $attributes[$field] ?? null;
88+
89+
// Check if value exists and is not already encrypted
90+
// Encrypted values start with 'eyJpdiI' (base64 of '{"iv"')
91+
if ($rawValue && !str_starts_with($rawValue, 'eyJpdiI')) {
92+
// Value is not encrypted, encrypt it now
93+
$decrypted = $rawValue; // Value is already decrypted in DB
94+
$model->setAttribute($field, $decrypted); // This will encrypt via cast
95+
$needsSave = true;
96+
$encrypted++;
97+
}
98+
}
99+
}
100+
101+
// Save if any fields were re-encrypted
102+
if ($needsSave) {
103+
$model->save();
104+
}
105+
}
106+
107+
// Update search index
77108
if (method_exists($model, 'updateSearchIndex')) {
78-
$model->updateSearchIndex(); // <-- FIXED
109+
$model->updateSearchIndex();
79110
}
80111
$count++;
81112
}
@@ -87,6 +118,10 @@ public function handle(): int
87118
$this->newLine();
88119
$this->info("Rebuilt index for {$count} records of {$class}.");
89120

121+
if ($encrypted > 0) {
122+
$this->info("Encrypted {$encrypted} unencrypted field(s) during rebuild.");
123+
}
124+
90125
return self::SUCCESS;
91126
}
92127
}

src/Traits/HasEncryptedSearchIndex.php

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)