These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more
1 | <?php |
||
2 | /** |
||
3 | * This file contains only the CSV class. |
||
4 | * |
||
5 | * @file |
||
6 | * @package Tabulate |
||
7 | */ |
||
8 | |||
9 | namespace WordPress\Tabulate; |
||
10 | |||
11 | use WordPress\Tabulate\DB\ChangeTracker; |
||
12 | use WordPress\Tabulate\DB\Record; |
||
13 | |||
14 | /** |
||
15 | * A class for parsing a CSV file has either just been uploaded (i.e. $_FILES is |
||
16 | * set), or is stored as a temporary file (as defined herein). |
||
17 | */ |
||
18 | class CSV { |
||
19 | |||
20 | /** |
||
21 | * The headers in the CSV data. |
||
22 | * |
||
23 | * @var string[] |
||
24 | */ |
||
25 | public $headers; |
||
26 | |||
27 | /** |
||
28 | * Two-dimenstional integer-indexed array of the CSV's data. |
||
29 | * |
||
30 | * @var array[] |
||
31 | */ |
||
32 | public $data; |
||
33 | |||
34 | /** |
||
35 | * Temporary identifier for CSV file. |
||
36 | * |
||
37 | * @var string |
||
38 | */ |
||
39 | public $hash = false; |
||
40 | |||
41 | /** |
||
42 | * The filesystem. |
||
43 | * |
||
44 | * @var \WP_Filesystem_Base |
||
45 | */ |
||
46 | protected $filesystem; |
||
47 | |||
48 | /** |
||
49 | * Create a new CSV object based on a file. |
||
50 | * |
||
51 | * 1. If a file is being uploaded (i.e. `$_FILES['file']` is set), attempt |
||
52 | * to use it as the CSV file. |
||
53 | * 2. On the otherhand, if we're given a hash, attempt to use this to locate |
||
54 | * a local temporary file. |
||
55 | * |
||
56 | * In either case, if a valid CSV file cannot be found and parsed, throw an |
||
57 | * exception. |
||
58 | * |
||
59 | * @param \WP_Filesystem_Base $filesystem The filesystem object. |
||
60 | * @param string|boolean $hash The hash of an in-progress import, or false. |
||
61 | * @param string[]|boolean $uploaded The result of wp_handle_upload(), or false. |
||
62 | */ |
||
63 | public function __construct( $filesystem, $hash = false, $uploaded = false ) { |
||
64 | $this->filesystem = $filesystem; |
||
65 | if ( $uploaded ) { |
||
66 | $this->save_file( $uploaded ); |
||
67 | } |
||
68 | |||
69 | if ( ! empty( $hash ) ) { |
||
70 | $this->hash = $hash; |
||
71 | } |
||
72 | |||
73 | $this->load_data(); |
||
74 | } |
||
75 | |||
76 | /** |
||
77 | * Check the (already-handled) upload and rename the uploaded file. |
||
78 | * |
||
79 | * @see wp_handle_upload() |
||
80 | * @param array $uploaded The array detailing the uploaded file. |
||
81 | * @throws \Exception On upload error or if the file isn't a CSV. |
||
82 | */ |
||
83 | private function save_file( $uploaded ) { |
||
84 | if ( isset( $uploaded['error'] ) ) { |
||
85 | throw new \Exception( $uploaded['error'] ); |
||
86 | } |
||
87 | if ( 'text/csv' !== $uploaded['type'] ) { |
||
88 | unlink( $uploaded['file'] ); |
||
89 | throw new \Exception( 'Only CSV files can be imported.' ); |
||
90 | } |
||
91 | $this->hash = uniqid( TABULATE_SLUG ); |
||
92 | rename( $uploaded['file'], get_temp_dir() . '/' . $this->hash ); |
||
93 | } |
||
94 | |||
95 | /** |
||
96 | * Load CSV data from the file identified by the current hash. If no hash is |
||
97 | * set, this method does nothing. |
||
98 | * |
||
99 | * @return void |
||
100 | * @throws \Exception If the hash-identified file doesn't exist. |
||
101 | */ |
||
102 | public function load_data() { |
||
103 | if ( ! $this->hash ) { |
||
104 | return; |
||
105 | } |
||
106 | $file_path = get_temp_dir() . '/' . $this->hash; |
||
107 | if ( ! file_exists( $file_path ) ) { |
||
108 | throw new \Exception( "No import was found with the identifier ‘$this->hash’" ); |
||
109 | } |
||
110 | |||
111 | // Get all rows. |
||
112 | $this->data = array(); |
||
113 | $lines = $this->filesystem->get_contents_array( $file_path ); |
||
114 | foreach ( $lines as $line ) { |
||
115 | $this->data[] = str_getcsv( $line ); |
||
116 | } |
||
117 | |||
118 | // Extract headers. |
||
119 | $this->headers = $this->data[0]; |
||
120 | unset( $this->data[0] ); |
||
121 | } |
||
122 | |||
123 | /** |
||
124 | * Get the number of data rows in the file (i.e. excluding the header row). |
||
125 | * |
||
126 | * @return integer The number of rows. |
||
127 | */ |
||
128 | public function row_count() { |
||
129 | return count( $this->data ); |
||
130 | } |
||
131 | |||
132 | /** |
||
133 | * Whether or not a file has been successfully loaded. |
||
134 | * |
||
135 | * @return boolean |
||
136 | */ |
||
137 | public function loaded() { |
||
138 | return false !== $this->hash; |
||
139 | } |
||
140 | |||
141 | /** |
||
142 | * Take a mapping of DB column name to CSV column name, and convert it to |
||
143 | * a mapping of CSV column number to DB column name. This ignores empty |
||
144 | * column headers in the CSV (so we don't have to distinguish between |
||
145 | * not-matching and matching-on-empty-string). |
||
146 | * |
||
147 | * @param string[] $column_map The map from column headings to indices. |
||
148 | * @return array Keys are CSV indexes, values are DB column names |
||
149 | */ |
||
150 | private function remap( $column_map ) { |
||
151 | $heads = array(); |
||
152 | foreach ( $column_map as $db_col_name => $csv_col_name ) { |
||
153 | foreach ( $this->headers as $head_num => $head_name ) { |
||
154 | // If the header has a name, and it matches that of the column. |
||
155 | if ( ! empty( $head_name ) && 0 === strcasecmp( $head_name, $csv_col_name ) ) { |
||
156 | $heads[ $head_num ] = $db_col_name; |
||
157 | } |
||
158 | } |
||
159 | } |
||
160 | return $heads; |
||
161 | } |
||
162 | |||
163 | /** |
||
164 | * Rename all keys in all data rows to match DB column names, and normalize |
||
165 | * all values to be valid for the `$table`. |
||
166 | * |
||
167 | * If a _value_ in the array matches a lowercased DB column header, the _key_ |
||
168 | * of that value is the DB column name to which that header has been matched. |
||
169 | * |
||
170 | * @param DB\Table $table The table object. |
||
171 | * @param array $column_map Associating the headings to the indices. |
||
172 | * @return array Array of error messages. |
||
173 | */ |
||
174 | public function match_fields( $table, $column_map ) { |
||
175 | // First get the indexes of the headers, including the PK if it's there. |
||
176 | $heads = $this->remap( $column_map ); |
||
177 | $pk_col_num = false; |
||
178 | foreach ( $heads as $head_index => $head_name ) { |
||
179 | if ( $head_name === $table->get_pk_column()->get_name() ) { |
||
180 | $pk_col_num = $head_index; |
||
181 | break; |
||
182 | } |
||
183 | } |
||
184 | |||
185 | // Collect all errors. |
||
186 | $errors = array(); |
||
187 | $row_count = $this->row_count(); |
||
188 | for ( $row_num = 1; $row_num <= $row_count; $row_num++ ) { |
||
189 | $pk_set = $pk_col_num && isset( $this->data[ $row_num ][ $pk_col_num ] ); |
||
190 | foreach ( $this->data[ $row_num ] as $col_num => $value ) { |
||
191 | if ( ! isset( $heads[ $col_num ] ) ) { |
||
192 | continue; |
||
193 | } |
||
194 | $col_errors = array(); |
||
195 | $db_column_name = $heads[ $col_num ]; |
||
196 | $column = $table->get_column( $db_column_name ); |
||
197 | // Required, is not an update, has no default, and is empty. |
||
198 | if ( $column->is_required() && ! $pk_set && ! $column->get_default() && empty( $value ) ) { |
||
199 | $col_errors[] = 'Required but empty'; |
||
200 | } |
||
201 | // Already exists, and is not an update. |
||
202 | if ( $column->is_unique() && ! $pk_set && $this->value_exists( $table, $column, $value ) ) { |
||
203 | $col_errors[] = "Unique value already present: '$value'"; |
||
204 | } |
||
205 | // Too long (if the column has a size and the value is greater than this). |
||
206 | if ( ! $column->is_foreign_key() && ! $column->is_boolean() |
||
207 | && $column->get_size() > 0 |
||
208 | && strlen( $value ) > $column->get_size() ) { |
||
209 | $col_errors[] = 'Value (' . $value . ') too long (maximum length of ' . $column->get_size() . ')'; |
||
210 | } |
||
211 | // Invalid foreign key value. |
||
212 | if ( ! empty( $value ) && $column->is_foreign_key() ) { |
||
213 | $err = $this->validate_foreign_key( $column, $value ); |
||
214 | if ( $err ) { |
||
0 ignored issues
–
show
|
|||
215 | $col_errors[] = $err; |
||
216 | } |
||
217 | } |
||
218 | // Dates. |
||
219 | View Code Duplication | if ( 'date' === $column->get_type() && ! empty( $value ) && 1 !== preg_match( '/\d{4}-\d{2}-\d{2}/', $value ) ) { |
|
220 | $col_errors[] = 'Value (' . $value . ') not in date format'; |
||
221 | } |
||
222 | View Code Duplication | if ( 'year' === $column->get_type() && ! empty( $value ) && ( $value < 1901 || $value > 2155 ) ) { |
|
223 | $col_errors[] = 'Year values must be between 1901 and 2155 (' . $value . ' given)'; |
||
224 | } |
||
225 | |||
226 | if ( count( $col_errors ) > 0 ) { |
||
227 | // Construct error details array. |
||
228 | $errors[] = array( |
||
229 | 'column_name' => $this->headers[ $col_num ], |
||
230 | 'column_number' => $col_num, |
||
231 | 'field_name' => $column->get_name(), |
||
232 | 'row_number' => $row_num, |
||
233 | 'messages' => $col_errors, |
||
234 | ); |
||
235 | } |
||
236 | }// End foreach(). |
||
237 | }// End for(). |
||
238 | return $errors; |
||
239 | } |
||
240 | |||
241 | /** |
||
242 | * Assume all data is now valid, and only FK values remain to be translated. |
||
243 | * |
||
244 | * @param DB\Table $table The table into which to import data. |
||
245 | * @param array $column_map array of DB names to import names. |
||
246 | * @return integer The number of rows imported. |
||
247 | */ |
||
248 | public function import_data( $table, $column_map ) { |
||
249 | global $wpdb; |
||
250 | $change_tracker = new ChangeTracker( $wpdb ); |
||
251 | $change_tracker->open_changeset( 'CSV import.', true ); |
||
252 | $count = 0; |
||
253 | $headers = $this->remap( $column_map ); |
||
254 | $row_count = $this->row_count(); |
||
255 | for ( $row_num = 1; $row_num <= $row_count; $row_num++ ) { |
||
256 | $row = array(); |
||
257 | foreach ( $this->data[ $row_num ] as $col_num => $value ) { |
||
258 | if ( ! isset( $headers[ $col_num ] ) ) { |
||
259 | continue; |
||
260 | } |
||
261 | $db_column_name = $headers[ $col_num ]; |
||
262 | $column = $table->get_column( $db_column_name ); |
||
263 | |||
264 | // Get actual foreign key value. |
||
265 | if ( $column->is_foreign_key() && ! empty( $value ) ) { |
||
266 | $fk_rows = $this->get_fk_rows( $column->get_referenced_table(), $value ); |
||
267 | $foreign_row = array_shift( $fk_rows ); |
||
268 | $value = $foreign_row->get_primary_key(); |
||
269 | } |
||
270 | |||
271 | // All other values are used as they are. |
||
272 | $row[ $db_column_name ] = $value; |
||
273 | } |
||
274 | |||
275 | $pk_name = $table->get_pk_column()->get_name(); |
||
276 | $pk_value = ( isset( $row[ $pk_name ] ) ) ? $row[ $pk_name ] : null; |
||
277 | $table->save_record( $row, $pk_value ); |
||
278 | $count++; |
||
279 | } |
||
280 | $change_tracker->close_changeset(); |
||
281 | return $count; |
||
282 | } |
||
283 | |||
284 | /** |
||
285 | * Determine whether a given value is valid for a foreign key (i.e. is the |
||
286 | * title of a foreign row). |
||
287 | * |
||
288 | * @param DB\Column $column The column to check in. |
||
289 | * @param string $value The value to validate. |
||
290 | * @return false|string False if the value is valid, error message otherwise. |
||
291 | */ |
||
292 | protected function validate_foreign_key( $column, $value ) { |
||
293 | $foreign_table = $column->get_referenced_table(); |
||
294 | if ( ! $this->get_fk_rows( $foreign_table, $value ) ) { |
||
295 | $link = '<a href="' . $foreign_table->get_url() . '" title="Opens in a new tab or window" target="_blank" >' |
||
296 | . $foreign_table->get_title() |
||
297 | . '</a>'; |
||
298 | return "Value <code>$value</code> not found in $link"; |
||
299 | } |
||
300 | return false; |
||
301 | } |
||
302 | |||
303 | /** |
||
304 | * Get the rows of a foreign table where the title column equals a given |
||
305 | * value. |
||
306 | * |
||
307 | * @param DB\Table $foreign_table The table from which to fetch rows. |
||
308 | * @param string $value The value to match against the title column. |
||
309 | * @return Record[] The foreign records. |
||
310 | */ |
||
311 | protected function get_fk_rows( $foreign_table, $value ) { |
||
312 | $foreign_table->reset_filters(); |
||
313 | $foreign_table->add_filter( $foreign_table->get_title_column()->get_name(), '=', $value ); |
||
314 | return $foreign_table->get_records(); |
||
315 | } |
||
316 | |||
317 | /** |
||
318 | * Determine whether the given value exists. |
||
319 | * |
||
320 | * @param DB\Table $table The table to check in. |
||
321 | * @param DB\Column $column The column to check. |
||
322 | * @param mixed $value The value to look for. |
||
323 | * @return boolean |
||
324 | */ |
||
325 | protected function value_exists( $table, $column, $value ) { |
||
326 | $db = $table->get_database()->get_wpdb(); |
||
327 | $sql = 'SELECT 1 FROM `' . $table->get_name() . '` ' |
||
328 | . 'WHERE `' . $column->get_name() . '` = %s ' |
||
329 | . 'LIMIT 1'; |
||
330 | $exists = $db->get_row( $db->prepare( $sql, array( $value ) ) ); |
||
331 | return ! is_null( $exists ); |
||
332 | } |
||
333 | } |
||
334 |
In PHP, under loose comparison (like
==
, or!=
, orswitch
conditions), values of different types might be equal.For
string
values, the empty string''
is a special case, in particular the following results might be unexpected: