Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion app/Http/Controllers/ProductImportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Chunk product-name prefetch to avoid DB parameter overflow

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 👍 / 👎.


// 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?
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid collapsing differently cased product names

Using a lowercased key for lookup means two import groups like "Widget" and "widget" now resolve to the same $productMap entry, so on case-sensitive databases (such as SQLite/PostgreSQL) the second group can be attached to the first product instead of creating/finding its own product. This changes import semantics and can silently place variants under the wrong product name.

Useful? React with 👍 / 👎.


if (!$product) {
$image = null;
Expand All @@ -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
Expand Down
125 changes: 125 additions & 0 deletions tests/Feature/ProductImportPerformanceTest.php
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");
}
}