diff --git a/.env.example b/.env.example index ae01e25..119a353 100644 --- a/.env.example +++ b/.env.example @@ -20,13 +20,15 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug +# ── Base de données — choisir pgsql ou mysql ────────────────────────────────── DB_CONNECTION=pgsql DB_HOST=127.0.0.1 -DB_PORT=5432 +DB_PORT=5432 # PostgreSQL : 5432 | MySQL : 3306 DB_DATABASE=mesreleves DB_USERNAME=mesreleves DB_PASSWORD=secret +# ── Sessions / Cache / Queue — driver "database" (pas de Redis requis) ──────── SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false @@ -35,13 +37,15 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +QUEUE_CONNECTION=database # database (défaut) | redis (optionnel) -CACHE_STORE=database +CACHE_STORE=database # database (défaut) | redis (optionnel) # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 +# ── Redis (optionnel — non requis par défaut) ───────────────────────────────── +# Pour l'activer : changer CACHE_STORE et SESSION_DRIVER en "redis" REDIS_CLIENT=phpredis REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index b483552..598e46a 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Enums\UserRole; use App\Http\Controllers\Controller; use App\Models\User; +use App\Support\DbCompat; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rules\Enum; @@ -22,9 +23,10 @@ class UserController extends Controller if ($request->filled('q')) { $q = trim($request->get('q')); + $like = DbCompat::like(); $query->where(fn ($wq) => $wq - ->where('name', 'ilike', "%{$q}%") - ->orWhere('email', 'ilike', "%{$q}%") + ->where('name', $like, "%{$q}%") + ->orWhere('email', $like, "%{$q}%") ); } diff --git a/app/Http/Controllers/ExportController.php b/app/Http/Controllers/ExportController.php index b79ee15..0d8f83c 100644 --- a/app/Http/Controllers/ExportController.php +++ b/app/Http/Controllers/ExportController.php @@ -6,6 +6,7 @@ use App\Enums\SourceStatus; use App\Models\Releve; use App\Models\Source; use App\Services\GedcomExportService; +use App\Support\DbCompat; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\DB; @@ -50,15 +51,17 @@ class ExportController extends Controller }); // $request déjà dans le use() if ($request->filled('q')) { - $q = trim($request->get('q')); - $query->where(function ($wq) use ($q) { - $wq->where('nom', 'ilike', "%{$q}%") - ->orWhere('prenom','ilike', "%{$q}%") - ->orWhere('date_evenement', 'ilike', "%{$q}%") - ->orWhereRaw( - "to_tsvector('french', data::text) @@ plainto_tsquery('french', ?)", - [$q] - ); + $q = trim($request->get('q')); + $like = DbCompat::like(); + $fts = DbCompat::ftsRaw(); + + $query->where(function ($wq) use ($q, $like, $fts) { + $wq->where('nom', $like, "%{$q}%") + ->orWhere('prenom', $like, "%{$q}%") + ->orWhere('date_evenement', $like, "%{$q}%"); + if ($fts) { + $wq->orWhereRaw($fts, [$q]); + } }); } @@ -76,7 +79,7 @@ class ExportController extends Controller $noms = collect($rows)->pluck('nom')->filter(); if ($noms->isNotEmpty()) { $pattern = $noms->map(fn ($n) => preg_quote($n, '/'))->join('|'); - $query->whereRaw("data::text ~* ?", [$pattern]); + $query->whereRaw(DbCompat::jsonRegexRaw('data'), [$pattern]); } } diff --git a/app/Http/Controllers/LieuController.php b/app/Http/Controllers/LieuController.php index d9fab90..d2aa5ee 100644 --- a/app/Http/Controllers/LieuController.php +++ b/app/Http/Controllers/LieuController.php @@ -6,6 +6,7 @@ use App\Http\Requests\StoreLieuRequest; use App\Http\Requests\UpdateLieuRequest; use App\Models\Lieu; use App\Models\LieuType; +use App\Support\DbCompat; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -20,9 +21,9 @@ class LieuController extends Controller $lieux = Lieu::with('lieuType') ->where(function ($query) use ($q) { - $query->where('nom_long', 'ilike', "%{$q}%") - ->orWhere('nom', 'ilike', "%{$q}%") - ->orWhere('code', 'ilike', "%{$q}%"); + $query->where('nom_long', DbCompat::like(), "%{$q}%") + ->orWhere('nom', DbCompat::like(), "%{$q}%") + ->orWhere('code', DbCompat::like(), "%{$q}%"); }) ->orderBy('nom_long') ->limit(25) @@ -49,9 +50,9 @@ class LieuController extends Controller if ($request->filled('q')) { $q = trim($request->get('q')); $query->where(function ($wq) use ($q) { - $wq->where('nom_long', 'ilike', "%{$q}%") - ->orWhere('nom', 'ilike', "%{$q}%") - ->orWhere('code', 'ilike', "%{$q}%"); + $wq->where('nom_long', DbCompat::like(), "%{$q}%") + ->orWhere('nom', DbCompat::like(), "%{$q}%") + ->orWhere('code', DbCompat::like(), "%{$q}%"); }); } diff --git a/app/Http/Controllers/RechercheController.php b/app/Http/Controllers/RechercheController.php index c3fd2b8..f282808 100644 --- a/app/Http/Controllers/RechercheController.php +++ b/app/Http/Controllers/RechercheController.php @@ -6,6 +6,7 @@ use App\Enums\SourceStatus; use App\Models\Lieu; use App\Models\Releve; use App\Models\SourceType; +use App\Support\DbCompat; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\View\View; @@ -50,15 +51,18 @@ class RechercheController extends Controller // ── Recherche textuelle ────────────────────────────────────────────── if ($request->filled('q')) { - $q = trim($request->get('q')); - $query->where(function ($wq) use ($q) { - $wq->where('nom', 'ilike', "%{$q}%") - ->orWhere('prenom','ilike', "%{$q}%") - ->orWhere('date_evenement', 'ilike', "%{$q}%") - ->orWhereRaw( - "to_tsvector('french', data::text) @@ plainto_tsquery('french', ?)", - [$q] - ); + $q = trim($request->get('q')); + $like = DbCompat::like(); + $fts = DbCompat::ftsRaw(); + + $query->where(function ($wq) use ($q, $like, $fts) { + $wq->where('nom', $like, "%{$q}%") + ->orWhere('prenom', $like, "%{$q}%") + ->orWhere('date_evenement', $like, "%{$q}%"); + + if ($fts) { + $wq->orWhereRaw($fts, [$q]); + } }); } @@ -66,11 +70,10 @@ class RechercheController extends Controller if ($request->filled('lieu_id')) { $lieuNoms = $this->getLieuNoms($request->integer('lieu_id')); if ($lieuNoms->isNotEmpty()) { - // Recherche regex case-insensitive dans le JSONB text $pattern = $lieuNoms ->map(fn ($n) => preg_quote($n, '/')) ->join('|'); - $query->whereRaw("data::text ~* ?", [$pattern]); + $query->whereRaw(DbCompat::jsonRegexRaw('data'), [$pattern]); } } diff --git a/app/Services/UpdateService.php b/app/Services/UpdateService.php index db33362..0eadea6 100644 --- a/app/Services/UpdateService.php +++ b/app/Services/UpdateService.php @@ -206,21 +206,30 @@ class UpdateService throw new RuntimeException("Fichier introuvable : {$path}"); } - $host = config('database.connections.pgsql.host'); - $port = config('database.connections.pgsql.port', 5432); - $db = config('database.connections.pgsql.database'); - $user = config('database.connections.pgsql.username'); - $password = config('database.connections.pgsql.password'); + $driver = config('database.default'); + $conn = config("database.connections.{$driver}"); - $cmd = sprintf( - 'PGPASSWORD=%s psql -h %s -p %s -U %s %s < %s', - escapeshellarg($password), - escapeshellarg($host), - (int) $port, - escapeshellarg($user), - escapeshellarg($db), - escapeshellarg($path), - ); + if ($driver === 'pgsql') { + $cmd = sprintf( + 'PGPASSWORD=%s psql -h %s -p %s -U %s %s < %s', + escapeshellarg($conn['password']), + escapeshellarg($conn['host']), + (int) ($conn['port'] ?? 5432), + escapeshellarg($conn['username']), + escapeshellarg($conn['database']), + escapeshellarg($path), + ); + } else { + $cmd = sprintf( + 'mysql -h %s -P %s -u %s %s %s < %s', + escapeshellarg($conn['host']), + (int) ($conn['port'] ?? 3306), + escapeshellarg($conn['username']), + $conn['password'] ? '-p' . escapeshellarg($conn['password']) : '', + escapeshellarg($conn['database']), + escapeshellarg($path), + ); + } Artisan::call('down'); $this->exec($cmd); @@ -238,27 +247,35 @@ class UpdateService $filename = "db-before-{$version}-" . date('Ymd-His') . ".sql"; $path = "{$dir}/{$filename}"; + $driver = config('database.default'); + $conn = config("database.connections.{$driver}"); - $host = config('database.connections.pgsql.host'); - $port = config('database.connections.pgsql.port', 5432); - $db = config('database.connections.pgsql.database'); - $user = config('database.connections.pgsql.username'); - $password = config('database.connections.pgsql.password'); - - $cmd = sprintf( - 'PGPASSWORD=%s pg_dump -h %s -p %s -U %s %s > %s', - escapeshellarg($password), - escapeshellarg($host), - (int) $port, - escapeshellarg($user), - escapeshellarg($db), - escapeshellarg($path), - ); + if ($driver === 'pgsql') { + $cmd = sprintf( + 'PGPASSWORD=%s pg_dump -h %s -p %s -U %s %s > %s', + escapeshellarg($conn['password']), + escapeshellarg($conn['host']), + (int) ($conn['port'] ?? 5432), + escapeshellarg($conn['username']), + escapeshellarg($conn['database']), + escapeshellarg($path), + ); + } else { + $cmd = sprintf( + 'mysqldump -h %s -P %s -u %s %s %s > %s', + escapeshellarg($conn['host']), + (int) ($conn['port'] ?? 3306), + escapeshellarg($conn['username']), + $conn['password'] ? '-p' . escapeshellarg($conn['password']) : '', + escapeshellarg($conn['database']), + escapeshellarg($path), + ); + } $this->exec($cmd); if (! file_exists($path) || filesize($path) === 0) { - throw new RuntimeException("La sauvegarde PostgreSQL a échoué (pg_dump disponible dans le container ?)"); + throw new RuntimeException("La sauvegarde de la base de données a échoué ({$driver})."); } return $path; diff --git a/app/Support/DbCompat.php b/app/Support/DbCompat.php new file mode 100644 index 0000000..180696d --- /dev/null +++ b/app/Support/DbCompat.php @@ -0,0 +1,74 @@ +getDriverName() === 'pgsql'; + } + + /** Opérateur LIKE insensible à la casse */ + public static function like(): string + { + return self::isPgsql() ? 'ilike' : 'like'; + } + + /** Caste une colonne JSON en texte brut */ + public static function jsonToText(string $column): string + { + return self::isPgsql() + ? "{$column}::text" + : "CAST({$column} AS CHAR)"; + } + + /** + * Condition de correspondance regex (insensible à la casse) sur une colonne JSON. + * Retourne la chaîne à passer à whereRaw() avec un seul binding `?`. + */ + public static function jsonRegexRaw(string $column = 'data'): string + { + return self::isPgsql() + ? "{$column}::text ~* ?" + : "CAST({$column} AS CHAR) REGEXP ?"; + } + + /** + * Condition de recherche plein-texte. + * Retourne null pour MySQL (le caller doit prévoir un fallback LIKE). + */ + public static function ftsRaw(): ?string + { + return self::isPgsql() + ? "to_tsvector('french', data::text) @@ plainto_tsquery('french', ?)" + : null; + } + + /** Syntaxe de la colonne générée stockée pour extraire un champ JSON de premier niveau */ + public static function generatedJsonCol(string $jsonKey): string + { + return self::isPgsql() + ? "data->>'$jsonKey'" + : "JSON_UNQUOTE(JSON_EXTRACT(data, '$.$jsonKey'))"; + } + + /** Syntaxe de la colonne générée stockée pour un champ JSON imbriqué (ex: date_evenement.valeur) */ + public static function generatedJsonNestedCol(string $jsonPath): string + { + if (self::isPgsql()) { + // data->'date_evenement'->>'valeur' + $parts = explode('.', $jsonPath); + $last = array_pop($parts); + $chain = implode('', array_map(fn ($p) => "->'{$p}'", $parts)); + return "data{$chain}->>'{$last}'"; + } + // MySQL: $.date_evenement.valeur + return "JSON_UNQUOTE(JSON_EXTRACT(data, '$.{$jsonPath}'))"; + } +} diff --git a/database/migrations/2024_01_01_100500_create_releves_table.php b/database/migrations/2024_01_01_100500_create_releves_table.php index 7036756..1175833 100644 --- a/database/migrations/2024_01_01_100500_create_releves_table.php +++ b/database/migrations/2024_01_01_100500_create_releves_table.php @@ -1,35 +1,56 @@ id(); $table->foreignId('source_id')->constrained()->cascadeOnDelete(); - $table->jsonb('data'); // champs variables selon source_type + // PostgreSQL : JSONB (binary JSON, indexable par GIN) + // MySQL : JSON + if ($isPgsql) { + $table->jsonb('data'); + } else { + $table->json('data'); + } $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); $table->foreignId('updated_by')->constrained('users')->restrictOnDelete(); $table->timestamps(); }); - // Colonnes générées stockées pour les champs les plus filtrés - DB::statement("ALTER TABLE releves ADD COLUMN nom TEXT GENERATED ALWAYS AS (data->>'nom') STORED"); - DB::statement("ALTER TABLE releves ADD COLUMN prenom TEXT GENERATED ALWAYS AS (data->>'prenom') STORED"); - DB::statement("ALTER TABLE releves ADD COLUMN date_evenement TEXT GENERATED ALWAYS AS (data->'date_evenement'->>'valeur') STORED"); + // ── Colonnes générées stockées (syntaxe différente selon le SGBD) ────── + $nomExpr = DbCompat::generatedJsonCol('nom'); + $prenomExpr = DbCompat::generatedJsonCol('prenom'); + $dateEvtExpr = DbCompat::generatedJsonNestedCol('date_evenement.valeur'); - // Index pour les colonnes générées + if ($isPgsql) { + DB::statement("ALTER TABLE releves ADD COLUMN nom TEXT GENERATED ALWAYS AS ({$nomExpr}) STORED"); + DB::statement("ALTER TABLE releves ADD COLUMN prenom TEXT GENERATED ALWAYS AS ({$prenomExpr}) STORED"); + DB::statement("ALTER TABLE releves ADD COLUMN date_evenement TEXT GENERATED ALWAYS AS ({$dateEvtExpr}) STORED"); + } else { + DB::statement("ALTER TABLE releves ADD COLUMN nom VARCHAR(255) GENERATED ALWAYS AS ({$nomExpr}) STORED"); + DB::statement("ALTER TABLE releves ADD COLUMN prenom VARCHAR(255) GENERATED ALWAYS AS ({$prenomExpr}) STORED"); + DB::statement("ALTER TABLE releves ADD COLUMN date_evenement VARCHAR(255) GENERATED ALWAYS AS ({$dateEvtExpr}) STORED"); + } + + // ── Index B-tree sur les colonnes générées ─────────────────────────── DB::statement('CREATE INDEX releves_nom_idx ON releves (nom)'); DB::statement('CREATE INDEX releves_prenom_idx ON releves (prenom)'); DB::statement('CREATE INDEX releves_date_evenement_idx ON releves (date_evenement)'); - // Index GIN pour la recherche fulltext sur l'ensemble du JSONB - DB::statement('CREATE INDEX releves_data_gin_idx ON releves USING gin (data)'); + // ── Index GIN sur le JSON complet (PostgreSQL uniquement) ───────────── + if ($isPgsql) { + DB::statement('CREATE INDEX releves_data_gin_idx ON releves USING gin (data)'); + } } public function down(): void diff --git a/docker-compose.mysql.yml b/docker-compose.mysql.yml new file mode 100644 index 0000000..789225a --- /dev/null +++ b/docker-compose.mysql.yml @@ -0,0 +1,27 @@ +# Développement local — MySQL 8 (alternative à PostgreSQL) +# Usage : docker compose -f docker-compose.mysql.yml up -d +# .env : DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 +services: + db: + image: mysql:8 + container_name: mesreleves_db_mysql + restart: unless-stopped + environment: + MYSQL_DATABASE: mesreleves + MYSQL_USER: mesreleves + MYSQL_PASSWORD: secret + MYSQL_ROOT_PASSWORD: secret + MYSQL_CHARSET: utf8mb4 + MYSQL_COLLATION: utf8mb4_unicode_ci + ports: + - "3306:3306" + volumes: + - mysqldata:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-umesreleves", "-psecret"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + mysqldata: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8e5da65..ed10622 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,4 +1,5 @@ -# Déploiement production — stack complète (app + db + redis + nginx) +# Déploiement production — stack complète (app + db + nginx) +# Pas de Redis — cache/sessions/queue utilisent la base de données # Usage : docker compose -f docker-compose.prod.yml up -d services: app: @@ -10,25 +11,20 @@ services: environment: APP_ENV: production APP_KEY: ${APP_KEY} - DB_CONNECTION: pgsql + DB_CONNECTION: ${DB_CONNECTION:-pgsql} DB_HOST: db - DB_PORT: 5432 + DB_PORT: ${DB_PORT:-5432} DB_DATABASE: ${DB_DATABASE:-mesreleves} DB_USERNAME: ${DB_USERNAME:-mesreleves} DB_PASSWORD: ${DB_PASSWORD} - CACHE_STORE: redis - SESSION_DRIVER: redis - QUEUE_CONNECTION: redis - REDIS_HOST: redis + CACHE_STORE: database + SESSION_DRIVER: database + QUEUE_CONNECTION: database depends_on: db: condition: service_healthy - redis: - condition: service_started volumes: - # Le code est monté depuis l'hôte : les mises à jour ne nécessitent pas de rebuild image - .:/var/www/html - # Le storage est un volume nommé (uploads, logs, sessions — persisté indépendamment du code) - app_storage:/var/www/html/storage networks: - internal @@ -50,35 +46,32 @@ services: - internal db: - image: postgres:18-alpine + image: ${DB_IMAGE:-postgres:18-alpine} container_name: mesreleves_db restart: unless-stopped environment: + # PostgreSQL POSTGRES_DB: ${DB_DATABASE:-mesreleves} POSTGRES_USER: ${DB_USERNAME:-mesreleves} POSTGRES_PASSWORD: ${DB_PASSWORD} + # MySQL (ignoré par PostgreSQL) + MYSQL_DATABASE: ${DB_DATABASE:-mesreleves} + MYSQL_USER: ${DB_USERNAME:-mesreleves} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} volumes: - - pgdata:/var/lib/postgresql + - dbdata:/var/lib/postgresql # PostgreSQL + # Pour MySQL, remplacer par : - dbdata:/var/lib/mysql healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-mesreleves}"] + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-mesreleves} || mysqladmin ping -h localhost -u${DB_USERNAME:-mesreleves} -p${DB_PASSWORD}"] interval: 10s timeout: 5s retries: 5 networks: - internal - redis: - image: redis:7-alpine - container_name: mesreleves_redis - restart: unless-stopped - volumes: - - redisdata:/data - networks: - - internal - volumes: - pgdata: - redisdata: + dbdata: app_storage: networks: diff --git a/docker-compose.yml b/docker-compose.yml index a9ec81f..ac80153 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -# Développement local — PostgreSQL + Redis uniquement +# Développement local — PostgreSQL uniquement (pas de Redis requis) # L'application PHP tourne avec `php artisan serve` en dehors de Docker services: db: @@ -19,15 +19,5 @@ services: timeout: 5s retries: 5 - redis: - image: redis:7-alpine - container_name: mesreleves_redis - restart: unless-stopped - ports: - - "6379:6379" - volumes: - - redisdata:/data - volumes: pgdata: - redisdata: