Skip to content

Commit fbcc903

Browse files
committed
First working version of MagicJoin!
1 parent a373a02 commit fbcc903

File tree

8 files changed

+270
-15
lines changed

8 files changed

+270
-15
lines changed

composer.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mouf/magic-query",
3-
"description": "A very clever library to generate PHP prepared statement with a variable number of parameters... and much more!",
3+
"description": "A very clever library to help you with SQL: generate prepared statements with a variable number of parameters, automatically writes joins... and much more!",
44
"keywords": ["database", "query", "mouf"],
55
"homepage": "http://mouf-php.com/packages/mouf/magic-query",
66
"type": "library",
@@ -17,7 +17,8 @@
1717
"mouf/utils.common.conditioninterface": "~2.0",
1818
"mouf/utils.value.value-interface": "~1.0",
1919
"mouf/utils.common.paginable-interface": "~1.0",
20-
"mouf/utils.common.sortable-interface": "~1.0"
20+
"mouf/utils.common.sortable-interface": "~1.0",
21+
"mouf/schema-analyzer": "~1.0"
2122
},
2223
"require-dev": {
2324
"phpunit/phpunit": "~4.0",

src/Mouf/Database/MagicQuery.php

Lines changed: 180 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,219 @@
22

33
namespace Mouf\Database;
44

5+
use Mouf\Database\SchemaAnalyzer\SchemaAnalyzer;
6+
use SQLParser\Node\ColRef;
7+
use SQLParser\Node\Equal;
8+
use SQLParser\Node\NodeInterface;
9+
use SQLParser\Node\Table;
10+
use SQLParser\Node\Traverser\DetectMagicJoinSelectVisitor;
11+
use SQLParser\Node\Traverser\DetectTablesVisitor;
12+
use SQLParser\Node\Traverser\MagicJoinSelect;
13+
use SQLParser\Node\Traverser\NodeTraverser;
14+
use SQLParser\Query\Select;
515
use SQLParser\Query\StatementFactory;
616
use SQLParser\SQLParser;
717
use SQLParser\SqlRenderInterface;
818

919
/**
1020
* The class MagicQuery offers special SQL voodoo methods to automatically strip down unused parameters
11-
* from parameterized SQL statements.
21+
* from parametrized SQL statements.
1222
*/
1323
class MagicQuery
1424
{
1525
private $connection;
26+
private $cache;
27+
private $schemaAnalyzer;
1628

1729
/**
1830
* @param \Doctrine\DBAL\Connection $connection
31+
* @param \Doctrine\Common\Cache\Cache $cache
32+
* @param SchemaAnalyzer $schemaAnalyzer (optional). If not set, it is initialized from the connection.
1933
*/
20-
public function __construct($connection = null)
34+
public function __construct($connection = null, $cache = null, SchemaAnalyzer $schemaAnalyzer = null)
2135
{
2236
$this->connection = $connection;
37+
$this->cache = $cache;
38+
if ($schemaAnalyzer) {
39+
$this->schemaAnalyzer = $schemaAnalyzer;
40+
}
2341
}
2442

2543
/**
2644
* Returns merged SQL from $sql and $parameters. Any parameters not available will be striped down
2745
* from the SQL.
2846
*
47+
* This is equivalent to calling `parse` and `toSql` successively.
48+
*
2949
* @param string $sql
3050
* @param array $parameters
3151
*
3252
* @return string
3353
*/
3454
public function build($sql, array $parameters = array())
3555
{
36-
$parser = new SQLParser();
37-
$parsed = $parser->parse($sql);
56+
$select = $this->parse($sql);
57+
return $this->toSql($select, $parameters);
58+
}
59+
60+
/**
61+
* Parses the $sql passed in parameter and returns a tree representation of it.
62+
* This tree representation can be used to manipulate the SQL.
63+
*
64+
* @param string $sql
65+
* @return NodeInterface
66+
* @throws MagicQueryMissingConnectionException
67+
* @throws MagicQueryParserException
68+
*/
69+
public function parse($sql) {
70+
$select = false;
71+
72+
if ($this->cache !== null) {
73+
// We choose md4 because it is fast.
74+
$cacheKey = "request_".hash("md4", $sql);
75+
$select = $this->cache->fetch($cacheKey);
76+
}
77+
78+
if ($select === false) {
79+
$parser = new SQLParser();
80+
$parsed = $parser->parse($sql);
81+
82+
if ($parsed == false) {
83+
throw new MagicQueryParserException('Unable to parse query "'.$sql.'"');
84+
}
85+
86+
$select = StatementFactory::toObject($parsed);
3887

39-
if ($parsed == false) {
40-
throw new MagicQueryParserException('Unable to parse query "'.$sql.'"');
88+
$this->magicJoin($select);
89+
90+
if ($this->cache !== null) {
91+
// Let's store the tree
92+
$this->cache->save($cacheKey, $select);
93+
}
4194
}
95+
return $select;
96+
}
4297

43-
$select = StatementFactory::toObject($parsed);
98+
/**
99+
* Transforms back a tree of SQL node into a SQL string.
100+
*
101+
* @param NodeInterface $sqlNode
102+
* @param array $parameters
103+
* @return string
104+
*/
105+
public function toSql(NodeInterface $sqlNode, array $parameters = array()) {
106+
return $sqlNode->toSql($parameters, $this->connection, 0, SqlRenderInterface::CONDITION_GUESS);
107+
}
44108

45-
$sql = $select->toSql($parameters, $this->connection, 0, SqlRenderInterface::CONDITION_GUESS);
109+
/**
110+
* Scans the SQL statement and replaces the "magicjoin" part with the correct joins.
111+
*
112+
* @param NodeInterface $select
113+
* @throws MagicQueryMissingConnectionException
114+
*/
115+
private function magicJoin(NodeInterface $select) {
116+
// Let's find if this is a MagicJoin query.
117+
$magicJoinDetector = new DetectMagicJoinSelectVisitor();
118+
$nodeTraverser = new NodeTraverser();
119+
$nodeTraverser->addVisitor($magicJoinDetector);
46120

47-
return $sql;
121+
$nodeTraverser->walk($select);
122+
123+
$magicJoinSelects = $magicJoinDetector->getMagicJoinSelects();
124+
if ($magicJoinSelects) {
125+
foreach ($magicJoinSelects as $magicJoinSelect) {
126+
// For each select in the query (there can be nested selects!), let's find the list of tables.
127+
$this->magicJoinOnOneQuery($magicJoinSelect);
128+
}
129+
}
130+
}
131+
132+
/**
133+
* For one given MagicJoin select, let's apply MagicJoin
134+
* @param MagicJoinSelect $magicJoinSelect
135+
* @return Select
136+
*/
137+
private function magicJoinOnOneQuery(MagicJoinSelect $magicJoinSelect) {
138+
$tableSearchNodeTraverser = new NodeTraverser();
139+
$detectTableVisitor = new DetectTablesVisitor();
140+
$tableSearchNodeTraverser->addVisitor($detectTableVisitor);
141+
142+
$select = $magicJoinSelect->getSelect();
143+
144+
$tableSearchNodeTraverser->walk($select);
145+
$tables = $detectTableVisitor->getTables();
146+
147+
$mainTable = $magicJoinSelect->getMainTable();
148+
// Let's remove the main table from the list of tables to be linked:
149+
unset($tables[$mainTable]);
150+
151+
$foreignKeysSet = new \SplObjectStorage();
152+
$completePath = [];
153+
154+
foreach ($tables as $table) {
155+
$path = $this->getSchemaAnalyzer()->getShortestPath($mainTable, $table);
156+
foreach ($path as $foreignKey) {
157+
// If the foreign key is not already in our complete path, let's add it.
158+
if (!$foreignKeysSet->contains($foreignKey)) {
159+
$completePath[] = $foreignKey;
160+
$foreignKeysSet->attach($foreignKey);
161+
}
162+
}
163+
}
164+
165+
// At this point, we have a complete path, we now just have to rewrite the FROM section.
166+
$tableNode = new Table();
167+
$tableNode->setTable($mainTable);
168+
$tables = [
169+
$tableNode
170+
];
171+
$currentTable = $mainTable;
172+
173+
foreach ($completePath as $foreignKey) {
174+
/* @var $foreignKey \Doctrine\DBAL\Schema\ForeignKeyConstraint */
175+
176+
$onNode = new Equal();
177+
$leftCol = new ColRef();
178+
$leftCol->setTable($foreignKey->getLocalTableName());
179+
$leftCol->setColumn($foreignKey->getLocalColumns()[0]);
180+
181+
$rightCol = new ColRef();
182+
$rightCol->setTable($foreignKey->getForeignTableName());
183+
$rightCol->setColumn($foreignKey->getForeignColumns()[0]);
184+
185+
$onNode->setLeftOperand($leftCol);
186+
$onNode->setRightOperand($rightCol);
187+
188+
$tableNode = new Table();
189+
$tableNode->setJoinType("LEFT JOIN");
190+
$tableNode->setRefClause($onNode);
191+
192+
if ($foreignKey->getLocalTableName() == $currentTable) {
193+
$tableNode->setTable($foreignKey->getForeignTableName());
194+
$currentTable = $foreignKey->getForeignTableName();
195+
} else {
196+
$tableNode->setTable($foreignKey->getLocalTableName());
197+
$currentTable = $foreignKey->getLocalTableName();
198+
}
199+
200+
$tables[] = $tableNode;
201+
}
202+
203+
$select->setFrom($tables);
204+
205+
}
206+
207+
/**
208+
* @return SchemaAnalyzer
209+
*/
210+
private function getSchemaAnalyzer() {
211+
if ($this->schemaAnalyzer === null) {
212+
if (!$this->schemaAnalyzer) {
213+
throw new MagicQueryMissingConnectionException('In order to use MagicJoin, you need to configure a DBAL connection.');
214+
}
215+
216+
$this->schemaAnalyzer = new SchemaAnalyzer($this->connection->getSchemaManager()->createSchema());
217+
}
218+
return $this->schemaAnalyzer;
48219
}
49220
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
namespace Mouf\Database;
4+
5+
class MagicQueryException extends \Exception
6+
{
7+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
namespace Mouf\Database;
3+
4+
5+
class MagicQueryMissingConnectionException extends MagicQueryException
6+
{
7+
8+
}

src/Mouf/Database/MagicQueryParserException.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
namespace Mouf\Database;
44

5-
class MagicQueryParserException extends \Exception
5+
class MagicQueryParserException extends MagicQueryException
66
{
77
}

src/SQLParser/Node/Traverser/DetectTablesVisitor.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public function resetVisitor() {
2828

2929
/**
3030
* Return the list of tables referenced in the Select.
31-
* @return Select[]
31+
* @return string[] The key and the value are the table name.
3232
*/
3333
public function getTables()
3434
{

tests/Mouf/Database/MagicQueryTest.php

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
namespace Mouf\Database;
44

5+
use Doctrine\Common\Cache\ArrayCache;
6+
use Doctrine\DBAL\Schema\Schema;
7+
use Mouf\Database\SchemaAnalyzer\SchemaAnalyzer;
8+
59
class MagicQueryTest extends \PHPUnit_Framework_TestCase
610
{
711
public function testStandardSelect()
@@ -35,14 +39,79 @@ public function testStandardSelect()
3539

3640
}
3741

42+
public function testWithCache() {
43+
$config = new \Doctrine\DBAL\Configuration();
44+
$connectionParams = array(
45+
'url' => 'mysql://root:@localhost/test',
46+
);
47+
$conn = \Doctrine\DBAL\DriverManager::getConnection($connectionParams, $config);
48+
49+
$cache = new ArrayCache();
50+
51+
$magicQuery = new MagicQuery($conn, $cache);
52+
53+
$sql = 'SELECT * FROM users';
54+
$this->assertEquals($sql, self::simplifySql($magicQuery->build($sql)));
55+
$select = $cache->fetch("request_".hash("md4", $sql));
56+
$this->assertInstanceOf('SQLParser\\Query\\Select', $select);
57+
$this->assertEquals($sql, self::simplifySql($magicQuery->build($sql)));
58+
}
59+
60+
/**
61+
* @expectedException \Mouf\Database\MagicQueryParserException
62+
*/
63+
public function testParseError() {
64+
$magicQuery = new MagicQuery();
65+
66+
$sql = '';
67+
$magicQuery->build($sql);
68+
}
69+
70+
public function testMagicJoin() {
71+
$schema = new Schema();
72+
$role = $schema->createTable("role");
73+
$role->addColumn("id", "integer", array("unsigned" => true));
74+
$role->addColumn("label", "string", array("length" => 32));
75+
76+
$right = $schema->createTable("right");
77+
$right->addColumn("id", "integer", array("unsigned" => true));
78+
$right->addColumn("label", "string", array("length" => 32));
79+
$role_right = $schema->createTable("role_right");
80+
81+
$role_right->addColumn("role_id", "integer", array("unsigned" => true));
82+
$role_right->addColumn("right_id", "integer", array("unsigned" => true));
83+
$role_right->addForeignKeyConstraint($schema->getTable('role'), array("role_id"), array("id"), array("onUpdate" => "CASCADE"));
84+
$role_right->addForeignKeyConstraint($schema->getTable('right'), array("right_id"), array("id"), array("onUpdate" => "CASCADE"));
85+
$role_right->setPrimaryKey(["role_id", "right_id"]);
86+
87+
$schemaAnalyzer = new SchemaAnalyzer($schema);
88+
89+
$magicQuery = new MagicQuery(null, null, $schemaAnalyzer);
90+
91+
$sql = "SELECT role.* FROM magicjoin(role) WHERE right.label = 'my_right'";
92+
$expectedSql = "SELECT role.* FROM role LEFT JOIN role_right ON (role_right.role_id = role.id) LEFT JOIN right ON (role_right.right_id = right.id) WHERE right.label = 'my_right'";
93+
$this->assertEquals($expectedSql, self::simplifySql($magicQuery->build($sql)));
94+
}
95+
96+
/**
97+
* @expectedException \Mouf\Database\MagicQueryMissingConnectionException
98+
*/
99+
public function testMisconfiguration() {
100+
$magicQuery = new MagicQuery();
101+
102+
$sql = "SELECT role.* FROM magicjoin(role) WHERE right.label = 'my_right'";
103+
$magicQuery->build($sql);
104+
105+
}
106+
38107
/**
39108
* Removes all artifacts.
40109
*/
41110
private static function simplifySql($sql)
42111
{
43112
$sql = str_replace("\n", ' ', $sql);
44113
$sql = str_replace("\t", ' ', $sql);
45-
$sql = str_replace('`', ' ', $sql);
114+
$sql = str_replace('`', '', $sql);
46115
$sql = str_replace(' ', ' ', $sql);
47116
$sql = str_replace(' ', ' ', $sql);
48117
$sql = str_replace(' ', ' ', $sql);

tests/SQLParser/Node/Traverser/NodeTraverserTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,4 @@ public function testStandardSelect()
5252
$magicJoinDetector->resetVisitor();
5353

5454
}
55-
5655
}

0 commit comments

Comments
 (0)