| 199 |
lars |
1 |
<?php namespace Clockwork\Storage;
|
|
|
2 |
|
|
|
3 |
use Clockwork\Clockwork;
|
|
|
4 |
use Clockwork\Request\Request;
|
|
|
5 |
|
|
|
6 |
use PDO;
|
|
|
7 |
|
|
|
8 |
// SQL storage for requests using PDO
|
|
|
9 |
class SqlStorage extends Storage
|
|
|
10 |
{
|
|
|
11 |
// PDO instance
|
|
|
12 |
protected $pdo;
|
|
|
13 |
|
|
|
14 |
// Name of the table with Clockwork requests metadata
|
|
|
15 |
protected $table;
|
|
|
16 |
|
|
|
17 |
// Metadata expiration time in minutes
|
|
|
18 |
protected $expiration;
|
|
|
19 |
|
|
|
20 |
// Schema for the Clockwork requests table
|
|
|
21 |
protected $fields = [
|
|
|
22 |
'id' => 'VARCHAR(100) PRIMARY KEY',
|
|
|
23 |
'version' => 'INTEGER',
|
|
|
24 |
'type' => 'VARCHAR(100) NULL',
|
|
|
25 |
'time' => 'DOUBLE PRECISION NULL',
|
|
|
26 |
'method' => 'VARCHAR(10) NULL',
|
|
|
27 |
'url' => 'TEXT NULL',
|
|
|
28 |
'uri' => 'TEXT NULL',
|
|
|
29 |
'headers' => 'TEXT NULL',
|
|
|
30 |
'controller' => 'VARCHAR(250) NULL',
|
|
|
31 |
'getData' => 'TEXT NULL',
|
|
|
32 |
'postData' => 'TEXT NULL',
|
|
|
33 |
'requestData' => 'TEXT NULL',
|
|
|
34 |
'sessionData' => 'TEXT NULL',
|
|
|
35 |
'authenticatedUser' => 'TEXT NULL',
|
|
|
36 |
'cookies' => 'TEXT NULL',
|
|
|
37 |
'responseTime' => 'DOUBLE PRECISION NULL',
|
|
|
38 |
'responseStatus' => 'INTEGER NULL',
|
|
|
39 |
'responseDuration' => 'DOUBLE PRECISION NULL',
|
|
|
40 |
'memoryUsage' => 'DOUBLE PRECISION NULL',
|
|
|
41 |
'middleware' => 'TEXT NULL',
|
|
|
42 |
'databaseQueries' => 'TEXT NULL',
|
|
|
43 |
'databaseQueriesCount' => 'INTEGER NULL',
|
|
|
44 |
'databaseSlowQueries' => 'INTEGER NULL',
|
|
|
45 |
'databaseSelects' => 'INTEGER NULL',
|
|
|
46 |
'databaseInserts' => 'INTEGER NULL',
|
|
|
47 |
'databaseUpdates' => 'INTEGER NULL',
|
|
|
48 |
'databaseDeletes' => 'INTEGER NULL',
|
|
|
49 |
'databaseOthers' => 'INTEGER NULL',
|
|
|
50 |
'databaseDuration' => 'DOUBLE PRECISION NULL',
|
|
|
51 |
'cacheQueries' => 'TEXT NULL',
|
|
|
52 |
'cacheReads' => 'INTEGER NULL',
|
|
|
53 |
'cacheHits' => 'INTEGER NULL',
|
|
|
54 |
'cacheWrites' => 'INTEGER NULL',
|
|
|
55 |
'cacheDeletes' => 'INTEGER NULL',
|
|
|
56 |
'cacheTime' => 'DOUBLE PRECISION NULL',
|
|
|
57 |
'modelsActions' => 'TEXT NULL',
|
|
|
58 |
'modelsRetrieved' => 'TEXT NULL',
|
|
|
59 |
'modelsCreated' => 'TEXT NULL',
|
|
|
60 |
'modelsUpdated' => 'TEXT NULL',
|
|
|
61 |
'modelsDeleted' => 'TEXT NULL',
|
|
|
62 |
'redisCommands' => 'TEXT NULL',
|
|
|
63 |
'queueJobs' => 'TEXT NULL',
|
|
|
64 |
'timelineData' => 'TEXT NULL',
|
|
|
65 |
'log' => 'TEXT NULL',
|
|
|
66 |
'events' => 'TEXT NULL',
|
|
|
67 |
'routes' => 'TEXT NULL',
|
|
|
68 |
'notifications' => 'TEXT NULL',
|
|
|
69 |
'emailsData' => 'TEXT NULL',
|
|
|
70 |
'viewsData' => 'TEXT NULL',
|
|
|
71 |
'userData' => 'TEXT NULL',
|
|
|
72 |
'subrequests' => 'TEXT NULL',
|
|
|
73 |
'xdebug' => 'TEXT NULL',
|
|
|
74 |
'commandName' => 'TEXT NULL',
|
|
|
75 |
'commandArguments' => 'TEXT NULL',
|
|
|
76 |
'commandArgumentsDefaults' => 'TEXT NULL',
|
|
|
77 |
'commandOptions' => 'TEXT NULL',
|
|
|
78 |
'commandOptionsDefaults' => 'TEXT NULL',
|
|
|
79 |
'commandExitCode' => 'INTEGER NULL',
|
|
|
80 |
'commandOutput' => 'TEXT NULL',
|
|
|
81 |
'jobName' => 'TEXT NULL',
|
|
|
82 |
'jobDescription' => 'TEXT NULL',
|
|
|
83 |
'jobStatus' => 'TEXT NULL',
|
|
|
84 |
'jobPayload' => 'TEXT NULL',
|
|
|
85 |
'jobQueue' => 'TEXT NULL',
|
|
|
86 |
'jobConnection' => 'TEXT NULL',
|
|
|
87 |
'jobOptions' => 'TEXT NULL',
|
|
|
88 |
'testName' => 'TEXT NULL',
|
|
|
89 |
'testStatus' => 'TEXT NULL',
|
|
|
90 |
'testStatusMessage' => 'TEXT NULL',
|
|
|
91 |
'testAsserts' => 'TEXT NULL',
|
|
|
92 |
'clientMetrics' => 'TEXT NULL',
|
|
|
93 |
'webVitals' => 'TEXT NULL',
|
|
|
94 |
'parent' => 'TEXT NULL',
|
|
|
95 |
'updateToken' => 'VARCHAR(100) NULL'
|
|
|
96 |
];
|
|
|
97 |
|
|
|
98 |
// List of Request keys that need to be serialized before they can be stored in database
|
|
|
99 |
protected $needsSerialization = [
|
|
|
100 |
'headers', 'getData', 'postData', 'requestData', 'sessionData', 'authenticatedUser', 'cookies', 'middleware',
|
|
|
101 |
'databaseQueries', 'cacheQueries', 'modelsActions', 'modelsRetrieved', 'modelsCreated', 'modelsUpdated',
|
|
|
102 |
'modelsDeleted', 'redisCommands', 'queueJobs', 'timelineData', 'log', 'events', 'routes', 'notifications',
|
|
|
103 |
'emailsData', 'viewsData', 'userData', 'subrequests', 'xdebug', 'commandArguments', 'commandArgumentsDefaults',
|
|
|
104 |
'commandOptions', 'commandOptionsDefaults', 'jobPayload', 'jobOptions', 'testAsserts', 'parent',
|
|
|
105 |
'clientMetrics', 'webVitals'
|
|
|
106 |
];
|
|
|
107 |
|
|
|
108 |
// Return a new storage, takes PDO object or DSN and optionally a table name and database credentials as arguments
|
|
|
109 |
public function __construct($dsn, $table = 'clockwork', $username = null, $password = null, $expiration = null)
|
|
|
110 |
{
|
|
|
111 |
$this->pdo = $dsn instanceof PDO ? $dsn : new PDO($dsn, $username, $password);
|
|
|
112 |
$this->table = $table;
|
|
|
113 |
$this->expiration = $expiration === null ? 60 * 24 * 7 : $expiration;
|
|
|
114 |
}
|
|
|
115 |
|
|
|
116 |
// Returns all requests
|
|
|
117 |
public function all(Search $search = null)
|
|
|
118 |
{
|
|
|
119 |
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
|
|
|
120 |
$search = SqlSearch::fromBase($search, $this->pdo);
|
|
|
121 |
$result = $this->query("SELECT {$fields} FROM {$this->table} {$search->query}", $search->bindings);
|
|
|
122 |
|
|
|
123 |
return $this->resultsToRequests($result);
|
|
|
124 |
}
|
|
|
125 |
|
|
|
126 |
// Return a single request by id
|
|
|
127 |
public function find($id)
|
|
|
128 |
{
|
|
|
129 |
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
|
|
|
130 |
$result = $this->query("SELECT {$fields} FROM {$this->table} WHERE id = :id", [ 'id' => $id ]);
|
|
|
131 |
|
|
|
132 |
$requests = $this->resultsToRequests($result);
|
|
|
133 |
return end($requests);
|
|
|
134 |
}
|
|
|
135 |
|
|
|
136 |
// Return the latest request
|
|
|
137 |
public function latest(Search $search = null)
|
|
|
138 |
{
|
|
|
139 |
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
|
|
|
140 |
$search = SqlSearch::fromBase($search, $this->pdo);
|
|
|
141 |
$result = $this->query(
|
|
|
142 |
"SELECT {$fields} FROM {$this->table} {$search->query} ORDER BY id DESC LIMIT 1", $search->bindings
|
|
|
143 |
);
|
|
|
144 |
|
|
|
145 |
$requests = $this->resultsToRequests($result);
|
|
|
146 |
return end($requests);
|
|
|
147 |
}
|
|
|
148 |
|
|
|
149 |
// Return requests received before specified id, optionally limited to specified count
|
|
|
150 |
public function previous($id, $count = null, Search $search = null)
|
|
|
151 |
{
|
|
|
152 |
$count = (int) $count;
|
|
|
153 |
|
|
|
154 |
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
|
|
|
155 |
$search = SqlSearch::fromBase($search, $this->pdo)->addCondition('id < :id', [ 'id' => $id ]);
|
|
|
156 |
$limit = $count ? "LIMIT {$count}" : '';
|
|
|
157 |
$result = $this->query(
|
|
|
158 |
"SELECT {$fields} FROM {$this->table} {$search->query} ORDER BY id DESC {$limit}", $search->bindings
|
|
|
159 |
);
|
|
|
160 |
|
|
|
161 |
return array_reverse($this->resultsToRequests($result));
|
|
|
162 |
}
|
|
|
163 |
|
|
|
164 |
// Return requests received after specified id, optionally limited to specified count
|
|
|
165 |
public function next($id, $count = null, Search $search = null)
|
|
|
166 |
{
|
|
|
167 |
$count = (int) $count;
|
|
|
168 |
|
|
|
169 |
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
|
|
|
170 |
$search = SqlSearch::fromBase($search, $this->pdo)->addCondition('id > :id', [ 'id' => $id ]);
|
|
|
171 |
$limit = $count ? "LIMIT {$count}" : '';
|
|
|
172 |
$result = $this->query(
|
|
|
173 |
"SELECT {$fields} FROM {$this->table} {$search->query} ORDER BY id ASC {$limit}", $search->bindings
|
|
|
174 |
);
|
|
|
175 |
|
|
|
176 |
return $this->resultsToRequests($result);
|
|
|
177 |
}
|
|
|
178 |
|
|
|
179 |
// Store the request in the database
|
|
|
180 |
public function store(Request $request)
|
|
|
181 |
{
|
|
|
182 |
$data = $request->toArray();
|
|
|
183 |
|
|
|
184 |
foreach ($this->needsSerialization as $key) {
|
|
|
185 |
$data[$key] = @json_encode($data[$key], \JSON_PARTIAL_OUTPUT_ON_ERROR);
|
|
|
186 |
}
|
|
|
187 |
|
|
|
188 |
$fields = implode(', ', array_map(function ($field) { return $this->quote($field); }, array_keys($this->fields)));
|
|
|
189 |
$bindings = implode(', ', array_map(function ($field) { return ":{$field}"; }, array_keys($this->fields)));
|
|
|
190 |
|
|
|
191 |
$this->query("INSERT INTO {$this->table} ($fields) VALUES ($bindings)", $data);
|
|
|
192 |
|
|
|
193 |
$this->cleanup();
|
|
|
194 |
}
|
|
|
195 |
|
|
|
196 |
// Update an existing request in the database
|
|
|
197 |
public function update(Request $request)
|
|
|
198 |
{
|
|
|
199 |
$data = $request->toArray();
|
|
|
200 |
|
|
|
201 |
foreach ($this->needsSerialization as $key) {
|
|
|
202 |
$data[$key] = @json_encode($data[$key], \JSON_PARTIAL_OUTPUT_ON_ERROR);
|
|
|
203 |
}
|
|
|
204 |
|
|
|
205 |
$values = implode(', ', array_map(function ($field) {
|
|
|
206 |
return $this->quote($field) . " = :{$field}";
|
|
|
207 |
}, array_keys($this->fields)));
|
|
|
208 |
|
|
|
209 |
$this->query("UPDATE {$this->table} SET {$values} WHERE id = :id", $data);
|
|
|
210 |
|
|
|
211 |
$this->cleanup();
|
|
|
212 |
}
|
|
|
213 |
|
|
|
214 |
// Cleanup old requests
|
|
|
215 |
public function cleanup()
|
|
|
216 |
{
|
|
|
217 |
if ($this->expiration === false) return;
|
|
|
218 |
|
|
|
219 |
$this->query("DELETE FROM {$this->table} WHERE time < :time", [ 'time' => time() - ($this->expiration * 60) ]);
|
|
|
220 |
}
|
|
|
221 |
|
|
|
222 |
// Create or update the Clockwork metadata table
|
|
|
223 |
protected function initialize()
|
|
|
224 |
{
|
|
|
225 |
// first we get rid of existing table if it exists by renaming it so we won't lose any data
|
|
|
226 |
try {
|
|
|
227 |
$table = $this->quote($this->table);
|
|
|
228 |
$backupTableName = $this->quote("{$this->table}_backup_" . date('Ymd'));
|
|
|
229 |
$this->pdo->exec("ALTER TABLE {$table} RENAME TO {$backupTableName};");
|
|
|
230 |
} catch (\PDOException $e) {
|
|
|
231 |
// this just means the table doesn't yet exist, nothing to do here
|
|
|
232 |
}
|
|
|
233 |
|
|
|
234 |
// create the metadata table
|
|
|
235 |
$this->pdo->exec($this->buildSchema($table));
|
|
|
236 |
|
|
|
237 |
$indexName = $this->quote("{$this->table}_time_index");
|
|
|
238 |
$this->pdo->exec("CREATE INDEX {$indexName} ON {$table} (". $this->quote('time') .')');
|
|
|
239 |
}
|
|
|
240 |
|
|
|
241 |
// Builds the query to create Clockwork database table
|
|
|
242 |
protected function buildSchema($table)
|
|
|
243 |
{
|
|
|
244 |
$textType = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql' ? 'MEDIUMTEXT' : 'TEXT';
|
|
|
245 |
|
|
|
246 |
$columns = implode(', ', array_map(function ($field, $type) use ($textType) {
|
|
|
247 |
return $this->quote($field) . ' ' . str_replace('TEXT', $textType, $type);
|
|
|
248 |
}, array_keys($this->fields), array_values($this->fields)));
|
|
|
249 |
|
|
|
250 |
return "CREATE TABLE {$table} ({$columns});";
|
|
|
251 |
}
|
|
|
252 |
|
|
|
253 |
// Executes an sql query, lazily initiates the clockwork database schema if it's old or doesn't exist yet, returns
|
|
|
254 |
// executed statement or false on error
|
|
|
255 |
protected function query($query, array $bindings = [], $firstTry = true)
|
|
|
256 |
{
|
|
|
257 |
try {
|
|
|
258 |
if ($stmt = $this->pdo->prepare($query)) {
|
|
|
259 |
if ($stmt->execute($bindings)) return $stmt;
|
|
|
260 |
throw new \PDOException;
|
|
|
261 |
}
|
|
|
262 |
} catch (\PDOException $e) {
|
|
|
263 |
$stmt = false;
|
|
|
264 |
}
|
|
|
265 |
|
|
|
266 |
// the query failed to execute, assume it's caused by missing or old schema, try to reinitialize database
|
|
|
267 |
if (! $stmt && $firstTry) {
|
|
|
268 |
$this->initialize();
|
|
|
269 |
return $this->query($query, $bindings, false);
|
|
|
270 |
}
|
|
|
271 |
}
|
|
|
272 |
|
|
|
273 |
// Quotes SQL identifier name properly for the current database
|
|
|
274 |
protected function quote($identifier)
|
|
|
275 |
{
|
|
|
276 |
return $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql' ? "`{$identifier}`" : "\"{$identifier}\"";
|
|
|
277 |
}
|
|
|
278 |
|
|
|
279 |
// Returns array of Requests instances from the executed PDO statement
|
|
|
280 |
protected function resultsToRequests($stmt)
|
|
|
281 |
{
|
|
|
282 |
return array_map(function ($data) {
|
|
|
283 |
return $this->dataToRequest($data);
|
|
|
284 |
}, $stmt->fetchAll(PDO::FETCH_ASSOC));
|
|
|
285 |
}
|
|
|
286 |
|
|
|
287 |
// Returns a Request instance from a single database record
|
|
|
288 |
protected function dataToRequest($data)
|
|
|
289 |
{
|
|
|
290 |
foreach ($this->needsSerialization as $key) {
|
|
|
291 |
$data[$key] = json_decode($data[$key], true);
|
|
|
292 |
}
|
|
|
293 |
|
|
|
294 |
return new Request($data);
|
|
|
295 |
}
|
|
|
296 |
}
|