330 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			330 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php namespace Clockwork\DataSource;
 | |
| 
 | |
| use Clockwork\Helpers\Serializer;
 | |
| use Clockwork\Helpers\StackTrace;
 | |
| use Clockwork\Request\Request;
 | |
| use Clockwork\Support\Laravel\Eloquent\ResolveModelLegacyScope;
 | |
| use Clockwork\Support\Laravel\Eloquent\ResolveModelScope;
 | |
| 
 | |
| use Illuminate\Database\ConnectionResolverInterface;
 | |
| use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
 | |
| 
 | |
| // Data source for Eloquent (Laravel ORM), provides database queries, stats, model actions and counts
 | |
| class EloquentDataSource extends DataSource
 | |
| {
 | |
| 	use Concerns\EloquentDetectDuplicateQueries;
 | |
| 
 | |
| 	// Database manager instance
 | |
| 	protected $databaseManager;
 | |
| 
 | |
| 	// Event dispatcher instance
 | |
| 	protected $eventDispatcher;
 | |
| 
 | |
| 	// Array of collected queries
 | |
| 	protected $queries = [];
 | |
| 
 | |
| 	// Query counts by type
 | |
| 	protected $count = [
 | |
| 		'total' => 0, 'slow' => 0, 'select' => 0, 'insert' => 0, 'update' => 0, 'delete' => 0, 'other' => 0
 | |
| 	];
 | |
| 
 | |
| 	// Collected models actions
 | |
| 	protected $modelsActions = [];
 | |
| 
 | |
| 	// Model action counts by model, eg. [ 'retrieved' => [ User::class => 1 ] ]
 | |
| 	protected $modelsCount = [
 | |
| 		'retrieved' => [], 'created' => [], 'updated' => [], 'deleted' => []
 | |
| 	];
 | |
| 
 | |
| 	// Whether we are collecting database queries or stats only
 | |
| 	protected $collectQueries = true;
 | |
| 
 | |
| 	// Whether we are collecting models actions or stats only
 | |
| 	protected $collectModelsActions = true;
 | |
| 
 | |
| 	// Whether we are collecting retrieved models as well when collecting models actions
 | |
| 	protected $collectModelsRetrieved = false;
 | |
| 
 | |
| 	// Query execution time threshold in ms after which the query is marked as slow
 | |
| 	protected $slowThreshold;
 | |
| 
 | |
| 	// Enable duplicate queries detection
 | |
| 	protected $detectDuplicateQueries = false;
 | |
| 
 | |
| 	// Model name to associate with the next executed query, used to map queries to models
 | |
| 	public $nextQueryModel;
 | |
| 
 | |
| 	// Create a new data source instance, takes a database manager, an event dispatcher as arguments and additional
 | |
| 	// options as arguments
 | |
| 	public function __construct(ConnectionResolverInterface $databaseManager, EventDispatcher $eventDispatcher, $collectQueries = true, $slowThreshold = null, $slowOnly = false, $detectDuplicateQueries = false, $collectModelsActions = true, $collectModelsRetrieved = false)
 | |
| 	{
 | |
| 		$this->databaseManager = $databaseManager;
 | |
| 		$this->eventDispatcher = $eventDispatcher;
 | |
| 
 | |
| 		$this->collectQueries         = $collectQueries;
 | |
| 		$this->slowThreshold          = $slowThreshold;
 | |
| 		$this->detectDuplicateQueries = $detectDuplicateQueries;
 | |
| 		$this->collectModelsActions   = $collectModelsActions;
 | |
| 		$this->collectModelsRetrieved = $collectModelsRetrieved;
 | |
| 
 | |
| 		if ($slowOnly) $this->addFilter(function ($query) { return $query['duration'] > $this->slowThreshold; });
 | |
| 	}
 | |
| 
 | |
| 	// Adds ran database queries, query counts, models actions and models counts to the request
 | |
| 	public function resolve(Request $request)
 | |
| 	{
 | |
| 		$request->databaseQueries = array_merge($request->databaseQueries, $this->queries);
 | |
| 
 | |
| 		$request->databaseQueriesCount += $this->count['total'];
 | |
| 		$request->databaseSlowQueries  += $this->count['slow'];
 | |
| 		$request->databaseSelects      += $this->count['select'];
 | |
| 		$request->databaseInserts      += $this->count['insert'];
 | |
| 		$request->databaseUpdates      += $this->count['update'];
 | |
| 		$request->databaseDeletes      += $this->count['delete'];
 | |
| 		$request->databaseOthers       += $this->count['other'];
 | |
| 
 | |
| 		$request->modelsActions = array_merge($request->modelsActions, $this->modelsActions);
 | |
| 
 | |
| 		$request->modelsRetrieved = $this->modelsCount['retrieved'];
 | |
| 		$request->modelsCreated   = $this->modelsCount['created'];
 | |
| 		$request->modelsUpdated   = $this->modelsCount['updated'];
 | |
| 		$request->modelsDeleted   = $this->modelsCount['deleted'];
 | |
| 
 | |
| 		$this->appendDuplicateQueriesWarnings($request);
 | |
| 
 | |
| 		return $request;
 | |
| 	}
 | |
| 
 | |
| 	// Reset the data source to an empty state, clearing any collected data
 | |
| 	public function reset()
 | |
| 	{
 | |
| 		$this->queries = [];
 | |
| 		$this->count = [
 | |
| 			'total' => 0, 'slow' => 0, 'select' => 0, 'insert' => 0, 'update' => 0, 'delete' => 0, 'other' => 0
 | |
| 		];
 | |
| 
 | |
| 		$this->modelsActions = [];
 | |
| 		$this->modelsCount = [
 | |
| 			'retrieved' => [], 'created' => [], 'updated' => [], 'deleted' => []
 | |
| 		];
 | |
| 
 | |
| 		$this->nextQueryModel = null;
 | |
| 	}
 | |
| 
 | |
| 	// Start listening to Eloquent events
 | |
| 	public function listenToEvents()
 | |
| 	{
 | |
| 		if ($scope = $this->getModelResolvingScope()) {
 | |
| 			$this->eventDispatcher->listen('eloquent.booted: *', function ($model, $data = null) use ($scope) {
 | |
| 				if (is_string($model) && is_array($data)) { // Laravel 5.4 wildcard event
 | |
| 					$model = reset($data);
 | |
| 				}
 | |
| 
 | |
| 				$model->addGlobalScope($scope);
 | |
| 			});
 | |
| 		}
 | |
| 
 | |
| 		if (class_exists(\Illuminate\Database\Events\QueryExecuted::class)) {
 | |
| 			// Laravel 5.2 and up
 | |
| 			$this->eventDispatcher->listen(\Illuminate\Database\Events\QueryExecuted::class, function ($event) {
 | |
| 				$this->registerQuery($event);
 | |
| 			});
 | |
| 		} else {
 | |
| 			// Laravel 5.0 to 5.1
 | |
| 			$this->eventDispatcher->listen('illuminate.query', function ($event) {
 | |
| 				$this->registerLegacyQuery($event);
 | |
| 			});
 | |
| 		}
 | |
| 
 | |
| 		// register all event listeners individually so we don't have to regex the event type and support Laravel <5.4
 | |
| 		$this->listenToModelEvent('retrieved');
 | |
| 		$this->listenToModelEvent('created');
 | |
| 		$this->listenToModelEvent('updated');
 | |
| 		$this->listenToModelEvent('deleted');
 | |
| 	}
 | |
| 
 | |
| 	// Register a listener collecting model events of specified type
 | |
| 	protected function listenToModelEvent($event)
 | |
| 	{
 | |
| 		$this->eventDispatcher->listen("eloquent.{$event}: *", function ($model, $data = null) use ($event) {
 | |
| 			if (is_string($model) && is_array($data)) { // Laravel 5.4 wildcard event
 | |
| 				$model = reset($data);
 | |
| 			}
 | |
| 
 | |
| 			$this->collectModelEvent($event, $model);
 | |
| 		});
 | |
| 	}
 | |
| 
 | |
| 	// Collect an executed database query
 | |
| 	protected function registerQuery($event)
 | |
| 	{
 | |
| 		$trace = StackTrace::get([ 'arguments' => $this->detectDuplicateQueries ])->resolveViewName();
 | |
| 
 | |
| 		if ($this->detectDuplicateQueries) $this->detectDuplicateQuery($trace);
 | |
| 
 | |
| 		$query = [
 | |
| 			'query'      => $this->createRunnableQuery($event->sql, $event->bindings, $event->connectionName),
 | |
| 			'duration'   => $event->time,
 | |
| 			'connection' => $event->connectionName,
 | |
| 			'time'       => microtime(true) - $event->time / 1000,
 | |
| 			'trace'      => (new Serializer)->trace($trace),
 | |
| 			'model'      => $this->nextQueryModel,
 | |
| 			'tags'       => $this->slowThreshold !== null && $event->time > $this->slowThreshold ? [ 'slow' ] : []
 | |
| 		];
 | |
| 
 | |
| 		$this->nextQueryModel = null;
 | |
| 
 | |
| 		if (! $this->passesFilters([ $query, $trace ], 'early')) return;
 | |
| 
 | |
| 		$this->incrementQueryCount($query);
 | |
| 
 | |
| 		if (! $this->collectQueries || ! $this->passesFilters([ $query, $trace ])) return;
 | |
| 
 | |
| 		$this->queries[] = $query;
 | |
| 	}
 | |
| 
 | |
| 	// Collect an executed database query (pre Laravel 5.2)
 | |
| 	protected function registerLegacyQuery($sql, $bindings, $time, $connection)
 | |
| 	{
 | |
| 		return $this->registerQuery((object) [
 | |
| 			'sql'            => $sql,
 | |
| 			'bindings'       => $bindings,
 | |
| 			'time'           => $time,
 | |
| 			'connectionName' => $connection
 | |
| 		]);
 | |
| 	}
 | |
| 
 | |
| 	// Collect a model event and update stats
 | |
| 	protected function collectModelEvent($event, $model)
 | |
| 	{
 | |
| 		$lastQuery = ($queryCount = count($this->queries)) ? $this->queries[$queryCount - 1] : null;
 | |
| 
 | |
| 		$action = [
 | |
| 			'model'      => $modelClass = get_class($model),
 | |
| 			'key'        => $this->getModelKey($model),
 | |
| 			'action'     => $event,
 | |
| 			'attributes' => $this->collectModelsRetrieved && $event == 'retrieved' ? $model->getOriginal() : [],
 | |
| 			'changes'    => $this->collectModelsActions ? $model->getChanges() : [],
 | |
| 			'time'       => microtime(true) / 1000,
 | |
| 			'query'      => $lastQuery ? $lastQuery['query'] : null,
 | |
| 			'duration'   => $lastQuery ? $lastQuery['duration'] : null,
 | |
| 			'connection' => $lastQuery ? $lastQuery['connection'] : null,
 | |
| 			'trace'      => null,
 | |
| 			'tags'       => []
 | |
| 		];
 | |
| 
 | |
| 		if ($lastQuery) $this->queries[$queryCount - 1]['model'] = $modelClass;
 | |
| 
 | |
| 		if (! $this->passesFilters([ $action ], 'models-early')) return;
 | |
| 
 | |
| 		$this->incrementModelsCount($action['action'], $action['model']);
 | |
| 
 | |
| 		if (! $this->collectModelsActions) return;
 | |
| 		if (! $this->collectModelsRetrieved && $event == 'retrieved') return;
 | |
| 		if (! $this->passesFilters([ $action ], 'models')) return;
 | |
| 
 | |
| 		$action['trace'] = (new Serializer)->trace(StackTrace::get()->resolveViewName());
 | |
| 
 | |
| 		$this->modelsActions[] = $action;
 | |
| 	}
 | |
| 
 | |
| 	// Takes a query, an array of bindings and the connection as arguments, returns runnable query with upper-cased keywords
 | |
| 	protected function createRunnableQuery($query, $bindings, $connection)
 | |
| 	{
 | |
| 		// add bindings to query
 | |
| 		$bindings = $this->databaseManager->connection($connection)->prepareBindings($bindings);
 | |
| 
 | |
| 		$index = 0;
 | |
| 		$query = preg_replace_callback('/\?/', function ($matches) use ($bindings, $connection, &$index) {
 | |
| 			$binding = $this->quoteBinding($bindings[$index++], $connection);
 | |
| 
 | |
| 			// convert binary bindings to hexadecimal representation
 | |
| 			if (! preg_match('//u', (string) $binding)) $binding = '0x' . bin2hex($binding);
 | |
| 
 | |
| 			// escape backslashes in the binding (preg_replace requires to do so)
 | |
| 			return (string) $binding;
 | |
| 		}, $query, count($bindings));
 | |
| 
 | |
| 		// highlight keywords
 | |
| 		$keywords = [
 | |
| 			'select', 'insert', 'update', 'delete', 'into', 'values', 'set', 'where', 'from', 'limit', 'is', 'null',
 | |
| 			'having', 'group by', 'order by', 'asc', 'desc'
 | |
| 		];
 | |
| 		$regexp = '/\b' . implode('\b|\b', $keywords) . '\b/i';
 | |
| 
 | |
| 		return preg_replace_callback($regexp, function ($match) { return strtoupper($match[0]); }, $query);
 | |
| 	}
 | |
| 
 | |
| 	// Takes a query binding and a connection name, returns a quoted binding value
 | |
| 	protected function quoteBinding($binding, $connection)
 | |
| 	{
 | |
| 		$connection = $this->databaseManager->connection($connection);
 | |
| 
 | |
| 		if (! method_exists($connection, 'getPdo')) return;
 | |
| 
 | |
| 		$pdo = $connection->getPdo();
 | |
| 
 | |
| 		if ($pdo === null) return;
 | |
| 
 | |
| 		if ($pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) === 'odbc') {
 | |
| 			// PDO_ODBC driver doesn't support the quote method, apply simple MSSQL style quoting instead
 | |
| 			return "'" . str_replace("'", "''", $binding) . "'";
 | |
| 		}
 | |
| 
 | |
| 		return is_string($binding) ? $pdo->quote($binding) : $binding;
 | |
| 	}
 | |
