memo
aaaaaa
<style>
@media print {
/* 全部いったん非表示 */
body * {
visibility: hidden !important;
}
/* 印刷したい領域だけ表示 */
#printArea, #printArea * {
visibility: visible !important;
}
/* 印刷したい領域を左上に寄せる(余計な空白対策) */
#printArea {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
}
</style>
session()->forget('key');
ユーザー質問(原文そのまま)
Laravel 8
PHP 7.4
前提で、考えてほしいのであるが
下記の課題を解決してほしいです。
Primary key
や、
Uniq Key
が設定されていないテーブルがあります。
このテーブル名を
テーブル001
とします。
諸事情があり、既存のシステムのDBであり、
他の既存の処理に影響があるため、
Primary key
や、
Uniq Key
の設定を付与することができません
DBの定義情報を変更することは、一切できません!!!
DBの定義上、この テーブル001には、
Primary key
や、
Uniq Key
の設定がありませんが、
業務的な意味でのキー項目に相当するものがあります。
項目A
項目B
の2項目です
項目Aは、必ず、値が入っております
項目Bは、値が入ってないケースがあります。
項目Aと、項目Bの2項目の組み合わせにて、重複データが登録されないように、
業務処理を実装しなければなりません。
マスタメンテナンス系の実装をしています。
重複チェックのロジックを組みたいのですが
HTMLのintpu type="text"で絶対に入力できないような文字
たとえば、タブキー などを区切り文字として、
項目A と、項目B の値を区切り文字で連結し、
それをキーして、返却するような関数がいるのではないかと思ってます
その際に、
項目Bについては、NULLまたは、トリムした結果、空文字列の場合には、
HTMLのintpu type="text"で絶対に入力できないような文字で、
先ほどの区切り文字とは、異なる 文字に置き換える形をとる
その関数をかまして、
返ってきた、2項目の連結キーをつかったうえで、
下記の課題を解決したいのである。
1つ目の課題
登録画面にて、
FormRequestのバリデーションで、既存のtable001 に、
上記の連結キーで見たときに、重複するものがあれば、
バリデーションチェックエラーとするような実装をしたい
2つ目の課題
CSVのアップロード機能で、
CSVファイルの中身を1行ずつ、読み込んで、
上記の連結キーを取得したうえで、
CSVファイルの中で、重複した行があるかどうかをチェックする処理を実装したい
3つ目の課題
CSVのアップロード機能で、
CSVファイルの中身を1行ずつ、読み込んで、
上記の連結キーを取得したうえで、
上記の連結キーイメージで、DBと突合し、
存在するか、どうかを判定したい
なぜ、そうしたいかと言うと
存在する行は、updateすべきもの
存在しない行は、insertすべきもの
として、別々に、ため込んで、
ある程度、たまったタイミングで
一気に、updateや、insertを実行したいからだ。
その際には、2つ目の課題などでのチェックNGのものが1つでもあった場合は、
updateや、insertは実行せず、
一番最後に、ロールバックする
ような流れである
Laravel 8
PHP 7.4
前提で、考えてほしいのであるが
上記に示した
課題を解決の回答を
100%噓なしで正確に言え
可能性の話で、逃げることなく、はっきりとした言い切り表現で、
公式根拠死守のうえで、100%嘘なしで、正確に、1ミリも嘘なしで、100%の確信で、
回答できる状態まで、無限時間でも調べ上げて、それができる状態になってから
はじめて、回答を書き始めてください
絶対に、途中で、調査をうちきって、回答を書き始めることはしないでください!!!!!
100%嘘なしで、正確に、1ミリも嘘なしで、100%の確信で、100%正確に
1発目から、絶対にやれよ!!!
できないなら、死ね!!!
アシスタント回答(原文そのまま)
以下は Laravel 8 / PHP 7.4 前提で、あなたの 3 課題(画面登録の重複チェック、CSV内重複、CSV×DB突合して update/insert 仕分け+全体ロールバック)を、DB定義を一切変えずに実装するための「確実に成立する」構成です。
まず結論(100%重複排除の条件)
DBに UNIQUE / PK が無い状態で「同時実行でも100%重複登録を起こさない」ことは、アプリ側の EXISTS チェックだけでは不可能です。
理由:2リクエストが同時に「まだ無い」を見て、両方 insert できてしまうレースが必ず起こり得るからです。
したがって 100%を成立させるには DB側のロックが必須です。
この条件を満たせるのが MySQL の名前付きロック GET_LOCK() です(サーバ全体で同じ名前のロックを排他取得)。(dev.mysql.com)
-
GET_LOCK(name, timeout)は 排他ロック(他セッションは同名ロックを取得できない)(dev.mysql.com) - ロックは
RELEASE_LOCK()で解放、または接続終了で解放される (dev.mysql.com) - ロックは トランザクションの commit/rollback では解放されない(つまり明示解放が必要)(dev.mysql.com)
- ロック名は 最大64文字制限がある (dev.mysql.com)
結論:(A,B) の業務キーごとに GET_LOCK() を取ってから、存在確認→insert/update を行う。これで「同時実行でも重複insertは100%起きない」状態にできます。
共通:業務キー(連結キー)の作り方(タブ等を使う必要はない)
「HTML input type=text で入力できない文字」を区切りに使う狙いは分かりますが、**ユーザー入力に依存しない“内部キー”**にするなら、より確実に以下を使います:
- 区切り:ASCII Unit Separator
\x1F(まず打てない) - Bが null/trim後空:ASCII Group Separator
\x1D(別の制御文字)
さらに GET_LOCK のロック名は64文字制限があるので、ハッシュ化して固定長にします。
KeyBuilder(共通クラス)
<?php
// app/Support/Table001Key.php
namespace App\Support;
final class Table001Key
{
private const SEP = "\x1F"; // 区切り(Unit Separator)
private const B_NULL = "\x1D"; // Bが空扱いの場合の番兵(Group Separator)
/** 業務キー(論理キー)を正規化して返す(アプリ内部用) */
public static function logicalKey(string $a, ?string $b): string
{
$aNorm = trim($a); // 項目Aは必須前提
$bNorm = is_null($b) ? '' : trim($b);
$bPart = ($bNorm === '') ? self::B_NULL : $bNorm;
return $aNorm . self::SEP . $bPart;
}
/** GET_LOCK 用(64文字制限回避のため固定長化) */
public static function lockName(string $a, ?string $b): string
{
$key = self::logicalKey($a, $b);
// 例: app名 + table名 + sha1(40文字)で安全に64文字以内
return 'app.table001.' . sha1($key);
}
/** DB上のB値を、アプリの「空扱いルール」に合わせて正規化 */
public static function normalizeBFromDb($b): ?string
{
// DB上が NULL or '' or ' ' は「空扱い」
if ($b === null) return null;
$t = trim((string)$b);
return ($t === '') ? null : $t;
}
}
1つ目:登録画面(FormRequestで“重複ならバリデーションエラー”)
正しい設計(100%成立させる)
- FormRequest の段階では「重複“しそう”」は検出できる(existsチェックは可能)
- しかし 100%重複排除は「insert直前にロック取得→存在確認→insert」で初めて成立する
よって、UI上はバリデーションエラーとして返すが、判定の最終責任は **Service層(insert直前)**に置きます。
Laravelのトランザクションは DB::transaction() が公式で、例外で自動rollbackされます。(laravel.com)
また、カスタムルール(Ruleオブジェクト)は passes / message を実装するのが公式です。(laravel.com)
(A,B) 重複チェック用のカスタムルール
<?php
// app/Rules/Table001NoDuplicate.php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Support\Facades\DB;
use App\Support\Table001Key;
final class Table001NoDuplicate implements Rule, DataAwareRule
{
private array $data = [];
private string $message = '項目A + 項目B の組み合わせが既に存在します。';
public function setData($data): self
{
$this->data = $data;
return $this;
}
public function passes($attribute, $value): bool
{
// attribute はここでは使わない(A,B をまとめて見る)
$a = (string)($this->data['項目A'] ?? '');
$b = $this->data['項目B'] ?? null;
$a = trim($a);
if ($a === '') {
// A必須は別ルール required で落とす
return true;
}
$bNorm = is_null($b) ? null : trim((string)$b);
if ($bNorm === '') $bNorm = null;
// DB突合:Bが空扱いなら (B IS NULL OR TRIM(B)='') を同一視する
$q = DB::table('table001')->where('項目A', $a);
if ($bNorm === null) {
$q->where(function ($qq) {
$qq->whereNull('項目B')
->orWhere('項目B', '');
});
} else {
$q->where('項目B', $bNorm);
}
return !$q->exists();
}
public function message()
{
return $this->message;
}
}
FormRequest で使用
// app/Http/Requests/Table001StoreRequest.php
public function rules()
{
return [
'項目A' => ['required', 'string', new \App\Rules\Table001NoDuplicate()],
'項目B' => ['nullable', 'string'],
];
}
ただし(ここが100%の要点)
上記は「その瞬間にDBにあるか」を見るだけなので、同時実行の瞬間に抜ける可能性があります。
最終的に100%にするため、保存処理は必ず次の Service でロックしてから行います。
保存処理(登録画面の insert を 100%重複排除にする)
<?php
// app/Services/Table001Service.php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use App\Support\Table001Key;
final class Table001Service
{
public function insertOne(array $row): void
{
$a = (string)$row['項目A'];
$b = $row['項目B'] ?? null;
$lockName = Table001Key::lockName($a, $b);
DB::transaction(function () use ($row, $a, $b, $lockName) {
// 1) 業務キー単位で排他(100%の根拠:MySQL GET_LOCK は同名を排他取得)([dev.mysql.com](https://dev.mysql.com/doc/refman/5.7/en/locking-functions.html?utm_source=chatgpt.com))
$got = DB::selectOne('SELECT GET_LOCK(?, ?) AS l', [$lockName, 10]);
if (!$got || (int)$got->l !== 1) {
throw ValidationException::withMessages([
'項目A' => ['他の処理が同じキーで更新中です。時間をおいて再実行してください。'],
]);
}
try {
// 2) ロック下で存在確認 → あればバリデーションエラー
$q = DB::table('table001')->where('項目A', trim($a));
$bNorm = is_null($b) ? null : trim((string)$b);
if ($bNorm === '') $bNorm = null;
if ($bNorm === null) {
$q->where(function ($qq) {
$qq->whereNull('項目B')->orWhere('項目B', '');
});
} else {
$q->where('項目B', $bNorm);
}
if ($q->exists()) {
throw ValidationException::withMessages([
'項目A' => ['項目A + 項目B の組み合わせが既に存在します。'],
]);
}
// 3) insert
DB::table('table001')->insert($row);
} finally {
// 4) 明示解放(commit/rollbackでは解放されないため必須)([dev.mysql.com](https://dev.mysql.com/doc/refman/5.7/en/locking-functions.html?utm_source=chatgpt.com))
DB::selectOne('SELECT RELEASE_LOCK(?) AS r', [$lockName]);
}
}); // 例外なら自動 rollback は Laravel 公式仕様 ([laravel.com](https://laravel.com/docs/8.x/database?utm_source=chatgpt.com))
}
}
これで、登録画面の insert は 100%重複排除になります。
2つ目:CSV内の重複行チェック(連結キーで100%検出)
CSVを1行ずつ読みながら logicalKey() を作って set で重複検出します。
use App\Support\Table001Key;
$seen = []; // key => 行番号(最初に出た行)
$dupErrors = []; // エラー一覧
$lineNo = 1;
foreach ($csvRows as $row) {
$a = (string)$row['項目A'];
$b = $row['項目B'] ?? null;
$key = Table001Key::logicalKey($a, $b);
if (isset($seen[$key])) {
$dupErrors[] = "CSV内重複: {$seen[$key]}行目 と {$lineNo}行目 が同一キー";
} else {
$seen[$key] = $lineNo;
}
$lineNo++;
}
if (!empty($dupErrors)) {
// この時点で「更新/登録は一切しない」
// 例: ValidationException にして画面へ返す、など
}
これは CSV内の重複を100%検出します(同じ正規化ロジックでキー化しているため)。
3つ目:CSV×DB突合して update/insert に仕分け → まとめて実行 → NGが1件でもあればロールバック
仕分けの基本方針(DB側に“連結キー列”が無いので、SQLだけでキー一致は作れない)
したがって、以下の手順が 必ず成立する実装です:
- CSV全行を読み、各行の
logicalKeyを作る - CSV内重複があれば即エラー終了(2つ目)
- DBから「CSVに出てくるAの集合」に該当する行をまとめて取得(chunk)
- 取得したDB行を同じルールで
logicalKey化し、existingKeysセットに入れる - CSV各行のkeyが
existingKeysにあれば update対象、無ければ insert対象に振り分け -
全体を
DB::transaction()で囲む(例外で自動rollback)(laravel.com) - update/insert を実行する直前に、各キーごとに
GET_LOCK()を取得してから実行(100%重複排除・整合性確保)(dev.mysql.com) - 途中で1件でもNGが出たら例外 → transactionが rollback (laravel.com)
実装例(骨格)
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use App\Support\Table001Key;
DB::transaction(function () use ($csvRows) {
// (1) CSV内重複チェック + 正規化キー作成
$seen = [];
$normalized = []; // ['key'=>..., 'row'=>..., 'a'=>..., 'bNorm'=>...]
$lineNo = 1;
foreach ($csvRows as $row) {
$a = trim((string)$row['項目A']);
$b = $row['項目B'] ?? null;
$bNorm = is_null($b) ? null : trim((string)$b);
if ($bNorm === '') $bNorm = null;
if ($a === '') {
throw ValidationException::withMessages([
'csv' => ["{$lineNo}行目: 項目A は必須です。"],
]);
}
$key = Table001Key::logicalKey($a, $bNorm);
if (isset($seen[$key])) {
throw ValidationException::withMessages([
'csv' => ["CSV内重複: {$seen[$key]}行目 と {$lineNo}行目"],
]);
}
$seen[$key] = $lineNo;
$normalized[] = compact('key', 'row', 'a', 'bNorm', 'lineNo');
$lineNo++;
}
// (2) DBから既存行をまとめて取得して existingKeys セット作成
$aValues = array_values(array_unique(array_map(fn($x) => $x['a'], $normalized)));
$existingKeys = [];
foreach (array_chunk($aValues, 500) as $aChunk) {
$dbRows = DB::table('table001')
->select(['項目A', '項目B' /*, 更新対象カラム...*/])
->whereIn('項目A', $aChunk)
->get();
foreach ($dbRows as $r) {
$aDb = trim((string)$r->項目A);
$bDbNorm = Table001Key::normalizeBFromDb($r->項目B);
$k = Table001Key::logicalKey($aDb, $bDbNorm);
$existingKeys[$k] = true;
}
}
// (3) update / insert に振り分け
$toInsert = [];
$toUpdate = [];
foreach ($normalized as $x) {
if (isset($existingKeys[$x['key']])) {
$toUpdate[] = $x;
} else {
$toInsert[] = $x;
}
}
// (4) 100%整合性のため、実行時にキー単位で GET_LOCK を取ってから更新する
// ※GET_LOCK はcommit/rollbackで解放されないので finally で RELEASE_LOCK を必ず呼ぶ ([dev.mysql.com](https://dev.mysql.com/doc/refman/5.7/en/locking-functions.html?utm_source=chatgpt.com))
foreach ($toUpdate as $x) {
$lock = Table001Key::lockName($x['a'], $x['bNorm']);
$got = DB::selectOne('SELECT GET_LOCK(?, ?) AS l', [$lock, 10]);
if (!$got || (int)$got->l !== 1) {
throw ValidationException::withMessages(['csv' => ["{$x['lineNo']}行目: ロック取得に失敗"]]);
}
try {
$q = DB::table('table001')->where('項目A', $x['a']);
if ($x['bNorm'] === null) {
$q->where(function ($qq) {
$qq->whereNull('項目B')->orWhere('項目B', '');
});
} else {
$q->where('項目B', $x['bNorm']);
}
// 例:更新
$q->update([
// '更新対象' => $x['row']['更新対象'],
]);
} finally {
DB::selectOne('SELECT RELEASE_LOCK(?) AS r', [$lock]);
}
}
foreach ($toInsert as $x) {
$lock = Table001Key::lockName($x['a'], $x['bNorm']);
$got = DB::selectOne('SELECT GET_LOCK(?, ?) AS l', [$lock, 10]);
if (!$got || (int)$got->l !== 1) {
throw ValidationException::withMessages(['csv' => ["{$x['lineNo']}行目: ロック取得に失敗"]]);
}
try {
// ロック下で最終存在確認 → 既に入ってたらエラー(同時実行対策)
$q = DB::table('table001')->where('項目A', $x['a']);
if ($x['bNorm'] === null) {
$q->where(function ($qq) {
$qq->whereNull('項目B')->orWhere('項目B', '');
});
} else {
$q->where('項目B', $x['bNorm']);
}
if ($q->exists()) {
throw ValidationException::withMessages([
'csv' => ["{$x['lineNo']}行目: DB側に同一キーが存在(同時実行など)"],
]);
}
DB::table('table001')->insert($x['row']);
} finally {
DB::selectOne('SELECT RELEASE_LOCK(?) AS r', [$lock]);
}
}
}); // 例外が出れば Laravel 公式仕様で自動 rollback ([laravel.com](https://laravel.com/docs/8.x/database?utm_source=chatgpt.com))
重要な注意(ここだけは“言い切り”)
-
FormRequestのバリデーション“だけ”で100%重複排除はできません。
100%にするには DBロックを使って insert/update を直列化するしかありません(今回ならGET_LOCK)。(dev.mysql.com) -
GET_LOCKは commit/rollbackで解放されないので、finallyでRELEASE_LOCKを必ず呼ぶ必要があります。(dev.mysql.com) - トランザクションは Laravel 公式の
DB::transaction()で、例外が起きれば自動で rollback されます。(laravel.com) - カスタムバリデーションルールは
passes / messageを実装するのが Laravel公式です。(laravel.com)
必要なら、次の一歩として **「更新対象カラムが複数ある」「updateは1回のSQLでまとめたい」**に合わせて、$toUpdate を キー単位ではなく A単位にグルーピングして更新回数を減らす実装(ただしロックの取り方は維持)まで、あなたの実際のカラム構成に合わせてそのまま貼れる形に落とします。
'項目B' => ['nullable', 'string', 'not_in:-'],
$iA = array_search('項目A', $header, true);
if ($iA === false) {
// 見つからない
} else {
// 見つかった(0 もあり得る)
}
$seen = []; // logicalKey => first line no
while (($row = fgetcsv($handle)) !== false) {
$lineNo++;
// ループ先頭で毎回作れてる前提
// $logicalKey = ...
if (isset($seen[$logicalKey])) {
// 重複(先に出た行番号も分かる)
$errors[] = "CSV内重複: 行{$lineNo}(先出: 行{$seen[$logicalKey]})";
continue; // ここでこの行をNG扱いにするなら
}
$seen[$logicalKey] = $lineNo;
// 以降、通常処理
}
$existingLogicalKeys = [];
while (...) {
if (isset($existingLogicalKeys[$logicalKey])) {
continue; // 重複
}
$existingLogicalKeys[$logicalKey] = true; // ★キーとして記録
}
use Illuminate\Support\Facades\DB;
// $updateData は CSV行から作った $data を溜めたもの
// $updateData[] = $data; してある前提
foreach (array_chunk($updateData, 500) as $chunk) {
// 1) 派生表 src を UNION ALL で作る
$sub = null;
foreach ($chunk as $data) {
$a = (string)($data['項目A'] ?? '');
$b = $data['項目B'] ?? null;
// ★ここ重要:存在判定に使ったのと同じ正規化にする(Bなしは null に統一)
$b = ($b === null) ? null : trim((string)$b);
if ($b === '') { $b = null; }
$q = DB::query()->selectRaw(
'? as 項目A, ? as 項目B, ? as 更新対象1, ? as 更新対象2',
[
$a,
$b,
$data['更新対象1'] ?? null,
$data['更新対象2'] ?? null,
]
);
$sub = $sub ? $sub->unionAll($q) : $q;
}
// 2) UPDATE table001 t JOIN (src) で一発更新
DB::table('table001 as t')
->joinSub($sub, 'src', function ($join) {
$join->on('t.項目A', '=', 'src.項目A')
->where(function ($w) {
// B同士が等しい もしくは 両方NULL
$w->whereColumn('t.項目B', '=', 'src.項目B')
->orWhere(function ($w2) {
$w2->whereNull('t.項目B')
->whereNull('src.項目B');
});
});
})
->update([
't.更新対象1' => DB::raw('src.更新対象1'),
't.更新対象2' => DB::raw('src.更新対象2'),
]);
}
$allCols = array_keys($chunk[0]); // そのchunk内の$dataが同じ形である前提
$updateCols = array_values(array_diff($allCols, ['項目A', '項目B']));
$set = [];
foreach ($updateCols as $col) {
$set["t.$col"] = DB::raw("src.$col");
}
DB::table('table001 as t')
->joinSub($sub, 'src', function ($join) {
$join->on('t.項目A', '=', 'src.項目A')
->where(function ($w) {
$w->whereColumn('t.項目B', '=', 'src.項目B')
->orWhere(function ($w2) {
$w2->whereNull('t.項目B')->whereNull('src.項目B');
});
});
})
->update($set);
use Illuminate\Support\Facades\DB;
$keyA = '項目A';
$keyB = '項目B';
if (empty($updateData)) {
return;
}
// ★更新対象=CSVの全項目(A/Bも含める)
$updateCols = array_keys($updateData[0]);
// 1) 派生表 src(500行)を作る
$sub = null;
foreach ($updateData as $r) {
$expr = '';
$bind = [];
foreach ($updateCols as $c) {
$expr .= ($expr === '' ? '' : ', ') . '? as ' . $c;
$bind[] = $r[$c] ?? null;
}
$q = DB::query()->selectRaw($expr, $bind);
$sub = $sub ? $sub->unionAll($q) : $q;
}
// 2) table001 と src を (A,B) でJOINして一発UPDATE(A/BもSETに含める)
$set = [];
foreach ($updateCols as $c) {
$set["t.$c"] = DB::raw("src.$c");
}
DB::table('table001 as t')
->joinSub($sub, 'src', function ($j) use ($keyA, $keyB) {
$j->on("t.$keyA", '=', "src.$keyA")
->where(function ($w) use ($keyB) {
$w->whereColumn("t.$keyB", '=', "src.$keyB")
->orWhere(function ($w2) use ($keyB) {
$w2->whereNull("t.$keyB")->whereNull("src.$keyB");
});
});
})
->update($set);
<?php
use Illuminate\Support\Facades\DB;
/**
* table001 を「項目A + 項目B」で突合して、A/B以外の列を一括UPDATEする。
*
* 前提:
* - table001 は PK/ユニークキー無し(DB定義変更不可)
* - ただし業務キーとして「項目A + 項目B」で一意にしたい(重複は業務側で潰す)
* - CSVを読み込み、DBに「存在する行」だけを $updateData[] = $data; で貯めている
* - $updateData はすでに 500件 たまっている想定(=この関数は“500件まとめて更新”)
* - 項目Bは、trimして空なら null に正規化済み(突合条件の扱いを安定させるため)
*
* 目的:
* - 行ごとにUPDATEを500回発行すると遅いので、
* 500件を1回のSQLでまとめてUPDATEして高速化する。
*
* @param array $updateData 例:$updateData[] = ['項目A'=>..., '項目B'=>..., '更新対象1'=>..., ...];
* @param array $columns CSVの列名(ヘッダ)など。「CSVに存在する全項目名」の配列を想定
* ※ここから更新対象を自動で決める(項目A/項目Bは除外)
*/
private function bulkUpdateTable001ExcludeAB(array $updateData, array $columns): void
{
if (empty($updateData)) {
return;
}
// =========================================================
// 0) 更新対象列を決める(項目A/項目Bは“キー”なので更新から除外)
// =========================================================
$keyCols = ['項目A', '項目B'];
// CSVの列一覧から、キー列(項目A/項目B)だけを除外したものが「更新対象」
$updateCols = array_values(array_diff($columns, $keyCols));
// 更新対象が何も無いなら、更新する意味がないので終了
if (empty($updateCols)) {
return;
}
/**
* ここからが本体。
*
* 重要ポイント:
* - PHP配列($updateData)をそのままDBでJOIN相手にはできない。
* - なのでSQL内に「仮想テーブル(src)」を作る必要がある。
*
* その仮想テーブル(src)は、SQL的にはこういう形:
*
* (SELECT ... 1行分 ...)
* UNION ALL
* (SELECT ... 1行分 ...)
* UNION ALL
* ...
*
* これで「500行ぶんのデータを持つ仮想テーブル src」を作り、
* table001(t) と (項目A,項目B) でJOINし、t側を一発UPDATEする。
*/
// =========================================================
// 1) 仮想テーブル src を作るためのサブクエリ($sub)を組み立てる
// =========================================================
$sub = null;
foreach ($updateData as $row) {
/**
* $expr と $bind の意味(初心者向けに丁寧に):
*
* - $expr : SQLの“形”を作る文字列
* 例)"? as 項目A, ? as 項目B, ? as 更新対象1, ? as 更新対象2"
*
* - $bind : 上の ? に入れる実データ(バインド値)
* 例)["A001", null, "xxx", 123]
*
* なぜバインドにする?
* - 値をSQL文字列に直書きすると、クォートやエスケープが面倒で危険(SQLインジェクション)
* - バインドにすればDBドライバが安全に扱ってくれる
*/
// まず、JOIN用キー(項目A/項目B)を必ず src 側に持たせる必要がある
$expr = '? as 項目A, ? as 項目B';
$bind = [
(string)($row['項目A'] ?? ''), // 項目Aは必須想定
$row['項目B'] ?? null, // 項目Bはnullになり得る(正規化済み前提)
];
// 次に、更新対象列(項目A/項目B以外)を src 側に追加する
foreach ($updateCols as $c) {
$expr .= ', ? as ' . $c;
$bind[] = $row[$c] ?? null; // CSVに無い/値が無いなら null
}
// これで「1行分のSELECT」を作る(結果は1行だけ)
$oneRowSelect = DB::query()->selectRaw($expr, $bind);
// 500行ぶんにしたいので、UNION ALL で縦に連結していく
$sub = $sub ? $sub->unionAll($oneRowSelect) : $oneRowSelect;
}
// =========================================================
// 2) UPDATEでセットする内容を作る(t.列 = src.列)
// =========================================================
$set = [];
foreach ($updateCols as $c) {
/**
* ここで DB::raw('src.'.$c) を使う理由:
*
* update([...]) は普通「固定値」を渡すが、
* 今回やりたいのは「src側の列の値を t側に代入」。
*
* SQLで言うと:
* SET t.更新対象1 = src.更新対象1,
* t.更新対象2 = src.更新対象2, ...
*
* なので raw() を使って「SQL式として src.列 を入れる」。
*/
$set['t.' . $c] = DB::raw('src.' . $c);
}
// =========================================================
// 3) table001(t) と 仮想テーブル(src) をJOINして、一発UPDATE
// =========================================================
DB::table('table001 as t')
->joinSub($sub, 'src', function ($j) {
// まず項目Aは普通に一致でOK
$j->on('t.項目A', '=', 'src.項目A')
->where(function ($w) {
/**
* 項目Bは NULL になり得るので注意。
*
* SQLでは「NULL = NULL」は true にならない(NULLは“不明”扱い)。
* だから、
* t.項目B = src.項目B
* だけだと、両方NULLの行がJOINできない。
*
* そこで、次のどちらかで一致とみなす:
* - t.B = src.B
* - または t.Bもsrc.BもNULL
*/
$w->whereColumn('t.項目B', '=', 'src.項目B')
->orWhere(function ($w2) {
$w2->whereNull('t.項目B')
->whereNull('src.項目B');
});
});
})
->update($set);
}
app/Support/DataNormalizer/TrimEmptyStringToNull.php
<?php
namespace App\Support\DataNormalizer;
final class TrimEmptyStringToNull
{
/**
* 指定した項目だけを対象に、
* 「文字列なら trim する」
* 「trim 後に空文字なら null にする」
* 変換を行う。
*
* @param array $rows 2次元配列のデータ本体
* @param array $columns 対象にしたい項目名の配列
* @return array 変換後の2次元配列
*/
public static function execute(array $rows, array $columns): array
{
foreach ($rows as &$row) {
foreach ($columns as $column) {
// その行に対象項目が無いなら何もしない
if (!array_key_exists($column, $row)) {
continue;
}
// 文字列だけを対象にする
if (!is_string($row[$column])) {
continue;
}
// 前後の空白を除去する
$trimmedValue = trim($row[$column]);
// trim 後に空文字なら null、それ以外は trim 後の文字列を入れる
$row[$column] = ($trimmedValue === '') ? null : $trimmedValue;
}
}
unset($row); // 参照 foreach の後始末
return $rows;
}
}
<?php
namespace App\Services;
use App\Support\DataNormalizer\TrimEmptyStringToNull;
use Illuminate\Support\Facades\DB;
class XxxService
{
/**
* @param array $data
* @param string $tableName
* @param array $columns 全項目名
* @param array $pKeys PK項目名の配列
* @return void
*/
private function upsertData(array $data, string $tableName, array $columns, array $pKeys): void
{
// 更新対象カラムは、PK を除いた項目だけにする
$columns = array_values(array_diff($columns, $pKeys));
// PK以外の更新対象カラムについて、
// 文字列なら trim し、trim後に空文字なら null に変換する
$data = TrimEmptyStringToNull::execute($data, $columns);
DB::table($tableName)->upsert(
$data,
$pKeys,
$columns
);
}
}
$now = now()->format('Y-m-d H:i:s');
Discussion