Skip to content

Instantly share code, notes, and snippets.

@sam-ngu
Last active September 22, 2023 01:53
Show Gist options
  • Save sam-ngu/a1ad17bc817a9173e3247ea2cd1e69c1 to your computer and use it in GitHub Desktop.
Save sam-ngu/a1ad17bc817a9173e3247ea2cd1e69c1 to your computer and use it in GitHub Desktop.

Revisions

  1. sam-ngu revised this gist Oct 28, 2020. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion SitewideSearchController.php
    Original file line number Diff line number Diff line change
    @@ -71,7 +71,7 @@ public function search(Request $request)
    // a. `match` -- the match found in our model records
    // b. `model` -- the related model name
    // c. `view_link` -- the URL for the user to navigate in the frontend to view the resource
    return $model::search($keyword)->get()->map(function ($modelRecord) use ($model, $keyword, $classname){
    return $model::search($keyword)->take(5)->get()->map(function ($modelRecord) use ($model, $keyword, $classname){

    // to create the `match` attribute, we need to join the value of all the searchable fields in
    // our model, ie all the fields defined in our 'toSearchableArray' model method
  2. sam-ngu created this gist Oct 13, 2020.
    165 changes: 165 additions & 0 deletions SitewideSearchController.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,165 @@
    <?php

    namespace App\Http\Controllers;

    use App\Http\Resources\SiteSearchResource;
    use App\Models\Comment;
    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Http\Request;
    use Illuminate\Support\Arr;
    use Illuminate\Support\Facades\File;
    use Illuminate\Support\Facades\URL;
    use Illuminate\Support\Str;
    use Symfony\Component\Finder\SplFileInfo;

    class SitewideSearchController extends Controller
    {
    const BUFFER = 10; // 10 characters: to show 10 neighbouring characters around the searched word

    /** A helper function to generate the model namespace
    * @return string
    */
    private function modelNamespacePrefix()
    {
    return app()->getNamespace() . 'Models\\';
    }

    public function search(Request $request)
    {
    $keyword = $request->search;

    // just for demonstration, you can include models that you want to exclude from the searches here
    $toExclude = [Comment::class];

    // getting all the model files from the model folder
    $files = File::allFiles(app()->basePath() . '/app/Models');

    // to get all the model classes
    $results = collect($files)->map(function (SplFileInfo $file){
    $filename = $file->getRelativePathname();

    // assume model name is equal to file name
    /* making sure it is a php file*/
    if (substr($filename, -4) !== '.php'){
    return null;
    }
    // removing .php
    return substr($filename, 0, -4);

    })->filter(function (?string $classname) use($toExclude){
    if($classname === null){
    return false;
    }

    // using reflection class to obtain class info dynamically
    $reflection = new \ReflectionClass($this->modelNamespacePrefix() . $classname);

    // making sure the class extended eloquent model
    $isModel = $reflection->isSubclassOf(Model::class);

    // making sure the model implemented the searchable trait
    $searchable = $reflection->hasMethod('search');

    // filter model that has the searchable trait and not in exclude array
    return $isModel && $searchable && !in_array($reflection->getName(), $toExclude, true);

    })->map(function ($classname) use ($keyword) {
    // for each class, call the search function
    $model = app($this->modelNamespacePrefix() . $classname);

    // Our goal here: to add these 3 attributes to each of our search result:
    // a. `match` -- the match found in our model records
    // b. `model` -- the related model name
    // c. `view_link` -- the URL for the user to navigate in the frontend to view the resource
    return $model::search($keyword)->get()->map(function ($modelRecord) use ($model, $keyword, $classname){

    // to create the `match` attribute, we need to join the value of all the searchable fields in
    // our model, ie all the fields defined in our 'toSearchableArray' model method
    //
    // We make use of the SEARCHABLE_FIELDS constant in our model
    // we dont want id in the match, so we filter it out.
    $fields = array_filter($model::SEARCHABLE_FIELDS, fn($field) => $field !== 'id');

    // only extracting the relevant fields from our model
    $fieldsData = $modelRecord->only($fields);

    // joining the fields together
    $serializedValues = collect($fieldsData)->join(' ');

    // finding the position of match
    $searchPos = strpos(strtolower($serializedValues), strtolower($keyword));

    // Our goal here:
    // After finding the match position, we also want to include the surrounding text, so our user would
    // have a better search experience.
    //
    // We append or prepend `...` if there are more text before / after our match + neighbouring text
    // including the found terms
    if($searchPos !== false){

    // the buffer number dictates how many neighbouring characters to display
    $start = $searchPos - self::BUFFER;

    // we don't want to go below 0 as the starting position
    $start = $start < 0 ? 0 : $start;

    // multiply 2 buffer to cover the text before and after the match
    $length = strlen($keyword) + 2 * self::BUFFER;

    // getting the match and neighbouring text
    $sliced = substr($serializedValues, $start, $length);

    // adding prefix and postfix dots

    // if start position is negative, there is no need to prepend `...`
    $shouldAddPrefix = $start > 0;
    // if end position went over the total length, there is no need to append `...`
    $shouldAddPostfix = ($start + $length) < strlen($serializedValues) ;

    $sliced = $shouldAddPrefix ? '...' . $sliced : $sliced;
    $sliced = $shouldAddPostfix ? $sliced . '...' : $sliced;
    }
    // use $slice as the match, otherwise if undefined we use the first 20 character of serialisedValues
    $modelRecord->setAttribute('match', $sliced ?? substr($serializedValues, 0, 20) . '...');
    // setting the model name
    $modelRecord->setAttribute('model', $classname);
    // setting the resource link
    $modelRecord->setAttribute('view_link', $this->resolveModelViewLink($modelRecord));
    return $modelRecord;

    });
    })->flatten(1);

    // using a standardised site search resource
    return SiteSearchResource::collection($results);

    }

    /** Helper function to retrieve resource URL
    * @param Model $model
    * @return string|string[]
    */
    private function resolveModelViewLink(Model $model)
    {
    // Here we list down all the alternative model-link mappings
    // if we dont have a record here, will default to /{model-name}/{model_id}
    $mapping = [
    \App\Models\Comment::class => '/comments/view/{id}'
    ];

    // getting the Fully Qualified Class Name of model
    $modelClass = get_class($model);

    // converting model name to kebab case
    $modelName = Str::plural(Arr::last(explode('\\', $modelClass)));
    $modelName = Str::kebab(Str::camel($modelName));

    // attempt to get from $mapping. We assume every entry has an `{id}` for us to replace
    if(Arr::has($mapping, $modelClass)){
    return str_replace('{id}', $model->id, $mapping[$modelClass]);
    }
    // assume /{model-name}/{model_id}
    return URL::to('/' . strtolower($modelName) . '/' . $model->id);

    }
    }