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