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
73 changes: 31 additions & 42 deletions src/Console/RebuildIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,40 +9,24 @@
/**
* Class RebuildIndex
*
* This Artisan command rebuilds the encrypted search index for a given Eloquent model.
* It is designed for maintenance operations where the search index may be outdated,
* corrupted, or needs regeneration after schema or normalization changes.
* Artisan command that rebuilds the encrypted search index for a given Eloquent model.
* It now supports short model names (e.g. "Client") and automatically resolves them
* under the `App\Models` namespace if not fully qualified.
*
* The command iterates over all records of the specified model and regenerates
* both "exact" and "prefix" tokens as defined by the model’s
* `HasEncryptedSearchIndex` trait configuration.
*
* Usage example:
* Example:
* php artisan encryption:index-rebuild Client
* php artisan encryption:index-rebuild "App\Models\Client"
*
* Options:
* --chunk=100 Number of records processed per batch (default: 100)
*
* Implementation details:
* - Before rebuilding, all existing search index entries for the given model
* are removed.
* - Records are then reprocessed in chunks to prevent memory exhaustion.
* - For each model instance, `updateSearchIndex()` is called to regenerate
* the normalized token rows.
*
* This ensures a clean and consistent index aligned with the current model data.
*/
class RebuildIndex extends Command
{
/**
* The name and signature of the console command.
*
* {model} The fully qualified class name (FQCN) of the Eloquent model.
* {--chunk=100} The number of model records to process per batch.
*
* @var string
*/
protected $signature = 'encryption:index-rebuild {model : FQCN of the Eloquent model} {--chunk=100}';
protected $signature = 'encryption:index-rebuild
{model : Model name or FQCN of the Eloquent model}
{--chunk=100 : Number of records processed per batch}';

/**
* The console command description.
Expand All @@ -54,49 +38,54 @@ class RebuildIndex extends Command
/**
* Execute the console command.
*
* This method performs the following steps:
* 1. Validates the provided model class.
* 2. Deletes all existing search index entries for that model.
* 3. Iterates through all model records in configurable chunks.
* 4. Calls `updateSearchIndex()` for each record to regenerate tokens.
* 5. Displays progress and a final summary of processed records.
*
* @return int Command exit code (0 on success, 1 on failure).
* @return int
*/
public function handle(): int
{
$input = trim($this->argument('model'));

// Automatically resolve models under App\Models namespace if not fully qualified
if (! class_exists($input)) {
$guessed = "App\\Models\\{$input}";
if (class_exists($guessed)) {
$input = $guessed;
}
}

/** @var class-string<Model> $class */
$class = $this->argument('model');
$class = $input;

if (! class_exists($class)) {
$this->error("Model class not found: {$class}");
$this->error("Model class not found: {$this->argument('model')}");
return self::FAILURE;
}

$chunk = (int) $this->option('chunk');
$this->info("Rebuilding encrypted search index for: {$class}");
$this->line("Processing in chunks of {$chunk}...");

// Remove all existing search tokens for this model
SearchIndex::where('model_type', $class)->delete();

/** @var \Illuminate\Database\Eloquent\Builder $q */
$q = $class::query();
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = $class::query();

$count = 0;

// Process model data in chunks to minimize memory usage
$q->chunk($chunk, function ($rows) use (&$count, $class) {
foreach ($rows as $model) {
if (method_exists($class, 'updateSearchIndex')) {
$class::updateSearchIndex($model);
$query->chunk($chunk, function ($models) use (&$count) {
foreach ($models as $model) {
if (method_exists($model, 'updateSearchIndex')) {
$model->updateSearchIndex(); // <-- FIXED
}
$count++;
}

// Write a dot to indicate progress
$this->output->write('.');
});

$this->newLine();
$this->info("Rebuilt index for {$count} records of {$class}.");
$this->info("Rebuilt index for {$count} records of {$class}.");

return self::SUCCESS;
}
Expand Down
4 changes: 2 additions & 2 deletions src/Observers/SearchIndexObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ protected function rebuildIndex(Model $model): void
{
if (method_exists($model, 'updateSearchIndex')) {
// @phpstan-ignore-next-line
$model::updateSearchIndex($model);
$model::updateSearchIndex();
}
}

Expand All @@ -113,7 +113,7 @@ protected function removeIndex(Model $model): void
{
if (method_exists($model, 'removeSearchIndex')) {
// @phpstan-ignore-next-line
$model::removeSearchIndex($model);
$model::removeSearchIndex();
}
}
}
64 changes: 29 additions & 35 deletions src/Traits/HasEncryptedSearchIndex.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,23 +47,6 @@
*/
trait HasEncryptedSearchIndex
{
/**
* Defines which fields should be included in the encrypted search index.
*
* Example:
* ```php
* protected array $encryptedSearch = [
* 'first_names' => ['exact' => true, 'prefix' => true],
* 'last_names' => ['exact' => true],
* ];
* ```
*
* Each entry specifies whether an exact or prefix index (or both)
* should be generated for that field.
*
* @var array<string, array{exact?: bool, prefix?: bool}>
*/
protected array $encryptedSearch = [];

/**
* Boot logic for the trait.
Expand Down Expand Up @@ -98,37 +81,34 @@ public static function bootHasEncryptedSearchIndex(): void
* @param \Illuminate\Database\Eloquent\Model $model
* @return void
*/
protected static function updateSearchIndex(Model $model): void
public function updateSearchIndex(): void
{
if (empty($model->encryptedSearch)) {
$config = $this->getEncryptedSearchConfiguration();

if (empty($config)) {
return;
}

$pepper = (string) config('encrypted-search.search_pepper', '');
$max = (int) config('encrypted-search.max_prefix_depth', 6);

// Remove previous entries for this model record
SearchIndex::where('model_type', get_class($model))
->where('model_id', $model->getKey())
SearchIndex::where('model_type', static::class)
->where('model_id', $this->getKey())
->delete();

// Generate new tokens
$rows = [];
foreach ($model->encryptedSearch as $field => $modes) {
$raw = (string) $model->getAttribute($field);
if ($raw === '') {
continue;
}

foreach ($config as $field => $modes) {
$raw = (string) $this->getAttribute($field);
if ($raw === '') continue;

$norm = Normalizer::normalize($raw);
if (! $norm) {
continue;
}
if (! $norm) continue;

if (! empty($modes['exact'])) {
$rows[] = [
'model_type' => get_class($model),
'model_id' => $model->getKey(),
'model_type' => static::class,
'model_id' => $this->getKey(),
'field' => $field,
'type' => 'exact',
'token' => Tokens::exact($norm, $pepper),
Expand All @@ -140,8 +120,8 @@ protected static function updateSearchIndex(Model $model): void
if (! empty($modes['prefix'])) {
foreach (Tokens::prefixes($norm, $max, $pepper) as $t) {
$rows[] = [
'model_type' => get_class($model),
'model_id' => $model->getKey(),
'model_type' => static::class,
'model_id' => $this->getKey(),
'field' => $field,
'type' => 'prefix',
'token' => $t,
Expand Down Expand Up @@ -236,4 +216,18 @@ public function scopeEncryptedPrefix(Builder $query, string $field, string $term
->whereIn('token', $tokens);
});
}

/**
* Get encrypted search configuration from the model.
*/
protected function getEncryptedSearchConfiguration(): array
{
// Laat modellen hun eigen configuratie bepalen
if (method_exists($this, 'getEncryptedSearchFields')) {
return $this->getEncryptedSearchFields();
}

// Fallback: gebruik property als die er nog is
return property_exists($this, 'encryptedSearch') ? $this->encryptedSearch : [];
}
}
Loading