clock-work

This commit is contained in:
noor
2023-04-24 17:39:09 +05:30
committed by RafficMohammed
parent cf4bec91a6
commit 1eea7ff15e
178 changed files with 13169 additions and 123 deletions

View File

@@ -0,0 +1,331 @@
<?php namespace Clockwork\Storage;
use Clockwork\Request\Request;
use Clockwork\Storage\Storage;
// File based storage for requests
class FileStorage extends Storage
{
// Path where files are stored
protected $path;
// Metadata expiration time in minutes
protected $expiration;
// Compress the files using gzip
protected $compress;
// Metadata cleanup chance
protected $cleanupChance = 100;
// Index file handle
protected $indexHandle;
// Return new storage, takes path where to store files as argument, throws exception if path is not writable
public function __construct($path, $dirPermissions = 0700, $expiration = null, $compress = false)
{
if (! file_exists($path)) {
// directory doesn't exist, try to create one
if (! @mkdir($path, $dirPermissions, true)) {
throw new \Exception("Directory \"{$path}\" does not exist.");
}
// create default .gitignore, to ignore stored json files
file_put_contents("{$path}/.gitignore", "*.json\n*.json.gz\nindex\n");
} elseif (! is_writable($path)) {
throw new \Exception("Path \"{$path}\" is not writable.");
}
if (! file_exists($indexFile = "{$path}/index")) {
file_put_contents($indexFile, '');
} elseif (! is_writable($indexFile)) {
throw new \Exception("Path \"{$indexFile}\" is not writable.");
}
$this->path = $path;
$this->expiration = $expiration === null ? 60 * 24 * 7 : $expiration;
$this->compress = $compress;
}
// Returns all requests
public function all(Search $search = null)
{
return $this->loadRequests($this->searchIndexForward($search));
}
// Return a single request by id
public function find($id)
{
return $this->loadRequest($id);
}
// Return the latest request
public function latest(Search $search = null)
{
$requests = $this->loadRequests($this->searchIndexBackward($search, null, 1));
return reset($requests);
}
// Return requests received before specified id, optionally limited to specified count
public function previous($id, $count = null, Search $search = null)
{
return $this->loadRequests($this->searchIndexBackward($search, $id, $count));
}
// Return requests received after specified id, optionally limited to specified count
public function next($id, $count = null, Search $search = null)
{
return $this->loadRequests($this->searchIndexForward($search, $id, $count));
}
// Store request, requests are stored in JSON representation in files named <request id>.json in storage path
public function store(Request $request, $skipIndex = false)
{
$path = "{$this->path}/{$request->id}.json";
$data = @json_encode($request->toArray(), \JSON_PARTIAL_OUTPUT_ON_ERROR);
$this->compress
? file_put_contents("{$path}.gz", gzcompress($data))
: file_put_contents($path, $data);
if (! $skipIndex) $this->updateIndex($request);
$this->cleanup();
}
// Update existing request
public function update(Request $request)
{
return $this->store($request, true);
}
// Cleanup old requests
public function cleanup($force = false)
{
if ($this->expiration === false || (! $force && rand(1, $this->cleanupChance) != 1)) return;
$this->openIndex('start', true, true); // reopen index with lock
$expirationTime = time() - ($this->expiration * 60);
$old = $this->searchIndexForward(
new Search([ 'received' => [ '<' . date('c', $expirationTime) ] ], [ 'stopOnFirstMismatch' => true ])
);
if (! count($old)) return $this->closeIndex(true);
$this->readPreviousIndex();
$this->trimIndex();
$this->closeIndex(true); // explicitly close index to unlock asap
foreach ($old as $id) {
$path = "{$this->path}/{$id}.json";
@unlink($this->compress ? "{$path}.gz" : $path);
}
}
// Load a single request by id from filesystem
protected function loadRequest($id)
{
$path = "{$this->path}/{$id}.json";
if (! is_readable($this->compress ? "{$path}.gz" : $path)) return;
$data = file_get_contents($this->compress ? "{$path}.gz" : $path);
return new Request(json_decode($this->compress ? gzuncompress($data) : $data, true));
}
// Load multiple requests by ids from filesystem
protected function loadRequests($ids)
{
return array_filter(array_map(function ($id) { return $this->loadRequest($id); }, $ids));
}
// Search index backward from specified ID or last record, with optional results count limit
protected function searchIndexBackward(Search $search = null, $id = null, $count = null)
{
return $this->searchIndex('previous', $search, $id, $count);
}
// Search index forward from specified ID or last record, with optional results count limit
protected function searchIndexForward(Search $search = null, $id = null, $count = null)
{
return $this->searchIndex('next', $search, $id, $count);
}
// Search index in specified direction from specified ID or last record, with optional results count limit
protected function searchIndex($direction, Search $search = null, $id = null, $count = null)
{
$this->openIndex($direction == 'previous' ? 'end' : 'start', false, true);
if ($id) {
while ($request = $this->readIndex($direction)) { if ($request->id == $id) break; }
}
$found = [];
while ($request = $this->readIndex($direction)) {
if (! $search || $search->matches($request)) {
$found[] = $request->id;
} elseif ($search->stopOnFirstMismatch) {
break;
}
if ($count && count($found) == $count) break;
}
return $direction == 'next' ? $found : array_reverse($found);
}
// Open index file, optionally lock or move file pointer to the end, existing handle will be returned by default
protected function openIndex($position = 'start', $lock = false, $force = false)
{
if ($this->indexHandle) {
if (! $force) return;
$this->closeIndex();
}
$this->indexHandle = fopen("{$this->path}/index", 'r');
if ($lock) flock($this->indexHandle, LOCK_EX);
if ($position == 'end') fseek($this->indexHandle, 0, SEEK_END);
}
// Close index file, optionally unlock
protected function closeIndex($lock = false)
{
if ($lock) flock($this->indexHandle, LOCK_UN);
fclose($this->indexHandle);
$this->indexHandle = null;
}
// Read a line from index in the specified direction (next or previous)
protected function readIndex($direction)
{
return $direction == 'next' ? $this->readNextIndex() : $this->readPreviousIndex();
}
// Read previous line from index
protected function readPreviousIndex()
{
$position = ftell($this->indexHandle) - 1;
if ($position <= 0) return;
$line = '';
// reads 1024B chunks of the file backwards from the current position, until a newline is found or we reach the top
while ($position > 0) {
// find next position to read from, make sure we don't read beyond file boundary
$position -= $chunkSize = min($position, 1024);
// read the chunk from the position
fseek($this->indexHandle, $position);
$chunk = fread($this->indexHandle, $chunkSize);
// if a newline is found, append only the part after the last newline, otherwise we can append the whole chunk
$line = ($newline = strrpos($chunk, "\n")) === false
? $chunk . $line : substr($chunk, $newline + 1) . $line;
// if a newline was found, fix the position so we read from that newline next time
if ($newline !== false) $position += $newline + 1;
// move file pointer to the correct position (revert fread, apply newline fix)
fseek($this->indexHandle, $position);
// if we reached a newline and put together a non-empty line we are done
if ($newline !== false) break;
}
return $this->makeRequestFromIndex(str_getcsv($line));
}
// Read next line from index
protected function readNextIndex()
{
if (feof($this->indexHandle)) return;
// File pointer is always at the start of the line, call extra fgets to skip current line
fgets($this->indexHandle);
$line = fgets($this->indexHandle);
// Check if we read an empty line or reached the end of file
if ($line === false) return;
// Reset the file pointer to the start of the read line
fseek($this->indexHandle, ftell($this->indexHandle) - strlen($line));
return $this->makeRequestFromIndex(str_getcsv($line));
}
// Trim index file from beginning to current position (including)
protected function trimIndex()
{
// File pointer is always at the start of the line, call extra fgets to skip current line
fgets($this->indexHandle);
// Read the rest of the index file
$trimmedLength = filesize("{$this->path}/index") - ftell($this->indexHandle);
$trimmed = $trimmedLength > 0 ? fread($this->indexHandle, $trimmedLength) : '';
// Rewrite the index file with a trimmed version
file_put_contents("{$this->path}/index", $trimmed);
}
// Create an incomplete request from index data
protected function makeRequestFromIndex($record)
{
$type = isset($record[7]) ? $record[7] : 'response';
if ($type == 'command') {
$nameField = 'commandName';
} elseif ($type == 'queue-job') {
$nameField = 'jobName';
} elseif ($type == 'test') {
$nameField = 'testName';
} else {
$nameField = 'uri';
}
return new Request(array_combine(
[ 'id', 'time', 'method', $nameField, 'controller', 'responseStatus', 'responseDuration', 'type' ],
array_slice($record, 0, 8) + [ null, null, null, null, null, null, null, 'response' ]
));
}
// Update index with a new request
protected function updateIndex(Request $request)
{
$handle = fopen("{$this->path}/index", 'a');
if (! $handle) return;
if (! flock($handle, LOCK_EX)) return fclose($handle);
if ($request->type == 'command') {
$nameField = 'commandName';
} elseif ($request->type == 'queue-job') {
$nameField = 'jobName';
} elseif ($request->type == 'test') {
$nameField = 'testName';
} else {
$nameField = 'uri';
}
fputcsv($handle, [
$request->id,
$request->time,
$request->method,
$request->$nameField,
$request->controller,
$request->responseStatus,
$request->getResponseDuration(),
$request->type
]);
flock($handle, LOCK_UN);
fclose($handle);
}
}

