Поддержите проект сделав пожертвование.
Inn_100_gramm

Если вы видите это, значит, я еще не придумал, что написать.

От MySQL к Typesense: Молниеносный полнотекстовый поиск в Laravel

Поиск — это неотъемлемая часть многих приложений: будь то поиск ближайшей заправки, нахождение учебного пособия на YouTube или поиск старого сообщения в чате. В этом посте мы рассмотрим, как реализовать функцию поиска в приложениях на Laravel. Мы начнем с базовых запросов MySQL с использованием оператора LIKE и перейдем к более производительным решениям с помощью полнотекстовых индексов и Typesense.

Настройка проекта и заполнение 2 миллионов строк

Представим приложение для обслуживания клиентов, где требуется быстро находить аккаунты клиентов по имени, электронной почте или адресу. Скорость поиска критична для эффективного ответа на запросы. Создадим это приложение и назовем его StarSupport:

composer create-project laravel/laravel starsupport

Или с использованием Laravel Installer:

laravel new starsupport

Выберите “No starter kit” при установке и настройте подключение к базе данных MySQL в файле .env.

Теперь создадим модель Customer с миграцией, фабрикой и сидером:

php artisan make:model Customer --migration --factory --seed

Эта команда создаст четыре файла:

app/Models/Customer.php database/migrations/2024_06_20_135645_create_customers_table.php database/factories/CustomerFactory.php database/seeders/CustomerSeeder.php Настроим миграцию:

$table->id();
$table->string('name'); 
$table->string('email')->unique();
$table->string('account_number')->unique();
$table->string('address');
$table->string('country');
$table->string('phone');
$table->timestamps();

Настроим фабрику для генерации фейковых данных:

'name' => fake()->firstName() . ' ' . fake()->lastName(),
'email' => fake()->unique()->safeEmail(),
'account_number' => fake()->unique()->randomNumber(8, true),
'address' => fake()->address(),
'country' => fake()->country(),
'phone' => fake()->phoneNumber(),

Используем фабрику в CustomerSeeder для создания двух миллионов записей:

public function run(): void
{
    $total = 2_000_000;
    $chunkSize = 100_000;
 
    for ($i = 0; $i < $total; $i += $chunkSize) {
        Customer::factory()->count($chunkSize)->create();
    }
}

Не забудьте вызвать этот сидер в DatabaseSeeder:

public function run(): void
{
    User::factory()->create([
        'name' => 'Test User',
        'email' => '[email protected]',
    ]);
 
    $this->call(CustomerSeeder::class); 
}

Теперь установим заполняемость всех полей модели Customer:

protected $guarded = [];

Заполним базу данных:

php artisan migrate:fresh --seed

Первый подход к поиску: Реализация запросов LIKE

Цель — создать функцию поиска клиентов по имени, электронной почте или адресу с помощью ключевого слова. Для этого создадим метод scope в модели Customer:

public function scopeSearch(Builder $query, string $keyword): Builder
{
    return $query->where('name', 'LIKE', "%{$keyword}%")
        ->orWhere('email', 'LIKE', "%{$keyword}%")
        ->orWhere('address', 'LIKE', "%{$keyword}%");
}

Проведем тестирование производительности этого запроса с помощью встроенного помощника Benchmark:

use App\Models\Customer;
use Illuminate\Support\Benchmark;

Benchmark::dd(fn () => Customer::search('john')->get());

Результат может быть около 3 секунд. Это недостаточно быстро для поиска в базе данных из двух миллионов записей.

Поиск более эффективно: Использование полнотекстовых индексов MySQL

С версии 5.6 MySQL поддерживает полнотекстовые индексы, которые улучшают производительность поиска по тексту. Создадим новый миграционный файл для добавления полнотекстового индекса:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('customers', function (Blueprint $table) {
            $table->fullText(['name', 'email', 'address']);
        });
    }

    public function down(): void
    {
        Schema::table('customers', function (Blueprint $table) {
            $table->dropFullText(['name', 'email', 'address']);
        });
    }
};

Обновим метод scope для использования полнотекстового поиска:

