Completed
Push — master ( 6cab57...287bd7 )
by Sam
02:34
created

src/CSV.php (2 issues)

Upgrade to new PHP Analysis Engine

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 &lsquo;$this->hash&rsquo;" );
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 ) {
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().
0 ignored issues
show
Unused Code Comprehensibility introduced by
43% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
237
		}// End for().
0 ignored issues
show
Unused Code Comprehensibility introduced by
43% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
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