xref: /plugin/struct/meta/CSVImporter.php (revision d6d97f6064c3b0f90310be8341edc9585520ee54)
1<?php
2
3namespace dokuwiki\plugin\struct\meta;
4
5/**
6 * Class CSVImporter
7 *
8 * Imports CSV data into a lookup schema
9 *
10 * @package dokuwiki\plugin\struct\meta
11 */
12abstract class CSVImporter
13{
14
15    /** @var  Schema */
16    protected $schema;
17
18    /** @var  resource */
19    protected $fh;
20
21    /** @var  \helper_plugin_sqlite */
22    protected $sqlite;
23
24    /** @var Column[] The single values to store index => col */
25    protected $columns = array();
26
27    /** @var int current line number */
28    protected $line = 0;
29
30    /** @var  list of headers */
31    protected $header;
32
33    /** @var  array list of validation errors */
34    protected $errors;
35
36    /**
37     * CSVImporter constructor.
38     *
39     * @throws StructException
40     * @param string $table
41     * @param string $file
42     */
43    public function __construct($table, $file)
44    {
45        $this->openFile($file);
46
47        $this->schema = new Schema($table);
48        if (!$this->schema->getId()) throw new StructException('Schema does not exist');
49
50        /** @var \helper_plugin_struct_db $db */
51        $db = plugin_load('helper', 'struct_db');
52        $this->sqlite = $db->getDB(true);
53    }
54
55    /**
56     * Import the data from file.
57     *
58     * @throws StructException
59     */
60    public function import()
61    {
62        // Do the import
63        $this->readHeaders();
64        $this->importCSV();
65    }
66
67    /**
68     * Open a given file path
69     *
70     * The main purpose of this method is to be overridden in a mock for testing
71     *
72     * @param string $file the file path
73     *
74     * @return void
75     */
76    protected function openFile($file)
77    {
78        $this->fh = fopen($file, 'rb');
79        if (!$this->fh) {
80            throw new StructException('Failed to open CSV file for reading');
81        }
82    }
83
84    /**
85     * Get a parsed line from the opened CSV file
86     *
87     * The main purpose of this method is to be overridden in a mock for testing
88     *
89     * @return array|false|null
90     */
91    protected function getLine()
92    {
93        return fgetcsv($this->fh);
94    }
95
96    /**
97     * Read the CSV headers and match it with the Schema columns
98     *
99     * @return array headers of file
100     */
101    protected function readHeaders()
102    {
103        $header = $this->getLine();
104        if (!$header) throw new StructException('Failed to read CSV');
105        $this->line++;
106
107        foreach ($header as $i => $head) {
108            $col = $this->schema->findColumn($head);
109            if (!$col) continue;
110            if (!$col->isEnabled()) continue;
111            $this->columns[$i] = $col;
112        }
113
114        if (!$this->columns) {
115            throw new StructException('None of the CSV headers matched any of the schema\'s fields');
116        }
117
118        $this->header = $header;
119    }
120
121    /**
122     * Creates the insert string for the single value table
123     *
124     * @return string
125     */
126    protected function getSQLforAllValues()
127    {
128        $colnames = array();
129        $placeholds = array();
130        foreach ($this->columns as $i => $col) {
131            $colnames[] = 'col' . $col->getColref();
132            $placeholds[] = '?';
133        }
134        $colnames = join(', ', $colnames);
135        $placeholds = join(', ', $placeholds);
136        $table = $this->schema->getTable();
137
138        return "INSERT INTO data_$table ($colnames) VALUES ($placeholds)";
139    }
140
141    /**
142     * Creates the insert string for the multi value table
143     *
144     * @return string
145     */
146    protected function getSQLforMultiValue()
147    {
148        $table = $this->schema->getTable();
149        /** @noinspection SqlResolve */
150        return "INSERT INTO multi_$table (pid, colref, row, value) VALUES (?,?,?,?)";
151    }
152
153    /**
154     * Walks through the CSV and imports
155     */
156    protected function importCSV()
157    {
158
159        $single = $this->getSQLforAllValues();
160        $multi = $this->getSQLforMultiValue();
161
162        while (($data = $this->getLine()) !== false) {
163            $this->sqlite->query('BEGIN TRANSACTION');
164            $this->line++;
165            $this->importLine($data, $single, $multi);
166            $this->sqlite->query('COMMIT TRANSACTION');
167        }
168    }
169
170    /**
171     * The errors that occured during validation
172     *
173     * @return string[] already translated error messages
174     */
175    public function getErrors()
176    {
177        return $this->errors;
178    }
179
180    /**
181     * Validate a single value
182     *
183     * @param Column $col the column of that value
184     * @param mixed &$rawvalue the value, will be fixed according to the type
185     * @return bool true if the data validates, otherwise false
186     */
187    protected function validateValue(Column $col, &$rawvalue)
188    {
189        //by default no validation
190        return true;
191    }
192
193    /**
194     * Read and validate CSV parsed line
195     *
196     * @param &$line
197     */
198    protected function readLine(&$line)
199    {
200        // prepare values for single value table
201        $values = array();
202        foreach ($this->columns as $i => $column) {
203            if (!isset($line[$i])) throw new StructException('Missing field at CSV line %d', $this->line);
204
205            if (!$this->validateValue($column, $line[$i])) return false;
206
207            if ($column->isMulti()) {
208                // multi values get split on comma
209                $line[$i] = array_map('trim', explode(',', $line[$i]));
210                $values[] = $line[$i][0];
211            } else {
212                $values[] = $line[$i];
213            }
214        }
215        //if no ok don't import
216        return $values;
217    }
218
219    /**
220     * INSERT $values into data_* table
221     *
222     * @param string[] $values
223     * @param string $single SQL for single table
224     *
225     * @return string last_insert_rowid()
226     */
227    protected function insertIntoSingle($values, $single)
228    {
229        $this->sqlite->query($single, $values);
230        $res = $this->sqlite->query('SELECT last_insert_rowid()');
231        $pid = $this->sqlite->res2single($res);
232        $this->sqlite->res_close($res);
233
234        return $pid;
235    }
236
237    /**
238     * INSERT one row into multi_* table
239     *
240     * @param string $multi SQL for multi table
241     * @param $pid string
242     * @param $column string
243     * @param $row string
244     * @param $value string
245     */
246    protected function insertIntoMulti($multi, $pid, $column, $row, $value)
247    {
248        $this->sqlite->query($multi, array($pid, $column->getColref(), $row + 1, $value));
249    }
250
251    /**
252     * Save one CSV line into database
253     *
254     * @param string[] $values parsed line values
255     * @param string $single SQL for single table
256     * @param string $multi SQL for multi table
257     */
258    protected function saveLine($values, $line, $single, $multi)
259    {
260        // insert into single value table (and create pid)
261        $pid = $this->insertIntoSingle($values, $single);
262
263        // insert all the multi values
264        foreach ($this->columns as $i => $column) {
265            if (!$column->isMulti()) continue;
266            foreach ($line[$i] as $row => $value) {
267                $this->insertIntoMulti($multi, $pid, $column, $row, $value);
268            }
269        }
270    }
271
272    /**
273     * Imports one line into the schema
274     *
275     * @param string[] $line the parsed CSV line
276     * @param string $single SQL for single table
277     * @param string $multi SQL for multi table
278     */
279    protected function importLine($line, $single, $multi)
280    {
281        //read values, false if no validation
282        $values = $this->readLine($line);
283
284        if ($values) {
285            $this->saveLine($values, $line, $single, $multi);
286        } else foreach ($this->errors as $error) {
287            msg($error, -1);
288        }
289    }
290}
291