Merge #19991 - toon export

Pull-request: #19991
This commit is contained in:
William Desportes 2026-02-06 10:29:33 +01:00
commit b89ee4e4fc
No known key found for this signature in database
GPG Key ID: 70684F4717D49A31
7 changed files with 572 additions and 4 deletions

View File

@ -264,6 +264,13 @@ rendered, for example in following document:
\end{document}
TOON
----
Data can be exported to TOON `(Token-Oriented Object Notation) <https://en.wikipedia.org/wiki/Token-Oriented_Object_Notation>`_,
which is a text-based data format. It encodes the JSON data model in a compact way.
TOON is designed to reduce token usage in LLM prompts while staying human-readable.
MediaWiki
---------

View File

@ -783,13 +783,13 @@ parameters:
-
message: '#^Cannot cast mixed to int\.$#'
identifier: cast.int
count: 2
count: 3
path: src/Config/Settings/Export.php
-
message: '#^Cannot cast mixed to string\.$#'
identifier: cast.string
count: 23
count: 24
path: src/Config/Settings/Export.php
-
@ -5812,7 +5812,7 @@ parameters:
path: src/Export/Options.php
-
message: '#^Only booleans are allowed in &&, bool\|int\<0, max\>\|string given on the right side\.$#'
message: '#^Only booleans are allowed in &&, bool\|int\|string given on the right side\.$#'
identifier: booleanAnd.rightNotBoolean
count: 1
path: src/Export/Options.php
@ -8901,6 +8901,21 @@ parameters:
count: 2
path: src/Plugins/Export/ExportTexytext.php
-
message: '''
#^Call to deprecated method getInstance\(\) of class PhpMyAdmin\\Dbal\\DatabaseInterface\:
Use dependency injection instead\.$#
'''
identifier: staticMethod.deprecated
count: 2
path: src/Plugins/Export/ExportToon.php
-
message: '#^Cannot cast mixed to int\.$#'
identifier: cast.int
count: 1
path: src/Plugins/Export/ExportToon.php
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed

View File

@ -5997,6 +5997,20 @@
<code><![CDATA[$mimeMap]]></code>
</PossiblyUndefinedVariable>
</file>
<file src="src/Plugins/Export/ExportToon.php">
<DeprecatedMethod>
<code><![CDATA[DatabaseInterface::getInstance()]]></code>
<code><![CDATA[DatabaseInterface::getInstance()]]></code>
</DeprecatedMethod>
<RedundantCondition>
<code><![CDATA[$exportConfig->toon_indent]]></code>
<code><![CDATA[$exportConfig->toon_separator]]></code>
</RedundantCondition>
<TypeDoesNotContainType>
<code><![CDATA[$this->indent]]></code>
<code><![CDATA[$this->separator]]></code>
</TypeDoesNotContainType>
</file>
<file src="src/Plugins/Export/ExportXml.php">
<InvalidArgument>
<code><![CDATA[$events]]></code>

View File

