Adapter
6 years ago
Schema
6 years ago
Adapter.php
6 years ago
AdapterInterface.php
6 years ago
BatchInsert.php
6 years ago
Schema.php
6 years ago
SchemaInterface.php
6 years ago
Settings.php
6 years ago
TransactionLevel.php
6 years ago
BatchInsert.php
307 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Piwik - free/libre analytics platform |
| 4 | * |
| 5 | * @link https://matomo.org |
| 6 | * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later |
| 7 | * |
| 8 | */ |
| 9 | namespace Piwik\Db; |
| 10 | |
| 11 | use Exception; |
| 12 | use Piwik\Common; |
| 13 | use Piwik\Config; |
| 14 | use Piwik\Container\StaticContainer; |
| 15 | use Piwik\Db; |
| 16 | use Piwik\Log; |
| 17 | use Piwik\SettingsServer; |
| 18 | use Piwik\SettingsPiwik; |
| 19 | |
| 20 | class BatchInsert |
| 21 | { |
| 22 | /** |
| 23 | * Performs a batch insert into a specific table by iterating through the data |
| 24 | * |
| 25 | * NOTE: you should use tableInsertBatch() which will fallback to this function if LOAD DATA INFILE not available |
| 26 | * |
| 27 | * @param string $tableName PREFIXED table name! you must call Common::prefixTable() before passing the table name |
| 28 | * @param array $fields array of unquoted field names |
| 29 | * @param array $values array of data to be inserted |
| 30 | * @param bool $ignoreWhenDuplicate Ignore new rows that contain unique key values that duplicate old rows |
| 31 | */ |
| 32 | public static function tableInsertBatchIterate($tableName, $fields, $values, $ignoreWhenDuplicate = true) |
| 33 | { |
| 34 | $fieldList = '(' . join(',', $fields) . ')'; |
| 35 | $ignore = $ignoreWhenDuplicate ? 'IGNORE' : ''; |
| 36 | |
| 37 | foreach ($values as $row) { |
| 38 | $query = "INSERT $ignore INTO " . $tableName . " |
| 39 | $fieldList |
| 40 | VALUES (" . Common::getSqlStringFieldsArray($row) . ")"; |
| 41 | Db::query($query, $row); |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Performs a batch insert into a specific table by sending all data in one SQL statement. |
| 47 | * |
| 48 | * @param string $tableName PREFIXED table name! you must call Common::prefixTable() before passing the table name |
| 49 | * @param array $fields array of unquoted field names |
| 50 | * @param array $values array of data to be inserted |
| 51 | * @param bool $ignoreWhenDuplicate Ignore new rows that contain unique key values that duplicate old rows |
| 52 | */ |
| 53 | public static function tableInsertBatchSql($tableName, $fields, $values, $ignoreWhenDuplicate = true) |
| 54 | { |
| 55 | $insertLines = array(); |
| 56 | $bind = array(); |
| 57 | foreach ($values as $row) { |
| 58 | $insertLines[] = "(" . Common::getSqlStringFieldsArray($row) . ")"; |
| 59 | $bind = array_merge($bind, $row); |
| 60 | } |
| 61 | |
| 62 | $fieldList = '(' . implode(',', $fields) . ')'; |
| 63 | $insertLines = implode(',', $insertLines); |
| 64 | $ignore = $ignoreWhenDuplicate ? 'IGNORE' : ''; |
| 65 | $query = "INSERT $ignore INTO $tableName $fieldList VALUES $insertLines"; |
| 66 | Db::query($query, $bind); |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Performs a batch insert into a specific table using either LOAD DATA INFILE or plain INSERTs, |
| 71 | * as a fallback. On MySQL, LOAD DATA INFILE is 20x faster than a series of plain INSERTs. |
| 72 | * |
| 73 | * @param string $tableName PREFIXED table name! you must call Common::prefixTable() before passing the table name |
| 74 | * @param array $fields array of unquoted field names |
| 75 | * @param array $values array of data to be inserted |
| 76 | * @param bool $throwException Whether to throw an exception that was caught while trying |
| 77 | * LOAD DATA INFILE, or not. |
| 78 | * @param string $charset The charset to use, defaults to utf8 |
| 79 | * @throws Exception |
| 80 | * @return bool True if the bulk LOAD was used, false if we fallback to plain INSERTs |
| 81 | */ |
| 82 | public static function tableInsertBatch($tableName, $fields, $values, $throwException = false, $charset = 'utf8') |
| 83 | { |
| 84 | $loadDataInfileEnabled = Config::getInstance()->General['enable_load_data_infile']; |
| 85 | |
| 86 | if ($loadDataInfileEnabled |
| 87 | && Db::get()->hasBulkLoader()) { |
| 88 | |
| 89 | $path = self::getBestPathForLoadData(); |
| 90 | $instanceId = SettingsPiwik::getPiwikInstanceId(); |
| 91 | if (empty($instanceId)) { |
| 92 | $instanceId = ''; |
| 93 | } |
| 94 | $filePath = $path . $tableName . '-' . $instanceId . Common::generateUniqId() . '.csv'; |
| 95 | |
| 96 | |
| 97 | try { |
| 98 | $fileSpec = array( |
| 99 | 'delim' => "\t", |
| 100 | 'quote' => '"', // chr(34) |
| 101 | 'escape' => '\\\\', // chr(92) |
| 102 | 'escapespecial_cb' => function ($str) { |
| 103 | return str_replace(array(chr(92), chr(34)), array(chr(92) . chr(92), chr(92) . chr(34)), $str); |
| 104 | }, |
| 105 | 'eol' => "\r\n", |
| 106 | 'null' => 'NULL', |
| 107 | 'charset' => $charset |
| 108 | ); |
| 109 | |
| 110 | self::createCSVFile($filePath, $fileSpec, $values); |
| 111 | |
| 112 | if (!is_readable($filePath)) { |
| 113 | throw new Exception("File $filePath could not be read."); |
| 114 | } |
| 115 | |
| 116 | $rc = self::createTableFromCSVFile($tableName, $fields, $filePath, $fileSpec); |
| 117 | if ($rc) { |
| 118 | unlink($filePath); |
| 119 | return true; |
| 120 | } |
| 121 | } catch (Exception $e) { |
| 122 | if ($throwException) { |
| 123 | throw $e; |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | // if all else fails, fallback to a series of INSERTs |
| 128 | if (file_exists($filePath)) { |
| 129 | @unlink($filePath); |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | self::tableInsertBatchIterate($tableName, $fields, $values); |
| 134 | |
| 135 | return false; |
| 136 | } |
| 137 | |
| 138 | private static function getBestPathForLoadData() |
| 139 | { |
| 140 | try { |
| 141 | $path = Db::fetchOne('SELECT @@secure_file_priv'); // was introduced in 5.0.38 |
| 142 | } catch (Exception $e) { |
| 143 | // we do not rethrow exception as an error is expected if MySQL is < 5.0.38 |
| 144 | // in this case tableInsertBatch might still work |
| 145 | } |
| 146 | |
| 147 | if (empty($path) || !@is_dir($path) || !@is_writable($path)) { |
| 148 | $path = StaticContainer::get('path.tmp') . '/assets/'; |
| 149 | } elseif (!Common::stringEndsWith($path, '/')) { |
| 150 | $path .= '/'; |
| 151 | } |
| 152 | |
| 153 | return $path; |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Batch insert into table from CSV (or other delimited) file. |
| 158 | * |
| 159 | * @param string $tableName Name of table |
| 160 | * @param array $fields Field names |
| 161 | * @param string $filePath Path name of a file. |
| 162 | * @param array $fileSpec File specifications (delimiter, line terminator, etc) |
| 163 | * |
| 164 | * @throws Exception |
| 165 | * @return bool True if successful; false otherwise |
| 166 | */ |
| 167 | public static function createTableFromCSVFile($tableName, $fields, $filePath, $fileSpec) |
| 168 | { |
| 169 | // Chroot environment: prefix the path with the absolute chroot path |
| 170 | $chrootPath = Config::getInstance()->General['absolute_chroot_path']; |
| 171 | if (!empty($chrootPath)) { |
| 172 | $filePath = $chrootPath . $filePath; |
| 173 | } |
| 174 | |
| 175 | // On Windows, MySQL expects forward slashes as directory separators |
| 176 | if (SettingsServer::isWindows()) { |
| 177 | $filePath = str_replace('\\', '/', $filePath); |
| 178 | } |
| 179 | |
| 180 | $query = " |
| 181 | '$filePath' |
| 182 | REPLACE |
| 183 | INTO TABLE |
| 184 | `" . $tableName . "`"; |
| 185 | |
| 186 | if (isset($fileSpec['charset'])) { |
| 187 | $query .= ' CHARACTER SET ' . $fileSpec['charset']; |
| 188 | } |
| 189 | |
| 190 | $fieldList = '(' . join(',', $fields) . ')'; |
| 191 | |
| 192 | $query .= " |
| 193 | FIELDS TERMINATED BY |
| 194 | '" . $fileSpec['delim'] . "' |
| 195 | ENCLOSED BY |
| 196 | '" . $fileSpec['quote'] . "' |
| 197 | "; |
| 198 | if (isset($fileSpec['escape'])) { |
| 199 | $query .= " ESCAPED BY '" . $fileSpec['escape'] . "'"; |
| 200 | } |
| 201 | $query .= " |
| 202 | LINES TERMINATED BY |
| 203 | '" . $fileSpec['eol'] . "' |
| 204 | $fieldList |
| 205 | "; |
| 206 | |
| 207 | /* |
| 208 | * First attempt: assume web server and MySQL server are on the same machine; |
| 209 | * this requires that the db user have the FILE privilege; however, since this is |
| 210 | * a global privilege, it may not be granted due to security concerns |
| 211 | */ |
| 212 | if (Config::getInstance()->General['multi_server_environment']) { |
| 213 | $keywords = array(); // don't try 'LOAD DATA INFILE' if in a multi_server_environment |
| 214 | } else { |
| 215 | $keywords = array(''); |
| 216 | } |
| 217 | |
| 218 | /* |
| 219 | * Second attempt: using the LOCAL keyword means the client reads the file and sends it to the server; |
| 220 | * the LOCAL keyword may trigger a known PHP PDO\MYSQL bug when MySQL not built with --enable-local-infile |
| 221 | * @see http://bugs.php.net/bug.php?id=54158 |
| 222 | */ |
| 223 | $openBaseDir = ini_get('open_basedir'); |
| 224 | $isUsingNonBuggyMysqlnd = function_exists('mysqli_get_client_stats') && version_compare(PHP_VERSION, '5.6.17', '>='); |
| 225 | $safeMode = ini_get('safe_mode'); |
| 226 | |
| 227 | if (($isUsingNonBuggyMysqlnd || empty($openBaseDir)) && empty($safeMode)) { |
| 228 | // php 5.x - LOAD DATA LOCAL INFILE only used if open_basedir is not set (or we're using a non-buggy version of mysqlnd) |
| 229 | // and if safe mode is not enabled |
| 230 | $keywords[] = 'LOCAL '; |
| 231 | } |
| 232 | |
| 233 | $exceptions = array(); |
| 234 | foreach ($keywords as $keyword) { |
| 235 | $queryStart = 'LOAD DATA ' . $keyword . 'INFILE '; |
| 236 | $sql = $queryStart . $query; |
| 237 | try { |
| 238 | $result = @Db::exec($sql); |
| 239 | if (empty($result) || $result < 0) { |
| 240 | continue; |
| 241 | } |
| 242 | |
| 243 | return true; |
| 244 | } catch (Exception $e) { |
| 245 | $code = $e->getCode(); |
| 246 | $message = $e->getMessage() . ($code ? "[$code]" : ''); |
| 247 | $exceptions[] = "\n Try #" . (count($exceptions) + 1) . ': ' . $queryStart . ": " . $message; |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | if (count($exceptions)) { |
| 252 | $message = "LOAD DATA INFILE failed... Error was: " . implode(",", $exceptions); |
| 253 | Log::info($message); |
| 254 | throw new Exception($message); |
| 255 | } |
| 256 | |
| 257 | return false; |
| 258 | } |
| 259 | |
| 260 | /** |
| 261 | * Create CSV (or other delimited) files |
| 262 | * |
| 263 | * @param string $filePath filename to create |
| 264 | * @param array $fileSpec File specifications (delimiter, line terminator, etc) |
| 265 | * @param array $rows Array of array corresponding to rows of values |
| 266 | * @throws Exception if unable to create or write to file |
| 267 | */ |
| 268 | protected static function createCSVFile($filePath, $fileSpec, $rows) |
| 269 | { |
| 270 | // Set up CSV delimiters, quotes, etc |
| 271 | $delim = $fileSpec['delim']; |
| 272 | $quote = $fileSpec['quote']; |
| 273 | $eol = $fileSpec['eol']; |
| 274 | $null = $fileSpec['null']; |
| 275 | $escapespecial_cb = $fileSpec['escapespecial_cb']; |
| 276 | |
| 277 | $fp = @fopen($filePath, 'wb'); |
| 278 | if (!$fp) { |
| 279 | throw new Exception('Error creating the tmp file ' . $filePath . ', please check that the webserver has write permission to write this file.'); |
| 280 | } |
| 281 | |
| 282 | foreach ($rows as $row) { |
| 283 | $output = ''; |
| 284 | foreach ($row as $value) { |
| 285 | if (!isset($value) || is_null($value) || $value === false) { |
| 286 | $output .= $null . $delim; |
| 287 | } else { |
| 288 | $output .= $quote . $escapespecial_cb($value) . $quote . $delim; |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | // Replace delim with eol |
| 293 | $output = substr_replace($output, $eol, -1); |
| 294 | |
| 295 | $ret = fwrite($fp, $output); |
| 296 | if (!$ret) { |
| 297 | fclose($fp); |
| 298 | throw new Exception('Error writing to the tmp file ' . $filePath); |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | fclose($fp); |
| 303 | |
| 304 | @chmod($filePath, 0777); |
| 305 | } |
| 306 | } |
| 307 |