public function scopeSearch(Builder $query, string $keyword): Builder
{
    return $query->whereFullText(['name', 'email', 'address'], $keyword);
}

Проведем тестирование производительности:

Benchmark::dd(fn () => Customer::search(Str::random(4))->get(), iterations: 10);

Результат может быть около 3 миллисекунд, что значительно быстрее.

Построение компонента Livewire для поиска

Установим Livewire:

composer require livewire/livewire

Создадим компонент поиска:

php artisan make:livewire customer-search

Редактируем компонент:

<?php

namespace App\Livewire;

use Livewire\Component;
use App\Models\Customer;
use Illuminate\Support\Collection;

class CustomerSearch extends Component
{
    public string $keyword = '';
    public Collection $customers;

    public function search()
    {
        $this->customers = strlen($this->keyword) > 2
            ? Customer::search($this->keyword)->take(20)->get()
            : collect([]);
    }

    public function render()
    {
        return view('livewire.customer-search');
    }
}

Обновим шаблон компонента:

<div class="customer-search">
    <input
        wire:model="keyword"
        wire:keyup.debounce="search"
        autofocus
        placeholder="Search" />
    @if ($keyword)
        <ul>
            @forelse ($customers as $customer)
                <li>
                    <div>{{ $customer['name'] }}</div>
                    <div>{{ $customer['email'] }}</div>
                    <div>{{ $customer['address'] }}</div>
                </li>
            @empty
                <li>
                    No matches found
                </li>
            @endforelse
        </ul>
    @endif
</div>

Добавим выделение ключевых слов в результатах поиска:

use Livewire\Attributes\Computed;

#[Computed]
public function highlightedCustomers()
{
    $fields = ['name', 'email', 'address'];
    $highlight = fn ($value) => preg_replace("/({$this->keyword})/i",'<mark>$1</mark>',$value);

    return $this->customers
        ->map(fn ($customer) => array_map($highlight, $customer->only($fields)));
}

И обновим шаблон:

@forelse ($this->highlightedCustomers as $customer)
    <li>
        <div>{!! $customer['name'] !!}</div>
        <div>{!! $customer['email'] !!}</div>
        <div>{!! $customer['address'] !!}</div>
    </li>
@empty
    <li>
        No matches found
    </li>
@endforelse

Ограничения полнотекстового поиска в MySQL

Отсутствие терпимости к ошибкам Поиск по ключевым словам не позволяет находить записи с ошибками в запросе. Пример: “jhon” вместо “john”.

Отсутствие поддержки суффиксного и инфиксного поиска Поиск по полнотекстовому индексу не поддерживает совпадения по частям слова. Пример: “tom*” не найдет записи с “atom”.

Проблемы с взвешиванием результатов Поиск с учетом значимости результатов не поддерживается.

Заключение

Мы рассмотрели, как использовать MySQL полнотекстовые индексы и Typesense для повышения производительности поиска в Laravel-приложениях. Мы также создали компонент Livewire для визуализации поиска и улучшения пользовательского опыта. В будущем вы можете рассмотреть другие варианты поисковых систем для еще более сложных требований.

Источник: https://tighten.com/insights/blazing-fast-full-text-search-in-laravel-from-mysql-to-typesense/

1
Inn_100_gramm

Если вы видите это, значит, я еще не придумал, что написать.

Защита от SQL-инъекций в Laravel: проверка параметров запроса

SQL-инъекции представляют серьезную угрозу для безопасности веб-приложений. В этой статье мы рассмотрим, как можно реализовать метод для проверки параметров запроса на наличие SQL-инъекций в Laravel, используя более универсальный и надежный подход.

Создание класса для проверки SQL-инъекций Сначала создадим класс AdvancedSqlInjectionChecker, который будет содержать метод для проверки строк на наличие SQL-инъекций.