View File

@@ -0,0 +1,166 @@
<?php namespace Clockwork\Storage;
use Clockwork\Request\Request;
use Clockwork\Request\RequestType;
// Rules for searching requests
class Search
{
// Search parameters
public $uri = [];
public $controller = [];
public $method = [];
public $status = [];
public $time = [];
public $received = [];
public $name = [];
public $type = [];
// Whether to stop search on the first not matching request
public $stopOnFirstMismatch = false;
// Create a new instance, takes search parameters and additional options
public function __construct($search = [], $options = [])
{
foreach ([ 'uri', 'controller', 'method', 'status', 'time', 'received', 'name', 'type' ] as $condition) {
$this->$condition = isset($search[$condition]) ? $search[$condition] : [];
}
foreach ([ 'stopOnFirstMismatch' ] as $option) {
$this->$option = isset($options[$option]) ? $options[$option] : $this->$condition;
}
$this->method = array_map('strtolower', $this->method);
}
// Create a new instance from request input
public static function fromRequest($data = [])
{
return new static($data);
}
// Check whether the request matches current search parameters
public function matches(Request $request)
{
if ($request->type == RequestType::COMMAND) {
return $this->matchesCommand($request);
} elseif ($request->type == RequestType::QUEUE_JOB) {
return $this->matchesQueueJob($request);
} elseif ($request->type == RequestType::TEST) {
return $this->matchesTest($request);
} else {
return $this->matchesRequest($request);
}
}
// Check whether a request type request matches
protected function matchesRequest(Request $request)
{
return $this->matchesString($this->type, RequestType::REQUEST)
&& $this->matchesString($this->uri, $request->uri)
&& $this->matchesString($this->controller, $request->controller)
&& $this->matchesExact($this->method, strtolower($request->method))
&& $this->matchesNumber($this->status, $request->responseStatus)
&& $this->matchesNumber($this->time, $request->responseDuration)
&& $this->matchesDate($this->received, $request->time);
}
// Check whether a command type request matches
protected function matchesCommand(Request $request)
{
return $this->matchesString($this->type, RequestType::COMMAND)
&& $this->matchesString($this->name, $request->commandName)
&& $this->matchesNumber($this->status, $request->commandExitCode)
&& $this->matchesNumber($this->time, $request->responseDuration)
&& $this->matchesDate($this->received, $request->time);
}
// Check whether a queue-job type request matches
protected function matchesQueueJob(Request $request)
{
return $this->matchesString($this->type, RequestType::QUEUE_JOB)
&& $this->matchesString($this->name, $request->jobName)
&& $this->matchesString($this->status, $request->jobStatus)
&& $this->matchesNumber($this->time, $request->responseDuration)
&& $this->matchesDate($this->received, $request->time);
}
// Check whether a test type request matches
protected function matchesTest(Request $request)
{
return $this->matchesString($this->type, RequestType::TEST)
&& $this->matchesString($this->name, $request->testName)
&& $this->matchesString($this->status, $request->testStatus)
&& $this->matchesNumber($this->time, $request->responseDuration)
&& $this->matchesDate($this->received, $request->time);
}
// Check if there are no search parameters specified
public function isEmpty()
{
return ! count($this->uri) && ! count($this->controller) && ! count($this->method) && ! count($this->status)
&& ! count($this->time) && ! count($this->received) && ! count($this->name) && ! count($this->type);
}
// Check if there are some search parameters specified
public function isNotEmpty()
{
return ! $this->isEmpty();
}
// Check if the value matches date type search parameter
protected function matchesDate($inputs, $value)
{
if (! count($inputs)) return true;
foreach ($inputs as $input) {
if (preg_match('/^<(.+)$/', $input, $match)) {
if ($value < strtotime($match[1])) return true;
} elseif (preg_match('/^>(.+)$/', $input, $match)) {
if ($value > strtotime($match[1])) return true;
}
}
return false;
}
// Check if the value matches exact type search parameter
protected function matchesExact($inputs, $value)
{
if (! count($inputs)) return true;
return in_array($value, $inputs);
}
// Check if the value matches number type search parameter
protected function matchesNumber($inputs, $value)
{
if (! count($inputs)) return true;
foreach ($inputs as $input) {
if (preg_match('/^<(\d+(?:\.\d+)?)$/', $input, $match)) {
if ($value < $match[1]) return true;
} elseif (preg_match('/^>(\d+(?:\.\d+)?)$/', $input, $match)) {
if ($value > $match[1]) return true;
} elseif (preg_match('/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/', $input, $match)) {
if ($match[1] < $value && $value < $match[2]) return true;
} else {
if ($value == $input) return true;
}
}
return false;
}
// Check if the value matches string type search parameter
protected function matchesString($inputs, $value)
{
if (! count($inputs)) return true;
foreach ($inputs as $input) {
if (strpos($value, $input) !== false) return true;
}
return false;
}
}

