Skip to content
Merged
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
1 change: 1 addition & 0 deletions config/packages/doctrine.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ doctrine:
natsort: App\Doctrine\Functions\Natsort
array_position: App\Doctrine\Functions\ArrayPosition
ilike: App\Doctrine\Functions\ILike
si_value_sort: App\Doctrine\Functions\SiValueSort

when@test:
doctrine:
Expand Down
25 changes: 25 additions & 0 deletions src/DataTables/PartsDataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
use App\DataTables\Filters\PartSearchFilter;
use App\DataTables\Helpers\ColumnSortHelper;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Doctrine\Functions\SiValueSort;
use App\Doctrine\Helpers\FieldHelper;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
Expand Down Expand Up @@ -118,6 +119,17 @@ public function configure(DataTable $dataTable, array $options): void
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
'orderField' => 'NATSORT(part.name)'
])
->add('si_value', TextColumn::class, [
'label' => $this->translator->trans('part.table.si_value'),
'render' => function ($value, Part $context): string {
$siValue = SiValueSort::sqliteSiValue($context->getName());
if ($siValue !== null) {
return htmlspecialchars(sprintf('%g', $siValue));
}
return '';
},
'orderField' => 'SI_VALUE_SORT(part.name)',
])
->add('id', TextColumn::class, [
'label' => $this->translator->trans('part.table.id'),
])
Expand Down Expand Up @@ -484,6 +496,19 @@ private function addJoins(QueryBuilder $builder): QueryBuilder
//$builder->addGroupBy('_bulkImportJob');
}

//When sorting by SI value, add NATSORT as a secondary sort so that parts without
//an SI-prefixed value fall back to natural string ordering seamlessly.
$orderByParts = $builder->getDQLPart('orderBy');
foreach ($orderByParts as $orderBy) {
foreach ($orderBy->getParts() as $part) {
if (str_contains($part, 'SI_VALUE_SORT')) {
$direction = str_contains($part, 'DESC') ? 'DESC' : 'ASC';
$builder->addOrderBy('NATSORT(part.name)', $direction);
break 2;
}
}
}

return $builder;
}

Expand Down
196 changes: 196 additions & 0 deletions src/Doctrine/Functions/SiValueSort.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

declare(strict_types=1);

namespace App\Doctrine\Functions;

use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;

/**
* Custom DQL function that extracts the first numeric value with an optional SI prefix
* from a string and returns the scaled numeric value for sorting.
*
* Usage: SI_VALUE_SORT(part.name)
*
* This enables sorting parts by their physical value. For example, capacitors
* named "100nF", "1uF", "10pF" will be sorted by actual value: 10pF < 100nF < 1uF.
*
* Supported SI prefixes: p (pico, 1e-12), n (nano, 1e-9), u/µ (micro, 1e-6),
* m (milli, 1e-3), k/K (kilo, 1e3), M (mega, 1e6), G (giga, 1e9), T (tera, 1e12).
*
* Only matches numbers at the very beginning of the string (ignoring leading whitespace).
* Names like "Crystal 20MHz" will NOT match since the number is not at the start.
* Names without a recognizable numeric+prefix pattern return NULL and sort last.
*/
class SiValueSort extends FunctionNode
{
private ?Node $field = null;

/**
* SI prefix multipliers. Used by the SQLite PHP callback.
*/
private const SI_MULTIPLIERS = [
'p' => 1e-12,
'n' => 1e-9,
'u' => 1e-6,
'µ' => 1e-6,
'm' => 1e-3,
'k' => 1e3,
'K' => 1e3,
'M' => 1e6,
'G' => 1e9,
'T' => 1e12,
];

public function parse(Parser $parser): void
{
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);

$this->field = $parser->ArithmeticExpression();

$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}

public function getSql(SqlWalker $sqlWalker): string
{
assert($this->field !== null, 'Field is not set');

$platform = $sqlWalker->getConnection()->getDatabasePlatform();
$rawField = $this->field->dispatch($sqlWalker);

// Normalize comma decimal separator to dot for SQL platforms (European locale support)
$fieldSql = "REPLACE({$rawField}, ',', '.')";

if ($platform instanceof PostgreSQLPlatform) {
return $this->getPostgreSQLSql($fieldSql);
}

if ($platform instanceof AbstractMySQLPlatform) {
return $this->getMySQLSql($fieldSql);
}

// SQLite: comma normalization is handled in the PHP callback
$fieldSql = $rawField;

if ($platform instanceof SQLitePlatform) {
return "SI_VALUE({$fieldSql})";
}

// Fallback: return NULL (no SI sorting available)
return 'NULL';
}