class AdvancedSqlInjectionChecker
{
    /**
     * Проверяет, содержит ли строка потенциально опасные SQL-инъекции.
     *
     * @param string $input
     * @return bool
     */
    public static function hasSqlInjection(string $input): bool
    {
        // Набор шаблонов для обнаружения SQL-инъекций
        $patterns = [
            '/(?:\b(select|union|insert|update|delete|drop|alter|create|truncate)\b)/i', // Ключевые слова SQL
            '/(?:--|\#|\;)/', // Комментарии и точка с запятой
            '/(?:\b(and|or|xor|not)\b\s+[\w\s]+\s*(=|like|>|<|in|is|between)\s+[\w\s]+)/i', // Условные операторы
            '/(?:\b(?:exec|execute|sp_executesql|xp_cmdshell)\b)/i', // Команды выполнения
            '/(?:\b(select|union)[\s\S]+(from|join|into|load_file|information_schema|mysql)\b)/i', // Комбинированные шаблоны
        ];

        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $input)) {
                return true;
            }
        }

        // Дополнительная проверка на символы, которые часто используются в инъекциях
        $specialChars = ['\'', '"', ';', '\\', '--', '#'];

        foreach ($specialChars as $char) {
           if (str_contains($input, $char)) {
                return true;
            }
        }

        return false;
    }
}

Проверка параметров запроса в Laravel

Теперь интегрируем наш класс проверки в Laravel. Мы создадим Middleware, который будет проверять все параметры запроса на наличие SQL-инъекций.

Создадим новый Middleware:

php artisan make:middleware CheckForSqlInjection

Внутри созданного Middleware реализуем проверку:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use AdvancedSqlInjectionChecker;

class CheckForSqlInjection
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {
        $allInputs = array_merge($request->all(), $request->route()->parameters());

        foreach ($allInputs as $key => $value) {
            if (is_string($value) && AdvancedSqlInjectionChecker::hasSqlInjection($value)) {
                return response()->json(['error' => 'Potential SQL Injection detected in parameter: ' . $key], 400);
            }
        }

        return $next($request);
    }
}

Зарегистрируем Middleware в app/Http/Kernel.php:

protected $middleware = [
    // ...
    \App\Http\Middleware\CheckForSqlInjection::class,
];

Юнит-тестирование

Создадим юнит-тест для проверки работы класса AdvancedSqlInjectionChecker.

Создадим тестовый класс:

php artisan make:test AdvancedSqlInjectionCheckerTest

Реализуем тесты внутри созданного класса:

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use App\Services\AdvancedSqlInjectionChecker;

class AdvancedSqlInjectionCheckerTest extends TestCase
{
    /**
     * @dataProvider sqlInjectionProvider
     */
    public function testHasSqlInjection($input, $expected)
    {
        $this->assertEquals($expected, AdvancedSqlInjectionChecker::hasSqlInjection($input));
    }

    public function sqlInjectionProvider()
    {
        return [
            ["SELECT * FROM users;", true],
            ["' OR 1=1 --", true],
            ["DROP TABLE users;", true],
            ["Safe string", false],
            ["12345", false],
        ];
    }
}

Заключение

Мы создали класс AdvancedSqlInjectionChecker, который проверяет строки на наличие SQL-инъекций с помощью регулярных выражений и анализа специальных символов. Затем мы интегрировали эту проверку в Laravel с помощью Middleware, который анализирует все параметры входящих запросов.

Этот метод позволяет обнаруживать потенциальные SQL-инъекции на раннем этапе и предотвращать выполнение опасных запросов. Однако, для полной защиты рекомендуется использовать подготовленные выражения и экранирование данных.

Пример использования Допустим, у нас есть контроллер, который принимает параметры запроса:

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class UserController extends Controller
{
    public function getUser(Request $request)
    {
        $name = $request->input('name');
        $email = $request->input('email');

        // Допустим, здесь мы используем Eloquent для поиска пользователя
        $user = \App\Models\User::where('name', $name)->where('email', $email)->first();

        return response()->json($user);
    }
}

С включенным Middleware CheckForSqlInjection, каждый параметр запроса будет проверяться на наличие SQL-инъекций перед выполнением основного кода контроллера. Это добавляет дополнительный уровень безопасности вашему приложению.

1