View File

@@ -0,0 +1,164 @@
<?php namespace Clockwork\Storage;
use Clockwork\Request\Request;
use PDO;
// Rules for searching requests using SQL storage, builds the SQL query conditions
class SqlSearch extends Search
{
// Generated SQL query and bindings
public $query;
public $bindings;
// Internal representation of the SQL where conditions
protected $conditions;
// PDO instance
protected $pdo;
// Create a new instance, takes search parameters
public function __construct($search = [], PDO $pdo = null)
{
parent::__construct($search);
$this->pdo = $pdo;
list($this->conditions, $this->bindings) = $this->resolveConditions();
$this->buildQuery();
}
// Creates a new instance from a base Search class instance
public static function fromBase(Search $search = null, PDO $pdo = null)
{
return new static((array) $search, $pdo);
}
// Add an additional where condition, takes the SQL condition and array of bindings
public function addCondition($condition, $bindings = [])
{
$this->conditions = array_merge([ $condition ], $this->conditions);
$this->bindings = array_merge($bindings, $this->bindings);
$this->buildQuery();
return $this;
}
// Resolve SQL conditions and bindings based on the search parameters
protected function resolveConditions()
{
if ($this->isEmpty()) return [ [], [] ];
$conditions = array_filter([
$this->resolveStringCondition([ 'type' ], $this->type),
$this->resolveStringCondition([ 'uri', 'commandName', 'jobName', 'testName' ], array_merge($this->uri, $this->name)),
$this->resolveStringCondition([ 'controller' ], $this->controller),
$this->resolveExactCondition([ 'method' ], $this->method),
$this->resolveNumberCondition([ 'responseStatus', 'commandExitCode', 'jobStatus', 'testStatus' ], $this->status),
$this->resolveNumberCondition([ 'responseDuration' ], $this->time),
$this->resolveDateCondition([ 'time' ], $this->received)
]);
$sql = array_map(function ($condition) { return $condition[0]; }, $conditions);
$bindings = array_reduce($conditions, function ($bindings, $condition) {
return array_merge($bindings, $condition[1]);
}, []);
return [ $sql, $bindings ];
}
// Resolve a date type condition and bindings
protected function resolveDateCondition($fields, $inputs)
{
if (! count($inputs)) return null;
$bindings = [];
$conditions = implode(' OR ', array_map(function ($field) use ($inputs, &$bindings) {
return implode(' OR ', array_map(function ($input, $index) use ($field, &$bindings) {
if (preg_match('/^<(.+)$/', $input, $match)) {
$bindings["{$field}{$index}"] = $match[1];
return $this->quote($field) . " < :{$field}{$index}";
} elseif (preg_match('/^>(.+)$/', $input, $match)) {
$bindings["{$field}{$index}"] = $match[1];
return $this->quote($field). " > :{$field}{$index}";
}
}, $inputs, array_keys($inputs)));
}, $fields));
return [ "({$conditions})", $bindings ];
}
// Resolve an exact type condition and bindings
protected function resolveExactCondition($fields, $inputs)
{
if (! count($inputs)) return null;
$bindings = [];
$values = implode(' OR ', array_map(function ($field) use ($inputs, &$bindings) {
return implode(', ', array_map(function ($input, $index) use ($field, &$bindings) {
$bindings["{$field}{$index}"] = $input;
return ":{$field}{$index}";
}, $inputs, array_keys($inputs)));
}, $fields));
return [ $this->quote($field) . " IN ({$values})", $bindings ];
}
// Resolve a number type condition and bindings
protected function resolveNumberCondition($fields, $inputs)
{
if (! count($inputs)) return null;
$bindings = [];
$conditions = implode(' OR ', array_map(function ($field) use ($inputs, &$bindings) {
return implode(' OR ', array_map(function ($input, $index) use ($field, &$bindings) {
if (preg_match('/^<(\d+(?:\.\d+)?)$/', $input, $match)) {
$bindings["{$field}{$index}"] = $match[1];
return $this->quote($field) . " < :{$field}{$index}";
} elseif (preg_match('/^>(\d+(?:\.\d+)?)$/', $input, $match)) {
$bindings["{$field}{$index}"] = $match[1];
return $this->quote($field) . " > :{$field}{$index}";
} elseif (preg_match('/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/', $input, $match)) {
$bindings["{$field}{$index}from"] = $match[1];
$bindings["{$field}{$index}to"] = $match[2];
$quotedField = $this->quote($field);
return "({$quotedField} > :{$field}{$index}from AND {$quotedField} < :{$field}{$index}to)";
} else {
$bindings["{$field}{$index}"] = $input;
return $this->quote($field) . " = :{$field}{$index}";
}
}, $inputs, array_keys($inputs)));
}, $fields));
return [ "({$conditions})", $bindings ];
}
// Resolve a string type condition and bindings
protected function resolveStringCondition($fields, $inputs)
{
if (! count($inputs)) return null;
$bindings = [];
$conditions = implode(' OR ', array_map(function ($field) use ($inputs, &$bindings) {
return implode(' OR ', array_map(function ($input, $index) use ($field, &$bindings) {
$bindings["{$field}{$index}"] = $input;
return $this->quote($field) . " LIKE :{$field}{$index}";
}, $inputs, array_keys($inputs)));
}, $fields));
return [ "({$conditions})", $bindings ];
}
// Build the where part of the SQL query
protected function buildQuery()
{
$this->query = count($this->conditions) ? 'WHERE ' . implode(' AND ', $this->conditions) : '';
}
// Quotes SQL identifier name properly for the current database
protected function quote($identifier)
{
return $this->pdo && $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql' ? "`{$identifier}`" : "\"{$identifier}\"";
}
}

