Skip to content

Commit 0d0e7fd

Browse files
- Added unit tests
- Added code documentation
1 parent f4b4a96 commit 0d0e7fd

File tree

12 files changed

+774
-33
lines changed

12 files changed

+774
-33
lines changed

.gitignore

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# ------------------------------------------------------------
2+
# Laravel Package .gitignore
3+
# ------------------------------------------------------------
4+
5+
# Composer dependencies
6+
/vendor/
7+
composer.lock
8+
9+
# Node dependencies
10+
/node_modules/
11+
npm-debug.log
12+
yarn-error.log
13+
pnpm-debug.log
14+
15+
# IDE & OS files
16+
.idea/
17+
.vscode/
18+
.DS_Store
19+
Thumbs.db
20+
21+
# Environment & local config
22+
.env
23+
.env.*
24+
.phpunit.result.cache
25+
26+
# Build / cache / temporary
27+
/storage/
28+
bootstrap/cache/
29+
coverage/
30+
*.cache
31+
*.log
32+
*.tmp
33+
34+
# Testbench / PHPUnit artifacts
35+
.phpunit.cache/
36+
.phpunit.result.cache
37+
tests/_output/
38+
tests/_support/_generated/
39+
40+
# Package build output
41+
/build/
42+
dist/
43+
*.zip
44+
45+
# Ignore generated stubs or local test DBs
46+
database/*.sqlite
47+
*.sql

composer.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,11 @@
2323
"require-dev": {
2424
"orchestra/testbench": "^9.0",
2525
"phpunit/phpunit": "^10.5|^11.0"
26+
},
27+
"autoload-dev": {
28+
"psr-4": {
29+
"Ginkelsoft\\EncryptedSearch\\Tests\\": "tests/",
30+
"Tests\\": "tests/"
31+
}
2632
}
2733
}

database/migrations/create_encrypted_search_index_table.php

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,72 @@
44
use Illuminate\Database\Schema\Blueprint;
55
use Illuminate\Support\Facades\Schema;
66

7-
return new class extends Migration {
7+
/**
8+
* CreateEncryptedSearchIndexTable
9+
*
10+
* This migration defines the `encrypted_search_index` table, which stores
11+
* normalized and hashed tokens to enable secure searching over encrypted data.
12+
*
13+
* Each record in this table represents a single searchable token for a given
14+
* model instance and field. For example, if a "Client" model has an encrypted
15+
* `last_name`, its normalized and tokenized forms (exact or prefix) will be
16+
* stored here. These tokens can be matched without revealing the original value.
17+
*
18+
* The combination of `model_type`, `model_id`, `field`, and `type` uniquely
19+
* identifies all searchable tokens for a specific model field.
20+
*
21+
* Indexing strategy:
22+
* - `esi_lookup` provides efficient token-based searches.
23+
* - `esi_row` accelerates lookup and cleanup operations per model instance.
24+
*
25+
* Typical usage:
26+
* - Generated automatically by the HasEncryptedSearchIndex trait.
27+
* - Used for both exact and prefix token searches.
28+
*/
29+
return new class extends Migration
30+
{
31+
/**
32+
* Run the migrations.
33+
*
34+
* Creates the `encrypted_search_index` table with tokenized fields for
35+
* secure search functionality. Tokens are generated as deterministic
36+
* hashes (e.g., SHA-256) and are not reversible, ensuring no sensitive
37+
* data is exposed while maintaining fast lookups.
38+
*/
839
public function up(): void
940
{
1041
Schema::create('encrypted_search_index', function (Blueprint $table) {
1142
$table->id();
12-
$table->string('model_type'); // FQCN
43+
44+
// The fully qualified model class name (e.g. App\Models\Client)
45+
$table->string('model_type');
46+
47+
// The primary key of the model this token belongs to
1348
$table->unsignedBigInteger('model_id');
14-
$table->string('field'); // bv. 'last_names'
15-
$table->string('type', 16); // 'exact' | 'prefix'
16-
$table->string('token', 80); // sha256 hex (64) of korter
49+
50+
// The model field (e.g. "last_names") from which this token was derived
51+
$table->string('field');
52+
53+
// Token type: 'exact' or 'prefix'
54+
$table->string('type', 16);
55+
56+
// The actual hashed token (e.g. SHA-256 or truncated prefix token)
57+
$table->string('token', 80);
58+
59+
// Record timestamps for auditing and debugging
1760
$table->timestamps();
1861

62+
// Composite indexes for optimized lookups
1963
$table->index(['model_type', 'field', 'type', 'token'], 'esi_lookup');
2064
$table->index(['model_type', 'model_id'], 'esi_row');
2165
});
2266
}
2367

68+
/**
69+
* Reverse the migrations.
70+
*
71+
* Drops the `encrypted_search_index` table if it exists.
72+
*/
2473
public function down(): void
2574
{
2675
Schema::dropIfExists('encrypted_search_index');

phpunit.xml.dist

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit
3+
bootstrap="vendor/autoload.php"
4+
colors="true"
5+
testdox="true"
6+
verbose="true"
7+
>
8+
<testsuites>
9+
<testsuite name="Package Test Suite">
10+
<directory suffix="Test.php">./tests</directory>
11+
</testsuite>
12+
</testsuites>
13+
14+
<php>
15+
<env name="APP_KEY" value="base64:8vXh4sV6JhZ8pUy8WZ4v7T+6W3lBd6pE7j28x7yT3Z8="/>
16+
<env name="SEARCH_PEPPER" value="test-pepper-secret"/>
17+
<env name="DB_CONNECTION" value="sqlite"/>
18+
<env name="DB_DATABASE" value=":memory:"/>
19+
<env name="APP_ENV" value="testing"/>
20+
</php>
21+
22+
<coverage processUncoveredFiles="true">
23+
<include>
24+
<directory suffix=".php">./src</directory>
25+
</include>
26+
</coverage>
27+
</phpunit>

src/Console/RebuildIndex.php

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,63 @@
66
use Illuminate\Database\Eloquent\Model;
77
use Ginkelsoft\EncryptedSearch\Models\SearchIndex;
88

9+
/**
10+
* Class RebuildIndex
11+
*
12+
* This Artisan command rebuilds the encrypted search index for a given Eloquent model.
13+
* It is designed for maintenance operations where the search index may be outdated,
14+
* corrupted, or needs regeneration after schema or normalization changes.
15+
*
16+
* The command iterates over all records of the specified model and regenerates
17+
* both "exact" and "prefix" tokens as defined by the model’s
18+
* `HasEncryptedSearchIndex` trait configuration.
19+
*
20+
* Usage example:
21+
* php artisan encryption:index-rebuild "App\Models\Client"
22+
*
23+
* Options:
24+
* --chunk=100 Number of records processed per batch (default: 100)
25+
*
26+
* Implementation details:
27+
* - Before rebuilding, all existing search index entries for the given model
28+
* are removed.
29+
* - Records are then reprocessed in chunks to prevent memory exhaustion.
30+
* - For each model instance, `updateSearchIndex()` is called to regenerate
31+
* the normalized token rows.
32+
*
33+
* This ensures a clean and consistent index aligned with the current model data.
34+
*/
935
class RebuildIndex extends Command
1036
{
37+
/**
38+
* The name and signature of the console command.
39+
*
40+
* {model} The fully qualified class name (FQCN) of the Eloquent model.
41+
* {--chunk=100} The number of model records to process per batch.
42+
*
43+
* @var string
44+
*/
1145
protected $signature = 'encryption:index-rebuild {model : FQCN of the Eloquent model} {--chunk=100}';
46+
47+
/**
48+
* The console command description.
49+
*
50+
* @var string
51+
*/
1252
protected $description = 'Rebuild encrypted search index for a given model.';
1353

54+
/**
55+
* Execute the console command.
56+
*
57+
* This method performs the following steps:
58+
* 1. Validates the provided model class.
59+
* 2. Deletes all existing search index entries for that model.
60+
* 3. Iterates through all model records in configurable chunks.
61+
* 4. Calls `updateSearchIndex()` for each record to regenerate tokens.
62+
* 5. Displays progress and a final summary of processed records.
63+
*
64+
* @return int Command exit code (0 on success, 1 on failure).
65+
*/
1466
public function handle(): int
1567
{
1668
/** @var class-string<Model> $class */
@@ -23,20 +75,23 @@ public function handle(): int
2375

2476
$chunk = (int) $this->option('chunk');
2577

26-
// Drop alle tokens voor dit model
78+
// Remove all existing search tokens for this model
2779
SearchIndex::where('model_type', $class)->delete();
2880

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

3284
$count = 0;
85+
86+
// Process model data in chunks to minimize memory usage
3387
$q->chunk($chunk, function ($rows) use (&$count, $class) {
3488
foreach ($rows as $model) {
3589
if (method_exists($class, 'updateSearchIndex')) {
3690
$class::updateSearchIndex($model);
3791
}
3892
$count++;
3993
}
94+
// Write a dot to indicate progress
4095
$this->output->write('.');
4196
});
4297

src/EncryptedSearchServiceProvider.php

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,76 @@
44

55
use Illuminate\Support\ServiceProvider;
66

7+
/**
8+
* Class EncryptedSearchServiceProvider
9+
*
10+
* Registers and bootstraps the Laravel Encrypted Search Index package.
11+
*
12+
* This service provider integrates the package into a Laravel application by:
13+
* - Registering configuration (`config/encrypted-search.php`)
14+
* - Publishing database migrations for the `encrypted_search_index` table
15+
* - Registering console commands (e.g., index rebuilding)
16+
*
17+
* The provider ensures that the encrypted search system is fully
18+
* self-contained and can be seamlessly installed into any Laravel project.
19+
*
20+
* Typical installation:
21+
* ```bash
22+
* composer require ginkelsoft/laravel-encrypted-search-index
23+
* php artisan vendor:publish --tag=config
24+
* php artisan vendor:publish --tag=migrations
25+
* php artisan migrate
26+
* ```
27+
*
28+
* After registration, Eloquent models can use the
29+
* `HasEncryptedSearchIndex` trait to automatically build searchable,
30+
* privacy-preserving index entries.
31+
*
32+
* @see \Ginkelsoft\EncryptedSearch\Traits\HasEncryptedSearchIndex
33+
* @see \Ginkelsoft\EncryptedSearch\Console\RebuildIndex
34+
*/
735
class EncryptedSearchServiceProvider extends ServiceProvider
836
{
37+
/**
38+
* Register bindings and configuration.
39+
*
40+
* This merges the package configuration into the application’s
41+
* global config namespace under the key `encrypted-search`.
42+
*
43+
* @return void
44+
*/
945
public function register(): void
1046
{
11-
$this->mergeConfigFrom(__DIR__.'/../config/encrypted-search.php', 'encrypted-search');
47+
$this->mergeConfigFrom(
48+
__DIR__ . '/../config/encrypted-search.php',
49+
'encrypted-search'
50+
);
1251
}
1352

53+
/**
54+
* Bootstrap package resources (config, migrations, and commands).
55+
*
56+
* This method is executed after all other service providers have
57+
* been registered and is responsible for publishing configuration,
58+
* registering migrations, and exposing console commands.
59+
*
60+
* @return void
61+
*/
1462
public function boot(): void
1563
{
16-
// Publish config & migration
64+
// Publish configuration file
1765
$this->publishes([
18-
__DIR__.'/../config/encrypted-search.php' => config_path('encrypted-search.php'),
66+
__DIR__ . '/../config/encrypted-search.php' => config_path('encrypted-search.php'),
1967
], 'config');
2068

69+
// Publish migration with timestamped filename
2170
$timestamp = date('Y_m_d_His');
2271
$this->publishes([
23-
__DIR__."/../database/migrations/create_encrypted_search_index_table.php"
72+
__DIR__ . '/../database/migrations/create_encrypted_search_index_table.php'
2473
=> database_path("migrations/{$timestamp}_create_encrypted_search_index_table.php"),
2574
], 'migrations');
2675

27-
// Commands
76+
// Register CLI commands only in console context
2877
if ($this->app->runningInConsole()) {
2978
$this->commands([
3079
Console\RebuildIndex::class,

src/Models/SearchIndex.php

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,50 @@
44

55
use Illuminate\Database\Eloquent\Model;
66

7+
/**
8+
* Class SearchIndex
9+
*
10+
* Represents a single tokenized entry in the encrypted search index.
11+
*
12+
* Each record in this table corresponds to one searchable token derived from
13+
* a field of an Eloquent model that uses the `HasEncryptedSearchIndex` trait.
14+
* These tokens enable efficient searching over encrypted or normalized fields
15+
* without exposing the original plaintext data.
16+
*
17+
* Structure overview:
18+
* - model_type: Fully Qualified Class Name (FQCN) of the related Eloquent model.
19+
* - model_id: Primary key of the model record.
20+
* - field: Name of the model field from which the token was derived.
21+
* - type: Type of token — "exact" for full-word matches, "prefix" for prefix matches.
22+
* - token: The deterministic, non-reversible hash (e.g. SHA-256) used for lookups.
23+
*
24+
* Typical usage:
25+
* - Automatically maintained by the HasEncryptedSearchIndex trait.
26+
* - Used internally to resolve `encryptedExact()` and `encryptedPrefix()` scopes.
27+
*
28+
* Security notes:
29+
* - Tokens are irreversible and contain no plaintext information.
30+
* - The table can be safely indexed and queried without leaking sensitive data.
31+
*/
732
class SearchIndex extends Model
833
{
34+
/**
35+
* The database table associated with the model.
36+
*
37+
* @var string
38+
*/
939
protected $table = 'encrypted_search_index';
1040

41+
/**
42+
* The attributes that are mass assignable.
43+
*
44+
* @var string[]
45+
*/
1146
protected $fillable = [
12-
'model_type', 'model_id', 'field', 'type', 'token',
47+
'model_type',
48+
'model_id',
49+
'field',
50+
'type',
51+
'token',
1352
];
1453
}

0 commit comments

Comments
 (0)