-
Notifications
You must be signed in to change notification settings - Fork 1
⚡ Optimize Product Import N+1 Query #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -181,6 +181,16 @@ public function store(Request $request) | |
| $groupedData = collect($previewData)->groupBy('name'); | ||
|
|
||
| DB::transaction(function () use ($groupedData, &$count) { | ||
| // Optimization: Pre-fetch products to avoid N+1 queries | ||
| $productNames = $groupedData->keys(); | ||
| $existingProducts = Product::whereIn('name', $productNames)->get(); | ||
|
|
||
| // Map by lowercase name for case-insensitive lookup | ||
| $productMap = []; | ||
| foreach ($existingProducts as $p) { | ||
| $productMap[strtolower($p->name)] = $p; | ||
| } | ||
|
|
||
| foreach ($groupedData as $productName => $variants) { | ||
| // Use the FIRST valid row's creation data for the product | ||
| // Find a row that has valid product data? Or just take the first one? | ||
|
|
@@ -191,7 +201,8 @@ public function store(Request $request) | |
| // But if user skips invalid rows, we proceed with valid ones. | ||
|
|
||
| // 1. Find or Create Product | ||
| $product = Product::where('name', $productName)->first(); | ||
| $lowerName = strtolower($productName); | ||
| $product = $productMap[$lowerName] ?? null; | ||
|
Comment on lines
+204
to
+205
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Using a lowercased key for lookup means two import groups like Useful? React with 👍 / 👎. |
||
|
|
||
| if (!$product) { | ||
| $image = null; | ||
|
|
@@ -211,6 +222,9 @@ public function store(Request $request) | |
| 'description' => $firstRow['description'], | ||
| 'image' => $image, | ||
| ]); | ||
|
|
||
| // Add to map for subsequent lookups in this loop (if any) | ||
| $productMap[$lowerName] = $product; | ||
| } | ||
|
|
||
| // 2. Add Variants | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| <?php | ||
|
|
||
| namespace Tests\Feature; | ||
|
|
||
| use App\Models\Category; | ||
| use App\Models\Product; | ||
| use App\Models\SubCategory; | ||
| use App\Models\Unit; | ||
| use App\Models\User; | ||
| use Illuminate\Foundation\Testing\RefreshDatabase; | ||
| use Illuminate\Support\Facades\DB; | ||
| use Tests\TestCase; | ||
| use Spatie\Permission\Models\Role; | ||
|
|
||
| class ProductImportPerformanceTest extends TestCase | ||
| { | ||
| use RefreshDatabase; | ||
|
|
||
| protected function setUp(): void | ||
| { | ||
| parent::setUp(); | ||
|
|
||
| // Setup roles | ||
| $this->seed(\Database\Seeders\RolesAndPermissionsSeeder::class); | ||
| } | ||
|
|
||
| public function test_product_import_n_plus_one_optimization() | ||
| { | ||
| // 1. Setup Data | ||
| $user = User::factory()->create(); | ||
| $user->assignRole('super admin'); | ||
|
|
||
| $category = Category::create(['name' => 'Electronics']); | ||
| $subCategory = SubCategory::create(['name' => 'Phones', 'category_id' => $category->id]); | ||
| $unit = Unit::create(['name' => 'Piece', 'short_name' => 'pc']); | ||
|
|
||
| // Create some existing products to ensure we hit the "find" path | ||
| for ($i = 0; $i < 5; $i++) { | ||
| Product::create([ | ||
| 'name' => "Existing Product $i", | ||
| 'category_id' => $category->id, | ||
| 'sub_category_id' => $subCategory->id, | ||
| 'description' => 'Existing Description', | ||
| 'image' => null, | ||
| ]); | ||
| } | ||
|
|
||
| // 2. Prepare Session Data (Simulation of Excel Import) | ||
| $previewData = []; | ||
| $productCount = 20; // Enough to show the N+1 vs 1 | ||
|
|
||
| for ($i = 0; $i < $productCount; $i++) { | ||
| $productName = "Imported Product $i"; | ||
|
|
||
| $previewData[] = [ | ||
| 'row_id' => $i, | ||
| 'name' => $productName, | ||
| 'category_id' => $category->id, | ||
| 'category_name' => $category->name, | ||
| 'sub_category_id' => $subCategory->id, | ||
| 'sub_category_name' => $subCategory->name, | ||
| 'description' => 'Description', | ||
| 'unit_id' => $unit->id, | ||
| 'unit_name' => $unit->name, | ||
| 'unit_value' => 1, | ||
| 'sku' => "SKU-$i", | ||
| 'selling_price' => 100, | ||
| 'limit_price' => 80, | ||
| 'quantity' => 10, | ||
| 'alert_quantity' => 5, | ||
| 'image_url' => null, | ||
| 'errors' => [] | ||
| ]; | ||
| } | ||
|
|
||
| // Add an existing product name to the import list to test the "find" logic | ||
| $existingProduct = Product::first(); | ||
| $previewData[] = [ | ||
| 'row_id' => $productCount, | ||
| 'name' => $existingProduct->name, | ||
| 'category_id' => $category->id, | ||
| 'category_name' => $category->name, | ||
| 'sub_category_id' => $subCategory->id, | ||
| 'sub_category_name' => $subCategory->name, | ||
| 'description' => 'Description', | ||
| 'unit_id' => $unit->id, | ||
| 'unit_name' => $unit->name, | ||
| 'unit_value' => 1, | ||
| 'sku' => "SKU-EXISTING", | ||
| 'selling_price' => 100, | ||
| 'limit_price' => 80, | ||
| 'quantity' => 10, | ||
| 'alert_quantity' => 5, | ||
| 'image_url' => null, | ||
| 'errors' => [] | ||
| ]; | ||
|
|
||
| // Store in session | ||
| session(['product_import_preview_data' => $previewData]); | ||
|
|
||
| // 3. Measure Queries | ||
| DB::enableQueryLog(); | ||
|
|
||
| $response = $this->actingAs($user)->post(route('products.import.store')); | ||
|
|
||
| $queries = DB::getQueryLog(); | ||
|
|
||
| // Assertions | ||
| $response->assertRedirect(route('products.index')); | ||
|
|
||
| $nPlusOneQueries = collect($queries)->filter(function ($q) { | ||
| return str_contains($q['query'], 'select * from "products" where "name" = ?'); | ||
| })->count(); | ||
|
|
||
| $optimizedQueries = collect($queries)->filter(function ($q) { | ||
| return str_contains($q['query'], 'select * from "products" where "name" in'); | ||
| })->count(); | ||
|
|
||
| echo "\nN+1 Queries: " . $nPlusOneQueries; | ||
| echo "\nOptimized Queries: " . $optimizedQueries . "\n"; | ||
|
|
||
| $this->assertEquals(0, $nPlusOneQueries, "Should have 0 N+1 queries"); | ||
| $this->assertEquals(1, $optimizedQueries, "Should have 1 optimized query"); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefetching with a single
whereIn('name', $productNames)binds one parameter per unique imported product name, so large imports can exceed parameter limits on some supported drivers (for example SQL Server) and fail the whole transaction. The previous per-name lookup avoided this single-statement limit, so this optimization needs chunking to remain safe at higher import sizes.Useful? React with 👍 / 👎.