View File

@@ -0,0 +1,296 @@
<?php namespace Clockwork\Storage;
use Clockwork\Clockwork;
use Clockwork\Request\Request;
use PDO;
// SQL storage for requests using PDO
class SqlStorage extends Storage
{
// PDO instance
protected $pdo;
// Name of the table with Clockwork requests metadata
protected $table;
// Metadata expiration time in minutes
protected $expiration;
// Schema for the Clockwork requests table
protected $fields = [
'id' => 'VARCHAR(100) PRIMARY KEY',
'version' => 'INTEGER',
'type' => 'VARCHAR(100) NULL',
'time' => 'DOUBLE PRECISION NULL',
'method' => 'VARCHAR(10) NULL',
'url' => 'TEXT NULL',
'uri' => 'TEXT NULL',
'headers' => 'TEXT NULL',
'controller' => 'VARCHAR(250) NULL',
'getData' => 'TEXT NULL',
'postData' => 'TEXT NULL',
'requestData' => 'TEXT NULL',
'sessionData' => 'TEXT NULL',
'authenticatedUser' => 'TEXT NULL',
'cookies' => 'TEXT NULL',
'responseTime' => 'DOUBLE PRECISION NULL',
'responseStatus' => 'INTEGER NULL',
'responseDuration' => 'DOUBLE PRECISION NULL',
'memoryUsage' => 'DOUBLE PRECISION NULL',
'middleware' => 'TEXT NULL',
'databaseQueries' => 'TEXT NULL',
'databaseQueriesCount' => 'INTEGER NULL',
'databaseSlowQueries' => 'INTEGER NULL',
'databaseSelects' => 'INTEGER NULL',
'databaseInserts' => 'INTEGER NULL',
'databaseUpdates' => 'INTEGER NULL',
'databaseDeletes' => 'INTEGER NULL',
'databaseOthers' => 'INTEGER NULL',
'databaseDuration' => 'DOUBLE PRECISION NULL',
'cacheQueries' => 'TEXT NULL',
'cacheReads' => 'INTEGER NULL',
'cacheHits' => 'INTEGER NULL',
'cacheWrites' => 'INTEGER NULL',
'cacheDeletes' => 'INTEGER NULL',
'cacheTime' => 'DOUBLE PRECISION NULL',
'modelsActions' => 'TEXT NULL',
'modelsRetrieved' => 'TEXT NULL',
'modelsCreated' => 'TEXT NULL',
'modelsUpdated' => 'TEXT NULL',
'modelsDeleted' => 'TEXT NULL',
'redisCommands' => 'TEXT NULL',
'queueJobs' => 'TEXT NULL',
'timelineData' => 'TEXT NULL',
'log' => 'TEXT NULL',
'events' => 'TEXT NULL',
'routes' => 'TEXT NULL',
'notifications' => 'TEXT NULL',
'emailsData' => 'TEXT NULL',
'viewsData' => 'TEXT NULL',
'userData' => 'TEXT NULL',
'subrequests' => 'TEXT NULL',
'xdebug' => 'TEXT NULL',
'commandName' => 'TEXT NULL',
'commandArguments' => 'TEXT NULL',
'commandArgumentsDefaults' => 'TEXT NULL',
'commandOptions' => 'TEXT NULL',
'commandOptionsDefaults' => 'TEXT NULL',
'commandExitCode' => 'INTEGER NULL',
'commandOutput' => 'TEXT NULL',
'jobName' => 'TEXT NULL',
'jobDescription' => 'TEXT NULL',
'jobStatus' => 'TEXT NULL',
'jobPayload' => 'TEXT NULL',
'jobQueue' => 'TEXT NULL',
'jobConnection' => 'TEXT NULL',
'jobOptions' => 'TEXT NULL',
'testName' => 'TEXT NULL',
'testStatus' => 'TEXT NULL',
'testStatusMessage' => 'TEXT NULL',
'testAsserts' => 'TEXT NULL',
'clientMetrics' => 'TEXT NULL',
'webVitals' => 'TEXT NULL',
'parent' => 'TEXT NULL',
'updateToken' => 'VARCHAR(100) NULL'
];
// List of Request keys that need to be serialized before they can be stored in database
protected $needsSerialization = [
'headers', 'getData', 'postData', 'requestData', 'sessionData', 'authenticatedUser', 'cookies', 'middleware',
'databaseQueries', 'cacheQueries', 'modelsActions', 'modelsRetrieved', 'modelsCreated', 'modelsUpdated',
'modelsDeleted', 'redisCommands', 'queueJobs', 'timelineData', 'log', 'events', 'routes', 'notifications',
'emailsData', 'viewsData', 'userData', 'subrequests', 'xdebug', 'commandArguments', 'commandArgumentsDefaults',
'commandOptions', 'commandOptionsDefaults', 'jobPayload', 'jobOptions', 'testAsserts', 'parent',
'clientMetrics', 'webVitals'
];
// Return a new storage, takes PDO object or DSN and optionally a table name and database credentials as arguments
public function __construct($dsn, $table = 'clockwork', $username = null, $password = null, $expiration = null)
{
$this->pdo = $dsn instanceof PDO ? $dsn : new PDO($dsn, $username, $password);
$this->table = $table;
$this->expiration = $expiration === null ? 60 * 24 * 7 : $expiration;
}
// Returns all requests
public function all(Search $search = null)
{
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
$search = SqlSearch::fromBase($search, $this->pdo);
$result = $this->query("SELECT {$fields} FROM {$this->table} {$search->query}", $search->bindings);
return $this->resultsToRequests($result);
}
// Return a single request by id
public function find($id)
{
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
$result = $this->query("SELECT {$fields} FROM {$this->table} WHERE id = :id", [ 'id' => $id ]);
$requests = $this->resultsToRequests($result);
return end($requests);
}
// Return the latest request
public function latest(Search $search = null)
{
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
$search = SqlSearch::fromBase($search, $this->pdo);
$result = $this->query(
"SELECT {$fields} FROM {$this->table} {$search->query} ORDER BY id DESC LIMIT 1", $search->bindings
);
$requests = $this->resultsToRequests($result);
return end($requests);
}
// Return requests received before specified id, optionally limited to specified count
public function previous($id, $count = null, Search $search = null)
{
$count = (int) $count;
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
$search = SqlSearch::fromBase($search, $this->pdo)->addCondition('id < :id', [ 'id' => $id ]);
$limit = $count ? "LIMIT {$count}" : '';
$result = $this->query(
"SELECT {$fields} FROM {$this->table} {$search->query} ORDER BY id DESC {$limit}", $search->bindings
);
return array_reverse($this->resultsToRequests($result));
}
// Return requests received after specified id, optionally limited to specified count
public function next($id, $count = null, Search $search = null)
{
$count = (int) $count;
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
$search = SqlSearch::fromBase($search, $this->pdo)->addCondition('id > :id', [ 'id' => $id ]);
$limit = $count ? "LIMIT {$count}" : '';
$result = $this->query(
"SELECT {$fields} FROM {$this->table} {$search->query} ORDER BY id ASC {$limit}", $search->bindings
);
return $this->resultsToRequests($result);
}
// Store the request in the database
public function store(Request $request)
{
$data = $request->toArray();
foreach ($this->needsSerialization as $key) {
$data[$key] = @json_encode($data[$key], \JSON_PARTIAL_OUTPUT_ON_ERROR);
}
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
$bindings = implode(', ', array_map(function ($field) { return ":{$field}"; }, array_keys($this->fields)));
$this->query("INSERT INTO {$this->table} ($fields) VALUES ($bindings)", $data);
$this->cleanup();
}
// Update an existing request in the database
public function update(Request $request)
{
$data = $request->toArray();
foreach ($this->needsSerialization as $key) {
$data[$key] = @json_encode($data[$key], \JSON_PARTIAL_OUTPUT_ON_ERROR);
}
$values = implode(', ', array_map(function ($field) {
return $this->quote($field) . " = :{$field}";
}, array_keys($this->fields)));
$this->query("UPDATE {$this->table} SET {$values} WHERE id = :id", $data);
$this->cleanup();
}
// Cleanup old requests
public function cleanup()
{
if ($this->expiration === false) return;
$this->query("DELETE FROM {$this->table} WHERE time < :time", [ 'time' => time() - ($this->expiration * 60) ]);
}
// Create or update the Clockwork metadata table
protected function initialize()
{
// first we get rid of existing table if it exists by renaming it so we won't lose any data
try {
$table = $this->quote($this->table);
$backupTableName = $this->quote("{$this->table}_backup_" . date('Ymd'));
$this->pdo->exec("ALTER TABLE {$table} RENAME TO {$backupTableName};");
} catch (\PDOException $e) {
// this just means the table doesn't yet exist, nothing to do here
}
// create the metadata table
$this->pdo->exec($this->buildSchema($table));
$indexName = $this->quote("{$this->table}_time_index");
$this->pdo->exec("CREATE INDEX {$indexName} ON {$table} (". $this->quote('time') .')');
}
// Builds the query to create Clockwork database table
protected function buildSchema($table)
{
$textType = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql' ? 'MEDIUMTEXT' : 'TEXT';
$columns = implode(', ', array_map(function ($field, $type) use ($textType) {
return $this->quote($field) . ' ' . str_replace('TEXT', $textType, $type);
}, array_keys($this->fields), array_values($this->fields)));
return "CREATE TABLE {$table} ({$columns});";
}
// Executes an sql query, lazily initiates the clockwork database schema if it's old or doesn't exist yet, returns
// executed statement or false on error
protected function query($query, array $bindings = [], $firstTry = true)
{
try {
if ($stmt = $this->pdo->prepare($query)) {
if ($stmt->execute($bindings)) return $stmt;
throw new \PDOException;
}
} catch (\PDOException $e) {
$stmt = false;
}
// the query failed to execute, assume it's caused by missing or old schema, try to reinitialize database
if (! $stmt && $firstTry) {
$this->initialize();
return $this->query($query, $bindings, false);
}
}
// Quotes SQL identifier name properly for the current database
protected function quote($identifier)
{
return $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql' ? "`{$identifier}`" : "\"{$identifier}\"";
}
// Returns array of Requests instances from the executed PDO statement
protected function resultsToRequests($stmt)
{
return array_map(function ($data) {
return $this->dataToRequest($data);
}, $stmt->fetchAll(PDO::FETCH_ASSOC));
}
// Returns a Request instance from a single database record
protected function dataToRequest($data)
{
foreach ($this->needsSerialization as $key) {
$data[$key] = json_decode($data[$key], true);
}
return new Request($data);
}
}