@ -55,6 +55,9 @@ use function in_array;
* csv_escaped: string,
* csv_terminated: string,
* csv_removeCRLF: bool,
* toon_indent: int,
* toon_separator: string,
* toon_structure_or_data: StructureOrDataType,
* excel_columns: bool,
* excel_null: string,
* excel_edition: 'mac_excel2003'|'mac_excel2008'|'win',
@ -442,6 +445,29 @@ final class Export
*/
public bool $csv_removeCRLF;
/**
* ```php
* $cfg['Export']['toon_indent'] = 2;
* ```
*/
public int $toon_indent;
/**
* ```php
* $cfg['Export']['toon_separator'] = ',';
* ```
*/
public string $toon_separator;
/**
* ```php
* $cfg['Export']['toon_structure_or_data'] = 'structure_and_data';
* ```
*
* @psalm-var StructureOrDataType
*/
public string $toon_structure_or_data;
/**
* ```php
* $cfg['Export']['excel_columns'] = true;
@ -1031,6 +1057,9 @@ final class Export
$this->csv_escaped = $this->setCsvEscaped($export);
$this->csv_terminated = $this->setCsvTerminated($export);
$this->csv_removeCRLF = $this->setCsvRemoveCRLF($export);
$this->toon_indent = $this->setToonIndent($export);
$this->toon_separator = $this->setToonSeparator($export);
$this->toon_structure_or_data = $this->setToonStructureOrData($export);
$this->excel_columns = $this->setExcelColumns($export);
$this->excel_null = $this->setExcelNull($export);
$this->excel_edition = $this->setExcelEdition($export);
@ -1146,6 +1175,9 @@ final class Export
'csv_escaped' => $this->csv_escaped,
'csv_terminated' => $this->csv_terminated,
'csv_removeCRLF' => $this->csv_removeCRLF,
'toon_indent' => $this->toon_indent,
'toon_separator' => $this->toon_separator,
'toon_structure_or_data' => $this->toon_structure_or_data,
'excel_columns' => $this->excel_columns,
'excel_null' => $this->excel_null,
'excel_edition' => $this->excel_edition,
@ -1680,6 +1712,43 @@ final class Export
return (bool) $export['csv_removeCRLF'];
}
/** @param array<int|string, mixed> $export */
private function setToonIndent(array $export): int
{
if (! isset($export['toon_indent'])) {
return 2;
}
return (int) $export['toon_indent'];
}
/** @param array<int|string, mixed> $export */
private function setToonSeparator(array $export): string
{
if (! isset($export['toon_separator'])) {
return ',';
}
return (string) $export['toon_separator'];
}
/**
* @param array<int|string, mixed> $export
*
* @psalm-return StructureOrDataType
*/
private function setToonStructureOrData(array $export): string
{
if (
! isset($export['toon_structure_or_data'])
|| ! in_array($export['toon_structure_or_data'], ['structure', 'data'], true)
) {
return 'structure_and_data';
}
return $export['toon_structure_or_data'];
}
/** @param array<int|string, mixed> $export */
private function setExcelColumns(array $export): bool
{

View File

@ -0,0 +1,228 @@
<?php
/**
* Export to TOON text.
*/
declare(strict_types=1);
namespace PhpMyAdmin\Plugins\Export;
use PhpMyAdmin\Config\Settings\Export;
use PhpMyAdmin\Dbal\DatabaseInterface;
use PhpMyAdmin\Export\StructureOrData;
use PhpMyAdmin\Http\ServerRequest;
use PhpMyAdmin\Plugins\ExportPlugin;
use PhpMyAdmin\Properties\Options\Groups\OptionsPropertyMainGroup;
use PhpMyAdmin\Properties\Options\Groups\OptionsPropertyRootGroup;
use PhpMyAdmin\Properties\Options\Items\HiddenPropertyItem;
use PhpMyAdmin\Properties\Options\Items\TextPropertyItem;
use PhpMyAdmin\Properties\Plugins\ExportPluginProperties;
use function __;
use function array_key_last;
use function is_int;
use function is_string;
use function sprintf;
use function str_repeat;
use function str_replace;
/**
* Handles the export for the TOON format
*/
class ExportToon extends ExportPlugin
{
private int $indent = 2;
private string $separator = ',';
/** @psalm-return non-empty-lowercase-string */
public function getName(): string
{
return 'toon';
}
protected function setProperties(): ExportPluginProperties
{
$exportPluginProperties = new ExportPluginProperties();
$exportPluginProperties->setText('TOON');
$exportPluginProperties->setExtension('toon');
$exportPluginProperties->setMimeType('text/toon');
$exportPluginProperties->setForceFile(true);
$exportPluginProperties->setOptionsText(__('Options'));
// create the root group that will be the options field for
// $exportPluginProperties
// this will be shown as "Format specific options"
$exportSpecificOptions = new OptionsPropertyRootGroup('Format Specific Options');
// general options main group
$generalOptions = new OptionsPropertyMainGroup('general_opts');
// create leaf items and add them to the group
$leaf = new TextPropertyItem(
'separator',
__('Columns separated with:'),
);
$generalOptions->addProperty($leaf);
$leaf = new TextPropertyItem(
'indent',
__('Indentation:'),
);
$generalOptions->addProperty($leaf);
// create primary items and add them to the group
$leaf = new HiddenPropertyItem('structure_or_data');
$generalOptions->addProperty($leaf);
// add the main group to the root group
$exportSpecificOptions->addProperty($generalOptions);
// set the options for the export plugin property item
$exportPluginProperties->setOptions($exportSpecificOptions);
return $exportPluginProperties;
}
/**
* Outputs the content of a table in JSON format
*
* @param string $db database name
* @param string $table table name
* @param string $sqlQuery SQL query for obtaining data
* @param mixed[] $aliases Aliases of db/table/columns
*/
public function exportData(
string $db,
string $table,
string $sqlQuery,
array $aliases = [],
): void {
$dbAlias = $this->getDbAlias($aliases, $db);
$tableAlias = $this->getTableAlias($aliases, $db, $table);
$dbi = DatabaseInterface::getInstance();
$result = $dbi->query($sqlQuery);
$columnsCnt = $result->numFields();
$rowsCnt = $result->numRows();
$fieldsMeta = $dbi->getFieldsMeta($result);
$columns = [];
foreach ($fieldsMeta as $i => $field) {
$colAs = $this->getColumnAlias($aliases, $db, $table, $field->name);
$columns[$i] = $colAs;
}
$buffer = sprintf(
'%s.%s[%s%s]{',
$dbAlias,
$tableAlias,
$rowsCnt,
$this->separator !== ',' ? $this->separator : '',
);
foreach ($columns as $index => $column) {
$buffer .= $column;
// phpcs:ignore SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed
if ($index !== array_key_last($columns)) {
$buffer .= $this->separator;
}
}
$buffer .= "}:\n";
$this->outputHandler->addLine($buffer);
$insertedLines = 0;
while ($row = $result->fetchRow()) {
$buffer = '';
foreach ($row as $index => $col) {
if ($index === 0) {
$buffer .= str_repeat(' ', $this->indent);
}
if ($col === null) {
$buffer .= 'null';
continue;
}
$buffer .= $col;
// phpcs:ignore SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed
if ($index !== $columnsCnt - 1) {
$buffer .= $this->separator;
}
}
$insertedLines++;
$buffer .= $insertedLines === $rowsCnt ? "\n\n" : "\n";
$this->outputHandler->addLine($buffer);
}
}
/**
* Outputs result raw query in TOON format
*
* @param string $db the database where the query is executed
* @param string $sqlQuery the rawquery to output
*/
public function exportRawQuery(string $db, string $sqlQuery): void
{
if ($db !== '') {
DatabaseInterface::getInstance()->selectDb($db);
}
$this->exportData($db, '', $sqlQuery);
}
private function setupExportConfiguration(): void
{
$this->separator = str_replace('\\t', "\011", $this->separator);
}
public function setExportOptions(ServerRequest $request, Export $exportConfig): void
{
// phpcs:disable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$this->structureOrData = $this->setStructureOrData(
$request->getParsedBodyParam('toon_structure_or_data'),
$exportConfig->toon_structure_or_data,
StructureOrData::StructureAndData,
);
$this->separator = $this->setStringValue(
$request->getParsedBodyParam('toon_separator'),
$exportConfig->toon_separator ?? $this->separator,
);
$this->indent = $this->setIntValue(
(int) $request->getParsedBodyParam('toon_indent'),
$exportConfig->toon_indent ?? $this->indent,
);
// phpcs:enable Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
$this->setupExportConfiguration();
}
private function setStringValue(mixed $fromRequest, mixed $fromConfig): string
{
if (is_string($fromRequest) && $fromRequest !== '') {
return $fromRequest;
}
if (is_string($fromConfig) && $fromConfig !== '') {
return $fromConfig;
}
return '';
}
private function setIntValue(mixed $fromRequest, mixed $fromConfig): int
{
if (is_int($fromRequest)) {
return $fromRequest;
}
if (is_int($fromConfig)) {
return $fromConfig;
}
return 0;
}
}

