diff --git a/Controller/PanelController.php b/Controller/PanelController.php new file mode 100644 index 0000000..4f3e9a5 --- /dev/null +++ b/Controller/PanelController.php @@ -0,0 +1,82 @@ + + */ +class PanelController extends ContainerAware +{ + /** + * This method renders the global Propel configuration. + */ + public function configurationAction() + { + $templating = $this->container->get('templating'); + + return $templating->renderResponse( + 'PropelBundle:Panel:configuration.html.twig', + array( + 'propel_version' => Propel::VERSION, + 'configuration' => $this->container->getParameter('propel.configuration'), + 'default_connection' => $this->container->getParameter('propel.dbal.default_connection'), + 'logging' => $this->container->getParameter('propel.logging'), + ) + ); + } + + /** + * Renders the profiler panel for the given token. + * + * @param string $token The profiler token + * @param string $connection The connection name + * @param integer $query + * + * @return Symfony\Component\HttpFoundation\Response A Response instance + */ + public function explainAction($token, $connection, $query) + { + $profiler = $this->container->get('profiler'); + $profiler->disable(); + + $profile = $profiler->loadProfile($token); + $queries = $profile->getCollector('propel')->getQueries(); + + if (!isset($queries[$query])) { + return new Response('This query does not exist.'); + } + + // Open the connection + $con = Propel::getConnection($connection); + + try { + $dataFetcher = $con->query('EXPLAIN ' . $queries[$query]['sql']); + $results = array(); + while (($results[] = $dataFetcher->fetch(\PDO::FETCH_ASSOC))); + } catch (\Exception $e) { + return new Response('
This query cannot be explained.
'); + } + + return $this->container->get('templating')->renderResponse( + 'PropelBundle:Panel:explain.html.twig', + array( + 'data' => $results, + 'query' => $query, + ) + ); + } +} diff --git a/DataCollector/PropelDataCollector.php b/DataCollector/PropelDataCollector.php new file mode 100644 index 0000000..d347be6 --- /dev/null +++ b/DataCollector/PropelDataCollector.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Propel\PropelBundle\DataCollector; + +use Propel\PropelBundle\Logger\PropelLogger; +use Propel\Runtime\Util\Profiler; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; + +/** + * The PropelDataCollector collector class collects information. + * + * @author Kévin Gomez + */ +class PropelDataCollector extends DataCollector +{ + protected $logger; + + public function __construct(PropelLogger $logger) + { + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public function collect(Request $request, Response $response, \Exception $exception = null) + { + $this->data = array( + 'queries' => $this->buildQueries(), + 'querycount' => $this->countQueries(), + ); + } + + /** + * Returns the collector name. + * + * @return string The collector name. + */ + public function getName() + { + return 'propel'; + } + + /** + * Returns queries. + * + * @return array Queries + */ + public function getQueries() + { + return $this->data['queries']; + } + + /** + * Returns the query count. + * + * @return int The query count + */ + public function getQueryCount() + { + return $this->data['querycount']; + } + + /** + * Returns the total time of queries. + * + * @return float The total time of queries + */ + public function getTime() + { + $time = 0; + foreach ($this->data['queries'] as $query) { + $time += (float) $query['time']; + } + + return $time; + } + + /** + * Creates an array of Build objects. + * + * @return array An array of Build objects + */ + private function buildQueries() + { + return $this->logger->getQueries(); + } + + /** + * Count queries. + * + * @return int The number of queries. + */ + private function countQueries() + { + return count($this->logger->getQueries()); + } +} diff --git a/Logger/PropelLogger.php b/Logger/PropelLogger.php new file mode 100644 index 0000000..f7620c4 --- /dev/null +++ b/Logger/PropelLogger.php @@ -0,0 +1,102 @@ + + */ +class PropelLogger implements LoggerInterface +{ + /** + * @var LoggerInterface + */ + protected $logger; + + /** + * @var array + */ + protected $queries = array(); + + /** + * @var Stopwatch + */ + protected $stopwatch; + + use LoggerTrait; + + /** + * Constructor. + * + * @param LoggerInterface $logger A LoggerInterface instance + * @param Stopwatch $stopwatch A Stopwatch instance + */ + public function __construct(LoggerInterface $logger = null, Stopwatch $stopwatch = null) + { + $this->logger = $logger; + $this->stopwatch = $stopwatch; + $this->isPrepared = false; + } + + /** + * Logs with an arbitrary level. + * + * @param mixed $level + * @param string $message + * @param array $context + * @return null + */ + public function log($level, $message, array $context = array()) + { + if (null === $this->logger) { + return; + } + + $add = true; + + if (null !== $this->stopwatch) { + $trace = debug_backtrace(); + $method = $trace[3]['function']; + + $watch = 'Propel Query '.(count($this->queries)+1); + if ('prepare' === $method) { + $this->isPrepared = true; + $this->stopwatch->start($watch, 'propel'); + + $add = false; + } elseif ($this->isPrepared) { + $this->isPrepared = false; + $event = $this->stopwatch->stop($watch); + } + } + + if ($add && isset($event)) { + $connection = $trace[2]['object']; + + $this->queries[] = array( + 'sql' => $message, + 'connection' => $connection->getName(), + 'time' => $event->getDuration() / 1000, + 'memory' => $event->getMemory(), + ); + } + + $this->logger->log($level, $message, $context); + } + + public function getQueries() + { + return $this->queries; + } +} diff --git a/PropelBundle.php b/PropelBundle.php index 800e1b5..cf96b09 100644 --- a/PropelBundle.php +++ b/PropelBundle.php @@ -30,6 +30,10 @@ class PropelBundle extends Bundle public function boot() { $this->configureConnections(); + + if ($this->container->getParameter('propel.logging')) { + $this->configureLogging(); + } } /** @@ -64,4 +68,18 @@ class PropelBundle extends Bundle $serviceContainer->setConnectionManager($name, $manager); } } + + protected function configureLogging() + { + $serviceContainer = Propel::getServiceContainer(); + $serviceContainer->setLogger('defaultLogger', $this->container->get('propel.logger')); + + foreach ($serviceContainer->getConnectionManagers() as $manager) { + $connection = $manager->getReadConnection($serviceContainer->getAdapter($manager->getName())); + $connection->setLogMethods(array_merge($connection->getLogMethods(), array('prepare'))); + + $connection = $manager->getWriteConnection(); + $connection->setLogMethods(array_merge($connection->getLogMethods(), array('prepare'))); + } + } } diff --git a/Resources/config/propel.xml b/Resources/config/propel.xml index e562719..93de36b 100644 --- a/Resources/config/propel.xml +++ b/Resources/config/propel.xml @@ -7,12 +7,30 @@ default + Propel\Runtime\Propel Propel\PropelBundle\Service\SchemaLocator + Propel\PropelBundle\DataCollector\PropelDataCollector + Propel\PropelBundle\Logger\PropelLogger + Propel\PropelBundle\Twig\Extension\SyntaxExtension + + + + + + + + + + + + + + diff --git a/Resources/views/Collector/propel.html.twig b/Resources/views/Collector/propel.html.twig new file mode 100644 index 0000000..ad1c45d --- /dev/null +++ b/Resources/views/Collector/propel.html.twig @@ -0,0 +1,111 @@ +{% extends 'WebProfilerBundle:Profiler:layout.html.twig' %} + +{% block toolbar %} + {# the web debug toolbar content #} + {% set icon %} + Propel + {{ collector.querycount }} + {% endset %} + {% set text %} +
+ DB Queries + {{ collector.querycount }} +
+
+ Query time + {{ '%0.2f'|format(collector.time * 1000) }} ms +
+{% endset %} + {% include 'WebProfilerBundle:Profiler:toolbar_item.html.twig' with { 'link': profiler_url } %} +{% endblock %} + +{% block menu %} + {# the menu content #} + + + Propel + + {{ collector.querycount }} + {{ '%0.0f'|format(collector.time * 1000) }} ms + + +{% endblock %} + +{% block panel %} + {# the panel content #} + + +

Queries

+ + + + + + + + {% if not collector.querycount %} + + {% else %} + {% for i, query in collector.queries %} + + + + {% endfor %} + {% endif %} + +
SQL queries
No queries.
+ + {{ query.sql|format_sql }} + {% if app.request.query.has('query') and app.request.query.get('query') == i %} +
+ {% render controller('PropelBundle:Panel:explain', { + 'token': token, + 'panel': 'propel', + 'query': app.request.query.get('query'), + 'connection': app.request.query.get('connection') + }) %} +
+ {% endif %} +
+ Time: {{ query.time }} - Memory: {{ query.memory|format_memory }} - Connection: {{ query.connection }} + + {% if app.request.query.get('query', -1) != i %} + - Explain the query + {% endif %} +
+
+ + {% render controller('PropelBundle:Panel:configuration') %} +{% endblock %} diff --git a/Resources/views/Panel/configuration.html.twig b/Resources/views/Panel/configuration.html.twig new file mode 100644 index 0000000..596d244 --- /dev/null +++ b/Resources/views/Panel/configuration.html.twig @@ -0,0 +1,69 @@ +

Propel configuration

+ + + + + + + + + + + + + + + + + +
Propel version{{ propel_version }}
Default connection{{ default_connection }}
Logging{{ logging ? 'enabled' : 'disabled' }}
+ +

Propel connections

+ + + + + + + + + + {% for name, config in configuration %} + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
Connection nameConfiguration parameters
+ {{ name }} + Adapter{{ config.adapter }}
DSN{{ config.connection.dsn }}
Class{{ config.connection.classname }}
Options +
    + {% for key, value in config.connection.options %} +
  • {{ key }} : {{ value }}
  • + {% endfor %} +
+
Attributes +
    + {% for key, value in config.connection.attributes %} +
  • {{ key }} : {{ value }}
  • + {% endfor %} +
+
diff --git a/Resources/views/Panel/explain.html.twig b/Resources/views/Panel/explain.html.twig new file mode 100644 index 0000000..709bf57 --- /dev/null +++ b/Resources/views/Panel/explain.html.twig @@ -0,0 +1,16 @@ +

Explanation

+ + + + {% for label in data[0]|keys %} + + {% endfor %} + + {% for row in data %} + + {% for item in row %} + + {% endfor %} + + {% endfor %} +
{{ label }}
{{ item }}
diff --git a/Twig/Extension/SyntaxExtension.php b/Twig/Extension/SyntaxExtension.php new file mode 100644 index 0000000..14ceee9 --- /dev/null +++ b/Twig/Extension/SyntaxExtension.php @@ -0,0 +1,139 @@ + + */ +class SyntaxExtension extends \Twig_Extension +{ + public function getFilters() + { + return array( + 'format_sql' => new \Twig_Filter_Method($this, 'formatSQL', array('is_safe' => array('html'))), + 'format_memory' => new \Twig_Filter_Method($this, 'formatMemory'), + ); + } + + public function getName() + { + return 'propel_syntax_extension'; + } + + /** + * Format a byte count into a human-readable representation. + * + * @param integer $bytes Byte count to convert. Can be negative. + * @param integer $precision How many decimals to include. + * + * @return string + */ + public function formatMemory($bytes, $precision = 3) + { + $absBytes = abs($bytes); + $sign = ($bytes == $absBytes) ? 1 : -1; + $suffix = array('B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'); + $total = count($suffix); + + for ($i = 0; $absBytes > 1024 && $i < $total; $i++) { + $absBytes /= 1024; + } + + return self::toPrecision($sign * $absBytes, $precision) . $suffix[$i]; + } + + public function formatSQL($sql) + { + // list of keywords to prepend a newline in output + $newlines = array( + 'FROM', + '(((FULL|LEFT|RIGHT)? ?(OUTER|INNER)?|CROSS|NATURAL)? JOIN)', + 'VALUES', + 'WHERE', + 'ORDER BY', + 'GROUP BY', + 'HAVING', + 'LIMIT', + ); + + // list of keywords to highlight + $keywords = array_merge($newlines, array( + // base + 'SELECT', 'UPDATE', 'DELETE', 'INSERT', 'REPLACE', + 'SET', + 'INTO', + 'AS', + 'DISTINCT', + + // most used methods + 'COUNT', + 'AVG', + 'MIN', + 'MAX', + + // joins + 'ON', 'USING', + + // where clause + '(IS (NOT)?)?NULL', + '(NOT )?IN', + '(NOT )?I?LIKE', + 'AND', 'OR', 'XOR', + 'BETWEEN', + + // order, group, limit .. + 'ASC', + 'DESC', + 'OFFSET', + )); + + $sql = preg_replace(array( + '/\b('.implode('|', $newlines).')\b/', + '/\b('.implode('|', $keywords).')\b/', + '/(\/\*.*\*\/)/', + '/(`[^`.]*`)/', + '/(([0-9a-zA-Z$_]+)\.([0-9a-zA-Z$_]+))/', + ), array( + '
\\1', + '\\1', + '\\1', + '\\1', + '\\1', + ), $sql); + + return $sql; + } + + /** + * Rounding to significant digits (sort of like JavaScript's toPrecision()). + * + * @param float $number Value to round + * @param integer $significantFigures Number of significant figures + * + * @return float + */ + public static function toPrecision($number, $significantFigures = 3) + { + if (0 === $number) { + return 0; + } + + $significantDecimals = floor($significantFigures - log10(abs($number))); + $magnitude = pow(10, $significantDecimals); + $shifted = round($number * $magnitude); + + return number_format($shifted / $magnitude, $significantDecimals); + } +}