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

src/CSV.php (1 issue)

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 ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $err of type false|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch 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:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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