View File

@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace PhpMyAdmin\Tests\Plugins\Export;
use PhpMyAdmin\Config;
use PhpMyAdmin\Config\Settings\Export;
use PhpMyAdmin\ConfigStorage\Relation;
use PhpMyAdmin\Current;
use PhpMyAdmin\Dbal\DatabaseInterface;
use PhpMyAdmin\Export\OutputHandler;
use PhpMyAdmin\Http\Factory\ServerRequestFactory;
use PhpMyAdmin\Plugins\Export\ExportToon;
use PhpMyAdmin\Plugins\ExportPlugin;
use PhpMyAdmin\Plugins\ExportType;
use PhpMyAdmin\Properties\Options\Groups\OptionsPropertyMainGroup;
use PhpMyAdmin\Properties\Options\Groups\OptionsPropertyRootGroup;
use PhpMyAdmin\Properties\Options\Items\HiddenPropertyItem;
use PhpMyAdmin\Properties\Options\Items\TextPropertyItem;
use PhpMyAdmin\Properties\Plugins\ExportPluginProperties;
use PhpMyAdmin\Tests\AbstractTestCase;
use PhpMyAdmin\Transformations;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Medium;
use ReflectionMethod;
use ReflectionProperty;
use function ob_get_clean;
use function ob_start;
#[CoversClass(ExportToon::class)]
#[Medium]
class ExportToonTest extends AbstractTestCase
{
protected function setUp(): void
{
parent::setUp();
OutputHandler::$asFile = false;
ExportPlugin::$exportType = ExportType::Table;
ExportPlugin::$singleTable = false;
Current::$database = '';
Current::$table = '';
Current::$lang = 'en';
}
public function testSetProperties(): void
{
$exportToon = $this->getExportToon();
$method = new ReflectionMethod(ExportToon::class, 'setProperties');
$method->invoke($exportToon, null);
$attrProperties = new ReflectionProperty(ExportToon::class, 'properties');
$properties = $attrProperties->getValue($exportToon);
self::assertInstanceOf(ExportPluginProperties::class, $properties);
self::assertSame(
'TOON',
$properties->getText(),
);
self::assertSame(
'toon',
$properties->getExtension(),
);
self::assertSame(
'text/toon',
$properties->getMimeType(),
);
self::assertSame(
'Options',
$properties->getOptionsText(),
);
$options = $properties->getOptions();
self::assertInstanceOf(OptionsPropertyRootGroup::class, $options);
self::assertSame(
'Format Specific Options',
$options->getName(),
);
$generalOptionsArray = $options->getProperties();
$generalOptions = $generalOptionsArray->current();
self::assertInstanceOf(OptionsPropertyMainGroup::class, $generalOptions);
self::assertSame(
'general_opts',
$generalOptions->getName(),
);
$generalProperties = $generalOptions->getProperties();
$property = $generalProperties->current();
$generalProperties->next();
self::assertInstanceOf(TextPropertyItem::class, $property);
self::assertSame(
'separator',
$property->getName(),
);
self::assertSame(
'Columns separated with:',
$property->getText(),
);
$property = $generalProperties->current();
$generalProperties->next();
self::assertInstanceOf(TextPropertyItem::class, $property);
self::assertSame(
'indent',
$property->getName(),
);
self::assertSame(
'Indentation:',
$property->getText(),
);
$property = $generalProperties->current();
self::assertInstanceOf(HiddenPropertyItem::class, $property);
self::assertSame(
'structure_or_data',
$property->getName(),
);
}
public function testExportHeader(): void
{
$exportToon = $this->getExportToon();
$this->expectNotToPerformAssertions();
$exportToon->exportHeader();
}
public function testExportFooter(): void
{
$exportToon = $this->getExportToon();
$this->expectNotToPerformAssertions();
$exportToon->exportFooter();
}
public function testExportDBHeader(): void
{
$exportToon = $this->getExportToon();
$this->expectNotToPerformAssertions();
$exportToon->exportDBHeader('testDB');
}
public function testExportDBFooter(): void
{
$exportToon = $this->getExportToon();
$this->expectNotToPerformAssertions();
$exportToon->exportDBFooter('testDB');
}
public function testExportDBCreate(): void
{
$exportToon = $this->getExportToon();
$this->expectNotToPerformAssertions();
$exportToon->exportDBCreate('testDB');
}
public function testExportData(): void
{
$exportToon = $this->getExportToon();
$request = ServerRequestFactory::create()->createServerRequest('POST', 'https://example.com/')
->withParsedBody([]);
$exportToon->setExportOptions($request, new Export());
ob_start();
$exportToon->exportData('test_db', 'test_table', 'SELECT * FROM `test_db`.`test_table`;');
$result = ob_get_clean();
self::assertIsString($result);
self::assertSame(
'test_db.test_table[3]{id,name,datetimefield}:' . "\n" .
' 1,abcd,2011-01-20 02:00:02' . "\n" .
' 2,foo,2010-01-20 02:00:02' . "\n" .
' 3,Abcd,2012-01-20 02:00:02' . "\n\n",
$result,
);
}
public function testExportDataWithCustomConfig(): void
{
$exportToon = $this->getExportToon();
$request = ServerRequestFactory::create()->createServerRequest('POST', 'https://example.com/')
->withParsedBody(['toon_separator' => '|', 'toon_indent' => '4']);
$exportToon->setExportOptions($request, new Export());
ob_start();
$exportToon->exportData('test_db', 'test_table', 'SELECT * FROM `test_db`.`test_table`;');
$result = ob_get_clean();
self::assertIsString($result);
self::assertSame(
'test_db.test_table[3|]{id|name|datetimefield}:' . "\n" .
' 1|abcd|2011-01-20 02:00:02' . "\n" .
' 2|foo|2010-01-20 02:00:02' . "\n" .
' 3|Abcd|2012-01-20 02:00:02' . "\n\n",
$result,
);
}
private function getExportToon(DatabaseInterface|null $dbi = null): ExportToon
{
$dbi ??= $this->createDatabaseInterface();
$config = new Config();
$relation = new Relation($dbi, $config);
return new ExportToon($relation, new OutputHandler(), new Transformations($dbi, $relation), $dbi, $config);
}
}

