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 @@ -278,13 +278,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
247 changes: 116 additions & 131 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 @@ -318,169 +361,64 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term
}

/**
* Scope: search across multiple fields with OR logic (any field matches).
* Scope: query models by prefix-based encrypted token match across multiple fields.
*
* Efficiently searches multiple fields for the same term in a single query.
* Returns models where at least one field matches.
*
* Example:
* Client::encryptedSearchAny(['first_names', 'last_names'], 'John', 'exact')->get();
* Searches for a prefix match in any of the specified fields (OR logic).
*
* @param Builder $query
* @param array<int, string> $fields Array of field names to search
* @param string $term Search term
* @param string $type Search type: 'exact' or 'prefix'
* @param array<int, string> $fields
* @param string $term
* @return Builder
*/
public function scopeEncryptedSearchAny(Builder $query, array $fields, string $term, string $type = 'exact'): 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');
}

// Generate tokens based on search type
if ($type === 'prefix') {
$minLength = (int) config('encrypted-search.min_prefix_length', 1);

if (mb_strlen($normalized, 'UTF-8') < $minLength) {
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
);
$tokens = Tokens::prefixes(
$normalized,
(int) config('encrypted-search.max_prefix_depth', 6),
$pepper,
$minLength
);

if (empty($tokens)) {
return $query->whereRaw('1=0');
}
} else {
$tokens = [Tokens::exact($normalized, $pepper)];
// 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)) {
$allModelIds = [];

foreach ($fields as $field) {
$modelIds = $this->searchElasticsearch($field, $tokens, $type);
$allModelIds = array_merge($allModelIds, $modelIds);
}

return $query->whereIn($this->getQualifiedKeyName(), array_unique($allModelIds));
$modelIds = $this->searchElasticsearchMulti($fields, $tokens, 'prefix');
return $query->whereIn($this->getQualifiedKeyName(), $modelIds);
}

// Fallback to database - use OR conditions
return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $tokens, $type) {
// 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)
->where('type', $type)
->whereIn('field', $fields)
->whereIn('token', $tokens);
->where('type', 'prefix')
->whereIn('token', $tokens)
->distinct();
});
}

/**
* Scope: search across multiple fields with AND logic (all fields must match).
*
* Returns models where ALL specified fields match their respective terms.
*
* Example:
* Client::encryptedSearchAll([
* 'first_names' => 'John',
* 'last_names' => 'Doe'
* ], 'exact')->get();
*
* @param Builder $query
* @param array<string, string> $fieldTerms Associative array of field => term
* @param string $type Search type: 'exact' or 'prefix'
* @return Builder
*/
public function scopeEncryptedSearchAll(Builder $query, array $fieldTerms, string $type = 'exact'): Builder
{
if (empty($fieldTerms)) {
return $query->whereRaw('1=0');
}

$pepper = (string) config('encrypted-search.search_pepper', '');
$minLength = (int) config('encrypted-search.min_prefix_length', 1);
$maxDepth = (int) config('encrypted-search.max_prefix_depth', 6);

// Check if Elasticsearch is enabled
if (config('encrypted-search.elasticsearch.enabled', false)) {
// Start with all IDs, then intersect
$resultIds = null;

foreach ($fieldTerms as $field => $term) {
$normalized = Normalizer::normalize($term);

if (!$normalized || ($type === 'prefix' && mb_strlen($normalized, 'UTF-8') < $minLength)) {
return $query->whereRaw('1=0');
}

$tokens = $type === 'prefix'
? Tokens::prefixes($normalized, $maxDepth, $pepper, $minLength)
: [Tokens::exact($normalized, $pepper)];

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

$modelIds = $this->searchElasticsearch($field, $tokens, $type);

if ($resultIds === null) {
$resultIds = $modelIds;
} else {
$resultIds = array_intersect($resultIds, $modelIds);
}

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

return $query->whereIn($this->getQualifiedKeyName(), $resultIds);
}

// Fallback to database - use nested queries with intersections
foreach ($fieldTerms as $field => $term) {
$normalized = Normalizer::normalize($term);

if (!$normalized || ($type === 'prefix' && mb_strlen($normalized, 'UTF-8') < $minLength)) {
return $query->whereRaw('1=0');
}

$tokens = $type === 'prefix'
? Tokens::prefixes($normalized, $maxDepth, $pepper, $minLength)
: [Tokens::exact($normalized, $pepper)];

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

$query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($field, $tokens, $type) {
$sub->select('model_id')
->from('encrypted_search_index')
->where('model_type', static::class)
->where('field', $field)
->where('type', $type)
->whereIn('token', $tokens);
});
}

return $query;
}

/**
* Check if a field has an encrypted cast.
*
Expand Down Expand Up @@ -545,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
Loading