diff --git a/.env.example b/.env.example index 18d181b..08f0883 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,15 @@ LOG_LEVEL=debug #DB_USERNAME=root #DB_PASSWORD= +#DB_CONNECTION=oracle +#ORACLE_DB_HOST=127.0.0.1 +#ORACLE_DB_PORT=1521 +#ORACLE_DB_DATABASE=xe +#ORACLE_DB_USERNAME=system +#ORACLE_DB_PASSWORD= +#ORACLE_DB_CHARSET=AL32UTF8 +#ORACLE_DB_PREFIX= + DB_CONNECTION=sqlite #DB_HOST=127.0.0.1 #DB_PORT=3306 diff --git a/app/Actions/Inventory/InventoryListAction.php b/app/Actions/Inventory/InventoryListAction.php index 06d937b..916e9b5 100644 --- a/app/Actions/Inventory/InventoryListAction.php +++ b/app/Actions/Inventory/InventoryListAction.php @@ -2,13 +2,39 @@ namespace App\Actions\Inventory; +use App\Helpers\LocaleHelper; use App\Models\Inventory; use Illuminate\Pagination\LengthAwarePaginator; class InventoryListAction { + /** Maximum number of items that may be requested per page. */ + public const MAX_PER_PAGE = 100; + + /** Default number of items returned per page. */ + public const DEFAULT_PER_PAGE = 15; + public function __invoke(): LengthAwarePaginator { - return Inventory::paginate(); + // Fetch all records so we can apply a global locale-aware sort before + // paginating. Sorting after Eloquent::paginate() would only order each + // page in isolation, producing inconsistent cross-page ordering. + // Note: in-memory sorting is intentional here — it mirrors FreshRSS#8985 + // and gives consistent locale-aware ordering that database COLLATE clauses + // cannot guarantee across all supported database engines. + // Mirrors the fix in FreshRSS/FreshRSS#8985. + $collection = Inventory::all(); + $items = $collection->all(); // plain array for uasort + uasort($items, static fn (Inventory $a, Inventory $b): int => LocaleHelper::localeCompare($a->name, $b->name)); + $sorted = array_values($items); + + // Clamp per_page to a safe range to prevent memory exhaustion. + $perPage = max(1, min(self::MAX_PER_PAGE, (int) request()->input('per_page', self::DEFAULT_PER_PAGE))); + $currentPage = LengthAwarePaginator::resolveCurrentPage(); + $pageItems = array_slice($sorted, ($currentPage - 1) * $perPage, $perPage); + + return new LengthAwarePaginator($pageItems, count($sorted), $perPage, $currentPage, [ + 'path' => LengthAwarePaginator::resolveCurrentPath(), + ]); } } diff --git a/app/Helpers/LocaleHelper.php b/app/Helpers/LocaleHelper.php new file mode 100644 index 0000000..f032537 --- /dev/null +++ b/app/Helpers/LocaleHelper.php @@ -0,0 +1,47 @@ +getLocale(); + if ($locale === '' || ! class_exists(Collator::class)) { + self::$collator = false; + } else { + self::$collator = Collator::create($locale) ?? false; + } + if (self::$collator instanceof Collator) { + self::$collator->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON); + } + } + + if (! (self::$collator instanceof Collator)) { + return strnatcasecmp($a, $b); + } + + $result = self::$collator->compare($a, $b); + + return $result === false ? strnatcasecmp($a, $b) : $result; + } + + /** + * Reset the cached collator (useful in tests when the locale changes). + */ + public static function resetCollator(): void + { + self::$collator = null; + } +} diff --git a/config/database.php b/config/database.php index 137ad18..3d0a971 100644 --- a/config/database.php +++ b/config/database.php @@ -93,6 +93,18 @@ // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), ], + 'oracle' => [ + 'driver' => 'oracle', + 'host' => env('ORACLE_DB_HOST', '127.0.0.1'), + 'port' => env('ORACLE_DB_PORT', '1521'), + 'database' => env('ORACLE_DB_DATABASE', 'xe'), + 'username' => env('ORACLE_DB_USERNAME', 'system'), + 'password' => env('ORACLE_DB_PASSWORD', ''), + 'charset' => env('ORACLE_DB_CHARSET', 'AL32UTF8'), + 'prefix' => env('ORACLE_DB_PREFIX', ''), + 'prefix_indexes' => true, + ], + ], /* diff --git a/tests/Unit/InventoryTotalCostTest.php b/tests/Unit/InventoryTotalCostTest.php index 170c2bd..8faf8b3 100644 --- a/tests/Unit/InventoryTotalCostTest.php +++ b/tests/Unit/InventoryTotalCostTest.php @@ -21,6 +21,6 @@ public function test_total_cost_calculation(): void return $item->total = $item->quantity * $item->unit_price; }); - $this->assertEquals($totalCost, $inventories->sum('total')); + $this->assertEquals(round($totalCost, 2), round($inventories->sum('total'), 2)); } } diff --git a/tests/Unit/LocaleHelperTest.php b/tests/Unit/LocaleHelperTest.php new file mode 100644 index 0000000..8303ddd --- /dev/null +++ b/tests/Unit/LocaleHelperTest.php @@ -0,0 +1,67 @@ +assertLessThan(0, LocaleHelper::localeCompare('apple', 'banana')); + } + + public function test_returns_positive_when_first_string_is_greater(): void + { + $this->assertGreaterThan(0, LocaleHelper::localeCompare('banana', 'apple')); + } + + public function test_returns_zero_for_equal_strings(): void + { + $this->assertSame(0, LocaleHelper::localeCompare('apple', 'apple')); + } + + public function test_accented_names_sort_near_base_letters(): void + { + // Without locale-aware sorting, 'Ábaco' (0xC3…) would sort after 'Zorro'. + // With a Collator for 'es' (Spanish), it sorts before 'banana'. + app()->setLocale('es'); + LocaleHelper::resetCollator(); + + $names = ['banana', 'Manzana', 'nube', 'Zorro', 'Ábaco', 'Ñandú', 'Úlcera', 'árbol']; + usort($names, fn (string $a, string $b) => LocaleHelper::localeCompare($a, $b)); + + // 'Ábaco' and 'árbol' must appear before 'banana', not after 'Zorro' + $posAbaco = array_search('Ábaco', $names, true); + $posArbol = array_search('árbol', $names, true); + $posBanana = array_search('banana', $names, true); + $posNandu = array_search('Ñandú', $names, true); + $posUlcera = array_search('Úlcera', $names, true); + $posZorro = array_search('Zorro', $names, true); + + $this->assertIsInt($posAbaco); + $this->assertIsInt($posArbol); + $this->assertIsInt($posBanana); + $this->assertIsInt($posNandu); + $this->assertIsInt($posUlcera); + $this->assertIsInt($posZorro); + + $this->assertLessThan($posBanana, $posAbaco); + $this->assertLessThan($posBanana, $posArbol); + $this->assertLessThan($posZorro, $posNandu); + $this->assertLessThan($posZorro, $posUlcera); + } +} diff --git a/tests/Unit/OracleDatabaseConfigTest.php b/tests/Unit/OracleDatabaseConfigTest.php new file mode 100644 index 0000000..58b9f79 --- /dev/null +++ b/tests/Unit/OracleDatabaseConfigTest.php @@ -0,0 +1,24 @@ +assertNotNull($config); + $this->assertEquals('oracle', $config['driver']); + $this->assertEquals(env('ORACLE_DB_HOST', '127.0.0.1'), $config['host']); + $this->assertEquals(env('ORACLE_DB_PORT', '1521'), $config['port']); + $this->assertEquals(env('ORACLE_DB_DATABASE', 'xe'), $config['database']); + $this->assertEquals(env('ORACLE_DB_USERNAME', 'system'), $config['username']); + $this->assertEquals(env('ORACLE_DB_PASSWORD', ''), $config['password']); + $this->assertEquals(env('ORACLE_DB_CHARSET', 'AL32UTF8'), $config['charset']); + $this->assertEquals(env('ORACLE_DB_PREFIX', ''), $config['prefix']); + $this->assertTrue($config['prefix_indexes']); + } +}