ENH Allow selecting multiple (or no) tables (#10953)

This commit is contained in:
Guy Sartorelli 2023-09-25 12:32:19 +13:00 committed by GitHub
parent b3b1d07616
commit b28749db44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 127 additions and 19 deletions

View File

@ -242,9 +242,25 @@ class DBQueryBuilder
public function buildFromFragment(SQLConditionalExpression $query, array &$parameters) public function buildFromFragment(SQLConditionalExpression $query, array &$parameters)
{ {
$from = $query->getJoins($joinParameters); $from = $query->getJoins($joinParameters);
$tables = [];
$joins = [];
// E.g. a naive "Select 1" statement is valid SQL
if (empty($from)) {
return '';
}
foreach ($from as $joinOrTable) {
if (preg_match(SQLConditionalExpression::getJoinRegex(), $joinOrTable)) {
$joins[] = $joinOrTable;
} else {
$tables[] = $joinOrTable;
}
}
$parameters = array_merge($parameters, $joinParameters); $parameters = array_merge($parameters, $joinParameters);
$nl = $this->getSeparator(); $nl = $this->getSeparator();
return "{$nl}FROM " . implode(' ', $from); return "{$nl}FROM " . implode(', ', $tables) . ' ' . implode(' ', $joins);
} }
/** /**

View File

@ -8,7 +8,6 @@ namespace SilverStripe\ORM\Queries;
*/ */
abstract class SQLConditionalExpression extends SQLExpression abstract class SQLConditionalExpression extends SQLExpression
{ {
/** /**
* An array of WHERE clauses. * An array of WHERE clauses.
* *
@ -226,7 +225,7 @@ abstract class SQLConditionalExpression extends SQLExpression
foreach ($this->from as $key => $tableClause) { foreach ($this->from as $key => $tableClause) {
if (is_array($tableClause)) { if (is_array($tableClause)) {
$table = '"' . $tableClause['table'] . '"'; $table = '"' . $tableClause['table'] . '"';
} elseif (is_string($tableClause) && preg_match('/JOIN +("[^"]+") +(AS|ON) +/i', $tableClause ?? '', $matches)) { } elseif (is_string($tableClause) && preg_match(self::getJoinRegex(), $tableClause ?? '', $matches)) {
$table = $matches[1]; $table = $matches[1];
} else { } else {
$table = $tableClause; $table = $tableClause;
@ -325,11 +324,16 @@ abstract class SQLConditionalExpression extends SQLExpression
return $from; return $from;
} }
// shift the first FROM table out from so we only deal with the JOINs // Remove the regular FROM tables out so we only deal with the JOINs
reset($from); $regularTables = [];
$baseFromAlias = key($from ?? []); foreach ($from as $alias => $tableClause) {
$baseFrom = array_shift($from); if (is_string($tableClause) && !preg_match(self::getJoinRegex(), $tableClause)) {
$regularTables[$alias] = $tableClause;
unset($from[$alias]);
}
}
// Sort the joins
$this->mergesort($from, function ($firstJoin, $secondJoin) { $this->mergesort($from, function ($firstJoin, $secondJoin) {
if (!is_array($firstJoin) if (!is_array($firstJoin)
|| !is_array($secondJoin) || !is_array($secondJoin)
@ -341,11 +345,14 @@ abstract class SQLConditionalExpression extends SQLExpression
} }
}); });
// Put the first FROM table back into the results // Put the regular FROM tables back into the results
if (!empty($baseFromAlias) && !is_numeric($baseFromAlias)) { $regularTables = array_reverse($regularTables, true);
$from = array_merge([$baseFromAlias => $baseFrom], $from); foreach ($regularTables as $alias => $tableName) {
} else { if (!empty($alias) && !is_numeric($alias)) {
array_unshift($from, $baseFrom); $from = array_merge([$alias => $tableName], $from);
} else {
array_unshift($from, $tableName);
}
} }
return $from; return $from;
@ -773,4 +780,12 @@ abstract class SQLConditionalExpression extends SQLExpression
$this->copyTo($update); $this->copyTo($update);
return $update; return $update;
} }
/**
* Get the regular expression pattern used to identify JOIN statements
*/
public static function getJoinRegex(): string
{
return '/JOIN +.*? +(AS|ON|USING\(?) +/i';
}
} }

View File

@ -162,6 +162,9 @@ class SQLSelect extends SQLConditionalExpression
$fields = [$fields]; $fields = [$fields];
} }
foreach ($fields as $idx => $field) { foreach ($fields as $idx => $field) {
if ($field === '') {
continue;
}
$this->selectField($field, is_numeric($idx) ? null : $idx); $this->selectField($field, is_numeric($idx) ? null : $idx);
} }
@ -713,4 +716,10 @@ class SQLSelect extends SQLConditionalExpression
$query->setLimit(1, $index); $query->setLimit(1, $index);
return $query; return $query;
} }
public function isEmpty()
{
// Empty if there's no select, or we're trying to select '*' but there's no FROM clause
return empty($this->select) || (empty($this->from) && array_key_exists('*', $this->select));
}
} }

View File

@ -67,19 +67,80 @@ class SQLSelectTest extends SapphireTest
} }
} }
public function provideIsEmpty()
{
return [
[
'query' => new SQLSelect(),
'expected' => true,
],
[
'query' => new SQLSelect(from: 'someTable'),
'expected' => false,
],
[
'query' => new SQLSelect(''),
'expected' => true,
],
[
'query' => new SQLSelect('', 'someTable'),
'expected' => true,
],
[
'query' => new SQLSelect('column', 'someTable'),
'expected' => false,
],
[
'query' => new SQLSelect('value'),
'expected' => false,
],
];
}
/**
* @dataProvider provideIsEmpty
*/
public function testIsEmpty(SQLSelect $query, $expected)
{
$this->assertSame($expected, $query->isEmpty());
}
public function testEmptyQueryReturnsNothing() public function testEmptyQueryReturnsNothing()
{ {
$query = new SQLSelect(); $query = new SQLSelect();
$this->assertSQLEquals('', $query->sql($parameters)); $this->assertSQLEquals('', $query->sql($parameters));
} }
public function testSelectFromBasicTable() public function provideSelectFrom()
{
return [
[
'from' => ['MyTable'],
'expected' => 'SELECT * FROM MyTable',
],
[
'from' => ['MyTable', 'MySecondTable'],
'expected' => 'SELECT * FROM MyTable, MySecondTable',
],
[
'from' => ['MyTable', 'INNER JOIN AnotherTable on AnotherTable.ID = MyTable.SomeFieldID'],
'expected' => 'SELECT * FROM MyTable INNER JOIN AnotherTable on AnotherTable.ID = MyTable.SomeFieldID',
],
[
'from' => ['MyTable', 'MySecondTable', 'INNER JOIN AnotherTable on AnotherTable.ID = MyTable.SomeFieldID'],
'expected' => 'SELECT * FROM MyTable, MySecondTable INNER JOIN AnotherTable on AnotherTable.ID = MyTable.SomeFieldID',
],
];
}
/**
* @dataProvider provideSelectFrom
*/
public function testSelectFrom(array $from, string $expected)
{ {
$query = new SQLSelect(); $query = new SQLSelect();
$query->setFrom('MyTable'); $query->setFrom($from);
$this->assertSQLEquals("SELECT * FROM MyTable", $query->sql($parameters)); $this->assertSQLEquals($expected, $query->sql($parameters));
$query->addFrom('MyJoin');
$this->assertSQLEquals("SELECT * FROM MyTable MyJoin", $query->sql($parameters));
} }
public function testSelectFromUserSpecifiedFields() public function testSelectFromUserSpecifiedFields()
@ -724,6 +785,13 @@ class SQLSelectTest extends SapphireTest
); );
} }
public function testSelectWithNoTable()
{
$query = new SQLSelect('200');
$this->assertSQLEquals('SELECT 200 AS "200"', $query->sql());
$this->assertSame([['200' => 200]], iterator_to_array($query->execute(), true));
}
/** /**
* Test passing in a LIMIT with OFFSET clause string. * Test passing in a LIMIT with OFFSET clause string.
*/ */
@ -819,12 +887,12 @@ class SQLSelectTest extends SapphireTest
// In SS4 the "explicitAlias" would be ignored // In SS4 the "explicitAlias" would be ignored
$query = SQLSelect::create('*', [ $query = SQLSelect::create('*', [
'MyTableAlias' => '"MyTable"', 'MyTableAlias' => '"MyTable"',
'explicitAlias' => ', (SELECT * FROM "MyTable" where "something" = "whatever") as "CrossJoin"' 'explicitAlias' => '(SELECT * FROM "MyTable" where "something" = "whatever") as "CrossJoin"'
]); ]);
$sql = $query->sql(); $sql = $query->sql();
$this->assertSQLEquals( $this->assertSQLEquals(
'SELECT * FROM "MyTable" AS "MyTableAlias" , ' . 'SELECT * FROM "MyTable" AS "MyTableAlias", ' .
'(SELECT * FROM "MyTable" where "something" = "whatever") as "CrossJoin" AS "explicitAlias"', '(SELECT * FROM "MyTable" where "something" = "whatever") as "CrossJoin" AS "explicitAlias"',
$sql $sql
); );