View File

@ -46,7 +46,7 @@ class PluginsTest extends AbstractTestCase
$isCurl = extension_loaded('curl');
self::assertSame(ExportType::Database, ExportPlugin::$exportType);
self::assertFalse(ExportPlugin::$singleTable);
$pluginCount = $isCurl ? 14 : 13;
$pluginCount = $isCurl ? 15 : 14;
self::assertCount($pluginCount, $plugins);
self::assertContainsOnlyInstancesOf(ExportPlugin::class, $plugins);
}
@ -432,6 +432,12 @@ class PluginsTest extends AbstractTestCase
<label for="text_texytext_null" class="form-label">Replace NULL with:</label><input class="form-control" type="text" name="texytext_null" value="NULL" id="text_texytext_null"></ul></div></li>
</div>
<div id="toon_options" class="format_specific_options"><h3>TOON</h3>
<div id="toon_general_opts"><ul class="list-group"><li class="list-group-item">
<label for="text_toon_separator" class="form-label">Columns separated with:</label><input class="form-control" type="text" name="toon_separator" value="," id="text_toon_separator"><li class="list-group-item">
<label for="text_toon_indent" class="form-label">Indentation:</label><input class="form-control" type="text" name="toon_indent" value="2" id="text_toon_indent"><li class="list-group-item"><input type="hidden" name="toon_structure_or_data" value="structure_and_data"></li></ul></div>
</div>
<div id="yaml_options" class="format_specific_options"><h3>YAML</h3>
<div id="yaml_general_opts"><ul class="list-group"><li class="list-group-item"><input type="hidden" name="yaml_structure_or_data" value="data"></li></ul></div>
<p class="card-text">This format has no options</p></div>