Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ pnpm-debug.log
.DS_Store
Thumbs.db

# Claude Code
.claude/

# Environment & local config
.env
.env.*
Expand Down
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,38 @@ When a record is saved, searchable tokens are automatically generated in `encryp

### Searching

#### Single Field Search

```php
// Exact match
// Exact match on a single field
$clients = Client::encryptedExact('last_names', 'Vermeer')->get();

// Prefix match
// Prefix match on a single field
$clients = Client::encryptedPrefix('first_names', 'Wie')->get();
```

#### Multi-Field Search

Search across multiple fields simultaneously using OR logic:

```php
// Exact match across multiple fields
// Finds records where 'John' appears in first_names OR last_names
$clients = Client::encryptedExactMulti(['first_names', 'last_names'], 'John')->get();

// Prefix match across multiple fields
// Finds records where 'Wie' is a prefix of first_names OR last_names
$clients = Client::encryptedPrefixMulti(['first_names', 'last_names'], 'Wie')->get();
```

**Use cases for multi-field search:**
- Search for a name that could be in either first name or last name fields
- Search across multiple encrypted fields without multiple queries
- Implement autocomplete across multiple fields
- Unified search experience across related fields

**Note:** Multi-field searches automatically deduplicate results, so if a record matches in multiple fields, it will only appear once in the results.

Attributes always override global or $encryptedSearch configuration for the same field.

---
Expand Down
6 changes: 4 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
"require": {
"php": "^8.1 || ^8.2 || ^8.3 || ^8.4",
"illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0",
"ext-intl": "*"
"ext-intl": "*",
"guzzlehttp/guzzle": "^7.2"
},
"require-dev": {
"phpunit/phpunit": "^9.5.10 || ^10.0 || ^11.0",
"orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0"
"orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0",
"doctrine/dbal": "^3.0"
},
"autoload": {
"psr-4": {
Expand Down
149 changes: 149 additions & 0 deletions src/Traits/HasEncryptedSearchIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,49 @@ public function scopeEncryptedExact(Builder $query, string $field, string $term)
});
}

/**
* Scope: query models by exact encrypted token match across multiple fields.
*
* Searches for an exact match in any of the specified fields (OR logic).
*
* @param Builder $query
* @param array<int, string> $fields
* @param string $term
* @return Builder
*/
public function scopeEncryptedExactMulti(Builder $query, array $fields, string $term): Builder
{
if (empty($fields)) {
return $query->whereRaw('1=0');
}

$pepper = (string) config('encrypted-search.search_pepper', '');
$normalized = Normalizer::normalize($term);

if (!$normalized) {
return $query->whereRaw('1=0');
}

$token = Tokens::exact($normalized, $pepper);

// Check if Elasticsearch is enabled
if (config('encrypted-search.elasticsearch.enabled', false)) {
$modelIds = $this->searchElasticsearchMulti($fields, $token, 'exact');
return $query->whereIn($this->getQualifiedKeyName(), $modelIds);
}

// Fallback to database - use OR logic for multiple fields
return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $token) {
$sub->select('model_id')
->from('encrypted_search_index')
->where('model_type', static::class)
->whereIn('field', $fields)
->where('type', 'exact')
->where('token', $token)
->distinct();
});
}

/**
* Scope: query models by prefix-based encrypted token match.
*
Expand Down Expand Up @@ -317,6 +360,65 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term
});
}

/**
* Scope: query models by prefix-based encrypted token match across multiple fields.
*
* Searches for a prefix match in any of the specified fields (OR logic).
*
* @param Builder $query
* @param array<int, string> $fields
* @param string $term
* @return Builder
*/
public function scopeEncryptedPrefixMulti(Builder $query, array $fields, string $term): Builder
{
if (empty($fields)) {
return $query->whereRaw('1=0');
}

$pepper = (string) config('encrypted-search.search_pepper', '');
$minLength = (int) config('encrypted-search.min_prefix_length', 1);
$normalized = Normalizer::normalize($term);

if (!$normalized) {
return $query->whereRaw('1=0');
}

// Check if search term meets minimum length requirement
if (mb_strlen($normalized, 'UTF-8') < $minLength) {
return $query->whereRaw('1=0');
}

$tokens = Tokens::prefixes(
$normalized,
(int) config('encrypted-search.max_prefix_depth', 6),
$pepper,
$minLength
);

// If no tokens generated (term too short), return no results
if (empty($tokens)) {
return $query->whereRaw('1=0');
}

// Check if Elasticsearch is enabled
if (config('encrypted-search.elasticsearch.enabled', false)) {
$modelIds = $this->searchElasticsearchMulti($fields, $tokens, 'prefix');
return $query->whereIn($this->getQualifiedKeyName(), $modelIds);
}

// Fallback to database - use OR logic for multiple fields
return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $tokens) {
$sub->select('model_id')
->from('encrypted_search_index')
->where('model_type', static::class)
->whereIn('field', $fields)
->where('type', 'prefix')
->whereIn('token', $tokens)
->distinct();
});
}

/**
* Check if a field has an encrypted cast.
*
Expand Down Expand Up @@ -381,6 +483,53 @@ protected function searchElasticsearch(string $field, $tokens, string $type): ar
}
}

/**
* Search for model IDs in Elasticsearch based on token(s) across multiple fields.
*
* @param array<int, string> $fields
* @param string|array<int, string> $tokens Single token or array of tokens
* @param string $type Either 'exact' or 'prefix'
* @return array<int, mixed> Array of model IDs
*/
protected function searchElasticsearchMulti(array $fields, $tokens, string $type): array
{
$index = config('encrypted-search.elasticsearch.index', 'encrypted_search');
$service = app(ElasticsearchService::class);

// Normalize tokens to array
$tokenArray = is_array($tokens) ? $tokens : [$tokens];

// Build Elasticsearch query with multiple fields (OR logic)
$query = [
'query' => [
'bool' => [
'must' => [
['term' => ['model_type.keyword' => static::class]],
['terms' => ['field.keyword' => $fields]],
['term' => ['type.keyword' => $type]],
['terms' => ['token.keyword' => $tokenArray]],
],
],
],
'_source' => ['model_id'],
'size' => 10000,
];

try {
$results = $service->search($index, $query);

// Extract unique model IDs from results
return collect($results)
->pluck('_source.model_id')
->unique()
->values()
->toArray();
} catch (\Throwable $e) {
logger()->warning('[EncryptedSearch] Elasticsearch multi-field search failed: ' . $e->getMessage());
return [];
}
}

/**
* Resolve the encrypted search configuration for this model.
*
Expand Down
Loading