Skip to content
Open
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion app/Actions/Inventory/InventoryListAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]);
}
}
47 changes: 47 additions & 0 deletions app/Helpers/LocaleHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Helpers;

use Collator;

class LocaleHelper
{
private static Collator|false|null $collator = null;

/**
* Compare two strings using locale-aware collation when available,
* falling back to strnatcasecmp() if the intl Collator cannot be created.
*
* Mirrors FreshRSS_Context::localeCompare() from FreshRSS/FreshRSS#8985.
*/
public static function localeCompare(string $a, string $b): int
{
if (self::$collator === null) {
$locale = app()->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;
}
}
12 changes: 12 additions & 0 deletions config/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],

],

/*
Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/InventoryTotalCostTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
67 changes: 67 additions & 0 deletions tests/Unit/LocaleHelperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace Tests\Unit;

use App\Helpers\LocaleHelper;
use Tests\TestCase;

class LocaleHelperTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
LocaleHelper::resetCollator();
}

protected function tearDown(): void
{
LocaleHelper::resetCollator();
parent::tearDown();
}

public function test_returns_negative_when_first_string_is_less(): void
{
$this->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);
}
}
24 changes: 24 additions & 0 deletions tests/Unit/OracleDatabaseConfigTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Tests\Unit;

use Tests\TestCase;

class OracleDatabaseConfigTest extends TestCase
{
public function test_oracle_database_configuration_is_present(): void
{
$config = config('database.connections.oracle');

$this->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']);
}
}