Skip to content

Commit 29db029

Browse files
authored
Add SI-prefix-aware sorting column for parts tableFeature/si value sort (#1344)
* Add SI-prefix-aware sorting column for the parts table Adds an optional "Name (SI)" column that parses numeric values with SI prefixes (p, n, u/µ, m, k/K, M, G, T) from part names and sorts by the resulting physical value. This is useful for electronic components where alphabetical sorting produces wrong results — e.g. 100nF, 10pF, 1uF should sort as 10pF < 100nF < 1uF. Implementation: - New SiValueSort DQL function with platform-specific SQL generation for PostgreSQL (POSIX regex), MySQL/MariaDB (REGEXP_SUBSTR), and SQLite (PHP callback registered via the existing middleware). - The regex is start-anchored: only names beginning with a number are matched. Part numbers like "MCP2515" or "Crystal 20MHz" are ignored. - When SI sort is active, NATSORT is appended as a secondary sort so that non-matching parts fall back to natural string ordering instead of appearing in arbitrary order. - The column is opt-in (not in default columns) and displays the parsed float value, or an empty cell for non-matching names. * Rename SI column from "Name (SI)" to "SI Value" The column now shows the parsed numeric value rather than the part name, so the label should reflect that. * Support comma as decimal separator in SI value parsing Part names using European decimal notation (e.g. "4,7 kΩ", "2,2uF") were parsed incorrectly because the regex only recognized dots. Now commas are normalized to dots before parsing, matching the existing pattern used elsewhere in the codebase (PartNormalizer, price providers).
1 parent 146e85f commit 29db029

7 files changed

Lines changed: 427 additions & 0 deletions

File tree

config/packages/doctrine.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ doctrine:
5656
natsort: App\Doctrine\Functions\Natsort
5757
array_position: App\Doctrine\Functions\ArrayPosition
5858
ilike: App\Doctrine\Functions\ILike
59+
si_value_sort: App\Doctrine\Functions\SiValueSort
5960

6061
when@test:
6162
doctrine:

src/DataTables/PartsDataTable.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
use App\DataTables\Filters\PartSearchFilter;
3939
use App\DataTables\Helpers\ColumnSortHelper;
4040
use App\DataTables\Helpers\PartDataTableHelper;
41+
use App\Doctrine\Functions\SiValueSort;
4142
use App\Doctrine\Helpers\FieldHelper;
4243
use App\Entity\Parts\ManufacturingStatus;
4344
use App\Entity\Parts\Part;
@@ -118,6 +119,17 @@ public function configure(DataTable $dataTable, array $options): void
118119
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
119120
'orderField' => 'NATSORT(part.name)'
120121
])
122+
->add('si_value', TextColumn::class, [
123+
'label' => $this->translator->trans('part.table.si_value'),
124+
'render' => function ($value, Part $context): string {
125+
$siValue = SiValueSort::sqliteSiValue($context->getName());
126+
if ($siValue !== null) {
127+
return htmlspecialchars(sprintf('%g', $siValue));
128+
}
129+
return '';
130+
},
131+
'orderField' => 'SI_VALUE_SORT(part.name)',
132+
])
121133
->add('id', TextColumn::class, [
122134
'label' => $this->translator->trans('part.table.id'),
123135
])
@@ -484,6 +496,19 @@ private function addJoins(QueryBuilder $builder): QueryBuilder
484496
//$builder->addGroupBy('_bulkImportJob');
485497
}
486498

