1<?php 2 3namespace dokuwiki\plugin\struct\meta; 4 5/** 6 * Class SchemaBuilder 7 * 8 * This class builds and updates the schema definitions for our tables. This includes CREATEing and ALTERing 9 * the actual data tables as well as updating the meta information in our meta data tables. 10 * 11 * To use, simply instantiate a new object of the Builder and run the build() method on it. 12 * 13 * Note: even though data tables use a data_ prefix in the database, this prefix is internal only and should 14 * never be passed as $table anywhere! 15 * 16 * @package dokuwiki\plugin\struct\meta 17 */ 18class SchemaBuilder { 19 20 /** 21 * @var array The posted new data for the schema 22 * @see Schema::AdminEditor() 23 */ 24 protected $data = array(); 25 26 /** 27 * @var string The table name associated with the schema 28 */ 29 protected $table = ''; 30 31 /** 32 * @var Schema the previously valid schema for this table 33 */ 34 protected $oldschema; 35 36 /** @var int the ID of the newly created schema */ 37 protected $newschemaid = 0; 38 39 /** @var \helper_plugin_struct_db */ 40 protected $helper; 41 42 /** @var \helper_plugin_sqlite|null */ 43 protected $sqlite; 44 45 /** @var int the time for which this schema should be created - default to time() can be overriden for tests */ 46 protected $time = 0; 47 48 /** 49 * SchemaBuilder constructor. 50 * 51 * @param string $table The table's name 52 * @param array $data The defining of the table (basically what get's posted in the schema editor form) 53 * @see Schema::AdminEditor() 54 */ 55 public function __construct($table, $data) { 56 $this->table = $table; 57 $this->data = $data; 58 $this->oldschema = new Schema($table, 0, $data['islookup']); 59 60 $this->helper = plugin_load('helper', 'struct_db'); 61 $this->sqlite = $this->helper->getDB(); 62 } 63 64 /** 65 * Create the new schema 66 * 67 * @param int $time when to create this schema 0 for now 68 * @return bool|int the new schema id on success 69 */ 70 public function build($time=0) { 71 $this->time = $time; 72 $this->fixLabelUniqueness(); 73 74 $this->sqlite->query('BEGIN TRANSACTION'); 75 $ok = true; 76 // create the data table if new schema 77 if(!$this->oldschema->getId()) { 78 if($this->oldschema->isLookup()) { 79 $ok = $this->newLookupTable(); 80 } else { 81 $ok = $this->newDataTable(); 82 } 83 } 84 85 // create a new schema 86 $ok = $ok && $this->newSchema(); 87 88 // update column info 89 $ok = $ok && $this->updateColumns(); 90 $ok = $ok && $this->addColumns(); 91 92 if (!$ok) { 93 $this->sqlite->query('ROLLBACK TRANSACTION'); 94 return false; 95 } 96 $this->sqlite->query('COMMIT TRANSACTION'); 97 98 return $this->newschemaid; 99 } 100 101 /** 102 * Makes sure all labels in the schema to save are unique 103 */ 104 protected function fixLabelUniqueness() { 105 $labels = array(); 106 107 if(isset($this->data['cols'])) foreach($this->data['cols'] as $idx => $column) { 108 $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 109 } 110 111 if(isset($this->data['new'])) foreach($this->data['new'] as $idx => $column) { 112 $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 113 } 114 } 115 116 /** 117 * Creates a unique label from the given one 118 * 119 * @param string $wantedlabel 120 * @param array $labels list of already assigned labels (will be filled) 121 * @return string 122 */ 123 protected function fixLabel($wantedlabel, &$labels) { 124 $wantedlabel = trim($wantedlabel); 125 $fixedlabel = $wantedlabel; 126 $idx = 1; 127 while(isset($labels[utf8_strtolower($fixedlabel)])) { 128 $fixedlabel = $wantedlabel.$idx++; 129 } 130 // did we actually do a rename? apply it. 131 if($fixedlabel != $wantedlabel) { 132 msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1); 133 $this->data['cols']['label'] = $fixedlabel; 134 } 135 $labels[utf8_strtolower($fixedlabel)] = 1; 136 return $fixedlabel; 137 } 138 139 /** 140 * Creates a new schema 141 * 142 * @todo use checksum or other heuristic to see if we really need a new schema OTOH we probably need one nearly always!? 143 */ 144 protected function newSchema() { 145 if(!$this->time) $this->time = time(); 146 147 $sql = "INSERT INTO schemas (tbl, ts, islookup) VALUES (?, ?, ?)"; 148 $this->sqlite->query($sql, $this->table, $this->time, (int) $this->oldschema->isLookup()); 149 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 150 $this->newschemaid = $this->sqlite->res2single($res); 151 $this->sqlite->res_close($res); 152 if(!$this->newschemaid) return false; 153 return true; 154 } 155 156 /** 157 * Updates all the existing column infos and adds them to the new schema 158 */ 159 protected function updateColumns() { 160 foreach($this->oldschema->getColumns() as $column) { 161 $oldEntry = $column->getType()->getAsEntry(); 162 $oldTid = $column->getTid(); 163 $newEntry = $oldEntry; 164 $newTid = $oldTid; 165 $sort = $column->getSort(); 166 if(isset($this->data['cols'][$column->getColref()])){ 167 // todo I'm not too happy with this hardcoded here - we should probably have a list of fields at one place 168 $newEntry['config'] = $this->data['cols'][$column->getColref()]['config']; 169 $newEntry['label'] = $this->data['cols'][$column->getColref()]['label']; 170 $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti']; 171 $newEntry['class'] = $this->data['cols'][$column->getColref()]['class']; 172 $sort = $this->data['cols'][$column->getColref()]['sort']; 173 $enabled = (bool) $this->data['cols'][$column->getColref()]['isenabled']; 174 175 // when the type definition has changed, we create a new one 176 if(array_diff_assoc($oldEntry, $newEntry)) { 177 $ok = $this->sqlite->storeEntry('types', $newEntry); 178 if(!$ok) return false; 179 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 180 if(!$res) return false; 181 $newTid = $this->sqlite->res2single($res); 182 $this->sqlite->res_close($res); 183 } 184 } else { 185 $enabled = false; // no longer there for some reason 186 } 187 188 // add this type to the schema columns 189 $schemaEntry = array( 190 'sid' => $this->newschemaid, 191 'colref' => $column->getColref(), 192 'enabled' => $enabled, 193 'tid' => $newTid, 194 'sort' => $sort 195 ); 196 $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry); 197 if(!$ok) return false; 198 } 199 return true; 200 } 201 202 /** 203 * Adds new columns to the new schema 204 * 205 * @return bool 206 */ 207 protected function addColumns() { 208 if(!isset($this->data['new'])) return true; 209 210 $colref = count($this->oldschema->getColumns())+1; 211 212 foreach($this->data['new'] as $column) { 213 if(!$column['isenabled']) continue; // we do not add a disabled column 214 215 // todo this duplicates the hardcoding as in the function above 216 $newEntry = array(); 217 $newEntry['config'] = $column['config']; 218 $newEntry['label'] = $column['label']; 219 $newEntry['ismulti'] = $column['ismulti']; 220 $newEntry['class'] = $column['class']; 221 $sort = $column['sort']; 222 223 224 // only save if the column got a name 225 if(!$newEntry['label']) continue; 226 227 // add new column to the data table 228 if(!$this->addDataTableColumn($colref)) { 229 return false; 230 } 231 232 // save the type 233 $ok = $this->sqlite->storeEntry('types', $newEntry); 234 if(!$ok) return false; 235 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 236 if(!$res) return false; 237 $newTid = $this->sqlite->res2single($res); 238 $this->sqlite->res_close($res); 239 240 241 // add this type to the schema columns 242 $schemaEntry = array( 243 'sid' => $this->newschemaid, 244 'colref' => $colref, 245 'enabled' => true, 246 'tid' => $newTid, 247 'sort' => $sort 248 ); 249 $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry); 250 if(!$ok) return false; 251 $colref++; 252 } 253 254 return true; 255 } 256 257 /** 258 * Create a completely new data table with no columns yet also create the appropriate 259 * multi value table for the schema 260 * 261 * @todo how do we want to handle indexes? 262 * @return bool 263 */ 264 protected function newDataTable() { 265 $ok = true; 266 267 $tbl = 'data_' . $this->table; 268 $sql = "CREATE TABLE $tbl ( 269 pid NOT NULL, 270 rev INTEGER NOT NULL, 271 latest BOOLEAN NOT NULL DEFAULT 0, 272 PRIMARY KEY(pid, rev) 273 )"; 274 $ok = $ok && (bool) $this->sqlite->query($sql); 275 276 $tbl = 'multi_' . $this->table; 277 $sql = "CREATE TABLE $tbl ( 278 colref INTEGER NOT NULL, 279 pid NOT NULL, 280 rev INTEGER NOT NULL, 281 row INTEGER NOT NULL, 282 value, 283 PRIMARY KEY(colref, pid, rev, row) 284 );"; 285 $ok = $ok && (bool) $this->sqlite->query($sql); 286 287 return $ok; 288 } 289 290 /** 291 * Creates a new lookup table with no columns 292 * 293 * This is basically the same as @see newDataTable() but sets 294 * different primary keys and types 295 * 296 * @return bool 297 */ 298 protected function newLookupTable() { 299 $ok = true; 300 301 $tbl = 'data_' . $this->table; 302 $sql = "CREATE TABLE $tbl ( 303 pid INTEGER PRIMARY KEY, 304 rev INTEGER NOT NULL DEFAULT 0, 305 latest BOOLEAN NOT NULL DEFAULT 1 306 )"; 307 $ok = $ok && (bool) $this->sqlite->query($sql); 308 309 $tbl = 'multi_' . $this->table; 310 $sql = "CREATE TABLE $tbl ( 311 colref INTEGER NOT NULL, 312 pid INTEGER NOT NULL, 313 rev INTEGER NOT NULL DEFAULT 0, 314 row INTEGER NOT NULL, 315 value, 316 PRIMARY KEY(colref, pid, row) 317 );"; 318 $ok = $ok && (bool) $this->sqlite->query($sql); 319 320 return $ok; 321 } 322 323 /** 324 * Add an additional column to the existing data table 325 * 326 * @param int $index the new column index to add 327 * @return bool 328 */ 329 protected function addDataTableColumn($index) { 330 $tbl = 'data_' . $this->table; 331 $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''"; 332 if(! $this->sqlite->query($sql)) { 333 return false; 334 } 335 return true; 336 } 337 338} 339