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