/**
* PostgreSQL implementation using substring() with POSIX regex.
*/
private function getPostgreSQLSql(string $field): string
{
// Extract the numeric part using POSIX regex, anchored at start (with optional leading whitespace)
$numericPart = "CAST(substring({$field} FROM '^\\s*(\\d+\\.?\\d*)\\s*[pnuµmkKMGT]?') AS DOUBLE PRECISION)";

// Extract the SI prefix character
$prefixPart = "substring({$field} FROM '^\\s*\\d+\\.?\\d*\\s*([pnuµmkKMGT])')";

return $this->buildCaseExpression($numericPart, $prefixPart);
}

/**
* MySQL/MariaDB implementation using REGEXP_SUBSTR.
*/
private function getMySQLSql(string $field): string
{
// Extract the numeric part, anchored at start (with optional leading whitespace)
$numericPart = "CAST(REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*') AS DECIMAL(30,15))";

// Extract the prefix: get the full number+prefix match anchored at start, then take the last char
$fullMatch = "REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*[[:space:]]*[pnuµmkKMGT]')";
$prefixPart = "RIGHT({$fullMatch}, 1)";

return $this->buildCaseExpression($numericPart, $prefixPart);
}

/**
* Build a CASE expression that maps an SI prefix character to a multiplier
* and multiplies it with the numeric value.
*
* @param string $numericExpr SQL expression that evaluates to the numeric part
* @param string $prefixExpr SQL expression that evaluates to the SI prefix character
* @return string SQL CASE expression
*/
private function buildCaseExpression(string $numericExpr, string $prefixExpr): string
{
return "(CASE" .
" WHEN {$numericExpr} IS NULL THEN NULL" .
" WHEN {$prefixExpr} = 'p' THEN {$numericExpr} * 1e-12" .
" WHEN {$prefixExpr} = 'n' THEN {$numericExpr} * 1e-9" .
" WHEN {$prefixExpr} = 'u' THEN {$numericExpr} * 1e-6" .
" WHEN {$prefixExpr} = 'µ' THEN {$numericExpr} * 1e-6" .
" WHEN {$prefixExpr} = 'm' THEN {$numericExpr} * 1e-3" .
" WHEN {$prefixExpr} = 'k' THEN {$numericExpr} * 1e3" .
" WHEN {$prefixExpr} = 'K' THEN {$numericExpr} * 1e3" .
" WHEN {$prefixExpr} = 'M' THEN {$numericExpr} * 1e6" .
" WHEN {$prefixExpr} = 'G' THEN {$numericExpr} * 1e9" .
" WHEN {$prefixExpr} = 'T' THEN {$numericExpr} * 1e12" .
" ELSE {$numericExpr} * 1" .
" END)";
}

/**
* PHP callback for SQLite's SI_VALUE function.
* Extracts the first numeric value with an optional SI prefix and returns the scaled value.
*
* @param string|null $value The input string
* @return float|null The scaled numeric value, or null if no number found
*/
public static function sqliteSiValue(?string $value): ?float
{
if ($value === null) {
return null;
}

// Normalize comma decimal separator to dot (European locale support)
$value = str_replace(',', '.', $value);

// Match a number at the very start (allowing leading whitespace), optionally followed by an SI prefix
if (!preg_match('/^\s*(\d+\.?\d*)\s*([pnuµmkKMGT])?/u', $value, $matches)) {
return null;
}

$number = (float) $matches[1];
$prefix = $matches[2] ?? '';

if ($prefix === '') {
return $number;
}

$multiplier = self::SI_MULTIPLIERS[$prefix] ?? 1.0;

Check failure on line 192 in src/Doctrine/Functions/SiValueSort.php

View workflow job for this annotation

GitHub Actions / Static analysis

Offset 'G'|'K'|'k'|'M'|'m'|'n'|'p'|'T'|'u'|'µ' on array{p: 1.0E-12, n: 1.0E-9, u: 1.0E-6, µ: 1.0E-6, m: 0.001, k: 1000.0, K: 1000.0, M: 1000000.0, ...} on left side of ?? always exists and is not nullable.

return $number * $multiplier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

namespace App\Doctrine\Middleware;

use App\Doctrine\Functions\SiValueSort;
use App\Exceptions\InvalidRegexException;
use Doctrine\DBAL\Driver\Connection;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
Expand Down Expand Up @@ -51,6 +52,9 @@ public function connect(#[\SensitiveParameter] array $params): Connection

//Create a new collation for natural sorting
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));

//Create a function for SI prefix value sorting
$native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/Settings/BehaviorSettings/PartTableColumns.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ enum PartTableColumns : string implements TranslatableInterface
case TAGS = "tags";
case ATTACHMENTS = "attachments";

case SI_VALUE = "si_value";

case EDA_REFERENCE = "eda_reference";

case EDA_VALUE = "eda_value";
Expand Down
Loading
Loading