| 
 | |
| 	// Increment query counts for collected query
 | |
| 	protected function incrementQueryCount($query)
 | |
| 	{
 | |
| 		$sql = ltrim($query['query']);
 | |
| 
 | |
| 		$this->count['total']++;
 | |
| 
 | |
| 		if (preg_match('/^select\b/i', $sql)) {
 | |
| 			$this->count['select']++;
 | |
| 		} elseif (preg_match('/^insert\b/i', $sql)) {
 | |
| 			$this->count['insert']++;
 | |
| 		} elseif (preg_match('/^update\b/i', $sql)) {
 | |
| 			$this->count['update']++;
 | |
| 		} elseif (preg_match('/^delete\b/i', $sql)) {
 | |
| 			$this->count['delete']++;
 | |
| 		} else {
 | |
| 			$this->count['other']++;
 | |
| 		}
 | |
| 
 | |
| 		if (in_array('slow', $query['tags'])) {
 | |
| 			$this->count['slow']++;
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Increment model counts for collected model action
 | |
| 	protected function incrementModelsCount($action, $model)
 | |
| 	{
 | |
| 		if (! isset($this->modelsCount[$action][$model])) {
 | |
| 			$this->modelsCount[$action][$model] = 0;
 | |
| 		}
 | |
| 
 | |
| 		$this->modelsCount[$action][$model]++;
 | |
| 	}
 | |
| 
 | |
| 	// Returns model resolving scope for the installed Laravel version
 | |
| 	protected function getModelResolvingScope()
 | |
| 	{
 | |
| 		if (interface_exists(\Illuminate\Database\Eloquent\ScopeInterface::class)) {
 | |
| 			// Laravel 5.0 to 5.1
 | |
| 			return new ResolveModelLegacyScope($this);
 | |
| 		}
 | |
| 
 | |
| 		return new ResolveModelScope($this);
 | |
| 	}
 | |
| 
 | |
| 	// Returns model key without crashing when using Eloquent strict mode and it's not loaded
 | |
| 	protected function getModelKey($model)
 | |
| 	{
 | |
| 		try {
 | |
| 			return $model->getKey();
 | |
| 		} catch (\Illuminate\Database\Eloquent\MissingAttributeException $e) {}
 | |
| 	}
 | |
| }
 | 
