Skip to content

Commit d60fb22

Browse files
Merge pull request #26 from ginkelsoft-development/feature/batch-query-optimization
add batch query optimization for multiple fields
2 parents 09e8e92 + 53651c5 commit d60fb22

File tree

2 files changed

+499
-0
lines changed

2 files changed

+499
-0
lines changed

src/Traits/HasEncryptedSearchIndex.php

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,170 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term
317317
});
318318
}
319319

320+
/**
321+
* Scope: search across multiple fields with OR logic (any field matches).
322+
*
323+
* Efficiently searches multiple fields for the same term in a single query.
324+
* Returns models where at least one field matches.
325+
*
326+
* Example:
327+
* Client::encryptedSearchAny(['first_names', 'last_names'], 'John', 'exact')->get();
328+
*
329+
* @param Builder $query
330+
* @param array<int, string> $fields Array of field names to search
331+
* @param string $term Search term
332+
* @param string $type Search type: 'exact' or 'prefix'
333+
* @return Builder
334+
*/
335+
public function scopeEncryptedSearchAny(Builder $query, array $fields, string $term, string $type = 'exact'): Builder
336+
{
337+
if (empty($fields)) {
338+
return $query->whereRaw('1=0');
339+
}
340+
341+
$pepper = (string) config('encrypted-search.search_pepper', '');
342+
$normalized = Normalizer::normalize($term);
343+
344+
if (!$normalized) {
345+
return $query->whereRaw('1=0');
346+
}
347+
348+
// Generate tokens based on search type
349+
if ($type === 'prefix') {
350+
$minLength = (int) config('encrypted-search.min_prefix_length', 1);
351+
352+
if (mb_strlen($normalized, 'UTF-8') < $minLength) {
353+
return $query->whereRaw('1=0');
354+
}
355+
356+
$tokens = Tokens::prefixes(
357+
$normalized,
358+
(int) config('encrypted-search.max_prefix_depth', 6),
359+
$pepper,
360+
$minLength
361+
);
362+
363+
if (empty($tokens)) {
364+
return $query->whereRaw('1=0');
365+
}
366+
} else {
367+
$tokens = [Tokens::exact($normalized, $pepper)];
368+
}
369+
370+
// Check if Elasticsearch is enabled
371+
if (config('encrypted-search.elasticsearch.enabled', false)) {
372+
$allModelIds = [];
373+
374+
foreach ($fields as $field) {
375+
$modelIds = $this->searchElasticsearch($field, $tokens, $type);
376+
$allModelIds = array_merge($allModelIds, $modelIds);
377+
}
378+
379+
return $query->whereIn($this->getQualifiedKeyName(), array_unique($allModelIds));
380+
}
381+
382+
// Fallback to database - use OR conditions
383+
return $query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($fields, $tokens, $type) {
384+
$sub->select('model_id')
385+
->from('encrypted_search_index')
386+
->where('model_type', static::class)
387+
->where('type', $type)
388+
->whereIn('field', $fields)
389+
->whereIn('token', $tokens);
390+
});
391+
}
392+
393+
/**
394+
* Scope: search across multiple fields with AND logic (all fields must match).
395+
*
396+
* Returns models where ALL specified fields match their respective terms.
397+
*
398+
* Example:
399+
* Client::encryptedSearchAll([
400+
* 'first_names' => 'John',
401+
* 'last_names' => 'Doe'
402+
* ], 'exact')->get();
403+
*
404+
* @param Builder $query
405+
* @param array<string, string> $fieldTerms Associative array of field => term
406+
* @param string $type Search type: 'exact' or 'prefix'
407+
* @return Builder
408+
*/
409+
public function scopeEncryptedSearchAll(Builder $query, array $fieldTerms, string $type = 'exact'): Builder
410+
{
411+
if (empty($fieldTerms)) {
412+
return $query->whereRaw('1=0');
413+
}
414+
415+
$pepper = (string) config('encrypted-search.search_pepper', '');
416+
$minLength = (int) config('encrypted-search.min_prefix_length', 1);
417+
$maxDepth = (int) config('encrypted-search.max_prefix_depth', 6);
418+
419+
// Check if Elasticsearch is enabled
420+
if (config('encrypted-search.elasticsearch.enabled', false)) {
421+
// Start with all IDs, then intersect
422+
$resultIds = null;
423+
424+
foreach ($fieldTerms as $field => $term) {
425+
$normalized = Normalizer::normalize($term);
426+
427+
if (!$normalized || ($type === 'prefix' && mb_strlen($normalized, 'UTF-8') < $minLength)) {
428+
return $query->whereRaw('1=0');
429+
}
430+
431+
$tokens = $type === 'prefix'
432+
? Tokens::prefixes($normalized, $maxDepth, $pepper, $minLength)
433+
: [Tokens::exact($normalized, $pepper)];
434+
435+
if (empty($tokens)) {
436+
return $query->whereRaw('1=0');
437+
}
438+
439+
$modelIds = $this->searchElasticsearch($field, $tokens, $type);
440+
441+
if ($resultIds === null) {
442+
$resultIds = $modelIds;
443+
} else {
444+
$resultIds = array_intersect($resultIds, $modelIds);
445+
}
446+
447+
if (empty($resultIds)) {
448+
return $query->whereRaw('1=0');
449+
}
450+
}
451+
452+
return $query->whereIn($this->getQualifiedKeyName(), $resultIds);
453+
}
454+
455+
// Fallback to database - use nested queries with intersections
456+
foreach ($fieldTerms as $field => $term) {
457+
$normalized = Normalizer::normalize($term);
458+
459+
if (!$normalized || ($type === 'prefix' && mb_strlen($normalized, 'UTF-8') < $minLength)) {
460+
return $query->whereRaw('1=0');
461+
}
462+
463+
$tokens = $type === 'prefix'
464+
? Tokens::prefixes($normalized, $maxDepth, $pepper, $minLength)
465+
: [Tokens::exact($normalized, $pepper)];
466+
467+
if (empty($tokens)) {
468+
return $query->whereRaw('1=0');
469+
}
470+
471+
$query->whereIn($this->getQualifiedKeyName(), function ($sub) use ($field, $tokens, $type) {
472+
$sub->select('model_id')
473+
->from('encrypted_search_index')
474+
->where('model_type', static::class)
475+
->where('field', $field)
476+
->where('type', $type)
477+
->whereIn('token', $tokens);
478+
});
479+
}
480+
481+
return $query;
482+
}
483+
320484
/**
321485
* Check if a field has an encrypted cast.
322486
*

0 commit comments

Comments
 (0)