499+
//When sorting by SI value, add NATSORT as a secondary sort so that parts without
500+
//an SI-prefixed value fall back to natural string ordering seamlessly.
501+
$orderByParts = $builder->getDQLPart('orderBy');
502+
foreach ($orderByParts as $orderBy) {
503+
foreach ($orderBy->getParts() as $part) {
504+
if (str_contains($part, 'SI_VALUE_SORT')) {
505+
$direction = str_contains($part, 'DESC') ? 'DESC' : 'ASC';
506+
$builder->addOrderBy('NATSORT(part.name)', $direction);
507+
break 2;
508+
}
509+
}
510+
}
511+
487512
return $builder;
488513
}
489514

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<?php
2+
/*
3+
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
4+
*
5+
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
6+
*
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU Affero General Public License as published
9+
* by the Free Software Foundation, either version 3 of the License, or
10+
* (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU Affero General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Affero General Public License
18+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
19+
*/
20+
21+
declare(strict_types=1);
22+
23+
namespace App\Doctrine\Functions;
24+
25+
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
26+
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
27+
use Doctrine\DBAL\Platforms\SQLitePlatform;
28+
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
29+
use Doctrine\ORM\Query\AST\Node;
30+
use Doctrine\ORM\Query\Parser;
31+
use Doctrine\ORM\Query\SqlWalker;
32+
use Doctrine\ORM\Query\TokenType;
33+
34+
/**
35+
* Custom DQL function that extracts the first numeric value with an optional SI prefix
36+
* from a string and returns the scaled numeric value for sorting.
37+
*
38+
* Usage: SI_VALUE_SORT(part.name)
39+
*
40+
* This enables sorting parts by their physical value. For example, capacitors
41+
* named "100nF", "1uF", "10pF" will be sorted by actual value: 10pF < 100nF < 1uF.
42+
*
43+
* Supported SI prefixes: p (pico, 1e-12), n (nano, 1e-9), u/µ (micro, 1e-6),
44+
* m (milli, 1e-3), k/K (kilo, 1e3), M (mega, 1e6), G (giga, 1e9), T (tera, 1e12).
45+
*
46+
* Only matches numbers at the very beginning of the string (ignoring leading whitespace).
47+
* Names like "Crystal 20MHz" will NOT match since the number is not at the start.
48+
* Names without a recognizable numeric+prefix pattern return NULL and sort last.
49+
*/
50+
class SiValueSort extends FunctionNode
51+
{
52+
private ?Node $field = null;
53+
54+
/**
55+
* SI prefix multipliers. Used by the SQLite PHP callback.
56+
*/
57+
private const SI_MULTIPLIERS = [
58+
'p' => 1e-12,
59+
'n' => 1e-9,
60+
'u' => 1e-6,
61+
'µ' => 1e-6,
62+
'm' => 1e-3,
63+
'k' => 1e3,
64+
'K' => 1e3,
65+
'M' => 1e6,
66+
'G' => 1e9,
67+
'T' => 1e12,
68+
];
69+
70+
public function parse(Parser $parser): void
71+
{
72+
$parser->match(TokenType::T_IDENTIFIER);
73+
$parser->match(TokenType::T_OPEN_PARENTHESIS);
74+
75+
$this->field = $parser->ArithmeticExpression();
76+
77+
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
78+
}
79+
80+
public function getSql(SqlWalker $sqlWalker): string
81+
{
82+
assert($this->field !== null, 'Field is not set');
83+
84+
$platform = $sqlWalker->getConnection()->getDatabasePlatform();
85+
$rawField = $this->field->dispatch($sqlWalker);
86+
87+
// Normalize comma decimal separator to dot for SQL platforms (European locale support)
88+
$fieldSql = "REPLACE({$rawField}, ',', '.')";
89+
90+
if ($platform instanceof PostgreSQLPlatform) {
91+
return $this->getPostgreSQLSql($fieldSql);
92+
}
93+
94+
if ($platform instanceof AbstractMySQLPlatform) {
95+
return $this->getMySQLSql($fieldSql);
96+
}
97+
98+
// SQLite: comma normalization is handled in the PHP callback
99+
$fieldSql = $rawField;
100+
101+
if ($platform instanceof SQLitePlatform) {
102+
return "SI_VALUE({$fieldSql})";
103+
}
104+
105+
// Fallback: return NULL (no SI sorting available)
106+
return 'NULL';
107+
}
108+
109+
/**
110+
* PostgreSQL implementation using substring() with POSIX regex.
111+
*/
112+
private function getPostgreSQLSql(string $field): string
113+
{
114+
// Extract the numeric part using POSIX regex, anchored at start (with optional leading whitespace)
115+
$numericPart = "CAST(substring({$field} FROM '^\\s*(\\d+\\.?\\d*)\\s*[pnuµmkKMGT]?') AS DOUBLE PRECISION)";
116+
117+
// Extract the SI prefix character
118+
$prefixPart = "substring({$field} FROM '^\\s*\\d+\\.?\\d*\\s*([pnuµmkKMGT])')";
119+
120+
return $this->buildCaseExpression($numericPart, $prefixPart);
121+
}
122+
123+
/**
124+
* MySQL/MariaDB implementation using REGEXP_SUBSTR.
125+
*/
126+
private function getMySQLSql(string $field): string
127+
{
128+
// Extract the numeric part, anchored at start (with optional leading whitespace)
129+
$numericPart = "CAST(REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*') AS DECIMAL(30,15))";
130+
131+
// Extract the prefix: get the full number+prefix match anchored at start, then take the last char
132+
$fullMatch = "REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*[[:space:]]*[pnuµmkKMGT]')";
133+
$prefixPart = "RIGHT({$fullMatch}, 1)";
134+
135+
return $this->buildCaseExpression($numericPart, $prefixPart);
136+
}
137+
138+
/**
139+
* Build a CASE expression that maps an SI prefix character to a multiplier
140+
* and multiplies it with the numeric value.
141+
*
142+
* @param string $numericExpr SQL expression that evaluates to the numeric part
143+
* @param string $prefixExpr SQL expression that evaluates to the SI prefix character
144+
* @return string SQL CASE expression
145+
*/
146+
private function buildCaseExpression(string $numericExpr, string $prefixExpr): string
147+
{
148+
return "(CASE" .
149+
" WHEN {$numericExpr} IS NULL THEN NULL" .
150+
" WHEN {$prefixExpr} = 'p' THEN {$numericExpr} * 1e-12" .
151+
" WHEN {$prefixExpr} = 'n' THEN {$numericExpr} * 1e-9" .
152+
" WHEN {$prefixExpr} = 'u' THEN {$numericExpr} * 1e-6" .
153+
" WHEN {$prefixExpr} = 'µ' THEN {$numericExpr} * 1e-6" .
154+
" WHEN {$prefixExpr} = 'm' THEN {$numericExpr} * 1e-3" .
155+
" WHEN {$prefixExpr} = 'k' THEN {$numericExpr} * 1e3" .
156+
" WHEN {$prefixExpr} = 'K' THEN {$numericExpr} * 1e3" .
157+
" WHEN {$prefixExpr} = 'M' THEN {$numericExpr} * 1e6" .
158+
" WHEN {$prefixExpr} = 'G' THEN {$numericExpr} * 1e9" .
159+
" WHEN {$prefixExpr} = 'T' THEN {$numericExpr} * 1e12" .
160+
" ELSE {$numericExpr} * 1" .
161+
" END)";
162+
}
163+
164+
/**
165+
* PHP callback for SQLite's SI_VALUE function.
166+
* Extracts the first numeric value with an optional SI prefix and returns the scaled value.
167+
*
168+
* @param string|null $value The input string
169+
* @return float|null The scaled numeric value, or null if no number found
170+
*/
171+
public static function sqliteSiValue(?string $value): ?float
172+
{
173+
if ($value === null) {
174+
return null;
175+
}
176+
177+
// Normalize comma decimal separator to dot (European locale support)
178+
$value = str_replace(',', '.', $value);
179+
180+
// Match a number at the very start (allowing leading whitespace), optionally followed by an SI prefix
181+
if (!preg_match('/^\s*(\d+\.?\d*)\s*([pnuµmkKMGT])?/u', $value, $matches)) {
182+
return null;
183+
}
184+
185+
$number = (float) $matches[1];
186+
$prefix = $matches[2] ?? '';
187+
188+
if ($prefix === '') {
189+
return $number;
190+
}
191+
192+
$multiplier = self::SI_MULTIPLIERS[$prefix] ?? 1.0;
193+
194+
return $number * $multiplier;
195+
}
196+
}

src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
namespace App\Doctrine\Middleware;
2525

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

5253
//Create a new collation for natural sorting
5354
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
55+
56+
//Create a function for SI prefix value sorting
57+
$native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC);
5458
}
5559
}
5660

src/Settings/BehaviorSettings/PartTableColumns.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ enum PartTableColumns : string implements TranslatableInterface
5252
case TAGS = "tags";
5353
case ATTACHMENTS = "attachments";
5454

55+
case SI_VALUE = "si_value";
56+
5557
case EDA_REFERENCE = "eda_reference";
5658

5759
case EDA_VALUE = "eda_value";

0 commit comments

Comments
 (0)