CSV::match_fields()   F
last analyzed

Complexity

Conditions 29
Paths 1167

Size

Total Lines 66

Duplication

Lines 6
Ratio 9.09 %

Importance

Changes 0
Metric Value
dl 6
loc 66
rs 0
c 0
b 0
f 0
cc 29
nc 1167
nop 2

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 );
0 ignored issues
show
Bug introduced by Sam Wilson
It seems like $uploaded defined by parameter $uploaded on line 63 can also be of type boolean; however, WordPress\Tabulate\CSV::save_file() does only seem to accept array, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
67
		}
68
69
		if ( ! empty( $hash ) ) {
70
			$this->hash = $hash;
0 ignored issues
show
Documentation Bug introduced by Sam Wilson
It seems like $hash can also be of type boolean. However, the property $hash is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
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 ) ) {
0 ignored issues
show
Security Bug introduced by Sam Wilson
It seems like $column defined by $table->get_column($db_column_name) on line 196 can also be of type false; however, WordPress\Tabulate\CSV::value_exists() does only seem to accept object<WordPress\Tabulate\DB\Column>, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
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 );
0 ignored issues
show
Security Bug introduced by Sam Wilson
It seems like $column defined by $table->get_column($db_column_name) on line 196 can also be of type false; however, WordPress\Tabulate\CSV::validate_foreign_key() does only seem to accept object<WordPress\Tabulate\DB\Column>, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
214
					if ( $err ) {
0 ignored issues
show
Bug Best Practice introduced by Sam Wilson
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().
0 ignored issues
show
Unused Code Comprehensibility introduced by Sam Wilson
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 Sam Wilson
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