View File

@@ -0,0 +1,12 @@
<?php namespace Clockwork\Storage;
use Clockwork\Storage\StorageInterface;
use Clockwork\Request\Request;
abstract class Storage implements StorageInterface
{
// Update existing request
public function update(Request $request)
{
}
}

View File

@@ -0,0 +1,31 @@
<?php namespace Clockwork\Storage;
use Clockwork\Request\Request;
// Interface for requests storage implementations
interface StorageInterface
{
// Returns all requests
public function all(Search $search = null);
// Return a single request by id
public function find($id);
// Return the latest request
public function latest(Search $search = null);
// Return requests received before specified id, optionally limited to specified count
public function previous($id, $count = null, Search $search = null);
// Return requests received after specified id, optionally limited to specified count
public function next($id, $count = null, Search $search = null);
// Store request
public function store(Request $request);
// Update existing request
public function update(Request $request);
// Cleanup old requests
public function cleanup();
}

View File

@@ -0,0 +1,55 @@
<?php namespace Clockwork\Storage;
use Clockwork\Request\Request;
use Clockwork\Support\Symfony\ProfileTransformer;
use Symfony\Component\HttpKernel\Profiler\Profiler;
// Storage wrapping Symfony profiler
class SymfonyStorage extends FileStorage
{
// Symfony profiler instance
protected $profiler;
// Symfony profiler path
protected $path;
// Create a new instance, takes Symfony profiler instance and path as argument
public function __construct(Profiler $profiler, $path)
{
$this->profiler = $profiler;
$this->path = $path;
}
// Store request, no-op since this is read-only storage implementation
public function store(Request $request, $skipIndex = false)
{
return;
}
// Cleanup old requests, no-op since this is read-only storage implementation
public function cleanup($force = false)
{
return;
}
protected function loadRequest($token)
{
return ($profile = $this->profiler->loadProfile($token)) ? (new ProfileTransformer)->transform($profile) : null;
}
// Open index file, optionally move file pointer to the end
protected function openIndex($position = 'start', $lock = null, $force = null)
{
$this->indexHandle = fopen("{$this->path}/index.csv", 'r');
if ($position == 'end') fseek($this->indexHandle, 0, SEEK_END);
}
protected function makeRequestFromIndex($record)
{
return new Request(array_combine(
[ 'id', 'ip', 'method', 'uri', 'time', 'parent', 'responseStatus' ], $record
));
}
}