From 9734195de4524b7b5c1847fb8abfe16536035075 Mon Sep 17 00:00:00 2001 From: sayuru-akash <48414692+sayuru-akash@users.noreply.github.com> Date: Mon, 9 Feb 2026 03:22:52 +0000 Subject: [PATCH] perf: Optimize product import N+1 query Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- .../Controllers/ProductImportController.php | 16 ++- .../Feature/ProductImportPerformanceTest.php | 125 ++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/ProductImportPerformanceTest.php diff --git a/app/Http/Controllers/ProductImportController.php b/app/Http/Controllers/ProductImportController.php index fead2fd..30da988 100644 --- a/app/Http/Controllers/ProductImportController.php +++ b/app/Http/Controllers/ProductImportController.php @@ -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; 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 diff --git a/tests/Feature/ProductImportPerformanceTest.php b/tests/Feature/ProductImportPerformanceTest.php new file mode 100644 index 0000000..f51b64c --- /dev/null +++ b/tests/Feature/ProductImportPerformanceTest.php @@ -0,0 +1,125 @@ +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"); + } +}